71 Commits

Author SHA1 Message Date
Mika Kuns
fb89e02b02 docs: note ResetTask hub method and TaskResetService 2026-04-21 17:46:00 +02:00
Mika Kuns
58c8210afa fix(ui): correct Reset button tooltip wording 2026-04-21 17:44:50 +02:00
Mika Kuns
2ce6b7bd3a feat(ui): add Continue and Reset buttons to agent strip 2026-04-21 17:44:00 +02:00
Mika Kuns
f90d3d8375 fix(ui): early-return in ResetAsync when ConfirmAsync is unwired 2026-04-21 17:42:36 +02:00
Mika Kuns
b03e858a8f feat(ui): add Continue and Reset commands to DetailsIslandViewModel 2026-04-21 17:40:32 +02:00
Mika Kuns
2278b516ea feat(ui): add ContinueTaskAsync and ResetTaskAsync to WorkerClient 2026-04-21 17:37:41 +02:00
Mika Kuns
219a231f32 feat(worker): expose ResetTask hub method
Wire TaskResetService into DI and add WorkerHub.ResetTask with the
same InvalidOperationException/KeyNotFoundException error-translation
pattern as ContinueTask.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:35:37 +02:00
Mika Kuns
74eb36d3c0 feat(worker): add TaskResetService for discard + reset flow
Orchestrates worktree discard, task reset to Manual, and SignalR broadcast.
Includes integration tests (happy path + running-task rejection).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:31:52 +02:00
Mika Kuns
202236a45b feat(data): add TaskRepository.ResetToManualAsync 2026-04-21 17:26:01 +02:00
Mika Kuns
88be19a231 test(worker): strengthen DiscardAsync test (cleanup + branch assertion) 2026-04-21 17:23:58 +02:00
Mika Kuns
44203f3c67 feat(worker): add WorktreeManager.DiscardAsync for task reset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:21:08 +02:00
Mika Kuns
133774cb86 docs: add implementation plan for continue and reset buttons 2026-04-21 16:47:51 +02:00
Mika Kuns
a3bb557d76 docs: add spec for continue and reset buttons on failed tasks 2026-04-21 16:43:54 +02:00
Mika Kuns
23f8fddc4d docs: add UI-rewrite notes, plans, and stream-formatter spec 2026-04-21 15:56:19 +02:00
Mika Kuns
a180e8446c chore(worker): tweak launchSettings 2026-04-21 15:56:14 +02:00
Mika Kuns
0406d35b61 style(ui): polish islands and remove terminal traffic-light dots 2026-04-21 15:56:07 +02:00
Mika Kuns
e6b37624a1 feat(ui): add settings modal and wire to worker hub 2026-04-21 15:55:53 +02:00
Mika Kuns
fca5d57fef feat(worker): extend ClaudeArgsBuilder with MaxTurns and PermissionMode 2026-04-21 15:55:40 +02:00
Mika Kuns
cfb9ca1ca4 feat(worker): add WorktreeMaintenanceService for idle-worktree cleanup 2026-04-21 15:55:35 +02:00
Mika Kuns
62a1121571 feat(data): add AppSettings entity, migration, and repository 2026-04-21 15:55:29 +02:00
Mika Kuns
4283c67d81 fix(worker): prefix broadcast lines with [stdout] so UI parser routes them 2026-04-21 15:35:40 +02:00
Mika Kuns
374e811e78 feat(ui): render user tool_result blocks as one-line summaries 2026-04-21 15:13:00 +02:00
Mika Kuns
ec679e45ed fix(ui): truncate WebFetch URL in tool_use arg 2026-04-21 15:11:17 +02:00
Mika Kuns
3a67fe81b4 feat(ui): render assistant tool_use blocks with per-tool args 2026-04-21 15:08:35 +02:00
Mika Kuns
dc6e3fe442 feat(ui): render assistant text blocks, skip thinking 2026-04-21 15:05:40 +02:00
Mika Kuns
b525498770 feat(ui): format system init message in StreamLineFormatter 2026-04-21 15:03:29 +02:00
Mika Kuns
668087cda4 refactor(ui): skeleton dispatch for StreamLineFormatter rewrite 2026-04-21 15:00:00 +02:00
Mika Kuns
b4741137d0 docs(settings-modal): add design spec
Approved design for a general-settings modal behind the user-footer ⋯ button: Claude defaults, worktree defaults + maintenance actions, About section.
2026-04-21 13:32:58 +02:00
mika kuns
e19a9d373e fix(ui): filled window icons, boxed task rows, proper explorer button
- Window controls (min/close) were stroke-only paths — PathIcon fills
  geometries, so only max rendered. Swap all three (and BrandCheck) to
  closed filled shapes.
- Task rows now have a 1px LineBrush border + rounded 8px box with
  subtle AccentSoft hover, matching the mock's visible row separation.
- "Open in explorer" button switched from icon-btn to btn class so it
  matches "Open diff" / "Worktree" framing, with a proper Lucide-style
  filled arrow geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:02:08 +02:00
mika kuns
42fb7cee0d fix(ui): NAVIGATOR eyebrow — drop broken converter binding
The Binding had no Path, so it bound to ListsIslandViewModel itself
and the UpperCase converter produced the fully-qualified type name,
which rendered as "CLAUDEDO.UI.VIEWMODELS.ISLAI..." in the header.
Replace with literal Text="NAVIGATOR".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:54:36 +02:00
mika kuns
5acc896d5c fix(ui): wire delete confirm, close-details, uppercase eyebrow, explorer button
- A1: list-name eyebrow runs through UpperCase converter.
- D2: + Open-in-explorer icon button in AgentStrip (Process.Start on worktree path).
- D4: DeleteTaskCommand prompts inline confirm Window before deleting; shell
  wires Details.CloseDetail to clear Tasks.SelectedTask and Details.DeleteFromList
  to reload the current list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:47:10 +02:00
mika kuns
9b1178ca2f style(ui): subtasks, notes, details metadata footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:39:40 +02:00
mika kuns
01af8cb7d7 style(ui): session terminal header, line columns, LIVE chip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:36:39 +02:00
mika kuns
c3f077e3b6 style(ui): agent strip with worktree panel and diff meter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:35:33 +02:00
mika kuns
b64ff3d908 style(ui): details header with logbook eyebrow and task-id badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:34:28 +02:00
mika kuns
82f2d526a0 style(ui): task section dividers overdue/tasks/completed
Expose OverdueItems / OpenItems / CompletedItems as separate observable
collections recomputed in LoadForList (and after add / toggle-done).

- OverdueItems: ScheduledFor.Date < Today && !Done
- OpenItems:    remaining !Done
- CompletedItems: Done

View renders three sections with eyebrow-style headers:
- OVERDUE (blood accent, only when non-empty)
- TASKS (shown only when overdue is also visible, matching the mock)
- COMPLETED · N (hidden when IsShowingCompleted is false)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:31:45 +02:00
mika kuns
0ef7113958 style(ui): task row chip set, selected/done states, live tail
- Expose IsOverdue, Tags, StepsCount/StepsCompleted, DiffAdditions/Deletions
  on TaskRowViewModel; parse DiffStat into numeric add/del.
- Rebuild TaskRowView with explicit chip set: status, list-with-dot, branch
  (GitBranch icon + mono), diff (+N moss / −M blood), per-tag chips.
- Selected row shows 2px accent left bar + AccentSoft background.
- Done rows dim to 0.55 opacity with faint title plus strikethrough.
- Live-tail row: mono 11px ellipsized text + slim 3px moss progress bar,
  visible only when Status=Running and HasLiveTail.
- task-row Border now transitions Background and Margin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:30:47 +02:00
mika kuns
940b72f8dd style(ui): tasks header toolbar and add-task row
- Reformat subtitle to "{Weekday}, {Month} {Day} · {open} open".
- Add right-aligned running/review status pill (kbd style).
- Add header icon toolbar: Sort, Eye (toggle completed), MoreHorizontal.
- Wire Eye to IsShowingCompleted [ObservableProperty] on the VM.
- Style add-task row as rounded Surface2 border with dashed Plus circle,
  borderless TextBox, and ENTER kbd chip visible on focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:30:32 +02:00
mika kuns
287e098c3a style(ui): lists search icon, kbd hint, footer actions
Rewrites ListsIslandView: NAVIGATOR eyebrow, search bar with PathIcon+borderless TextBox+kbd chip,
two ItemsControls for SmartLists/UserLists with icon/dot + active left-bar,
+ New list button, and footer profile row (avatar initials, username, machine/local, MoreHorizontal button).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:22:41 +02:00
mika kuns
4531b95c42 style(ui): lists icons, section headers, active state
- Add central icon library (Icon.Sun/Activity/Star/Calendar/Eye/Inbox/Folder/Search/Plus/MoreHorizontal/GitBranch/Copy/Trash/Sort/X/Check) to IslandStyles.axaml
- Add list-section-label, search-wrap, kbd, new-list-btn, avatar-circle styles
- Add UpperCaseConverter, IconKeyConverter, DotBrushConverter; register in App.axaml
- Expose SmartLists / UserLists filtered collections from ListsIslandViewModel
- Add DotColorKey (Moss/Peat/Sage rotation) and UserInitials/UserName/MachineName props

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:22:35 +02:00
mika kuns
0bf2d78fba style(ui): background gradient and stronger island shadow
Add a RadialGradientBrush border (DeepBrush center → VoidBrush edges)
filling the island row. Island BoxShadow was already set to IslandShadow
token — no change needed there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:17:17 +02:00
mika kuns
480d05975d style(ui): custom title bar with brand and window controls
Replace character-glyph window buttons with PathIcon controls using
Lucide-style StreamGeometry. Add left brand block (check glyph +
CLAUDEDO + separator dot + selected list name) and draggable middle
strip. Close button hover turns BloodBrush.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:16:39 +02:00
mika kuns
27c6a4b859 fix(ui): drop icon-btn sizing from AgentStrip text buttons
Switched the four text-label buttons (Open diff, Worktree, Stop,
Approve & merge) from Classes="icon-btn" (24x24 fixed size) to
Classes="btn" so content is not clipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:01:14 +02:00
mika kuns
2d1a4881aa fix(ui): use Tag-attribute selectors for terminal log colors
Rewrote all Border.terminal TextBlock log-kind selectors from CSS
class form (.log-sys) to attribute form ([Tag=log-sys]), matching
the pattern used by the diff-line selectors. Classes are not set on
dynamically created TextBlocks; Tag binding works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:01:07 +02:00
mika kuns
62aac7eedb fix(ui): guard Bind/LoadForList against interleaved DbContext awaits
Added CancellationTokenSource per-load in both DetailsIslandViewModel
and TasksIslandViewModel. Public entry points cancel any in-flight load
before starting a new one. DB calls and collection mutations after awaits
are guarded with ThrowIfCancellationRequested. DetailsIslandViewModel
now sets _subscribedTaskId only after the DB confirms the entity exists,
preventing the SignalR handler from routing messages to a stale load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:00:58 +02:00
mika kuns
279f2c7598 fix(ui): wire modal delegates from DetailsIslandView owner
Moved ShowDiffModal/ShowWorktreeModal delegate wiring from
AgentStripView (child, possibly detached) to DetailsIslandView
(the VM's DataContext owner). TopLevel is resolved at invocation
time, not at wiring time, so attachment order no longer matters.
AgentStripView reduced to InitializeComponent() only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:00:45 +02:00
mika kuns
95146518b2 fix(ui): remove stale brush overrides in App.axaml
Deleted inline SolidColorBrush resources whose keys collide with
Tokens.axaml (AccentBrush, TextDimBrush, etc.) so the moss-green
palette from the merged token file takes effect. Updated the three
ListBoxItem styles to reference AccentGlowBrush/AccentSoftBrush
instead of hard-coded hex.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:00:28 +02:00
mika kuns
eee98b7828 fix(app): restore ViewModels using for IslandsShellViewModel 2026-04-20 10:44:31 +02:00
mika kuns
5a17a727b9 chore(ui): remove obsolete pre-rewrite views and viewmodels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:41:14 +02:00
mika kuns
6dade011b0 feat(ui): keyboard shortcuts (/ Ctrl+N Space Esc)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:37:52 +02:00
mika kuns
47e8e1ff94 feat(ui): pulse, hover, modal, and row-add animations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:33:39 +02:00
mika kuns
abd7733c90 feat(ui): worktree modal with tree view and M/A badges
Adds WorktreeModalView/ViewModel showing git status --porcelain as a
recursive file tree with M/A/D/? status badges. Wires the Worktree
button in AgentStripView to OpenWorktreeCommand on DetailsIslandViewModel.
Adds GetStatusPorcelainAsync to GitService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:31:12 +02:00
mika kuns
4d68543cf2 feat(ui): diff modal with file sidebar and tinted hunks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:30:03 +02:00
mika kuns
f94bb35db7 feat(ui): tasks island with rows, chips, add-task, selection
TaskRowView with status chip (EqStatus converter + parameter),
StrikeIfTrue, NotNullToBool converters. TasksIslandView with header,
add-task TextBox (Enter=AddCommand), ItemsControl + flat Button for
selection. Converters registered in App.axaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:24:36 +02:00
mika kuns
4f41b084fa feat(ui): details island with agent strip, terminal, subtasks, notes
Adds AgentStripView (status/model/turns/tokens row, worktree path,
branch line, action buttons), SessionTerminalView (scrollable log with
auto-scroll on CollectionChanged, prompt TextBox with Enter binding),
and replaces DetailsIslandView placeholder with full ScrollViewer layout
containing editable title, agent strip, terminal, subtasks, notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:23:04 +02:00
mika kuns
fcf53ab4f5 feat(ui): DetailsIslandViewModel with agent state and log
Implements LogLineViewModel (LogKind enum + ClassName), full
DetailsIslandViewModel (editable title, notes, prompt, agent strip
fields, Log/Subtasks collections, Bind method, SendPromptCommand,
ApproveMergeCommand, StopCommand). Wires TaskMessageEvent for live log.
Updates Program.cs DI for new IDbContextFactory + WorkerClient deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:22:57 +02:00
mika kuns
0034accb4f feat(ui): TasksIslandViewModel with smart/virtual/user filtering
Uses IDbContextFactory directly (singleton-safe). Adds ToggleDone,
ToggleStar, Select commands. Fixes pre-existing SessionTerminalView
compiled-binding error on Classes property via x:CompileBindings="False".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:22:11 +02:00
mika kuns
f167120c90 feat(ui): Lists island view with search and nav items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:21:12 +02:00
mika kuns
dc1b648b4c feat(ui): TaskRowViewModel with status chip mapping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:20:57 +02:00
mika kuns
06cc141176 feat(ui): ListsIslandViewModel with smart/virtual/user lists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:19:58 +02:00
mika kuns
05404f46f2 feat(ui): chromeless three-island shell 2026-04-20 10:17:20 +02:00
mika kuns
8909119d1b feat(ui): scaffold islands shell and child VMs 2026-04-20 10:15:05 +02:00
mika kuns
55917c921a feat(ui): merge Tokens and IslandStyles into App 2026-04-20 10:12:49 +02:00
mika kuns
1893576b6a feat(ui): embed Inter Tight and JetBrains Mono fonts
Adds variable-weight TTF files (from google/fonts) as Avalonia resources
under Assets/Fonts/. OFL license files included for both families.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:11:06 +02:00
mika kuns
9a05907170 refactor(data): centralize list seeding in MigrateAndConfigure, add default-value test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:07:12 +02:00
mika kuns
92a6e0642e feat(ui): add design Tokens resource dictionary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 10:06:10 +02:00
mika kuns
579b527dcd feat(ui): add island control styles 2026-04-20 10:05:28 +02:00
mika kuns
bd8a4d0565 feat(data): seed default Lists (My Day, Important, Planned) 2026-04-20 10:02:07 +02:00
mika kuns
928dde1358 feat(data): migration for IsStarred/IsMyDay/Notes columns 2026-04-20 09:59:55 +02:00
mika kuns
a1190a35bd feat(data): add IsStarred, IsMyDay, Notes to TaskEntity 2026-04-20 09:59:12 +02:00
mika kuns
eff1045e63 General Changes 2026-04-20 09:54:13 +02:00
119 changed files with 14786 additions and 2942 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,803 @@
# Continue & Reset Buttons for Failed Tasks — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add two buttons (Continue, Reset) to the details pane for `Failed` tasks so the user can either nudge the agent to continue or discard the worktree and return the task to `Manual`.
**Architecture:** Spec is at `docs/superpowers/specs/2026-04-21-continue-and-reset-failed-tasks-design.md`. Backend adds one git-discard helper, one task-repository method, a small orchestration service, and a new hub method `ResetTask`. `ContinueTask` is already wired in the hub. UI adds two commands in `DetailsIslandViewModel` and a button row in `DetailsIslandView`.
**Tech Stack:** .NET 8, EF Core (SQLite), ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit 2.5 (integration tests with real SQLite + real git).
---
## File Structure
**New files:**
- `src/ClaudeDo.Worker/Services/TaskResetService.cs` — orchestrates the reset (load task, discard worktree, reset DB row, broadcast).
- `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs` — integration tests for the orchestration.
**Modified files:**
- `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — add `DiscardAsync`.
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — add `ResetToManualAsync`.
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `ResetTask` endpoint; DI-inject the new service.
- `src/ClaudeDo.Worker/Program.cs` — register `TaskResetService` in DI.
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `ContinueTaskAsync` and `ResetTaskAsync` wrappers.
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add observable properties and commands.
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` — add button row bound to the new commands.
- `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` — add `DiscardAsync` test.
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` — add `ResetToManualAsync` test.
---
## Task 1: `WorktreeManager.DiscardAsync` (TDD)
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`
`GitService` already exposes `WorktreeRemoveAsync(workingDir, path, force, ct)` and `BranchDeleteAsync(workingDir, branch, force, ct)` — verify via `git grep -n "public async Task WorktreeRemoveAsync\|public async Task BranchDeleteAsync" src/ClaudeDo.Data/Git`. If either is missing, stop and add the git wrapper first.
- [ ] **Step 1: Add the failing test**
Add at the bottom of `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` (before the `Dispose` method):
```csharp
[Fact]
public async Task DiscardAsync_RemovesWorktreeAndBranch_AndSetsStateDiscarded()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
var worktreePath = ctx.WorktreePath;
WorktreeEntity wt;
using (var readCtx = db.CreateContext())
wt = (await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id))!;
await mgr.DiscardAsync(wt, list.WorkingDir!, CancellationToken.None);
Assert.False(Directory.Exists(worktreePath), "worktree directory should be gone");
using var readCtx2 = db.CreateContext();
var row = await new WorktreeRepository(readCtx2).GetByTaskIdAsync(task.Id);
Assert.NotNull(row);
Assert.Equal(WorktreeState.Discarded, row!.State);
// Branch should no longer exist on the main repo.
var branchList = await new GitService().RunForOutputAsync(repo.RepoDir, new[] { "branch", "--list", ctx.BranchName }, CancellationToken.None);
Assert.True(string.IsNullOrWhiteSpace(branchList),
$"branch {ctx.BranchName} should be deleted, got: {branchList}");
}
```
Note on `RunForOutputAsync`: if `GitService` does not expose a generic run helper, replace the branch-check with a direct `System.Diagnostics.Process` invocation of `git branch --list <branch>` in the test. If such a helper exists with a different name, use it.
- [ ] **Step 2: Run test, verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DiscardAsync_RemovesWorktreeAndBranch" -v minimal`
Expected: FAIL — `DiscardAsync` does not exist.
- [ ] **Step 3: Implement `DiscardAsync`**
Add to `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` after `CommitIfChangedAsync`:
```csharp
public async Task DiscardAsync(WorktreeEntity wt, string workingDir, CancellationToken ct)
{
// Remove the git worktree first; --force drops uncommitted changes (user already confirmed).
try
{
await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: true, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "git worktree remove failed for {Path}", wt.Path);
throw;
}
// Delete the branch. If worktree removal succeeded but branch delete fails,
// we still record the worktree as Discarded — the folder is gone, and a dangling
// branch is recoverable; leaving the DB out of sync is worse.
try
{
await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: true, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "git branch -D {Branch} failed after worktree removal; continuing", wt.BranchName);
}
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(wt.TaskId, WorktreeState.Discarded, ct);
_logger.LogInformation("Discarded worktree for task {TaskId} (branch {Branch})", wt.TaskId, wt.BranchName);
}
```
- [ ] **Step 4: Run test, verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DiscardAsync_RemovesWorktreeAndBranch" -v minimal`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
git commit -m "feat(worker): add WorktreeManager.DiscardAsync for task reset"
```
---
## Task 2: `TaskRepository.ResetToManualAsync` (TDD)
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs`
- [ ] **Step 1: Add the failing test**
Add to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (follow the existing test style in that file — reuse any helpers it already has for creating a list + task):
```csharp
[Fact]
public async Task ResetToManualAsync_ClearsResultFields_AndSetsStatusManual()
{
using var db = new DbFixture();
using var ctx = db.CreateContext();
var listRepo = new ListRepository(ctx);
var taskRepo = new TaskRepository(ctx);
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", CreatedAt = DateTime.UtcNow };
await listRepo.AddAsync(list);
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "T",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Failed,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow,
Result = "boom",
};
await taskRepo.AddAsync(task);
await taskRepo.ResetToManualAsync(task.Id);
using var readCtx = db.CreateContext();
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
Assert.NotNull(after);
Assert.Equal(TaskStatus.Manual, after!.Status);
Assert.Null(after.StartedAt);
Assert.Null(after.FinishedAt);
Assert.Null(after.Result);
}
```
- [ ] **Step 2: Run test, verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ResetToManualAsync_ClearsResultFields" -v minimal`
Expected: FAIL — `ResetToManualAsync` does not exist.
- [ ] **Step 3: Implement `ResetToManualAsync`**
Add to `src/ClaudeDo.Data/Repositories/TaskRepository.cs` inside the `#region Status transitions` block, after `FlipAllRunningToFailedAsync`:
```csharp
public async Task ResetToManualAsync(string taskId, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Manual)
.SetProperty(t => t.StartedAt, (DateTime?)null)
.SetProperty(t => t.FinishedAt, (DateTime?)null)
.SetProperty(t => t.Result, (string?)null), ct);
}
```
- [ ] **Step 4: Run test, verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ResetToManualAsync_ClearsResultFields" -v minimal`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
git commit -m "feat(data): add TaskRepository.ResetToManualAsync"
```
---
## Task 3: `TaskResetService` (TDD)
**Files:**
- Create: `src/ClaudeDo.Worker/Services/TaskResetService.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs`
This service orchestrates Task 1 + Task 2, plus the "reject if Running" safety check and the SignalR broadcast.
- [ ] **Step 1: Add the failing test — happy path**
Create `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Services;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
public class TaskResetServiceTests : IDisposable
{
private readonly List<GitRepoFixture> _fixtures = new();
private readonly List<DbFixture> _dbFixtures = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
[Fact]
public async Task ResetAsync_FailedTaskWithWorktree_ClearsEverything_AndPreservesRunHistory()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture(); _fixtures.Add(repo);
var db = new DbFixture(); _dbFixtures.Add(db);
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "T", CreatedAt = DateTime.UtcNow };
using (var seed = db.CreateContext())
{
await new ListRepository(seed).AddAsync(list);
await new TaskRepository(seed).AddAsync(task);
}
var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
// Seed a Failed task with a run row (we'll assert it's preserved).
using (var ctx = db.CreateContext())
{
await new TaskRepository(ctx).MarkFailedAsync(task.Id, DateTime.UtcNow, "it broke");
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = task.Id,
RunNumber = 1,
IsRetry = false,
Prompt = "p",
SessionId = "s1",
FinishedAt = DateTime.UtcNow,
});
}
var broadcaster = new FakeHubBroadcaster();
var svc = new TaskResetService(db.CreateFactory(), wtMgr, broadcaster, NullLogger<TaskResetService>.Instance);
await svc.ResetAsync(task.Id, CancellationToken.None);
using var readCtx = db.CreateContext();
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Manual, after!.Status);
Assert.Null(after.Result);
Assert.Null(after.StartedAt);
Assert.Null(after.FinishedAt);
var wtAfter = await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Discarded, wtAfter!.State);
Assert.False(Directory.Exists(wtCtx.WorktreePath));
var runs = await new TaskRunRepository(readCtx).GetByTaskIdAsync(task.Id);
Assert.Single(runs);
Assert.Contains(task.Id, broadcaster.TaskUpdatedIds);
Assert.Contains(task.Id, broadcaster.WorktreeUpdatedIds);
}
[Fact]
public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate()
{
var db = new DbFixture(); _dbFixtures.Add(db);
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", CreatedAt = DateTime.UtcNow };
var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "T", CreatedAt = DateTime.UtcNow, Status = TaskStatus.Running, StartedAt = DateTime.UtcNow };
using (var seed = db.CreateContext())
{
await new ListRepository(seed).AddAsync(list);
await new TaskRepository(seed).AddAsync(task);
}
var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
var svc = new TaskResetService(db.CreateFactory(), wtMgr, new FakeHubBroadcaster(), NullLogger<TaskResetService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.ResetAsync(task.Id, CancellationToken.None));
using var readCtx = db.CreateContext();
var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Running, after!.Status);
}
public void Dispose()
{
foreach (var f in _fixtures) f.Dispose();
foreach (var d in _dbFixtures) d.Dispose();
}
private sealed class FakeHubBroadcaster : HubBroadcaster
{
public List<string> TaskUpdatedIds { get; } = new();
public List<string> WorktreeUpdatedIds { get; } = new();
public FakeHubBroadcaster() : base(new FakeHubContext()) { }
public new Task TaskUpdated(string taskId) { TaskUpdatedIds.Add(taskId); return Task.CompletedTask; }
public new Task WorktreeUpdated(string taskId) { WorktreeUpdatedIds.Add(taskId); return Task.CompletedTask; }
}
}
```
Check existing fakes: the test file assumes `FakeHubContext` exists under `ClaudeDo.Worker.Tests.Infrastructure` (the Worker.Tests CLAUDE.md lists `FakeHubContext`, `FakeHubClients`, `FakeClientProxy`). If `HubBroadcaster` methods are not virtual, the `new` keyword above will not intercept calls — instead, use the real `HubBroadcaster` with `FakeHubContext` and inspect the fake's recorded calls. Adjust the test implementation to use whichever approach matches the existing test conventions (see `QueueServiceTests` for precedent).
- [ ] **Step 2: Run tests, verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskResetServiceTests" -v minimal`
Expected: FAIL — `TaskResetService` does not exist.
- [ ] **Step 3: Implement `TaskResetService`**
Create `src/ClaudeDo.Worker/Services/TaskResetService.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Services;
public sealed class TaskResetService
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeManager _wtManager;
private readonly HubBroadcaster _broadcaster;
private readonly ILogger<TaskResetService> _logger;
public TaskResetService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeManager wtManager,
HubBroadcaster broadcaster,
ILogger<TaskResetService> logger)
{
_dbFactory = dbFactory;
_wtManager = wtManager;
_broadcaster = broadcaster;
_logger = logger;
}
public async Task ResetAsync(string taskId, CancellationToken ct)
{
bool worktreeChanged = false;
using (var ctx = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(ctx);
var task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot reset a running task. Cancel it first.");
var listRepo = new ListRepository(ctx);
var list = await listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wtRepo = new WorktreeRepository(ctx);
var wt = await wtRepo.GetByTaskIdAsync(taskId, ct);
if (wt is not null && wt.State == Data.Models.WorktreeState.Active && list.WorkingDir is not null)
{
// DiscardAsync uses its own DbContext internally; we close this one first.
ctx.Dispose();
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
worktreeChanged = true;
}
}
using (var ctx = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(ctx);
await taskRepo.ResetToManualAsync(taskId, ct);
}
await _broadcaster.TaskUpdated(taskId);
if (worktreeChanged)
await _broadcaster.WorktreeUpdated(taskId);
_logger.LogInformation("Reset task {TaskId} to Manual (worktree discarded: {Discarded})", taskId, worktreeChanged);
}
}
```
Note: the `ctx.Dispose()` inside a `using` block works because `Dispose` is idempotent. If you prefer, refactor to scope the first block with `{ }` + explicit `await using` and move the dispose before the call.
- [ ] **Step 4: Run tests, verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskResetServiceTests" -v minimal`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Services/TaskResetService.cs tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs
git commit -m "feat(worker): add TaskResetService for discard + reset flow"
```
---
## Task 4: Wire `TaskResetService` into DI and add `WorkerHub.ResetTask`
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- [ ] **Step 1: Register the service in DI**
Open `src/ClaudeDo.Worker/Program.cs`. Locate the block where `QueueService`, `WorktreeManager`, `HubBroadcaster`, `WorktreeMaintenanceService`, etc. are registered (look for `builder.Services.AddSingleton<QueueService>` or similar). Add next to them:
```csharp
builder.Services.AddSingleton<TaskResetService>();
```
Match the lifetime of sibling services (most are `AddSingleton`). If the sibling services use a different lifetime, match it.
- [ ] **Step 2: Inject it into `WorkerHub`**
Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs`:
In the field block (near `_wtMaintenance`):
```csharp
private readonly TaskResetService _resetService;
```
In the constructor signature, append `TaskResetService resetService` and assign it. The full updated constructor:
```csharp
public WorkerHub(
QueueService queue,
AgentFileService agentService,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
}
```
- [ ] **Step 3: Add the `ResetTask` hub method**
Add inside `WorkerHub` (place it near `ContinueTask` for symmetry):
```csharp
public async Task ResetTask(string taskId)
{
try
{
await _resetService.ResetAsync(taskId, CancellationToken.None);
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
}
```
- [ ] **Step 4: Build the worker to verify wiring**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: SUCCESS (no compile errors). Note: `dotnet build ClaudeDo.slnx` requires .NET 9 — build individual csproj files instead.
- [ ] **Step 5: Run the full worker test suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests -v minimal`
Expected: PASS (all existing tests plus the new ones from Tasks 1-3).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): expose ResetTask hub method"
```
---
## Task 5: Add `ContinueTaskAsync` and `ResetTaskAsync` to `WorkerClient`
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add both methods**
Open `src/ClaudeDo.Ui/Services/WorkerClient.cs`. Next to `RunNowAsync` (around line 166):
```csharp
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
{
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
}
public async Task ResetTaskAsync(string taskId)
{
await _hub.InvokeAsync("ResetTask", taskId);
}
```
If the existing `RunNowAsync` fires a local event first (e.g. `RunNowRequestedEvent?.Invoke(taskId)`), do **not** mirror that — Continue/Reset don't need UI-local optimistic state; we rely on `TaskUpdated` broadcasts.
- [ ] **Step 2: Build the UI project**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: SUCCESS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add ContinueTaskAsync and ResetTaskAsync to WorkerClient"
```
---
## Task 6: Add commands and state to `DetailsIslandViewModel`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
Background you need before editing:
- The VM already has a `Task` property (`TaskRowViewModel?`) that represents the selected task.
- Status is tracked via `AgentStatusLabel` and exposed as `IsRunning`/`IsDone`/`IsFailed`.
- `TaskRowViewModel` may not currently hold the latest `SessionId`. You need a way to read the latest run's `SessionId` for the selected task — query `TaskRunRepository` during the existing task-load flow. If the VM already loads task runs (search for `TaskRunRepository` usage in `DetailsIslandViewModel`), piggyback on that; otherwise add a DB query inside the task-load method.
- [ ] **Step 1: Add observable properties for button visibility/enablement**
In the observable-property block (after `_promptInput`, around line 29), add:
```csharp
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
private bool _showFailedActions;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string? _latestRunSessionId;
```
Also hook `AgentStatusLabel` changes to refresh `ShowFailedActions`. Update the existing `OnAgentStatusLabelChanged` partial method:
```csharp
partial void OnAgentStatusLabelChanged(string value)
{
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsFailed));
ShowFailedActions = value == "Failed";
}
```
- [ ] **Step 2: Populate `LatestRunSessionId` during task load**
Find the method in `DetailsIslandViewModel` that loads details for the selected task (likely named `LoadAsync`, `OnTaskChanged`, or similar — search for where `Turns`, `Tokens`, or `AgentStatusLabel` are assigned). Inside that method, after loading the task entity:
```csharp
using var runCtx = _dbFactory.CreateDbContext();
var runRepo = new TaskRunRepository(runCtx);
var latestRun = await runRepo.GetLatestByTaskIdAsync(Task.Id);
LatestRunSessionId = latestRun?.SessionId;
```
Verify the method name `GetLatestByTaskIdAsync` exists on `TaskRunRepository` (it is used in `TaskRunner.ContinueAsync`). If the name differs, use whatever is exposed. Make sure this runs inside the same cancellation-safe block as the other loads — copy the existing pattern verbatim.
Also ensure `LatestRunSessionId` is reset to `null` when the selected task clears. If the VM has an `OnTaskChanged` partial method that clears other fields, add `LatestRunSessionId = null;` there too.
- [ ] **Step 3: Add the two commands**
Add at the end of the class (next to `RunNowAsync` / `CanRunNow`):
```csharp
[RelayCommand(CanExecute = nameof(CanContinue))]
private async System.Threading.Tasks.Task ContinueAsync()
{
if (Task == null) return;
await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task.");
}
private bool CanContinue() =>
Task != null
&& _worker.IsConnected
&& ShowFailedActions
&& !string.IsNullOrEmpty(LatestRunSessionId);
[RelayCommand(CanExecute = nameof(CanReset))]
private async System.Threading.Tasks.Task ResetAsync()
{
if (Task == null) return;
if (ConfirmAsync == null) return;
var confirmed = await ConfirmAsync(
$"Discard worktree and reset task?\nThis deletes branch claudedo/{Task.Id.Replace("-", "")} and all uncommitted changes.");
if (!confirmed) return;
await _worker.ResetTaskAsync(Task.Id);
}
private bool CanReset() =>
Task != null
&& _worker.IsConnected
&& ShowFailedActions;
```
Also update the worker-connection PropertyChanged handler (around line 112) to notify the new commands:
```csharp
_worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(WorkerClient.IsConnected))
{
RunNowCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged();
}
};
```
- [ ] **Step 4: Build the UI project**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: SUCCESS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): add Continue and Reset commands to DetailsIslandViewModel"
```
---
## Task 7: Add the button row to `DetailsIslandView.axaml`
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- [ ] **Step 1: Inspect the existing layout**
Read `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` end-to-end so you understand the grid layout. The `AgentStripView` sits at `Grid.Row="0"`. Decide whether to add a new grid row below it or to extend the agent strip itself. Simplest: add the button row to `AgentStripView.axaml`, since that control already contains `RunNowCommand` / `StopCommand` buttons and is bound to the same VM.
- [ ] **Step 2: Add the buttons to `AgentStripView.axaml`**
Open `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`. Locate the existing `RunNowCommand` button (around line 49). After it, add:
```xml
<Button
Content="Continue"
Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Resume the failed Claude session with 'Continue working on this task.'"
Margin="4,0,0,0"/>
<Button
Content="Reset"
Command="{Binding ResetCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Discard the worktree and return the task to Manual"
Margin="4,0,0,0"/>
```
Match the style (classes, padding, height) of the surrounding `RunNow` / `Stop` buttons — copy their `Classes`, `Padding`, and `Height` attributes verbatim so the row stays visually consistent.
For the Continue button's disabled-with-tooltip affordance when there's no session_id: the `CanExecute` binding already disables the button; Avalonia shows tooltips on disabled controls when `ToolTip.ShowOnDisabled="True"` — set that on the Continue button and add a second tooltip hinting at the reason is unnecessary since the button will simply be greyed out. If you want an explicit "No session to resume" hint, add a `Classes.disabled` trigger or use a `MultiBinding`; skip this refinement unless it is trivial in the existing theme.
- [ ] **Step 3: Wire the confirmation dialog**
The VM's `ResetAsync` uses `ConfirmAsync` (a `Func<string, Task<bool>>` already declared on the VM at line 100). Search the codebase for where `ConfirmAsync` is assigned on the `DetailsIslandViewModel` instance — there is an existing assignment because `DeleteTaskCommand` already uses it. No new wiring needed; the same dialog will handle Reset confirmations.
- [ ] **Step 4: Launch the UI and smoke-test**
1. In one terminal: `dotnet run --project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
2. In another terminal: `dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`
3. Create a task that will fail (e.g. a task pointing at a non-existent working dir, or type something into Claude's prompt that makes it error). Wait for status `Failed`.
4. Verify the Continue and Reset buttons appear in the details pane.
5. Click Reset → confirm → verify the task row flips to `Manual`, the worktree directory is gone from disk, and the branch is gone from `git branch --list | grep claudedo/` in the target repo.
6. Create another failing task. Click Continue → verify a new run starts (status flips to `Running`), resumes the same Claude session, and completes (Done or Failed again).
7. Verify on a task that has no session_id (e.g. cancel before Claude emits anything), the Continue button is disabled.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml
git commit -m "feat(ui): add Continue and Reset buttons to agent strip"
```
---
## Task 8: Update project docs
**Files:**
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- [ ] **Step 1: Update Worker CLAUDE.md**
Under the `SignalR Hub` section, extend the `WorkerHub methods` line:
```
**WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `ResetTask(taskId)`, `GetAgents()`, `RefreshAgents()`
```
Under `Key Components`, add one line:
```
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
```
- [ ] **Step 2: Update UI CLAUDE.md**
Extend the `WorkerClient` description to mention the two new methods:
```
- **WorkerClient** — ... Methods: StartAsync, RunNowAsync, CancelTaskAsync, ContinueTaskAsync, ResetTaskAsync, WakeQueueAsync. Events: ...
```
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs: note ResetTask hub method and TaskResetService"
```
---
## Self-Review
**Spec coverage:**
- Continue button (canned prompt, one-click, disabled without session) → Task 6 (`ContinueCommand`, `CanContinue`) + Task 7 (button).
- Reset button (always enabled on Failed, confirm dialog) → Task 6 (`ResetCommand`, `CanReset`) + Task 7 (button + confirm).
- Buttons only on Failed → `ShowFailedActions` drives `IsVisible` (Task 6, Task 7).
- Hub `ResetTask` → Task 4.
- `WorktreeManager.DiscardAsync` → Task 1.
- `TaskRepository.ResetToManualAsync` → Task 2.
- Reject reset on Running → Task 3.
- Worktree-remove failure leaves task Failed → Task 3 (`DiscardAsync` throws before `ResetToManualAsync` is called).
- Run history preserved → Task 2 and Task 3 assertions.
- Tests — WorktreeManager.DiscardAsync, TaskRepository.ResetToManualAsync, TaskResetService full flow, reject running → Tasks 1, 2, 3.
- Test for "ResetTask rejects running" → Task 3 test 2.
- Test for "worktree remove failure leaves task Failed" → covered implicitly by the code structure (Task 3 does not call `ResetToManualAsync` if `DiscardAsync` throws). If you want an explicit test, add one in Task 3 by injecting a failure; marking optional as the control flow is straightforward.
**Placeholder scan:** no TBDs; every code step has code; commands include expected output.
**Type consistency:** `DiscardAsync(WorktreeEntity wt, string workingDir, ct)` used consistently (Tasks 1 and 3). `ResetToManualAsync(taskId, ct)` used consistently (Tasks 2 and 3). `ContinueTaskAsync`/`ResetTaskAsync` on `WorkerClient` match the hub method names. `ShowFailedActions`, `LatestRunSessionId`, `CanContinue`, `CanReset` referenced consistently across Task 6 and Task 7.

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,130 @@
# Continue & Reset Buttons for Failed Tasks
## Problem
When a task ends in `Failed` status (Claude exited without marking the work done, cancelled mid-run, crashed, etc.), the user has no way to act on it from the UI:
- **Nudging the agent** is only possible via the hub method `ContinueTask`, which is not wired into the UI.
- **Rolling back** the worktree requires shelling into git manually to remove the branch and folder, then editing the task in the DB. In practice the worktree is just abandoned.
We want two explicit actions in the details pane for a failed task: **Continue** (resume the Claude session with a follow-up prompt) and **Reset** (discard the worktree and return the task to an editable `Manual` state).
## Scope
- Actions are shown **only when the selected task has `Status == Failed`**.
- `Continue` is the multi-turn mechanism already implemented in `TaskRunner.ContinueAsync` — this spec only wires it into the UI.
- `Reset` is new end-to-end (hub method, worktree discard, task status reset).
- Run history (`task_runs` rows) is **preserved** across a Reset for audit.
- Out of scope: Continue/Reset on `Done` tasks, undo of Reset, modifying the follow-up prompt before sending.
## UX
Both buttons live in `DetailsIslandView`, inside a new horizontal button row that is visible only when the currently selected task is `Failed`.
### Continue
- One-click. Sends the canned prompt `"Continue working on this task."` via `WorkerHub.ContinueTask(taskId, prompt)`.
- Enabled **only if** the task's latest `TaskRunEntity` has a non-null `SessionId`.
- When disabled, a tooltip reads `No session to resume`.
- No confirmation dialog.
### Reset
- Always enabled when the task is `Failed`.
- Opens a confirmation dialog:
> Discard worktree and reset task?
> This deletes branch `claudedo/<id>` and all uncommitted changes.
- On confirm, calls `WorkerHub.ResetTask(taskId)`.
## Backend
### New hub method — `WorkerHub.ResetTask(string taskId)`
Preconditions:
- Task exists.
- Task status is **not** `Running`. If it is, throw — resetting a task that is actively executing would race with the runner.
Steps:
1. Load the task and its worktree (if any).
2. If a worktree exists and its `State == Active`, call `WorktreeManager.DiscardAsync(worktree, ct)` (see below).
3. Call `TaskRepository.ResetToManualAsync(taskId, ct)` to clear the result fields and flip the status.
4. Broadcast `TaskUpdated(taskId)`; broadcast `WorktreeUpdated(taskId)` if the worktree state changed.
If `WorktreeManager.DiscardAsync` throws (e.g. folder locked, branch checked out elsewhere), the hub method surfaces the error to the caller and leaves the task as `Failed` with the worktree still `Active`, so the user can retry. `TaskRepository.ResetToManualAsync` is **not** called in the failure path.
### New — `WorktreeManager.DiscardAsync(WorktreeEntity wt, CancellationToken ct)`
Shape mirrors the existing `CommitIfChangedAsync`. Steps:
1. `git worktree remove --force <wt.Path>` via `GitService`. The `--force` flag drops any uncommitted changes — expected, since the user already confirmed.
2. `git branch -D <wt.BranchName>` via `GitService`.
3. Update `WorktreeRepository`: set `State = Discarded`.
`GitService` gains two thin wrappers if they do not already exist: `WorktreeRemoveAsync(path, force: true)` and `BranchDeleteForceAsync(branch)`.
### New — `TaskRepository.ResetToManualAsync(string taskId, CancellationToken ct)`
Single UPDATE that sets:
- `Status = Manual`
- `Result = null`
- `StartedAt = null`
- `FinishedAt = null`
`LogPath` and the `task_runs` rows are left intact — they are the audit trail.
### Continue wiring
No backend changes. The UI calls `WorkerHub.ContinueTask(taskId, prompt)` and `TaskRunner.ContinueAsync` handles the rest.
## UI
### `DetailsIslandViewModel`
New members:
- `[ObservableProperty] bool showFailedActions` — true when the selected task's status is `Failed`.
- `[ObservableProperty] bool canContinue` — true when `showFailedActions` **and** the latest run of the selected task has a non-null `SessionId`.
- `[RelayCommand(CanExecute = nameof(CanContinue))] Task ContinueAsync()` — calls `HubClient.ContinueTask(task.Id, "Continue working on this task.")`.
- `[RelayCommand(CanExecute = nameof(ShowFailedActions))] Task ResetAsync()` — opens confirmation; on confirm, calls `HubClient.ResetTask(task.Id)`.
`ShowFailedActions` and `CanContinue` recompute whenever the selected task or its runs change (subscribe to the existing selection / task-updated signals).
### `DetailsIslandView.axaml`
A single `StackPanel` (orientation horizontal) inside the existing details layout, bound to `ShowFailedActions` for visibility, with two `Button`s wired to the commands.
### Confirmation dialog
Reuse the existing modal pattern (see `WorktreeModalView` for the shape). A minimal `ConfirmDialog` with title, body, `Cancel` + `Confirm` buttons is acceptable and reusable; if a simpler inline approach is idiomatic in this codebase, use that instead.
### `HubClient`
Add `Task ResetTask(string taskId)` alongside the existing `ContinueTask` wrapper.
## Error handling
| Failure | Behaviour |
|---|---|
| `ResetTask` called on a `Running` task | Hub throws; UI shows the error. The Reset button is CanExecute-gated anyway, so this is a defensive check. |
| `git worktree remove` fails | Hub throws; task stays `Failed`, worktree stays `Active`, user can retry or clean up manually. |
| `git branch -D` fails after worktree removal succeeded | Worktree state still gets set to `Discarded` (the folder is gone; leaving the branch dangling is less bad than leaving the DB out of sync). Log a warning. |
| `Continue` with no session_id | Button is disabled — the call cannot happen from the UI. Hub still guards with the existing `InvalidOperationException` in `ContinueAsync` for safety. |
## Testing
Integration tests (real SQLite, real git) in `ClaudeDo.Worker.Tests`:
1. **`WorktreeManager_DiscardAsync_removes_worktree_and_branch`** — create a worktree, call Discard, assert branch is gone from `git branch --list`, folder is gone, DB state is `Discarded`.
2. **`TaskRepository_ResetToManualAsync_clears_result_fields`** — seed a Failed task with Result/FinishedAt/StartedAt, call Reset, assert all cleared and status is Manual.
3. **`ResetTask_full_flow`** — seed a Failed task with an Active worktree and run history; invoke the hub method; assert status=Manual, worktree=Discarded, `task_runs` rows still present.
4. **`ResetTask_rejects_running_task`** — seed a Running task, assert the hub method throws and nothing is modified.
5. **`ResetTask_worktree_remove_failure_leaves_task_failed`** — simulate a git failure (e.g. lock the folder), assert task stays Failed and worktree stays Active.
No new UI tests — the commands are thin forwarders and are exercised manually.
## Open questions
None.

View File

@@ -0,0 +1,132 @@
# Settings Modal — Design
**Date:** 2026-04-21
**Status:** Approved for planning
## Goal
Add a general-settings modal reachable from the **⋯** button in the user footer of the Lists island (`ListsIslandView.axaml:68`). The modal exposes app-wide defaults for Claude runs, worktree behavior, and maintenance actions (cleanup / force-remove worktrees), plus a read-only "About" section with paths.
## Scope
**In scope**
- Claude defaults: instructions, model, max turns, permission mode
- Worktree defaults: strategy, central root, auto-cleanup toggle + days
- Worktree maintenance actions: cleanup finished, force-remove all
- About section: version, data/logs/config paths with "Open in Explorer"
- Single settings row persisted in SQLite
- SignalR surface for read/update + maintenance
- `ClaudeArgsBuilder` merge behavior for per-task overrides
**Out of scope**
- Worker-side infrastructure settings (hub URL, auto-start worker) — stays in `worker.config.json`
- Per-task "inherit defaults" toggle (always inherit; task values override per rule below)
- Any UI-layer tests (project has none today)
## Architecture
### Persistence
New single-row entity `AppSettingsEntity` (Id = 1) in SQLite. Access via new `AppSettingsRepository` with `GetAsync` / `UpdateAsync`. Seeded by a new EF migration `AddAppSettings`.
Fields:
| Column | Type | Seed default |
|---|---|---|
| `DefaultClaudeInstructions` | text | `""` |
| `DefaultModel` | string | `sonnet` |
| `DefaultMaxTurns` | int | `30` |
| `DefaultPermissionMode` | string | `acceptEdits` |
| `WorktreeStrategy` | string | `sibling` |
| `CentralWorktreeRoot` | string? | `null` |
| `WorktreeAutoCleanupEnabled` | bool | `false` |
| `WorktreeAutoCleanupDays` | int | `7` |
Rationale for DB over `worker.config.json`: transactional writes from the UI, no file-watcher dance, and the Worker already uses `IDbContextFactory<ClaudeDoDbContext>`.
### Merge rules for per-task overrides
`ClaudeArgsBuilder` gains a dependency on `AppSettingsRepository` and merges at build time:
- **Instructions:** `global + "\n\n" + task` (skip separator if either side empty)
- **Model / MaxTurns / PermissionMode:** `task ?? global` (task value wins when set)
No `TaskEntity` schema change.
### SignalR surface (new hub methods)
| Method | Returns | Notes |
|---|---|---|
| `GetAppSettings()` | `AppSettingsDto` | Single row |
| `UpdateAppSettings(dto)` | void | Full-row replace |
| `CleanupFinishedWorktrees()` | `int removedCount` | Skips Active |
| `ResetAllWorktrees()` | `{ removed, tasksAffected }` | Fails if any task is Running |
Maintenance logic lives in a new `WorktreeMaintenanceService` in the Worker; the hub stays thin. Service uses existing `GitService` + `WorktreeRepository`.
**Running-task guard:** `ResetAllWorktrees()` checks for any `Running` tasks before touching anything. If present, returns an error — the modal surfaces *"Cannot force-remove: N task(s) still running. Cancel them first."*
Affected worktrees after force-remove are marked `Discarded` in `WorktreeRepository`.
## UI
### Entry point
`ListsIslandView.axaml:68` ⋯ button binds to a new `OpenSettingsCommand` on `IslandsShellViewModel`. Command resolves `SettingsModalViewModel` and shows `SettingsModalView` via the existing modal pattern (`TaskCompletionSource<bool>` on save/cancel — same as `WorktreeModalView` / `DiffModalView`).
### Layout
Single scrollable modal, ~560 px wide, matches existing modal chrome (header eyebrow, monospace labels, close affordance). No tabs.
**Sections (top to bottom):**
1. **CLAUDE DEFAULTS** — instructions textarea (6 lines), model picker, max-turns numeric, permission-mode picker
2. **WORKTREES** — strategy picker, central-root folder picker, auto-cleanup toggle + days, then the two maintenance buttons
3. **ABOUT** — version (read-only), data folder / logs folder / worker.config path, each with "Open in Explorer" icon button
Footer: `[ Cancel ]` `[ Save ]`, right-aligned. Save button disabled while the form is invalid.
### Destructive action UX
- **Cleanup finished** — single click, inline result line under the button (`"Removed 3 worktree(s)."`), auto-clears ~4 s
- **Force-remove all** — click reveals an inline confirm row: *"Remove ALL N worktrees? Uncommitted work will be lost."* with red `Remove All` and neutral `Cancel`. Two-click confirm, no typed string (matches the delete-task confirm already in the app)
Both run against the worker over SignalR and leave the modal open on completion.
### Open-in-Explorer
Uses `Process.Start("explorer.exe", path)`. Windows-only is acceptable (app ships Windows-only via WPF installer).
### Validation
Modal-side only, block `Save`:
- Max turns: integer 1200
- Auto-cleanup days: integer 1365 (required only when toggle is on)
- Central root: required when Strategy = Central; must be an existing directory
- Instructions: no length cap
Invalid fields get a red eyebrow with the specific error text.
## Testing (ClaudeDo.Worker.Tests)
- **`AppSettingsRepositoryTests`** — round-trip `Get` / `Update` on real SQLite
- **`ClaudeArgsBuilderTests`** — four merge cases: both empty, only global, only task, both set (prepend + separator behavior)
- **`WorktreeMaintenanceServiceTests`** — real git worktree fixtures:
- cleanup skips `Active`, removes `Merged` / `Discarded` / `Kept`
- force-remove fails while any task is `Running`
- force-remove succeeds otherwise and flips affected worktrees to `Discarded`
No UI-layer tests (project has none today).
## Build order (high level)
1. Data: entity + configuration + migration + repository
2. Worker: `WorktreeMaintenanceService` + `ClaudeArgsBuilder` wiring + hub methods + DTO
3. UI: SignalR client methods on `WorkerClient`, `SettingsModalViewModel`, `SettingsModalView`
4. Wire ⋯ button on `ListsIslandView``IslandsShellViewModel.OpenSettingsCommand`
5. Tests (Worker.Tests)
Detailed step-by-step sequencing belongs in the implementation plan, not here.

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.

View File

@@ -2,33 +2,24 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ClaudeDo.App.App"
xmlns:local="using:ClaudeDo.App"
xmlns:converters="using:ClaudeDo.Ui.Converters"
RequestedThemeVariant="Dark">
<Application.Resources>
<!-- Accent: Forest Teal -->
<SolidColorBrush x:Key="AccentBrush" Color="#3d9474"/>
<SolidColorBrush x:Key="AccentLightBrush" Color="#6bb89e"/>
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#1A3D9474"/>
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#263D9474"/>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://ClaudeDo.Ui/Design/Tokens.axaml" />
</ResourceDictionary.MergedDictionaries>
<!-- Text -->
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#f1f5f9"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#c8d0dc"/>
<SolidColorBrush x:Key="TextMutedBrush" Color="#8892a2"/>
<SolidColorBrush x:Key="TextDimBrush" Color="#6b7688"/>
<!-- Converters -->
<converters:NotNullToBoolConverter x:Key="NotNullToBool"/>
<converters:StrikeIfTrueConverter x:Key="StrikeIfTrue"/>
<converters:EqStatusConverter x:Key="EqStatus"/>
<converters:UpperCaseConverter x:Key="UpperCase"/>
<converters:IconKeyConverter x:Key="IconKey"/>
<converters:DotBrushConverter x:Key="DotBrush"/>
<!-- Borders & Backgrounds -->
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#3a3f46"/>
<SolidColorBrush x:Key="WindowBgBrush" Color="#1c1e21"/>
<SolidColorBrush x:Key="IslandBgBrush" Color="#272a2e"/>
<SolidColorBrush x:Key="SidebarBgBrush" Color="#272a2e"/>
<SolidColorBrush x:Key="ContentBgBrush" Color="#272a2e"/>
<!-- Status colors (for checkboxes) -->
<SolidColorBrush x:Key="StatusGrayBrush" Color="#475569"/>
<SolidColorBrush x:Key="StatusOrangeBrush" Color="#e67e22"/>
<SolidColorBrush x:Key="StatusGreenBrush" Color="#3d9474"/>
<SolidColorBrush x:Key="StatusRedBrush" Color="#ef4444"/>
</ResourceDictionary>
</Application.Resources>
<Application.DataTemplates>
@@ -37,14 +28,15 @@
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="#333d9474"/>
<Setter Property="Background" Value="{DynamicResource AccentGlowBrush}"/>
</Style>
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#1A3D9474"/>
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}"/>
</Style>
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#403D9474"/>
<Setter Property="Background" Value="{DynamicResource AccentGlowBrush}"/>
</Style>
</Application.Styles>
</Application>

View File

@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.Views;
using Microsoft.Extensions.DependencyInjection;
@@ -22,8 +23,12 @@ public partial class App : Application
{
desktop.MainWindow = new MainWindow
{
DataContext = Services.GetRequiredService<MainWindowViewModel>(),
DataContext = Services.GetRequiredService<IslandsShellViewModel>(),
};
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
// is not up yet, or goes down and comes back.
_ = Services.GetRequiredService<WorkerClient>().StartAsync();
}
base.OnFrameworkInitializationCompleted();

View File

@@ -1,10 +1,11 @@
using Avalonia;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
@@ -28,8 +29,8 @@ sealed class Program
using (var scope = services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
var db = scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>();
ClaudeDoDbContext.MigrateAndConfigure(db);
}
try
@@ -75,30 +76,22 @@ sealed class Program
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
// ViewModels
sc.AddTransient<ListEditorViewModel>();
sc.AddTransient<TaskEditorViewModel>();
sc.AddSingleton<StatusBarViewModel>();
sc.AddSingleton<TaskDetailViewModel>();
sc.AddSingleton<TaskListViewModel>(sp =>
{
var dbFactory = sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>();
var worker = sp.GetRequiredService<WorkerClient>();
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
return new TaskListViewModel(
dbFactory, worker,
() => sp.GetRequiredService<TaskEditorViewModel>(),
msg => statusBar.ShowMessage(msg));
});
sc.AddSingleton<MainWindowViewModel>(sp =>
{
return new MainWindowViewModel(
sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<SettingsModalViewModel>();
// Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp =>
new ListsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp));
sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TaskListViewModel>(),
sp.GetRequiredService<TaskDetailViewModel>(),
sp.GetRequiredService<StatusBarViewModel>(),
() => sp.GetRequiredService<ListEditorViewModel>());
});
sp));
sc.AddSingleton<IslandsShellViewModel>();
return sc.BuildServiceProvider();
}

View File

@@ -1,4 +1,5 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Seeding;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
@@ -16,6 +17,7 @@ public class ClaudeDoDbContext : DbContext
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -73,5 +75,6 @@ public class ClaudeDoDbContext : DbContext
}
db.Database.Migrate();
DefaultListsSeeder.SeedAsync(db).GetAwaiter().GetResult();
}
}

View File

@@ -0,0 +1,36 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettingsEntity>
{
public void Configure(EntityTypeBuilder<AppSettingsEntity> builder)
{
builder.ToTable("app_settings");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(s => s.DefaultClaudeInstructions)
.HasColumnName("default_claude_instructions").IsRequired().HasDefaultValue(string.Empty);
builder.Property(s => s.DefaultModel)
.HasColumnName("default_model").IsRequired().HasDefaultValue("sonnet");
builder.Property(s => s.DefaultMaxTurns)
.HasColumnName("default_max_turns").IsRequired().HasDefaultValue(30);
builder.Property(s => s.DefaultPermissionMode)
.HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions");
builder.Property(s => s.WorktreeStrategy)
.HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling");
builder.Property(s => s.CentralWorktreeRoot)
.HasColumnName("central_worktree_root");
builder.Property(s => s.WorktreeAutoCleanupEnabled)
.HasColumnName("worktree_auto_cleanup_enabled").IsRequired().HasDefaultValue(false);
builder.Property(s => s.WorktreeAutoCleanupDays)
.HasColumnName("worktree_auto_cleanup_days").IsRequired().HasDefaultValue(7);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
}
}

View File

@@ -48,6 +48,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.Model).HasColumnName("model");
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
builder.Property(t => t.AgentPath).HasColumnName("agent_path");
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
builder.Property(t => t.Notes).HasColumnName("notes");
builder.HasOne(t => t.List)
.WithMany(l => l.Tasks)

View File

@@ -27,6 +27,14 @@ public sealed class GitService
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
}
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
@@ -50,6 +58,36 @@ public sealed class GitService
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
}
public async Task<string> GetDiffAsync(string worktreePath, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "HEAD"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
// If nothing staged vs HEAD, try the index (untracked is never in diff)
if (string.IsNullOrWhiteSpace(stdout))
{
var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct);
if (e2 == 0) return s2;
}
return stdout;
}
/// <summary>
/// Full diff between <paramref name="baseRef"/> and the current working tree
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
/// task's total impact relative to where the branch started.
/// </summary>
public async Task<string> GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(worktreePath,
["diff", baseRef], ct);
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
return stdout;
// Fallback: whatever the worktree has vs HEAD (uncommitted only).
return await GetDiffAsync(worktreePath, ct);
}
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
@@ -70,6 +108,41 @@ public sealed class GitService
throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}");
}
public async Task<List<string>> ListWorktreePathsForBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct);
if (exitCode != 0) return new();
var target = $"refs/heads/{branchName}";
var paths = new List<string>();
string? currentPath = null;
foreach (var raw in stdout.Split('\n'))
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("worktree ", StringComparison.Ordinal))
{
currentPath = line["worktree ".Length..].Trim();
}
else if (line.StartsWith("branch ", StringComparison.Ordinal))
{
var b = line["branch ".Length..].Trim();
if (b == target && currentPath is not null) paths.Add(currentPath);
}
else if (string.IsNullOrWhiteSpace(line))
{
currentPath = null;
}
}
return paths;
}
public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git worktree prune failed (exit {exitCode}): {stderr}");
}
public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default)
{
var flag = force ? "-D" : "-d";

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddTaskFlagsAndNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_my_day",
table: "tasks",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_starred",
table: "tasks",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "notes",
table: "tasks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_my_day",
table: "tasks");
migrationBuilder.DropColumn(
name: "is_starred",
table: "tasks");
migrationBuilder.DropColumn(
name: "notes",
table: "tasks");
}
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddAppSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "app_settings",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false),
default_claude_instructions = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
default_model = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "sonnet"),
default_max_turns = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 30),
default_permission_mode = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "bypassPermissions"),
worktree_strategy = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "sibling"),
central_worktree_root = table.Column<string>(type: "TEXT", nullable: true),
worktree_auto_cleanup_enabled = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
worktree_auto_cleanup_days = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 7)
},
constraints: table =>
{
table.PrimaryKey("PK_app_settings", x => x.id);
});
migrationBuilder.InsertData(
table: "app_settings",
columns: new[] { "id", "central_worktree_root", "default_claude_instructions", "default_max_turns", "default_model", "default_permission_mode", "worktree_auto_cleanup_days", "worktree_strategy" },
values: new object[] { 1, null, "", 30, "sonnet", "bypassPermissions", 7, "sibling" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "app_settings");
}
}
}

View File

@@ -17,6 +17,80 @@ namespace ClaudeDo.Data.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 30,
DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
@@ -170,6 +244,18 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
@@ -183,6 +269,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");

View File

@@ -0,0 +1,18 @@
namespace ClaudeDo.Data.Models;
public sealed class AppSettingsEntity
{
public const int SingletonId = 1;
public int Id { get; set; } = SingletonId;
public string DefaultClaudeInstructions { get; set; } = string.Empty;
public string DefaultModel { get; set; } = "sonnet";
public int DefaultMaxTurns { get; set; } = 30;
public string DefaultPermissionMode { get; set; } = "bypassPermissions";
public string WorktreeStrategy { get; set; } = "sibling";
public string? CentralWorktreeRoot { get; set; }
public bool WorktreeAutoCleanupEnabled { get; set; }
public int WorktreeAutoCleanupDays { get; set; } = 7;
}

View File

@@ -26,6 +26,9 @@ public sealed class TaskEntity
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
public bool IsStarred { get; set; }
public bool IsMyDay { get; set; }
public string? Notes { get; set; }
// Navigation properties
public ListEntity List { get; set; } = null!;

View File

@@ -0,0 +1,48 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class AppSettingsRepository
{
private readonly ClaudeDoDbContext _context;
public AppSettingsRepository(ClaudeDoDbContext context) => _context = context;
public async Task<AppSettingsEntity> GetAsync(CancellationToken ct = default)
{
var row = await _context.AppSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
if (row is not null) return row;
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
return row;
}
public async Task UpdateAsync(AppSettingsEntity updated, CancellationToken ct = default)
{
var row = await _context.AppSettings
.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
if (row is null)
{
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
}
row.DefaultClaudeInstructions = updated.DefaultClaudeInstructions ?? string.Empty;
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
row.DefaultMaxTurns = updated.DefaultMaxTurns;
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
? "bypassPermissions" : updated.DefaultPermissionMode;
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
? null : updated.CentralWorktreeRoot;
row.WorktreeAutoCleanupEnabled = updated.WorktreeAutoCleanupEnabled;
row.WorktreeAutoCleanupDays = updated.WorktreeAutoCleanupDays;
await _context.SaveChangesAsync(ct);
}
}

View File

@@ -98,6 +98,17 @@ public sealed class TaskRepository
.SetProperty(t => t.Result, resultText), ct);
}
public async Task ResetToManualAsync(string taskId, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Manual)
.SetProperty(t => t.StartedAt, (DateTime?)null)
.SetProperty(t => t.FinishedAt, (DateTime?)null)
.SetProperty(t => t.Result, (string?)null), ct);
}
#endregion
#region Tags

View File

@@ -40,4 +40,17 @@ public sealed class WorktreeRepository
{
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
}
public async Task<List<WorktreeEntity>> GetAllAsync(CancellationToken ct = default)
{
return await _context.Worktrees.AsNoTracking().ToListAsync(ct);
}
public async Task<List<WorktreeEntity>> GetByStatesAsync(
IReadOnlyCollection<WorktreeState> states, CancellationToken ct = default)
{
return await _context.Worktrees.AsNoTracking()
.Where(w => states.Contains(w.State))
.ToListAsync(ct);
}
}

View File

@@ -0,0 +1,25 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Seeding;
public static class DefaultListsSeeder
{
private static readonly string[] Defaults = { "My Day", "Important", "Planned" };
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default)
{
var existing = await ctx.Lists.Select(l => l.Name).ToListAsync(ct);
var now = DateTime.UtcNow;
foreach (var name in Defaults.Where(n => !existing.Contains(n)))
{
ctx.Lists.Add(new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = name,
CreatedAt = now,
});
}
await ctx.SaveChangesAsync(ct);
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright 2022 The Inter Project Authors (https://github.com/rsms/inter-tight)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -31,7 +31,7 @@ All views use compiled bindings (`x:DataType`).
## Services
- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated
- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated
## Converters

View File

@@ -18,4 +18,10 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets/Fonts/*.ttf" />
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,24 @@
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClaudeDo.Ui.Converters;
public class DotBrushConverter : IValueConverter
{
public static DotBrushConverter Instance { get; } = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var key = value?.ToString();
if (string.IsNullOrEmpty(key)) key = "Moss";
var resourceKey = $"{key}Brush";
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
return res as IBrush;
return Brushes.Transparent;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,26 @@
using System.Globalization;
using Avalonia.Data.Converters;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Converters;
/// <summary>
/// Returns true when the bound TaskStatus equals the ConverterParameter string.
/// Usage: Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
/// </summary>
public class EqStatusConverter : IValueConverter
{
public static EqStatusConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is TaskStatus status && parameter is string name &&
Enum.TryParse<TaskStatus>(name, ignoreCase: true, out var target))
return status == target;
return false;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,28 @@
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClaudeDo.Ui.Converters;
/// <summary>
/// Converts an icon key string (e.g. "Sun") to the matching StreamGeometry resource "Icon.Sun".
/// </summary>
public class IconKeyConverter : IValueConverter
{
public static IconKeyConverter Instance { get; } = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string key || string.IsNullOrEmpty(key))
return null;
var resourceKey = $"Icon.{key}";
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
return res as StreamGeometry;
return null;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,15 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace ClaudeDo.Ui.Converters;
public class NotNullToBoolConverter : IValueConverter
{
public static NotNullToBoolConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is not null;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,16 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClaudeDo.Ui.Converters;
public class StrikeIfTrueConverter : IValueConverter
{
public static StrikeIfTrueConverter Instance { get; } = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? TextDecorations.Strikethrough : null;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,15 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace ClaudeDo.Ui.Converters;
public class UpperCaseConverter : IValueConverter
{
public static UpperCaseConverter Instance { get; } = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value?.ToString()?.ToUpperInvariant();
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,831 @@
<!--
ClaudeDo component styles for Avalonia.
Depends on Tokens.axaml being merged first.
How to use each style:
<Border Classes="island"> — floating island container
<Border Classes="chip running"> — status chip
<Button Classes="icon-btn"> — 24×24 icon button
<Button Classes="btn primary"> — rounded-rect button
<TextBlock Classes="eyebrow"> — uppercase mono label
<Border Classes="agent-strip running"> — agent status strip
<Border Classes="terminal"> — terminal/log window
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ============================================================ -->
<!-- ICON GEOMETRY RESOURCES -->
<!-- ============================================================ -->
<Styles.Resources>
<!-- Window control icons — filled geometries (PathIcon fills, not strokes) -->
<StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry>
<!-- Brand check glyph — filled rounded square with inset tick -->
<StreamGeometry x:Key="Icon.BrandCheck">M3 3 H21 V21 H3 Z M6 12 L7 11 L10 14 L17 7 L18 8 L10 16 Z</StreamGeometry>
<!-- ============================================================ -->
<!-- Icons — central icon library (Phase B) -->
<!-- All d-strings sourced from icons.jsx -->
<!-- ============================================================ -->
<!-- Icon.Sun -->
<StreamGeometry x:Key="Icon.Sun">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M12 3v2M12 19v2M3 12h2M19 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4</StreamGeometry>
<!-- Icon.Activity (pulse waveform) -->
<StreamGeometry x:Key="Icon.Activity">M3 12h4l2-6 4 12 2-8 2 2h4</StreamGeometry>
<!-- Icon.Star -->
<StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry>
<!-- Icon.Calendar -->
<StreamGeometry x:Key="Icon.Calendar">M3.5 5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v15a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z M3.5 10h17M8 3v4M16 3v4</StreamGeometry>
<!-- Icon.Eye -->
<StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry>
<!-- Icon.Inbox -->
<StreamGeometry x:Key="Icon.Inbox">M3 13h5l1 2h6l1-2h5M3 13l3-8h12l3 8v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
<!-- Icon.Folder -->
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
<!-- Icon.Search -->
<StreamGeometry x:Key="Icon.Search">M11 4a7 7 0 1 0 0 14A7 7 0 0 0 11 4z M20 20l-3.5-3.5</StreamGeometry>
<!-- Icon.Plus -->
<StreamGeometry x:Key="Icon.Plus">M12 5v14M5 12h14</StreamGeometry>
<!-- Icon.MoreHorizontal (three filled dots) — uses fill so rendered via PathIcon with fill brush -->
<StreamGeometry x:Key="Icon.MoreHorizontal">M5 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M12 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M19 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0</StreamGeometry>
<!-- Icon.GitBranch -->
<StreamGeometry x:Key="Icon.GitBranch">M6 3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M6 19a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M18 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M6 7v10M6 13c0-4 12-2 12-4</StreamGeometry>
<!-- Icon.Copy -->
<StreamGeometry x:Key="Icon.Copy">M8 8h12a1.5 1.5 0 0 1 1.5 1.5v12A1.5 1.5 0 0 1 20 23H8a1.5 1.5 0 0 1-1.5-1.5v-12A1.5 1.5 0 0 1 8 8z M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3</StreamGeometry>
<!-- Icon.Trash -->
<StreamGeometry x:Key="Icon.Trash">M4 7h16M10 11v6M14 11v6M5 7l1 13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-13M9 7V4h6v3</StreamGeometry>
<!-- Icon.Sort -->
<StreamGeometry x:Key="Icon.Sort">M7 4v16M7 20l-3-3M7 4l-3 3M14 8h7M14 12h5M14 16h3</StreamGeometry>
<!-- Icon.X -->
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
<!-- Icon.Check -->
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry>
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
</Styles.Resources>
<!-- ============================================================ -->
<!-- TITLE BAR CONTROLS -->
<!-- ============================================================ -->
<Style Selector="Button.title-ctrl">
<Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<Style Selector="Button.title-ctrl:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.title-ctrl.close:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource BloodBrush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- ============================================================ -->
<!-- ISLAND -->
<!-- ============================================================ -->
<Style Selector="Border.island">
<Setter Property="Background" Value="{StaticResource IslandBackgroundBrush}" />
<Setter Property="BorderBrush" Value="#0DFFFFFF" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource IslandCornerRadius}" />
<Setter Property="BoxShadow" Value="{StaticResource IslandShadow}" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<!-- Island header separator (apply on the header Border inside an island) -->
<Style Selector="Border.island-header">
<Setter Property="Padding" Value="{StaticResource IslandHeaderPadding}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
</Style>
<!-- ============================================================ -->
<!-- CHIPS / BADGES -->
<!-- ============================================================ -->
<Style Selector="Border.chip">
<Setter Property="CornerRadius" Value="{StaticResource ChipCornerRadius}" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.chip > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- Status variants — tint background 12% alpha of the status hue -->
<Style Selector="Border.chip.running">
<Setter Property="Background" Value="#1F7C9166" />
<Setter Property="BorderBrush" Value="#4C7C9166" />
</Style>
<Style Selector="Border.chip.running > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusRunningBrush}" />
</Style>
<Style Selector="Border.chip.review">
<Setter Property="Background" Value="#1FD4A574" />
<Setter Property="BorderBrush" Value="#4CD4A574" />
</Style>
<Style Selector="Border.chip.review > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusReviewBrush}" />
</Style>
<Style Selector="Border.chip.error">
<Setter Property="Background" Value="#1FC87060" />
<Setter Property="BorderBrush" Value="#4CC87060" />
</Style>
<Style Selector="Border.chip.error > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusErrorBrush}" />
</Style>
<!-- queued → Sage (#8B9D7A) -->
<Style Selector="Border.chip.queued">
<Setter Property="Background" Value="#1F8B9D7A" />
<Setter Property="BorderBrush" Value="#4C8B9D7A" />
</Style>
<Style Selector="Border.chip.queued > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
</Style>
<!-- idle → TextMute (#6B7973) -->
<Style Selector="Border.chip.idle">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<Style Selector="Border.chip.idle > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<!-- ============================================================ -->
<!-- BUTTONS -->
<!-- ============================================================ -->
<Style Selector="Button.btn">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource ButtonCornerRadius}" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.12" />
<BrushTransition Property="BorderBrush" Duration="0:0:0.12" />
</Transitions>
</Setter>
</Style>
<Style Selector="Button.btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
</Style>
<Style Selector="Button.btn.primary">
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Icon button: 24×24 square with hover surface -->
<Style Selector="Button.icon-btn">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- ============================================================ -->
<!-- INPUTS -->
<!-- ============================================================ -->
<Style Selector="TextBox.search">
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource InputCornerRadius}" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="TextBox.search:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="BoxShadow" Value="0 0 0 3 #387C9166" />
</Style>
<!-- ============================================================ -->
<!-- FLAT BUTTON — replaces the entire Button template with a -->
<!-- bare ContentPresenter so no Fluent chrome (bg / hover / -->
<!-- pressed) can render. Used to wrap TaskRowView for clicks. -->
<!-- ============================================================ -->
<Style Selector="Button.flat">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Name="PART_ContentPresenter"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
Padding="0"
CornerRadius="0"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
</Style>
<!-- ============================================================ -->
<!-- TASK ROW -->
<!-- ============================================================ -->
<Style Selector="Border.task-row">
<Setter Property="Padding" Value="12,10" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.10"/>
<BrushTransition Property="BorderBrush" Duration="0:0:0.10"/>
</Transitions>
</Setter>
</Style>
<Style Selector="Border.task-row:pointerover">
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
</Style>
<Style Selector="Border.task-row.selected">
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
<Style Selector="Ellipse.task-check">
<Setter Property="Width" Value="18" />
<Setter Property="Height" Value="18" />
<Setter Property="StrokeThickness" Value="1.5" />
<Setter Property="Stroke" Value="{StaticResource TextMuteBrush}" />
<Setter Property="Fill" Value="Transparent" />
</Style>
<Style Selector="Ellipse.task-check.done">
<Setter Property="Stroke" Value="{StaticResource AccentBrush}" />
<Setter Property="Fill" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Status dot pulse (applied when running) -->
<Style Selector="Ellipse.status-pulse">
<Style.Animations>
<Animation Duration="0:0:1.2" IterationCount="INFINITE" Easing="CubicEaseInOut">
<KeyFrame Cue="0%"><Setter Property="Opacity" Value="0.4"/></KeyFrame>
<KeyFrame Cue="50%"><Setter Property="Opacity" Value="1.0"/></KeyFrame>
<KeyFrame Cue="100%"><Setter Property="Opacity" Value="0.4"/></KeyFrame>
</Animation>
</Style.Animations>
</Style>
<!-- ============================================================ -->
<!-- AGENT STRIP -->
<!-- ============================================================ -->
<Style Selector="Border.agent-strip">
<Setter Property="Padding" Value="12,10" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<Style Selector="Border.agent-strip.running">
<Setter Property="Background" Value="#147C9166" />
<Setter Property="BorderBrush" Value="#4C7C9166" />
</Style>
<Style Selector="Border.agent-strip.review">
<Setter Property="Background" Value="#14D4A574" />
<Setter Property="BorderBrush" Value="#4CD4A574" />
</Style>
<Style Selector="Border.agent-strip.error">
<Setter Property="Background" Value="#14C87060" />
<Setter Property="BorderBrush" Value="#4CC87060" />
</Style>
<!-- queued → Sage tint -->
<Style Selector="Border.agent-strip.queued">
<Setter Property="Background" Value="#148B9D7A" />
<Setter Property="BorderBrush" Value="#4C8B9D7A" />
</Style>
<!-- idle → neutral (same as base, explicit for clarity) -->
<Style Selector="Border.agent-strip.idle">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<!-- ============================================================ -->
<!-- TERMINAL / LOG -->
<!-- ============================================================ -->
<Style Selector="Border.terminal">
<Setter Property="Background" Value="#FF080C0B" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.terminal TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock[Tag=log-sys]">
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock[Tag=log-tool]">
<Setter Property="Foreground" Value="{StaticResource SageBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock[Tag=log-claude]">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- log-stdout: program output — dim text, same as base -->
<Style Selector="Border.terminal TextBlock[Tag=log-stdout]">
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock[Tag=log-stderr]">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
</Style>
<Style Selector="Border.terminal TextBlock[Tag=log-done]">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}" />
</Style>
<!-- log-msg: generic informational message — slightly brighter than sys -->
<Style Selector="Border.terminal TextBlock[Tag=log-msg]">
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- ============================================================ -->
<!-- TERMINAL HEADER -->
<!-- ============================================================ -->
<!-- traffic-light dot colors -->
<Style Selector="Ellipse.dot-red">
<Setter Property="Width" Value="8" />
<Setter Property="Height" Value="8" />
<Setter Property="Fill" Value="#5A2A26" />
</Style>
<Style Selector="Ellipse.dot-yellow">
<Setter Property="Width" Value="8" />
<Setter Property="Height" Value="8" />
<Setter Property="Fill" Value="#6A5A28" />
</Style>
<Style Selector="Ellipse.dot-green">
<Setter Property="Width" Value="8" />
<Setter Property="Height" Value="8" />
<Setter Property="Fill" Value="#2F4D2F" />
</Style>
<!-- LIVE chip (pulsing dot + text) -->
<Style Selector="Border.live-chip">
<Setter Property="Padding" Value="5,2" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Background" Value="#267C9166" />
</Style>
<Style Selector="Border.live-chip > StackPanel > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="9" />
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
<Setter Property="LetterSpacing" Value="1.2" />
</Style>
<Style Selector="Border.live-chip > StackPanel > Ellipse">
<Setter Property="Width" Value="6" />
<Setter Property="Height" Value="6" />
<Setter Property="Fill" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="Border.live-chip.pulsing > StackPanel > Ellipse">
<Style.Animations>
<Animation Duration="0:0:1.4" IterationCount="INFINITE" Easing="CubicEaseInOut">
<KeyFrame Cue="0%"> <Setter Property="Opacity" Value="1.0"/></KeyFrame>
<KeyFrame Cue="50%"> <Setter Property="Opacity" Value="0.3"/></KeyFrame>
<KeyFrame Cue="100%"><Setter Property="Opacity" Value="1.0"/></KeyFrame>
</Animation>
</Style.Animations>
</Style>
<!-- Terminal log-line timestamp column -->
<Style Selector="TextBlock.log-ts">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="Width" Value="60" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Margin" Value="0,0,4,0" />
</Style>
<!-- Kind marker column -->
<Style Selector="TextBlock.log-kind">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
<Setter Property="Width" Value="46" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Margin" Value="0,0,6,0" />
</Style>
<Style Selector="TextBlock.log-kind[Tag=log-tool]">
<Setter Property="Foreground" Value="{StaticResource SageBrush}" />
</Style>
<Style Selector="TextBlock.log-kind[Tag=log-claude]">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="TextBlock.log-kind[Tag=log-stderr]">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
</Style>
<Style Selector="TextBlock.log-kind[Tag=log-done]">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}" />
</Style>
<!-- ============================================================ -->
<!-- WORKTREE MODAL STATUS BADGES -->
<!-- Tag="M" → peat, "A" → moss, "D" → blood, "?" → faint -->
<!-- ============================================================ -->
<Style Selector="Border[Tag=M]">
<Setter Property="Background" Value="#26A06040"/>
</Style>
<Style Selector="Border[Tag=M] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource PeatBrush}"/>
</Style>
<Style Selector="Border[Tag=A]">
<Setter Property="Background" Value="#267C9166"/>
</Style>
<Style Selector="Border[Tag=A] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource MossBrush}"/>
</Style>
<Style Selector="Border[Tag=D]">
<Setter Property="Background" Value="#26C87060"/>
</Style>
<Style Selector="Border[Tag=D] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border[Tag=?]">
<Setter Property="Background" Value="#1A888888"/>
</Style>
<Style Selector="Border[Tag=?] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
</Style>
<!-- ============================================================ -->
<!-- LIST NAV ITEM -->
<!-- ============================================================ -->
<Style Selector="Border.list-item">
<Setter Property="Padding" Value="10,7" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Border.list-item:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
</Style>
<Style Selector="Border.list-item.active">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
</Style>
<!-- Active item text / icon colors -->
<Style Selector="Border.list-item.active TextBlock.list-label">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Border.list-item.active PathIcon.list-icon">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<!-- ============================================================ -->
<!-- LIST SECTION HEADER -->
<!-- ============================================================ -->
<Style Selector="TextBlock.list-section-label">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="9" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="Margin" Value="10,10,10,4" />
<Setter Property="LetterSpacing" Value="1.2" />
</Style>
<!-- ============================================================ -->
<!-- SEARCH BOX WRAPPER -->
<!-- ============================================================ -->
<Style Selector="Border.search-wrap">
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="8,0" />
</Style>
<Style Selector="Border.search-wrap:focus-within">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Borderless TextBox inside search-wrap -->
<Style Selector="Border.search-wrap TextBox.search-inner">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="4,7" />
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="Border.search-wrap TextBox.search-inner /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="BoxShadow" Value="none" />
</Style>
<!-- ============================================================ -->
<!-- KBD CHIP -->
<!-- ============================================================ -->
<Style Selector="Border.kbd">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="6,2" />
</Style>
<Style Selector="Border.kbd > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
</Style>
<!-- ============================================================ -->
<!-- NEW LIST BUTTON -->
<!-- ============================================================ -->
<Style Selector="Button.new-list-btn">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button.new-list-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- ============================================================ -->
<!-- FOOTER PROFILE ROW -->
<!-- ============================================================ -->
<Style Selector="Border.avatar-circle">
<Setter Property="Width" Value="28" />
<Setter Property="Height" Value="28" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
</Style>
<!-- ============================================================ -->
<!-- ADD-TASK ROW -->
<!-- ============================================================ -->
<Style Selector="Border.add-task">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="14,12" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="BorderBrush" Duration="0:0:0.15" />
</Transitions>
</Setter>
</Style>
<Style Selector="Border.add-task:focus-within">
<Setter Property="BorderBrush" Value="{StaticResource AccentDimBrush}" />
</Style>
<!-- Plus circle inside the add-task row -->
<Style Selector="Border.add-task-plus">
<Setter Property="Width" Value="20" />
<Setter Property="Height" Value="20" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="Border.add-task-plus > PathIcon">
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!-- Borderless TextBox inside the add-task row -->
<Style Selector="TextBox.add-task-input">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="MinHeight" Value="20" />
</Style>
<Style Selector="TextBox.add-task-input /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="BoxShadow" Value="none" />
</Style>
<!-- kbd-enter variant (no extra styles needed — base kbd works) -->
<Style Selector="Border.kbd.kbd-enter > TextBlock">
<Setter Property="LetterSpacing" Value="1.2" />
</Style>
<!-- ============================================================ -->
<!-- HEADER TOOLBAR icon-btn active state -->
<!-- ============================================================ -->
<Style Selector="Button.icon-btn.active /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<!-- ============================================================ -->
<!-- TASK ROW — extensions (C2) -->
<!-- ============================================================ -->
<!-- Augment base task-row transitions to include Margin -->
<Style Selector="Border.task-row">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.12" />
<ThicknessTransition Property="Margin" Duration="0:0:0.15" />
</Transitions>
</Setter>
</Style>
<!-- Selected state: rely on the left accent bar from TaskRowView;
no heavy bg or perimeter border. -->
<Style Selector="Border.task-row.selected">
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
</Style>
<!-- Left accent bar for selected row -->
<Style Selector="Border.task-row-accent">
<Setter Property="Width" Value="2" />
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="CornerRadius" Value="1" />
<Setter Property="Margin" Value="0,4" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Stretch" />
</Style>
<!-- Done state: dim the whole row and strike through the title -->
<Style Selector="Border.task-row.done">
<Setter Property="Opacity" Value="0.45" />
</Style>
<Style Selector="Border.task-row.done TextBlock.task-title">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="TextDecorations" Value="Strikethrough" />
</Style>
<!-- ============================================================ -->
<!-- CHIP EXTENSIONS -->
<!-- ============================================================ -->
<!-- Slightly slimmer chips inside task rows -->
<Style Selector="Border.task-row Border.chip">
<Setter Property="Padding" Value="6,2" />
<Setter Property="CornerRadius" Value="4" />
</Style>
<Style Selector="Border.task-row Border.chip > StackPanel > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- Tag chip: faint text -->
<Style Selector="Border.chip.chip-tag > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
</Style>
<!-- Diff chip add/del coloring -->
<Style Selector="TextBlock.diff-add">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}" />
</Style>
<Style Selector="TextBlock.diff-del">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
</Style>
<!-- ============================================================ -->
<!-- STAR BUTTON -->
<!-- ============================================================ -->
<Style Selector="Button.star-btn">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="Opacity" Value="0.6" />
</Style>
<Style Selector="Button.star-btn.on">
<Setter Property="Foreground" Value="{StaticResource PeatBrush}" />
<Setter Property="Opacity" Value="1.0" />
</Style>
<Style Selector="Button.star-btn.on /template/ ContentPresenter">
<Setter Property="TextElement.Foreground" Value="{StaticResource PeatBrush}" />
</Style>
<!-- ============================================================ -->
<!-- LIVE-TAIL PREVIEW ROW -->
<!-- ============================================================ -->
<Style Selector="Border.task-live-tail">
<Setter Property="Background" Value="#FF080C0B" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="5" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="Margin" Value="0,2,0,0" />
</Style>
<Style Selector="Border.task-live-tail TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- ============================================================ -->
<!-- DIFF METER -->
<!-- ============================================================ -->
<!-- Outer track (full width, line-bright bg) -->
<Style Selector="Border.diff-meter-track">
<Setter Property="Height" Value="4" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Background" Value="{StaticResource LineBrightBrush}" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<!-- Filled portion (moss; width set via ScaleTransform or Width binding in view) -->
<Style Selector="Rectangle.diff-meter-fill">
<Setter Property="Height" Value="4" />
<Setter Property="Fill" Value="{StaticResource MossBrightBrush}" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<!-- ============================================================ -->
<!-- SUBTASK ROW -->
<!-- ============================================================ -->
<Style Selector="Border.subtask-row">
<Setter Property="Padding" Value="8,5" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.10"/>
</Transitions>
</Setter>
</Style>
<Style Selector="Border.subtask-row:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
</Style>
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
<Setter Property="Opacity" Value="0.5" />
<Setter Property="TextDecorations" Value="Strikethrough" />
</Style>
<!-- ============================================================ -->
<!-- SECTION LABELS (OVERDUE / TASKS / COMPLETED) -->
<!-- ============================================================ -->
<Style Selector="TextBlock.section-label">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="LetterSpacing" Value="1.4" />
</Style>
<Style Selector="TextBlock.section-label.overdue">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
</Style>
</Styles>

View File

@@ -0,0 +1,201 @@
<!--
ClaudeDo design tokens for Avalonia.
Merge into App.axaml via <Application.Resources><ResourceDictionary.MergedDictionaries>.
All colors are sRGB hex. Accent uses a single hue (88 = moss); swap to 40 for peat, 180 for sea.
-->
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=netstandard">
<!-- ============================================================ -->
<!-- BASE PALETTE -->
<!-- ============================================================ -->
<!-- Void / deep / surfaces (windowpane layering, dark-first) -->
<Color x:Key="VoidColor">#FF0A0E0C</Color>
<Color x:Key="DeepColor">#FF0D1311</Color>
<Color x:Key="SurfaceColor">#FF161D1A</Color>
<Color x:Key="Surface2Color">#FF1C2422</Color>
<Color x:Key="Surface3Color">#FF222B28</Color>
<Color x:Key="LineColor">#FF2A3330</Color>
<Color x:Key="LineBrightColor">#FF3A4542</Color>
<!-- Text scale -->
<Color x:Key="TextColor">#FFE4EBE4</Color>
<Color x:Key="TextDimColor">#FF9AA8A0</Color>
<Color x:Key="TextMuteColor">#FF6B7973</Color>
<Color x:Key="TextFaintColor">#FF4A5550</Color>
<!-- Accent family (moss / sage / peat / blood) -->
<Color x:Key="MossColor">#FF4A6B4A</Color>
<Color x:Key="MossBrightColor">#FF6B8E6B</Color>
<Color x:Key="SageColor">#FF8B9D7A</Color>
<Color x:Key="PeatColor">#FFD4A574</Color>
<Color x:Key="PeatSoftColor">#FFB88D5E</Color>
<Color x:Key="BloodColor">#FFC87060</Color>
<!-- Primary accent — equivalent to oklch(58% 0.08 88) -->
<Color x:Key="AccentColor">#FF7C9166</Color>
<Color x:Key="AccentDimColor">#FF64785A</Color>
<Color x:Key="AccentSoftColor">#FF3E4B39</Color>
<Color x:Key="AccentGlowColor">#387C9166</Color> <!-- 22% alpha -->
<!-- Status colors -->
<Color x:Key="StatusRunningColor">#FF7C9166</Color>
<Color x:Key="StatusReviewColor">#FFD4A574</Color>
<Color x:Key="StatusErrorColor">#FFC87060</Color>
<Color x:Key="StatusQueuedColor">#FF8B9D7A</Color>
<Color x:Key="StatusIdleColor">#FF6B7973</Color>
<!-- ============================================================ -->
<!-- BRUSHES -->
<!-- ============================================================ -->
<SolidColorBrush x:Key="VoidBrush" Color="{StaticResource VoidColor}" />
<SolidColorBrush x:Key="DeepBrush" Color="{StaticResource DeepColor}" />
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}" />
<SolidColorBrush x:Key="Surface2Brush" Color="{StaticResource Surface2Color}" />
<SolidColorBrush x:Key="Surface3Brush" Color="{StaticResource Surface3Color}" />
<SolidColorBrush x:Key="LineBrush" Color="{StaticResource LineColor}" />
<SolidColorBrush x:Key="LineBrightBrush" Color="{StaticResource LineBrightColor}" />
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}" />
<SolidColorBrush x:Key="TextDimBrush" Color="{StaticResource TextDimColor}" />
<SolidColorBrush x:Key="TextMuteBrush" Color="{StaticResource TextMuteColor}" />
<SolidColorBrush x:Key="TextFaintBrush" Color="{StaticResource TextFaintColor}" />
<!-- MossBrush at #7C9166 (design-token reference value = AccentColor) -->
<SolidColorBrush x:Key="MossBrush" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="MossDarkBrush" Color="{StaticResource MossColor}" />
<SolidColorBrush x:Key="MossBrightBrush" Color="{StaticResource MossBrightColor}" />
<SolidColorBrush x:Key="SageBrush" Color="{StaticResource SageColor}" />
<SolidColorBrush x:Key="PeatBrush" Color="{StaticResource PeatColor}" />
<SolidColorBrush x:Key="PeatSoftBrush" Color="{StaticResource PeatSoftColor}" />
<SolidColorBrush x:Key="BloodBrush" Color="{StaticResource BloodColor}" />
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="AccentDimBrush" Color="{StaticResource AccentDimColor}" />
<SolidColorBrush x:Key="AccentSoftBrush" Color="{StaticResource AccentSoftColor}" />
<SolidColorBrush x:Key="AccentGlowBrush" Color="{StaticResource AccentGlowColor}" />
<SolidColorBrush x:Key="StatusRunningBrush" Color="{StaticResource StatusRunningColor}" />
<SolidColorBrush x:Key="StatusReviewBrush" Color="{StaticResource StatusReviewColor}" />
<SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" />
<SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" />
<SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" />
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#FF05070A" />
<GradientStop Offset="0.5" Color="#FF0A0D10" />
<GradientStop Offset="1" Color="#FF060A08" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="IslandBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="{StaticResource SurfaceColor}" />
<GradientStop Offset="1" Color="#FF131917" />
</LinearGradientBrush>
<!-- ============================================================ -->
<!-- SPACING -->
<!-- ============================================================ -->
<x:Double x:Key="SpaceXs">4</x:Double>
<x:Double x:Key="SpaceSm">8</x:Double>
<x:Double x:Key="SpaceMd">12</x:Double>
<x:Double x:Key="SpaceLg">14</x:Double> <!-- island gap -->
<x:Double x:Key="SpaceXl">18</x:Double> <!-- island interior padding -->
<x:Double x:Key="Space2Xl">24</x:Double>
<Thickness x:Key="IslandGapMargin">7</Thickness> <!-- half of 14 on each side -->
<Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness>
<Thickness x:Key="IslandBodyPadding">14</Thickness>
<Thickness x:Key="WindowBodyPadding">14</Thickness>
<!-- ============================================================ -->
<!-- CORNERS & BORDERS -->
<!-- ============================================================ -->
<CornerRadius x:Key="IslandCornerRadius">14</CornerRadius>
<CornerRadius x:Key="ButtonCornerRadius">6</CornerRadius>
<CornerRadius x:Key="ChipCornerRadius">10</CornerRadius>
<CornerRadius x:Key="PillCornerRadius">999</CornerRadius>
<CornerRadius x:Key="InputCornerRadius">8</CornerRadius>
<CornerRadius x:Key="ModalCornerRadius">12</CornerRadius>
<!-- Radius doubles for use in numeric contexts -->
<x:Double x:Key="IslandRadius">14</x:Double>
<x:Double x:Key="ModalRadius">12</x:Double>
<x:Double x:Key="ChipRadius">10</x:Double>
<x:Double x:Key="RowRadius">8</x:Double>
<x:Double x:Key="ButtonRadius">6</x:Double>
<Thickness x:Key="HairlineBorder">1</Thickness>
<!-- ============================================================ -->
<!-- TYPOGRAPHY -->
<!-- ============================================================ -->
<!--
Pack these fonts with the app (use Avalonia's FontFamily='avares://...#Family Name' syntax).
Sans: Inter Tight (display) + Inter (body fallback)
Mono: JetBrains Mono
-->
<FontFamily x:Key="SansFont">avares://ClaudeDo.Ui/Assets/Fonts/#Inter Tight, Inter, Segoe UI, -apple-system, sans-serif</FontFamily>
<FontFamily x:Key="MonoFont">avares://ClaudeDo.Ui/Assets/Fonts/#JetBrains Mono, IBM Plex Mono, Cascadia Mono, Consolas, monospace</FontFamily>
<!-- Aliases matching plan-required key names -->
<FontFamily x:Key="SansFamily">avares://ClaudeDo.Ui/Assets/Fonts/#Inter Tight, Inter, Segoe UI, -apple-system, sans-serif</FontFamily>
<FontFamily x:Key="MonoFamily">avares://ClaudeDo.Ui/Assets/Fonts/#JetBrains Mono, IBM Plex Mono, Cascadia Mono, Consolas, monospace</FontFamily>
<!-- Type scale -->
<x:Double x:Key="FontSizeEyebrow">10</x:Double> <!-- uppercase label, 0.14em tracking -->
<x:Double x:Key="FontSizeMono">11</x:Double> <!-- chips, log lines, filepaths -->
<x:Double x:Key="FontSizeMicro">11</x:Double> <!-- meta rows -->
<x:Double x:Key="FontSizeBody">13</x:Double>
<x:Double x:Key="FontSizeTaskTitle">14</x:Double>
<x:Double x:Key="FontSizeH3">18</x:Double>
<x:Double x:Key="FontSizeH2">24</x:Double> <!-- island titles ("My Day") -->
<x:Double x:Key="FontSizeH1">32</x:Double>
<!-- Common text styles -->
<Style x:Key="EyebrowText" Selector="TextBlock.eyebrow">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeEyebrow}" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="LetterSpacing" Value="1.4" />
</Style>
<Style x:Key="MonoText" Selector="TextBlock.mono">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<Style x:Key="IslandTitle" Selector="TextBlock.island-title">
<Setter Property="FontFamily" Value="{StaticResource SansFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeH2}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<!-- ============================================================ -->
<!-- SHADOWS (use on Island Border via BoxShadow) -->
<!-- ============================================================ -->
<BoxShadows x:Key="IslandShadow">0 20 40 0 #59000000, 0 2 4 0 #4D000000</BoxShadows>
<BoxShadows x:Key="ModalShadow">0 40 80 0 #B2000000</BoxShadows>
<BoxShadows x:Key="SubtleShadow">0 2 4 0 #33000000</BoxShadows>
<!-- ============================================================ -->
<!-- MOTION -->
<!-- ============================================================ -->
<sys:TimeSpan x:Key="MotionFast">0:0:0.12</sys:TimeSpan>
<sys:TimeSpan x:Key="MotionBase">0:0:0.18</sys:TimeSpan>
<sys:TimeSpan x:Key="MotionSlow">0:0:0.30</sys:TimeSpan>
<!-- Standard easing: cubic-bezier(0.4, 0, 0.2, 1) — equivalent to Avalonia's CubicEaseOut for most UI -->
</ResourceDictionary>

View File

@@ -6,6 +6,7 @@ namespace ClaudeDo.Ui.Helpers;
public class StreamLineFormatter
{
private const int MaxLength = 50_000;
private const int MaxArgChars = 120;
public string? FormatLine(string line)
{
@@ -22,75 +23,295 @@ public class StreamLineFormatter
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
return null;
if (!root.TryGetProperty("type", out var typeProp))
return null;
var type = typeProp.GetString();
switch (type)
return typeProp.GetString() switch
{
case "stream_event":
return FormatStreamEvent(root);
case "result":
if (root.TryGetProperty("result", out var resultProp))
return $"\n--- Result ---\n{resultProp.GetString()}\n";
return null;
case "system":
if (root.TryGetProperty("subtype", out var subtypeProp) &&
subtypeProp.GetString() == "api_retry")
return "\n[Retrying API call...]\n";
return null;
default:
return null;
}
"system" => FormatSystem(root),
"assistant" => FormatAssistant(root),
"user" => FormatUser(root),
"result" => FormatResult(root),
_ => null,
};
}
}
private static string? FormatStreamEvent(JsonElement root)
private static string? FormatSystem(JsonElement root)
{
if (!root.TryGetProperty("event", out var ev))
return null;
if (!ev.TryGetProperty("type", out var evTypeProp))
if (!root.TryGetProperty("subtype", out var subtypeProp))
return null;
var evType = evTypeProp.GetString();
switch (evType)
var subtype = subtypeProp.GetString();
switch (subtype)
{
case "content_block_delta":
if (!ev.TryGetProperty("delta", out var delta))
return null;
if (!delta.TryGetProperty("type", out var deltaTypeProp))
return null;
var deltaType = deltaTypeProp.GetString();
if (deltaType == "text_delta")
case "api_retry":
return "[Retrying API call...]\n";
case "init":
{
var sessionId = root.TryGetProperty("session_id", out var sid)
? sid.GetString() : null;
var model = root.TryGetProperty("model", out var m)
? m.GetString() : null;
var shortId = sessionId is { Length: >= 8 }
? sessionId[..8]
: sessionId ?? "?";
var modelPart = string.IsNullOrEmpty(model) ? "" : $" · {model}";
return $"[session {shortId}{modelPart}]\n";
}
default:
return null;
}
}
private static string? FormatAssistant(JsonElement root)
{
if (!TryGetContentArray(root, out var content))
return null;
var sb = new StringBuilder();
foreach (var block in content.EnumerateArray())
{
if (block.ValueKind != JsonValueKind.Object) continue;
if (!block.TryGetProperty("type", out var blockTypeProp)) continue;
switch (blockTypeProp.GetString())
{
case "text":
if (block.TryGetProperty("text", out var textProp))
{
var text = textProp.GetString();
if (!string.IsNullOrEmpty(text))
{
sb.Append(text);
if (!text.EndsWith('\n')) sb.Append('\n');
}
}
break;
case "tool_use":
sb.Append(FormatToolUse(block));
sb.Append('\n');
break;
case "thinking":
default:
// Filtered.
break;
}
}
return sb.Length == 0 ? null : sb.ToString();
}
private static bool TryGetContentArray(JsonElement root, out JsonElement content)
{
content = default;
if (!root.TryGetProperty("message", out var message)) return false;
if (message.ValueKind != JsonValueKind.Object) return false;
if (!message.TryGetProperty("content", out var c)) return false;
if (c.ValueKind != JsonValueKind.Array) return false;
content = c;
return true;
}
private static string? FormatUser(JsonElement root)
{
if (!TryGetContentArray(root, out var content))
return null;
var sb = new StringBuilder();
foreach (var block in content.EnumerateArray())
{
if (block.ValueKind != JsonValueKind.Object) continue;
if (!block.TryGetProperty("type", out var blockTypeProp)) continue;
if (blockTypeProp.GetString() != "tool_result") continue;
var summary = BuildToolResultSummary(root, block);
if (!string.IsNullOrEmpty(summary))
{
sb.Append("→ ");
sb.Append(summary);
sb.Append('\n');
}
}
return sb.Length == 0 ? null : sb.ToString();
}
private static string BuildToolResultSummary(JsonElement root, JsonElement block)
{
var isError = block.TryGetProperty("is_error", out var errProp)
&& errProp.ValueKind == JsonValueKind.True;
var contentText = ResolveContentText(block);
if (isError)
{
var msg = FirstNonEmptyLine(contentText);
return string.IsNullOrEmpty(msg) ? "error" : $"error: {Truncate(msg, MaxArgChars)}";
}
// tool_use_result.file.numLines shortcut for Read-style results
if (root.TryGetProperty("tool_use_result", out var tur)
&& tur.ValueKind == JsonValueKind.Object
&& tur.TryGetProperty("file", out var file)
&& file.ValueKind == JsonValueKind.Object
&& file.TryGetProperty("numLines", out var nl)
&& nl.ValueKind == JsonValueKind.Number
&& nl.TryGetInt32(out var lines))
{
return $"{lines} lines";
}
if (string.IsNullOrWhiteSpace(contentText))
return "ok";
var first = FirstNonEmptyLine(contentText);
return Truncate(first, MaxArgChars);
}
private static string ResolveContentText(JsonElement block)
{
if (!block.TryGetProperty("content", out var c))
return "";
if (c.ValueKind == JsonValueKind.String)
return c.GetString() ?? "";
if (c.ValueKind == JsonValueKind.Array)
{
var sb = new StringBuilder();
foreach (var part in c.EnumerateArray())
{
if (part.ValueKind != JsonValueKind.Object) continue;
if (!part.TryGetProperty("type", out var pt)) continue;
if (pt.GetString() != "text") continue;
if (part.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String)
{
return delta.TryGetProperty("text", out var textProp)
? textProp.GetString()
: null;
if (sb.Length > 0) sb.Append('\n');
sb.Append(t.GetString());
}
return null; // input_json_delta and others → skip
}
return sb.ToString();
}
case "content_block_stop":
return "\n";
return "";
}
case "content_block_start":
if (!ev.TryGetProperty("content_block", out var cb))
return null;
if (cb.TryGetProperty("type", out var cbTypeProp) &&
cbTypeProp.GetString() == "tool_use" &&
cb.TryGetProperty("name", out var nameProp))
return $"\n[Tool: {nameProp.GetString()}]\n";
private static string FirstNonEmptyLine(string s)
{
if (string.IsNullOrEmpty(s)) return "";
foreach (var raw in s.Split('\n'))
{
var line = raw.TrimEnd('\r').Trim();
if (line.Length > 0) return line;
}
return "";
}
private static string? FormatResult(JsonElement root)
{
if (root.TryGetProperty("result", out var resultProp))
return $"\n--- Result ---\n{resultProp.GetString()}\n";
return null;
}
private static string FormatToolUse(JsonElement block)
{
var name = block.TryGetProperty("name", out var nameProp)
? nameProp.GetString() ?? "?"
: "?";
JsonElement input = default;
var hasInput = block.TryGetProperty("input", out input)
&& input.ValueKind == JsonValueKind.Object;
var label = name;
if (hasInput && (name == "Task" || name == "Agent"))
{
var sub = GetStr(input, "subagent_type");
if (!string.IsNullOrEmpty(sub))
label = $"{name}: {sub}";
}
string? arg = hasInput ? BuildToolArg(name, input) : null;
return string.IsNullOrEmpty(arg)
? $"[{label}]"
: $"[{label}] {arg}";
}
private static string? BuildToolArg(string toolName, JsonElement input)
{
switch (toolName)
{
case "Read":
case "Write":
case "Edit":
case "NotebookEdit":
return Basename(GetStr(input, "file_path"));
case "Bash":
case "PowerShell":
{
var cmd = GetStr(input, "command");
return string.IsNullOrEmpty(cmd) ? null : "$ " + Truncate(cmd, MaxArgChars);
}
case "Grep":
{
var p = GetStr(input, "pattern");
return string.IsNullOrEmpty(p) ? null : $"\"{Truncate(p, MaxArgChars)}\"";
}
case "Glob":
return Truncate(GetStr(input, "pattern"), MaxArgChars);
case "Task":
case "Agent":
return Truncate(GetStr(input, "description"), MaxArgChars);
case "WebFetch":
return Truncate(GetStr(input, "url"), MaxArgChars);
case "WebSearch":
{
var q = GetStr(input, "query");
return string.IsNullOrEmpty(q) ? null : $"\"{Truncate(q, MaxArgChars)}\"";
}
case "TodoWrite":
return null;
default:
return null; // message_start, message_delta, etc.
return null;
}
}
private static string? GetStr(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String
? p.GetString()
: null;
private static string Basename(string? path)
{
if (string.IsNullOrEmpty(path)) return "";
var i = path.LastIndexOfAny(new[] { '/', '\\' });
return i < 0 ? path : path[(i + 1)..];
}
private static string Truncate(string? s, int max)
{
if (string.IsNullOrEmpty(s)) return "";
return s.Length <= max ? s : s[..max] + "…";
}
public string FormatFile(string filePath)
{
var sb = new StringBuilder();

View File

@@ -169,6 +169,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("RunNow", taskId);
}
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
{
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
}
public async Task ResetTaskAsync(string taskId)
{
await _hub.InvokeAsync("ResetTask", taskId);
}
public async Task CancelTaskAsync(string taskId)
{
await _hub.InvokeAsync("CancelTask", taskId);
@@ -226,6 +236,47 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.DisposeAsync();
}
public async Task<AppSettingsDto?> GetAppSettingsAsync()
{
try
{
return await _hub.InvokeAsync<AppSettingsDto>("GetAppSettings");
}
catch
{
return null;
}
}
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
{
await _hub.InvokeAsync("UpdateAppSettings", dto);
}
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
{
try
{
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees");
}
catch
{
return null;
}
}
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
{
try
{
return await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
}
catch
{
return null;
}
}
// DTOs for deserializing hub responses
private sealed class ActiveTaskDto
{
@@ -234,3 +285,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public DateTime StartedAt { get; set; }
}
}
public sealed record AppSettingsDto(
string DefaultClaudeInstructions,
string DefaultModel,
int DefaultMaxTurns,
string DefaultPermissionMode,
string WorktreeStrategy,
string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled,
int WorktreeAutoCleanupDays);
public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);

View File

@@ -0,0 +1,448 @@
using System.Collections.ObjectModel;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class DetailsIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly IServiceProvider _services;
// Current task row (set by IslandsShellViewModel via Bind)
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
private TaskRowViewModel? _task;
// Editable fields
[ObservableProperty] private string _editableTitle = "";
[ObservableProperty] private string _notes = "";
[ObservableProperty] private string _promptInput = "";
// Short task-id badge, e.g. "#T1A"
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
// Agent strip fields
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RunNowCommand))]
private string _agentStatusLabel = "Idle";
public bool IsRunning => AgentStatusLabel == "Running";
public bool IsDone => AgentStatusLabel == "Done";
public bool IsFailed => AgentStatusLabel == "Failed";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
private bool _showFailedActions;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string? _latestRunSessionId;
partial void OnAgentStatusLabelChanged(string value)
{
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsFailed));
ShowFailedActions = value == "Failed";
}
[ObservableProperty] private string? _model;
[ObservableProperty] private string? _worktreePath;
[ObservableProperty] private string? _worktreeBaseCommit;
[ObservableProperty] private string? _branchLine;
[ObservableProperty] private int _turns;
[ObservableProperty] private int _tokens;
[ObservableProperty] private int _diffAdditions;
[ObservableProperty] private int _diffDeletions;
[ObservableProperty] private int _commitsOnBranch;
public string TokensFormatted => Tokens >= 1000 ? $"{Tokens / 1000.0:F1}k" : Tokens.ToString();
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
partial void OnDiffAdditionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
partial void OnDiffDeletionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
// 0.01.0 additions share for the diff meter
public double DiffMeterRatio
{
get
{
var total = DiffAdditions + DiffDeletions;
return total == 0 ? 0.0 : (double)DiffAdditions / total;
}
}
public ObservableCollection<LogLineViewModel> Log { get; } = new();
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
// Claude CLI stream-json parser + buffer for partial text deltas
private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _claudeBuf = new();
// The task ID we are currently subscribed to for live log messages
private string? _subscribedTaskId;
private CancellationTokenSource? _loadCts;
// Set by shell so CloseDetailCommand can clear SelectedTask
public Action? CloseDetail { get; set; }
// Set by shell so DeleteTaskCommand can remove from list
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
// Set by the view so OpenDiffCommand can show the modal as a dialog
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
// Set by the view so OpenWorktreeCommand can show the modal as a dialog
public Func<WorktreeModalViewModel, System.Threading.Tasks.Task>? ShowWorktreeModal { get; set; }
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
{
_dbFactory = dbFactory;
_worker = worker;
_services = services;
// Subscribe once; filter by current task id inside the handler
_worker.TaskMessageEvent += OnTaskMessage;
// Re-evaluate CanExecute when worker connection flips.
_worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(WorkerClient.IsConnected))
{
RunNowCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged();
}
};
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
_worker.TaskStartedEvent += (slot, taskId, startedAt) =>
{
if (Task?.Id == taskId) AgentStatusLabel = "Running";
};
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) =>
{
if (Task?.Id != taskId) return;
FlushClaudeBuffer();
Log.Add(new LogLineViewModel
{
Kind = LogKind.Done,
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
});
AgentStatusLabel = status;
// Re-query to pick up worktree created during the run.
_ = RefreshWorktreeAsync(taskId);
};
_worker.WorktreeUpdatedEvent += taskId =>
{
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
};
}
private void OnTaskMessage(string taskId, string line)
{
if (taskId != _subscribedTaskId) return;
// `[stdout] ...json...` lines are Claude CLI stream-json; parse through the
// formatter so the user sees human text, not raw JSON.
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
{
var body = line["[stdout]".Length..].TrimStart();
var formatted = _formatter.FormatLine(body);
if (formatted is null) return; // filter noise (message_start, etc.)
AppendClaudeText(formatted);
return;
}
// Non-stdout tagged lines: flush any buffered text then classify by prefix.
FlushClaudeBuffer();
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
: line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr
: line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done
: LogKind.Msg;
Log.Add(new LogLineViewModel { Kind = kind, Text = line });
}
private void AppendClaudeText(string chunk)
{
_claudeBuf.Append(chunk);
// Emit a log entry for every completed line; keep the trailing remainder buffered.
while (true)
{
var text = _claudeBuf.ToString();
var nl = text.IndexOf('\n');
if (nl < 0) break;
var piece = text[..nl].TrimEnd('\r');
if (!string.IsNullOrWhiteSpace(piece))
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
_claudeBuf.Clear();
_claudeBuf.Append(text[(nl + 1)..]);
}
}
private void FlushClaudeBuffer()
{
if (_claudeBuf.Length == 0) return;
var piece = _claudeBuf.ToString().TrimEnd();
_claudeBuf.Clear();
if (!string.IsNullOrWhiteSpace(piece))
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
}
public void Bind(TaskRowViewModel? row)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
Task = row;
OnPropertyChanged(nameof(TaskIdBadge));
Log.Clear();
Subtasks.Clear();
_claudeBuf.Clear();
if (row == null)
{
_subscribedTaskId = null;
EditableTitle = "";
Notes = "";
Model = null;
WorktreePath = null;
BranchLine = null;
AgentStatusLabel = "Idle";
LatestRunSessionId = null;
ShowFailedActions = false;
return;
}
_ = BindAsync(row, ct);
}
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var subtaskRepo = new SubtaskRepository(ctx);
// Own query with Include so WorktreePath/BranchLine are populated.
var entity = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
ct.ThrowIfCancellationRequested();
if (entity == null) return;
EditableTitle = entity.Title;
Notes = entity.Notes ?? "";
Model = entity.Model;
WorktreePath = entity.Worktree?.Path;
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
AgentStatusLabel = entity.Status.ToString();
var runRepo = new TaskRunRepository(ctx);
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
ct.ThrowIfCancellationRequested();
LatestRunSessionId = latestRun?.SessionId;
// Subscribe only after DB load confirms the task exists
_subscribedTaskId = row.Id;
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
ct.ThrowIfCancellationRequested();
foreach (var s in subs)
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
}
catch (OperationCanceledException) { }
}
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
if (entity == null || Task?.Id != taskId) return;
WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
AgentStatusLabel = entity.Status.ToString();
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
row.DiffStat = stat;
}
catch { /* best-effort refresh */ }
}
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync()
{
if (WorktreePath == null || ShowDiffModal == null) return;
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
{
WorktreePath = WorktreePath,
BaseRef = WorktreeBaseCommit,
};
await diffVm.LoadAsync();
await ShowDiffModal(diffVm);
}
private bool CanOpenDiff() => WorktreePath != null;
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
private void OpenWorktree()
{
if (WorktreePath == null) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = WorktreePath,
UseShellExecute = true,
});
}
catch { /* explorer open is best-effort */ }
}
private bool CanOpenWorktree() => WorktreePath != null;
partial void OnWorktreePathChanged(string? value)
{
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
private async System.Threading.Tasks.Task SendPromptAsync()
{
if (string.IsNullOrWhiteSpace(PromptInput) || Task == null) return;
Log.Add(new LogLineViewModel { Kind = LogKind.Msg, Text = $"[you] {PromptInput}" });
// TODO: WorkerClient has no SendPromptAsync — no matching hub method found.
// When the worker gains a "SendPrompt" hub method, call:
// await _worker.SendPromptAsync(Task.Id, PromptInput);
PromptInput = "";
await System.Threading.Tasks.Task.CompletedTask;
}
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
[RelayCommand]
private async System.Threading.Tasks.Task DeleteTaskAsync()
{
if (Task == null) return;
var row = Task;
if (ConfirmAsync != null)
{
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
if (!ok) return;
}
await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
await repo.DeleteAsync(row.Id);
if (DeleteFromList != null)
await DeleteFromList(row);
CloseDetail?.Invoke();
}
[RelayCommand]
private async System.Threading.Tasks.Task SaveNotesAsync()
{
if (Task == null) return;
await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
var entity = await repo.GetByIdAsync(Task.Id);
if (entity == null) return;
entity.Notes = Notes;
await repo.UpdateAsync(entity);
}
[RelayCommand]
private async System.Threading.Tasks.Task ApproveMergeAsync()
{
if (Task == null) return;
// TODO: call worker merge hub method when available
await System.Threading.Tasks.Task.CompletedTask;
}
[RelayCommand]
private async System.Threading.Tasks.Task StopAsync()
{
if (Task == null) return;
await _worker.CancelTaskAsync(Task.Id);
}
[RelayCommand(CanExecute = nameof(CanRunNow))]
private async System.Threading.Tasks.Task RunNowAsync()
{
if (Task == null) return;
AgentStatusLabel = "Running";
try
{
await _worker.RunNowAsync(Task.Id);
}
catch
{
AgentStatusLabel = "Failed";
throw;
}
}
private bool CanRunNow() =>
Task != null && _worker.IsConnected && !IsRunning;
[RelayCommand(CanExecute = nameof(CanContinue))]
private async System.Threading.Tasks.Task ContinueAsync()
{
if (Task == null) return;
await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task.");
}
private bool CanContinue() =>
Task != null && _worker.IsConnected && ShowFailedActions && !string.IsNullOrEmpty(LatestRunSessionId);
[RelayCommand(CanExecute = nameof(CanReset))]
private async System.Threading.Tasks.Task ResetAsync()
{
if (Task == null) return;
if (ConfirmAsync == null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync($"Discard worktree and reset task?\nThis deletes branch {branchName} and all uncommitted changes.");
if (!ok) return;
await _worker.ResetTaskAsync(Task.Id);
}
private bool CanReset() =>
Task != null && _worker.IsConnected && ShowFailedActions;
}
public sealed partial class SubtaskRowViewModel : ViewModelBase
{
public required string Id { get; init; }
[ObservableProperty] private string _title = "";
[ObservableProperty] private bool _done;
}

View File

@@ -0,0 +1,14 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class ListNavItemViewModel : ViewModelBase
{
public required string Id { get; init; }
public required string Name { get; init; }
public required ListKind Kind { get; init; }
[ObservableProperty] private int _count;
[ObservableProperty] private bool _isActive;
public string? IconKey { get; init; }
public string? DotColorKey { get; init; }
}

View File

@@ -0,0 +1,112 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User }
public sealed partial class ListsIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services;
public event EventHandler? SelectionChanged;
public event EventHandler? FocusSearchRequested;
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
[RelayCommand]
private async Task OpenSettings()
{
if (ShowSettingsModal is null || _services is null) return;
var settingsVm = _services.GetRequiredService<SettingsModalViewModel>();
await settingsVm.LoadAsync();
await ShowSettingsModal(settingsVm);
}
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private ListNavItemViewModel? _selectedList;
public string UserName { get; } = Environment.UserName;
public string MachineName { get; } = Environment.MachineName;
public string UserInitials { get; }
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null)
{
_dbFactory = dbFactory;
_services = services;
var parts = Environment.UserName.Split('.', '_', '-', ' ');
UserInitials = parts.Length >= 2
? $"{parts[0][0]}{parts[1][0]}".ToUpperInvariant()
: Environment.UserName.Length >= 2
? Environment.UserName[..2].ToUpperInvariant()
: Environment.UserName.ToUpperInvariant();
}
public async Task LoadAsync(CancellationToken ct = default)
{
Items.Clear();
SmartLists.Clear();
UserLists.Clear();
var smart = new[]
{
new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" },
};
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
var dotColors = new[] { "Moss", "Peat", "Sage" };
int idx = 0;
foreach (var l in await lists.GetAllAsync(ct))
{
if (seedNames.Contains(l.Name)) continue;
var item = new ListNavItemViewModel
{
Id = $"user:{l.Id}",
Name = l.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = dotColors[idx % dotColors.Length],
};
Items.Add(item);
UserLists.Add(item);
idx++;
}
await RefreshCountsAsync(ct);
SelectedList = Items.FirstOrDefault();
}
public async Task RefreshCountsAsync(CancellationToken ct = default)
{
foreach (var i in Items) i.Count = 0;
await Task.CompletedTask;
}
[RelayCommand]
private void Select(ListNavItemViewModel item) => SelectedList = item;
partial void OnSelectedListChanged(ListNavItemViewModel? value)
{
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -0,0 +1,32 @@
namespace ClaudeDo.Ui.ViewModels.Islands;
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }
public sealed class LogLineViewModel
{
public required LogKind Kind { get; init; }
public required string Text { get; init; }
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
public string KindMarker => Kind switch
{
LogKind.Sys => "sys",
LogKind.Tool => "tool",
LogKind.Claude => "claude",
LogKind.Stdout => "out",
LogKind.Stderr => "err",
LogKind.Done => "done",
LogKind.Msg => "claude",
_ => "",
};
public string ClassName => Kind switch
{
LogKind.Sys => "log-sys",
LogKind.Tool => "log-tool",
LogKind.Claude => "log-claude",
LogKind.Stdout => "log-stdout",
LogKind.Stderr => "log-stderr",
LogKind.Done => "log-done",
LogKind.Msg => "log-msg",
_ => "",
};
}

View File

@@ -0,0 +1,101 @@
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TaskRowViewModel : ViewModelBase
{
public required string Id { get; init; }
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _listName = "";
[ObservableProperty] private bool _done;
[ObservableProperty] private bool _isStarred;
[ObservableProperty] private bool _isMyDay;
[ObservableProperty] private bool _isSelected;
[ObservableProperty] private TaskStatus _status;
[ObservableProperty] private string? _branch;
[ObservableProperty] private string? _diffStat;
[ObservableProperty] private string? _liveTail;
[ObservableProperty] private DateTime? _scheduledFor;
[ObservableProperty] private int _diffAdditions;
[ObservableProperty] private int _diffDeletions;
public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
public int StepsCount { get; init; }
public int StepsCompleted { get; init; }
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
public bool HasTags => Tags.Count > 0;
public bool HasSteps => StepsCount > 0;
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
public bool IsRunning => Status == TaskStatus.Running;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
public string DiffAdditionsText => $"+{DiffAdditions}";
public string DiffDeletionsText => $"{DiffDeletions}";
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
public string StatusChipClass => Status switch
{
TaskStatus.Running => "running",
TaskStatus.Failed => "error",
TaskStatus.Done => "review",
TaskStatus.Queued => "queued",
_ => "idle",
};
partial void OnStatusChanged(TaskStatus value)
{
OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(HasLiveTail));
}
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
partial void OnScheduledForChanged(DateTime? value) => OnPropertyChanged(nameof(IsOverdue));
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
public static TaskRowViewModel FromEntity(TaskEntity t)
{
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
return new TaskRowViewModel
{
Id = t.Id,
Title = t.Title,
ListName = t.List?.Name ?? "",
Done = t.Status == TaskStatus.Done,
IsStarred = t.IsStarred,
IsMyDay = t.IsMyDay,
Status = t.Status,
Branch = t.Worktree?.BranchName,
DiffStat = t.Worktree?.DiffStat,
ScheduledFor = t.ScheduledFor,
DiffAdditions = add,
DiffDeletions = del,
CreatedAt = t.CreatedAt,
};
}
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
private static (int add, int del) ParseDiffStat(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return (0, 0);
int add = 0, del = 0;
var parts = s.Split(new[] { ' ', ',', '\t' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var p in parts)
{
if (p.Length > 1 && p[0] == '+' && int.TryParse(p.AsSpan(1), out var a)) add = a;
else if (p.Length > 1 && (p[0] == '-' || p[0] == '\u2212') && int.TryParse(p.AsSpan(1), out var d)) del = d;
}
return (add, del);
}
}

View File

@@ -0,0 +1,215 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TasksIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts;
public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested;
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
[ObservableProperty] private string _newTaskTitle = "";
[ObservableProperty] private TaskRowViewModel? _selectedTask;
[ObservableProperty] private string _headerTitle = "";
[ObservableProperty] private string _headerEyebrow = "";
[ObservableProperty] private string _subtitle = "";
[ObservableProperty] private string _statusPill = "";
[ObservableProperty] private bool _hasStatusPill;
[ObservableProperty] private bool _isShowingCompleted = true;
[ObservableProperty] private bool _hasOverdue;
[ObservableProperty] private bool _hasOpen;
[ObservableProperty] private bool _hasCompleted;
[ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = "COMPLETED";
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public void LoadForList(ListNavItemViewModel? list)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
_currentList = list;
Items.Clear();
OverdueItems.Clear();
OpenItems.Clear();
CompletedItems.Clear();
HasOverdue = false;
HasOpen = false;
HasCompleted = false;
ShowOpenLabel = false;
if (list is null) return;
HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
_ = LoadForListAsync(list, ct);
}
private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct)
{
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var all = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
IEnumerable<TaskEntity> filtered = list.Kind switch
{
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
_ => Enumerable.Empty<TaskEntity>(),
};
foreach (var t in filtered)
Items.Add(TaskRowViewModel.FromEntity(t));
Regroup();
UpdateSubtitle();
}
catch (OperationCanceledException) { }
}
private void Regroup()
{
OverdueItems.Clear();
OpenItems.Clear();
CompletedItems.Clear();
var today = DateTime.Today;
foreach (var r in Items)
{
if (r.Done)
CompletedItems.Add(r);
else if (r.ScheduledFor is { } d && d.Date < today)
OverdueItems.Add(r);
else
OpenItems.Add(r);
}
HasOverdue = OverdueItems.Count > 0;
HasOpen = OpenItems.Count > 0;
HasCompleted = CompletedItems.Count > 0;
ShowOpenLabel = HasOpen && HasOverdue;
CompletedHeader = $"COMPLETED · {CompletedItems.Count}";
}
private void UpdateSubtitle()
{
var now = DateTime.Now;
var open = Items.Count(i => !i.Done);
var running = Items.Count(i => i.Status == TaskStatus.Running);
var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null);
Subtitle = open == 1 ? "1 open task" : $"{open} open tasks";
if (running > 0 || review > 0)
{
StatusPill = $"{running} running · {review} review";
HasStatusPill = true;
}
else
{
StatusPill = "";
HasStatusPill = false;
}
}
[RelayCommand]
private async Task AddAsync()
{
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
var listId = _currentList.Id["user:".Length..];
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString("N"),
ListId = listId,
Title = NewTaskTitle.Trim(),
CreatedAt = DateTime.UtcNow,
};
await using var db = await _dbFactory.CreateDbContextAsync();
db.Tasks.Add(entity);
await db.SaveChangesAsync();
var row = TaskRowViewModel.FromEntity(entity);
Items.Insert(0, row);
Regroup();
NewTaskTitle = "";
UpdateSubtitle();
}
[RelayCommand]
private async Task ToggleDoneAsync(TaskRowViewModel row)
{
row.Done = !row.Done;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Manual;
row.Status = entity.Status;
await db.SaveChangesAsync();
}
Regroup();
UpdateSubtitle();
}
[RelayCommand]
private async Task ToggleStarAsync(TaskRowViewModel row)
{
row.IsStarred = !row.IsStarred;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.IsStarred = row.IsStarred;
await db.SaveChangesAsync();
}
}
[RelayCommand]
private void Select(TaskRowViewModel row) => SelectedTask = row;
[RelayCommand]
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
[RelayCommand]
private void Sort() { /* placeholder — UI-only */ }
[RelayCommand]
private void More() { /* placeholder — UI-only */ }
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
{
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -0,0 +1,71 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.ViewModels;
public sealed partial class IslandsShellViewModel : ViewModelBase
{
public ListsIslandViewModel Lists { get; }
public TasksIslandViewModel Tasks { get; }
public DetailsIslandViewModel Details { get; }
public WorkerClient Worker { get; }
public string ConnectionText =>
Worker.IsConnected ? "Online"
: Worker.IsReconnecting ? "Connecting…"
: "Offline";
public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting;
[ObservableProperty]
private double _windowWidth = 1280;
public bool ShowDetails => WindowWidth >= 1100;
public bool ShowLists => WindowWidth >= 780;
[RelayCommand]
private void FocusSearch() => Lists.RequestFocusSearch();
[RelayCommand]
private void FocusAddTask() => Tasks.RequestFocusAddTask();
public async Task ToggleSelectedDoneAsync()
{
if (Tasks.SelectedTask is { } row)
await Tasks.ToggleDoneCommand.ExecuteAsync(row);
}
partial void OnWindowWidthChanged(double value)
{
OnPropertyChanged(nameof(ShowDetails));
OnPropertyChanged(nameof(ShowLists));
}
public IslandsShellViewModel(
ListsIslandViewModel lists,
TasksIslandViewModel tasks,
DetailsIslandViewModel details,
WorkerClient worker)
{
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.DeleteFromList = _ =>
{
Tasks.LoadForList(Lists.SelectedList);
return System.Threading.Tasks.Task.CompletedTask;
};
Worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
{
OnPropertyChanged(nameof(ConnectionText));
OnPropertyChanged(nameof(IsOffline));
}
};
_ = Lists.LoadAsync();
}
}

View File

@@ -1,125 +0,0 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AgentInfo = ClaudeDo.Data.Models.AgentInfo;
namespace ClaudeDo.Ui.ViewModels;
public partial class ListEditorViewModel : ViewModelBase
{
[ObservableProperty] private string _name = "";
[ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType = "chore";
[ObservableProperty] private string _windowTitle = "New List";
// Config fields
[ObservableProperty] private string _model = "Sonnet";
[ObservableProperty] private string? _systemPrompt;
[ObservableProperty] private AgentInfo? _selectedAgent;
private string? _editId;
private DateTime _createdAt;
private TaskCompletionSource<ListEntity?> _tcs = new();
public event Action? RequestClose;
public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
public static string[] ModelDisplayNames { get; } = ["Sonnet", "Opus", "Haiku"];
private static readonly Dictionary<string, string> ModelToId = new()
{
["Sonnet"] = "claude-sonnet-4-6",
["Opus"] = "claude-opus-4-6",
["Haiku"] = "claude-haiku-4-5",
};
private static readonly Dictionary<string, string> IdToModel =
ModelToId.ToDictionary(kv => kv.Value, kv => kv.Key);
public static string ModelIdToDisplay(string? modelId) =>
modelId is not null && IdToModel.TryGetValue(modelId, out var display) ? display : "Sonnet";
public static string? ModelDisplayToId(string display) =>
ModelToId.TryGetValue(display, out var id) ? id : null;
public List<AgentInfo> AvailableAgents { get; set; } = [];
public async Task LoadAgentsAsync(WorkerClient worker)
{
AvailableAgents = await worker.GetAgentsAsync();
}
public void InitForCreate()
{
_tcs = new TaskCompletionSource<ListEntity?>();
_editId = null;
_createdAt = DateTime.UtcNow;
WindowTitle = "New List";
}
public void InitForEdit(ListEntity entity, ListConfigEntity? config)
{
_tcs = new TaskCompletionSource<ListEntity?>();
_editId = entity.Id;
_createdAt = entity.CreatedAt;
Name = entity.Name;
WorkingDir = entity.WorkingDir;
DefaultCommitType = entity.DefaultCommitType;
WindowTitle = $"Edit List: {entity.Name}";
if (config is not null)
{
Model = ModelIdToDisplay(config.Model);
SystemPrompt = config.SystemPrompt;
SelectedAgent = AvailableAgents.FirstOrDefault(a => a.Path == config.AgentPath);
}
}
public ListConfigEntity? BuildConfig(string listId)
{
var modelId = ModelDisplayToId(Model);
if (modelId is null && SystemPrompt is null && SelectedAgent is null)
return null;
return new ListConfigEntity
{
ListId = listId,
Model = modelId,
SystemPrompt = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt.Trim(),
AgentPath = SelectedAgent?.Path,
};
}
[RelayCommand]
private void Save()
{
if (string.IsNullOrWhiteSpace(Name)) return;
var entity = new ListEntity
{
Id = _editId ?? Guid.NewGuid().ToString(),
Name = Name.Trim(),
CreatedAt = _createdAt,
WorkingDir = string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir.Trim(),
DefaultCommitType = DefaultCommitType,
};
_tcs.TrySetResult(entity);
RequestClose?.Invoke();
}
[RelayCommand]
private void Cancel()
{
_tcs.TrySetResult(null);
RequestClose?.Invoke();
}
public void OnWindowClosed()
{
_tcs.TrySetResult(null);
}
public Task<ListEntity?> ShowAndWaitAsync() => _tcs.Task;
}

View File

@@ -1,43 +0,0 @@
using System;
using Avalonia.Media;
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels;
public partial class ListItemViewModel : ViewModelBase
{
[ObservableProperty] private string _name;
[ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType;
private static readonly IBrush[] DotPalette =
[
new SolidColorBrush(Color.Parse("#3d9474")), // green
new SolidColorBrush(Color.Parse("#5571a1")), // blue
new SolidColorBrush(Color.Parse("#d4964a")), // amber
new SolidColorBrush(Color.Parse("#7c6aad")), // purple
new SolidColorBrush(Color.Parse("#c25d6a")), // rose
];
public IBrush DotBrush => DotPalette[Math.Abs(Id.GetHashCode()) % DotPalette.Length];
public string Id { get; }
public ListItemViewModel(ListEntity entity)
{
Id = entity.Id;
_name = entity.Name;
_workingDir = entity.WorkingDir;
_defaultCommitType = entity.DefaultCommitType;
}
public ListEntity ToEntity() => new()
{
Id = Id,
Name = Name,
CreatedAt = DateTime.MinValue, // not used for update
WorkingDir = string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType = DefaultCommitType,
};
}

View File

@@ -1,224 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Views;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public partial class MainWindowViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly Func<ListEditorViewModel> _listEditorFactory;
public ObservableCollection<ListItemViewModel> Lists { get; } = new();
[ObservableProperty] private ListItemViewModel? _selectedList;
public TaskListViewModel TaskList { get; }
public TaskDetailViewModel TaskDetail { get; }
public StatusBarViewModel StatusBar { get; }
private readonly Action<string> _onTaskChanged;
public MainWindowViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerClient worker,
TaskListViewModel taskList,
TaskDetailViewModel taskDetail,
StatusBarViewModel statusBar,
Func<ListEditorViewModel> listEditorFactory)
{
_dbFactory = dbFactory;
_worker = worker;
_listEditorFactory = listEditorFactory;
TaskList = taskList;
TaskDetail = taskDetail;
StatusBar = statusBar;
_onTaskChanged = taskId => _ = TaskList.RefreshSingleAsync(taskId);
TaskList.SelectedTaskChanged += OnSelectedTaskChanged;
TaskDetail.TaskChanged += _onTaskChanged;
}
public void Dispose()
{
TaskList.SelectedTaskChanged -= OnSelectedTaskChanged;
TaskDetail.TaskChanged -= _onTaskChanged;
}
public async Task InitializeAsync()
{
try
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
var lists = await listRepo.GetAllAsync();
foreach (var l in lists)
Lists.Add(new ListItemViewModel(l));
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error loading lists: {ex.Message}");
}
_ = _worker.StartAsync().ContinueWith(t =>
{
if (t.IsFaulted)
System.Diagnostics.Debug.WriteLine($"Worker connection failed: {t.Exception?.Message}");
}, TaskScheduler.Default);
}
partial void OnSelectedListChanged(ListItemViewModel? value)
{
_ = TaskList.LoadAsync(value?.Id);
TaskDetail.Clear();
}
private async void OnSelectedTaskChanged(TaskItemViewModel? task)
{
if (task is null)
TaskDetail.Clear();
else
await TaskDetail.LoadAsync(task.Id);
}
[RelayCommand]
private async Task AddList()
{
var editor = _listEditorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate();
var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync();
if (entity is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.AddAsync(entity);
var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null)
await listRepo.SetConfigAsync(configEntity);
Lists.Add(new ListItemViewModel(entity));
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error creating list: {ex.Message}");
}
}
[RelayCommand]
private async Task EditList()
{
if (SelectedList is null) return;
ListEntity? existing;
ListConfigEntity? config;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
existing = await listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return;
config = await listRepo.GetConfigAsync(existing.Id);
}
var editor = _listEditorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(existing, config);
var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync();
if (entity is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.UpdateAsync(entity);
var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null)
await listRepo.SetConfigAsync(configEntity);
SelectedList.Name = entity.Name;
SelectedList.WorkingDir = entity.WorkingDir;
SelectedList.DefaultCommitType = entity.DefaultCommitType;
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error updating list: {ex.Message}");
}
}
[ObservableProperty] private bool _isDeleteConfirmVisible;
private ListItemViewModel? _pendingDeleteList;
[RelayCommand]
private void DeleteList()
{
if (SelectedList is null) return;
_pendingDeleteList = SelectedList;
IsDeleteConfirmVisible = true;
}
[RelayCommand]
private async Task ConfirmDeleteList()
{
IsDeleteConfirmVisible = false;
if (_pendingDeleteList is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.DeleteAsync(_pendingDeleteList.Id);
Lists.Remove(_pendingDeleteList);
if (SelectedList == _pendingDeleteList)
SelectedList = null;
}
catch (Exception ex)
{
StatusBar.ShowMessage($"Error deleting list: {ex.Message}");
}
finally
{
_pendingDeleteList = null;
}
}
[RelayCommand]
private void CancelDeleteList()
{
IsDeleteConfirmVisible = false;
_pendingDeleteList = null;
}
private static async Task ShowDialogAsync(Window dialog)
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
&& desktop.MainWindow is not null)
{
await dialog.ShowDialog(desktop.MainWindow);
}
else
{
dialog.Show();
}
}
}

View File

@@ -0,0 +1,160 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
namespace ClaudeDo.Ui.ViewModels.Modals;
public enum DiffLineKind { Add, Del, Ctx }
public sealed class DiffLineViewModel
{
public required DiffLineKind Kind { get; init; }
public int? OldNo { get; init; }
public int? NewNo { get; init; }
public required string Text { get; init; }
public string ClassName => Kind switch
{
DiffLineKind.Add => "add",
DiffLineKind.Del => "del",
_ => "ctx",
};
public string Sign => Kind switch
{
DiffLineKind.Add => "+",
DiffLineKind.Del => "-",
_ => " ",
};
}
public sealed class DiffFileViewModel
{
public required string Path { get; init; }
public int Additions { get; init; }
public int Deletions { get; init; }
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
}
public sealed partial class DiffModalViewModel : ViewModelBase
{
private readonly GitService _git;
public required string WorktreePath { get; init; }
public string? BaseRef { get; init; }
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
[ObservableProperty] private DiffFileViewModel? _selectedFile;
// Injected action to close the owning Window
public Action? CloseAction { get; set; }
public DiffModalViewModel(GitService git)
{
_git = git;
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
public async Task LoadAsync(CancellationToken ct = default)
{
Files.Clear();
string raw;
try
{
raw = BaseRef is not null
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
: await _git.GetDiffAsync(WorktreePath, ct);
}
catch { return; }
if (string.IsNullOrWhiteSpace(raw)) return;
// Parse unified diff — state machine over lines
DiffFileViewModel? current = null;
int oldLine = 0, newLine = 0;
foreach (var line in raw.Split('\n'))
{
if (line.StartsWith("diff --git ", StringComparison.Ordinal))
{
// e.g. "diff --git a/src/Foo.cs b/src/Foo.cs"
var parts = line.Split(' ');
var path = parts.Length >= 4 ? parts[3][2..] : line;
current = new DiffFileViewModel { Path = path };
Files.Add(current);
oldLine = 0; newLine = 0;
continue;
}
if (current == null) continue;
if (line.StartsWith("@@ ", StringComparison.Ordinal))
{
// e.g. "@@ -10,7 +10,9 @@"
ParseHunkHeader(line, out oldLine, out newLine);
continue;
}
// Skip diff metadata lines
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
line.StartsWith("index ", StringComparison.Ordinal) ||
line.StartsWith("new file", StringComparison.Ordinal) ||
line.StartsWith("deleted file", StringComparison.Ordinal) ||
line.StartsWith("Binary ", StringComparison.Ordinal))
continue;
if (line.StartsWith('+'))
{
current.Lines.Add(new DiffLineViewModel
{
Kind = DiffLineKind.Add,
NewNo = newLine++,
Text = line.Length > 1 ? line[1..] : "",
});
// Count additions on the file VM
}
else if (line.StartsWith('-'))
{
current.Lines.Add(new DiffLineViewModel
{
Kind = DiffLineKind.Del,
OldNo = oldLine++,
Text = line.Length > 1 ? line[1..] : "",
});
}
else if (line.StartsWith(' '))
{
current.Lines.Add(new DiffLineViewModel
{
Kind = DiffLineKind.Ctx,
OldNo = oldLine++,
NewNo = newLine++,
Text = line.Length > 1 ? line[1..] : "",
});
}
}
SelectedFile = Files.Count > 0 ? Files[0] : null;
}
private static void ParseHunkHeader(string header, out int oldStart, out int newStart)
{
oldStart = 1; newStart = 1;
// Format: @@ -<old>,<count> +<new>,<count> @@
var at = header.IndexOf("@@", 3, StringComparison.Ordinal);
var inner = at > 0 ? header[3..at].Trim() : header;
var segs = inner.Split(' ');
foreach (var seg in segs)
{
if (seg.StartsWith('-') && int.TryParse(seg[1..].Split(',')[0], out var o))
oldStart = o;
else if (seg.StartsWith('+') && int.TryParse(seg[1..].Split(',')[0], out var n))
newStart = n;
}
}
}

View File

@@ -0,0 +1,176 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class SettingsModalViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
[ObservableProperty] private string _defaultClaudeInstructions = "";
[ObservableProperty] private string _defaultModel = "sonnet";
[ObservableProperty] private int _defaultMaxTurns = 30;
[ObservableProperty] private string _defaultPermissionMode = "bypassPermissions";
[ObservableProperty] private string _worktreeStrategy = "sibling";
[ObservableProperty] private string? _centralWorktreeRoot;
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
[ObservableProperty] private int _worktreeAutoCleanupDays = 7;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _showResetConfirm;
[ObservableProperty] private string _validationError = "";
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
public IReadOnlyList<string> PermissionModes { get; } = new[]
{ "bypassPermissions", "acceptEdits", "plan", "default" };
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
public string AppVersion { get; } =
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
public string DataFolderPath { get; } = Paths.AppDataRoot();
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
public Action? CloseAction { get; set; }
public SettingsModalViewModel(WorkerClient worker)
{
_worker = worker;
}
public async Task LoadAsync()
{
IsBusy = true;
try
{
var dto = await _worker.GetAppSettingsAsync();
if (dto is not null)
{
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
DefaultModel = dto.DefaultModel ?? "sonnet";
DefaultMaxTurns = dto.DefaultMaxTurns;
DefaultPermissionMode = dto.DefaultPermissionMode ?? "bypassPermissions";
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
CentralWorktreeRoot = dto.CentralWorktreeRoot;
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays;
}
else
{
StatusMessage = "Worker offline — settings read-only.";
}
}
finally { IsBusy = false; }
}
private bool Validate()
{
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
{ ValidationError = "Max turns must be between 1 and 200."; return false; }
if (WorktreeAutoCleanupEnabled &&
(WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365))
{ ValidationError = "Cleanup days must be between 1 and 365."; return false; }
if (WorktreeStrategy == "central")
{
if (string.IsNullOrWhiteSpace(CentralWorktreeRoot))
{ ValidationError = "Central worktree root is required for Central strategy."; return false; }
if (!Directory.Exists(CentralWorktreeRoot))
{ ValidationError = $"Directory not found: {CentralWorktreeRoot}"; return false; }
}
ValidationError = "";
return true;
}
[RelayCommand]
private async Task Save()
{
if (!Validate()) return;
IsBusy = true;
try
{
var dto = new AppSettingsDto(
DefaultClaudeInstructions ?? "",
DefaultModel ?? "sonnet",
DefaultMaxTurns,
DefaultPermissionMode ?? "bypassPermissions",
WorktreeStrategy ?? "sibling",
string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot,
WorktreeAutoCleanupEnabled,
WorktreeAutoCleanupDays);
await _worker.UpdateAppSettingsAsync(dto);
CloseAction?.Invoke();
}
catch (Exception ex)
{
StatusMessage = $"Save failed: {ex.Message}";
}
finally { IsBusy = false; }
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
[RelayCommand]
private async Task CleanupWorktrees()
{
IsBusy = true;
StatusMessage = "";
try
{
var result = await _worker.CleanupFinishedWorktreesAsync();
StatusMessage = result is null
? "Worker offline."
: $"Removed {result.Removed} worktree(s).";
}
finally { IsBusy = false; }
}
[RelayCommand]
private void RequestResetConfirm() => ShowResetConfirm = true;
[RelayCommand]
private void CancelResetConfirm() => ShowResetConfirm = false;
[RelayCommand]
private async Task ConfirmResetAll()
{
ShowResetConfirm = false;
IsBusy = true;
StatusMessage = "";
try
{
var result = await _worker.ResetAllWorktreesAsync();
if (result is null)
StatusMessage = "Worker offline.";
else if (result.Blocked)
StatusMessage = $"Cannot force-remove: {result.RunningTasks} task(s) still running. Cancel them first.";
else
StatusMessage = $"Removed {result.Removed} worktree(s) from {result.TasksAffected} task(s).";
}
finally { IsBusy = false; }
}
[RelayCommand]
private void OpenPath(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return;
try
{
var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null);
if (target is null) return;
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true });
}
catch { /* ignore */ }
}
}

View File

@@ -0,0 +1,86 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class WorktreeNodeViewModel : ViewModelBase
{
public required string Name { get; init; }
public string? Status { get; init; }
public bool IsDirectory { get; init; }
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
}
public sealed partial class WorktreeModalViewModel : ViewModelBase
{
private readonly GitService _git;
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
[ObservableProperty] private string _worktreePath = "";
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
public Action? CloseAction { get; set; }
public WorktreeModalViewModel(GitService git)
{
_git = git;
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
public async Task LoadAsync(CancellationToken ct = default)
{
Root.Clear();
string stdout;
try { stdout = await _git.GetStatusPorcelainAsync(WorktreePath, ct); }
catch { return; }
if (string.IsNullOrWhiteSpace(stdout)) return;
var dirs = new Dictionary<string, WorktreeNodeViewModel>(StringComparer.Ordinal);
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (line.Length < 4) continue;
// porcelain format: XY<space>path (XY = two-char status)
var xy = line[..2];
// Pick staged char first, fall back to unstaged
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
var status = statusChar != ' ' ? statusChar.ToString() : null;
var path = line[3..].Trim().Replace('\\', '/');
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0) continue;
WorktreeNodeViewModel? parent = null;
var accumulated = "";
for (var i = 0; i < segments.Length - 1; i++)
{
accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i];
if (!dirs.TryGetValue(accumulated, out var dir))
{
dir = new WorktreeNodeViewModel { Name = segments[i], IsDirectory = true };
dirs[accumulated] = dir;
if (parent == null) Root.Add(dir);
else parent.Children.Add(dir);
}
parent = dir;
}
var leaf = new WorktreeNodeViewModel
{
Name = segments[^1],
Status = status,
IsDirectory = false
};
if (parent == null) Root.Add(leaf);
else parent.Children.Add(leaf);
}
}
}

View File

@@ -1,55 +0,0 @@
using System.Collections.Specialized;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels;
public partial class StatusBarViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
[ObservableProperty] private string _connectionStatus = "Offline";
[ObservableProperty] private string _activeTasksSummary = "";
[ObservableProperty] private string _statusMessage = "";
public StatusBarViewModel(WorkerClient worker)
{
_worker = worker;
worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(WorkerClient.IsConnected) ||
e.PropertyName == nameof(WorkerClient.IsReconnecting))
{
ConnectionStatus = worker.IsConnected ? "Online"
: worker.IsReconnecting ? "Connecting..."
: "Offline";
}
};
worker.ActiveTasks.CollectionChanged += OnActiveTasksChanged;
RefreshActiveSummary();
}
private void OnActiveTasksChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
RefreshActiveSummary();
private void RefreshActiveSummary()
{
if (_worker.ActiveTasks.Count == 0)
{
ActiveTasksSummary = "";
return;
}
var parts = _worker.ActiveTasks
.Select(t => $"{t.Slot}: {Shorten(t.TaskId)}")
.ToList();
ActiveTasksSummary = string.Join(" | ", parts);
}
private static string Shorten(string id) =>
id.Length > 8 ? id[..8] : id;
public void ShowMessage(string msg) => StatusMessage = msg;
}

View File

@@ -1,23 +0,0 @@
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels;
public partial class SubtaskItemViewModel : ObservableObject
{
[ObservableProperty] private string _title = string.Empty;
[ObservableProperty] private bool _completed;
public string Id { get; set; } = string.Empty;
public string? OriginalTitle { get; set; }
public bool OriginalCompleted { get; set; }
public static SubtaskItemViewModel From(SubtaskEntity e) => new()
{
Id = e.Id,
Title = e.Title,
Completed = e.Completed,
OriginalTitle = e.Title,
OriginalCompleted = e.Completed,
};
}

View File

@@ -1,548 +0,0 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskDetailViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly WorkerClient _worker;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description;
[ObservableProperty] private string? _result;
[ObservableProperty] private string? _logPath;
[ObservableProperty] private string _statusText = "";
[ObservableProperty] private string _statusChoice = "Manual";
[ObservableProperty] private string _commitType = "chore";
[ObservableProperty] private string _modelChoice = "(list default)";
[ObservableProperty] private string? _systemPromptOverride;
[ObservableProperty] private AgentInfo? _selectedAgent;
public List<AgentInfo> AvailableAgents { get; } = [];
public static string[] StatusChoices { get; } = ["Manual", "Queued", "Running", "Done", "Failed"];
public static string[] CommitTypes { get; } = ["feat", "fix", "refactor", "docs", "test", "chore", "ci", "perf", "style", "build"];
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
// Worktree
[ObservableProperty] private bool _hasWorktree;
[ObservableProperty] private string? _branchName;
[ObservableProperty] private string? _diffStat;
[ObservableProperty] private string? _worktreePath;
[ObservableProperty] private string _worktreeState = "";
// Live stream
[ObservableProperty] private string _liveText = "";
private StreamLineFormatter _formatter = new();
public ObservableCollection<TagEntity> Tags { get; } = new();
[ObservableProperty] private string _newTagInput = "";
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
private string? _taskId;
private string? _listId;
private bool _isLoading;
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
// before the previous load finished — prevents torn state on _taskId,
// Subtasks, Tags, etc.
private CancellationTokenSource? _loadCts;
public event Action<string>? TaskChanged;
public TaskDetailViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, GitService git, WorkerClient worker)
{
_dbFactory = dbFactory;
_git = git;
_worker = worker;
worker.TaskMessageEvent += OnTaskMessage;
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
worker.TaskUpdatedEvent += OnTaskUpdated;
worker.RunNowRequestedEvent += OnRunNowRequested;
worker.TaskStartedEvent += OnTaskStarted;
}
public async Task LoadAsync(string taskId)
{
// Cancel any in-flight load so rapid TaskUpdated events don't race
// on _taskId / Subtasks / Tags. The newest caller wins.
var oldCts = _loadCts;
var cts = new CancellationTokenSource();
_loadCts = cts;
oldCts?.Cancel();
oldCts?.Dispose();
var ct = cts.Token;
_taskId = taskId;
HasWorktree = false;
WorktreeState = "";
BranchName = null;
DiffStat = null;
WorktreePath = null;
OnPropertyChanged(nameof(CanWorktreeAction));
LiveText = "";
_formatter = new StreamLineFormatter();
try
{
TaskEntity? task;
List<TagEntity> tags;
List<SubtaskEntity> subtasks;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct);
if (task is null) return;
ct.ThrowIfCancellationRequested();
tags = await taskRepo.GetTagsAsync(taskId, ct);
ct.ThrowIfCancellationRequested();
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(taskId, ct);
}
ct.ThrowIfCancellationRequested();
if (AvailableAgents.Count == 0)
{
var agents = await _worker.GetAgentsAsync();
ct.ThrowIfCancellationRequested();
AvailableAgents.AddRange(agents);
OnPropertyChanged(nameof(AvailableAgents));
}
_isLoading = true;
try
{
_listId = task.ListId;
Title = task.Title;
Description = task.Description;
Result = task.Result;
LogPath = task.LogPath;
if (task.LogPath is not null
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
&& File.Exists(task.LogPath))
{
_formatter = new StreamLineFormatter();
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
}
StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString();
CommitType = task.CommitType;
ModelChoice = task.Model is not null
? ListEditorViewModel.ModelIdToDisplay(task.Model)
: "(list default)";
SystemPromptOverride = task.SystemPrompt;
if (task.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
Tags.Clear();
foreach (var tag in tags)
Tags.Add(tag);
// Tear down old subtask subscriptions before replacing them.
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear();
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
}
}
finally
{
_isLoading = false;
}
await LoadWorktreeAsync(taskId);
}
catch (OperationCanceledException)
{
// Superseded by a newer LoadAsync — nothing to do.
}
}
public async Task SaveAsync()
{
if (_isLoading || _taskId is null) return;
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entity = await taskRepo.GetByIdAsync(_taskId);
if (entity is null) return;
entity.Title = Title;
entity.Description = Description;
entity.CommitType = CommitType;
entity.Model = ModelChoice != "(list default)"
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
: null;
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
entity.AgentPath = SelectedAgent?.Path;
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
entity.Status = status;
await taskRepo.UpdateAsync(entity);
StatusText = entity.Status.ToString().ToLowerInvariant();
TaskChanged?.Invoke(_taskId);
}
[RelayCommand]
private async Task AddTag()
{
var name = NewTagInput.Trim();
if (string.IsNullOrEmpty(name) || _taskId is null) return;
using var context = _dbFactory.CreateDbContext();
var tagRepo = new TagRepository(context);
var taskRepo = new TaskRepository(context);
var tagId = await tagRepo.GetOrCreateAsync(name);
await taskRepo.AddTagAsync(_taskId, tagId);
Tags.Clear();
var tags = await taskRepo.GetTagsAsync(_taskId);
foreach (var tag in tags)
Tags.Add(tag);
NewTagInput = "";
TaskChanged?.Invoke(_taskId);
}
[RelayCommand]
private async Task RemoveTag(TagEntity tag)
{
if (_taskId is null) return;
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.RemoveTagAsync(_taskId, tag.Id);
Tags.Remove(tag);
TaskChanged?.Invoke(_taskId);
}
[RelayCommand]
private async Task AddSubtask()
{
if (_taskId is null) return;
var entity = new SubtaskEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = _taskId,
Title = "",
Completed = false,
OrderNum = Subtasks.Count,
CreatedAt = DateTime.UtcNow,
};
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.AddAsync(entity);
var vm = SubtaskItemViewModel.From(entity);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
}
[RelayCommand]
private async Task RemoveSubtask(SubtaskItemViewModel item)
{
if (!string.IsNullOrEmpty(item.Id))
{
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.DeleteAsync(item.Id);
}
item.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Remove(item);
}
private async void OnSubtaskPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
try
{
if (_taskId is null) return;
using var context = _dbFactory.CreateDbContext();
var orig = await context.Subtasks.AsNoTracking().FirstOrDefaultAsync(s => s.Id == vm.Id);
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.UpdateAsync(new SubtaskEntity
{
Id = vm.Id,
TaskId = _taskId,
Title = vm.Title,
Completed = vm.Completed,
OrderNum = Subtasks.IndexOf(vm),
CreatedAt = orig?.CreatedAt ?? DateTime.UtcNow,
});
}
catch (Exception ex)
{
// async void must never throw — surface via Debug.
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
}
}
public void SetAgentFromPath(string path)
{
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
if (existing is null)
{
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
AvailableAgents.Add(existing);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = existing;
}
public void Clear()
{
// Cancel any load in flight so it doesn't resurrect state after Clear.
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = null;
_taskId = null;
_listId = null;
Title = "";
Description = null;
Result = null;
LogPath = null;
StatusText = "";
HasWorktree = false;
LiveText = "";
_formatter = new StreamLineFormatter();
Tags.Clear();
NewTagInput = "";
foreach (var s in Subtasks) s.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear();
StatusChoice = "Manual";
CommitType = "chore";
ModelChoice = "(list default)";
SystemPromptOverride = null;
SelectedAgent = null;
}
private async Task LoadWorktreeAsync(string taskId)
{
using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
var wt = await wtRepo.GetByTaskIdAsync(taskId);
HasWorktree = wt is not null;
if (wt is not null)
{
BranchName = wt.BranchName;
DiffStat = wt.DiffStat;
WorktreePath = wt.Path;
WorktreeState = wt.State.ToString().ToLowerInvariant();
}
else
{
BranchName = null;
DiffStat = null;
WorktreePath = null;
WorktreeState = "";
}
OnPropertyChanged(nameof(CanWorktreeAction));
}
public bool CanWorktreeAction => HasWorktree && WorktreeState == "active";
[RelayCommand]
private void OpenWorktree()
{
if (WorktreePath is null) return;
try
{
Process.Start(new ProcessStartInfo
{
FileName = WorktreePath,
UseShellExecute = true,
});
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to open worktree: {ex.Message}");
}
}
[RelayCommand]
private void ShowDiff()
{
if (WorktreePath is null) return;
try
{
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/k git -C \"{WorktreePath}\" diff HEAD~1",
UseShellExecute = true,
});
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to show diff: {ex.Message}");
}
}
[RelayCommand]
private async Task MergeIntoMainAsync()
{
if (_taskId is null || _listId is null) return;
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
}
await LoadWorktreeAsync(_taskId);
}
[RelayCommand]
private async Task KeepAsBranchAsync()
{
if (_taskId is null || _listId is null) return;
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
}
await LoadWorktreeAsync(_taskId);
}
[RelayCommand]
private async Task DiscardAsync()
{
if (_taskId is null || _listId is null) return;
WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
}
await LoadWorktreeAsync(_taskId);
}
private void OnTaskMessage(string taskId, string line)
{
if (taskId != _taskId) return;
var formatted = _formatter.FormatLine(line);
if (formatted is not null)
{
LiveText += formatted;
if (LiveText.Length > 50_000)
LiveText = StreamLineFormatter.Trim(LiveText);
}
}
private void OnRunNowRequested(string taskId)
{
if (taskId != _taskId) return;
StatusText = "starting...";
LiveText = "";
_formatter = new StreamLineFormatter();
}
private void OnTaskStarted(string slot, string taskId, DateTime startedAt)
{
if (taskId != _taskId) return;
StatusText = "running";
}
private async void OnWorktreeUpdated(string taskId)
{
if (taskId != _taskId) return;
try
{
await LoadWorktreeAsync(taskId);
}
catch (Exception ex)
{
// async void must never throw.
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
}
}
private async void OnTaskUpdated(string taskId)
{
if (taskId != _taskId) return;
try
{
await LoadAsync(taskId);
}
catch (Exception ex)
{
// async void must never throw.
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
}
}

View File

@@ -1,264 +0,0 @@
using System.Collections.ObjectModel;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskEditorViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description;
[ObservableProperty] private string _commitType = "chore";
[ObservableProperty] private string _statusChoice = "manual";
[ObservableProperty] private string _tagsInput = "";
[ObservableProperty] private string _windowTitle = "New Task";
[ObservableProperty] private string _modelChoice = "(list default)";
[ObservableProperty] private string? _systemPromptOverride;
[ObservableProperty] private AgentInfo? _selectedAgent;
public List<AgentInfo> AvailableAgents { get; set; } = [];
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
private string? _editId;
private string _listId = "";
private DateTime _createdAt;
private TaskCompletionSource<TaskEntity?> _tcs = new();
public event Action? RequestClose;
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
public static string[] StatusChoices { get; } =
["manual", "queued"];
public TaskEditorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task LoadAgentsAsync(WorkerClient worker)
{
AvailableAgents = await worker.GetAgentsAsync();
}
public void SetAgentFromPath(string path)
{
var existing = AvailableAgents.FirstOrDefault(a => a.Path == path);
if (existing is null)
{
existing = new AgentInfo(Path.GetFileNameWithoutExtension(path), "(external)", path);
AvailableAgents.Add(existing);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = existing;
}
public IReadOnlyList<string> SelectedTagNames =>
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct()
.ToList();
public void InitForCreate(string listId, string defaultCommitType = "chore")
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = null;
_listId = listId;
_createdAt = DateTime.UtcNow;
CommitType = defaultCommitType;
WindowTitle = "New Task";
Subtasks.Clear();
}
public async Task InitForEditAsync(TaskEntity entity, IReadOnlyList<TagEntity> taskTags, CancellationToken ct = default)
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = entity.Id;
_listId = entity.ListId;
_createdAt = entity.CreatedAt;
Title = entity.Title;
Description = entity.Description;
CommitType = entity.CommitType;
StatusChoice = entity.Status switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
_ => entity.Status.ToString().ToLowerInvariant(),
};
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
ModelChoice = entity.Model is not null
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
: "(list default)";
SystemPromptOverride = entity.SystemPrompt;
if (entity.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
WindowTitle = $"Edit Task: {entity.Title}";
Subtasks.Clear();
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
var list = await subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
foreach (var s in list)
Subtasks.Add(SubtaskItemViewModel.From(s));
}
// Keep old sync overload for callers that haven't loaded agents yet
public void InitForEdit(TaskEntity entity, IReadOnlyList<TagEntity> taskTags)
{
_tcs = new TaskCompletionSource<TaskEntity?>();
_editId = entity.Id;
_listId = entity.ListId;
_createdAt = entity.CreatedAt;
Title = entity.Title;
Description = entity.Description;
CommitType = entity.CommitType;
StatusChoice = entity.Status switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
_ => entity.Status.ToString().ToLowerInvariant(),
};
TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
ModelChoice = entity.Model is not null
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
: "(list default)";
SystemPromptOverride = entity.SystemPrompt;
if (entity.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(entity.AgentPath), "(external)", entity.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
WindowTitle = $"Edit Task: {entity.Title}";
}
[RelayCommand]
private void AddSubtask() => Subtasks.Add(new SubtaskItemViewModel());
[RelayCommand]
private void RemoveSubtask(SubtaskItemViewModel item) => Subtasks.Remove(item);
[RelayCommand]
private async Task Save()
{
if (string.IsNullOrWhiteSpace(Title)) return;
var status = StatusChoice switch
{
"queued" => TaskStatus.Queued,
_ => TaskStatus.Manual,
};
var taskId = _editId ?? Guid.NewGuid().ToString();
var entity = new TaskEntity
{
Id = taskId,
ListId = _listId,
Title = Title.Trim(),
Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(),
Status = status,
CommitType = CommitType,
CreatedAt = _createdAt,
};
entity.Model = ModelChoice != "(list default)"
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
: null;
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
entity.AgentPath = SelectedAgent?.Path;
// Persist subtask changes
if (_editId is not null)
{
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
var existing = await subtaskRepo.GetByTaskIdAsync(taskId);
var existingIds = existing.Select(s => s.Id).ToHashSet();
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
// Deleted
foreach (var id in existingIds.Except(currentIds))
await subtaskRepo.DeleteAsync(id);
// Updated
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
{
if (vm.Id == "") continue;
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
{
var origSub = existing.FirstOrDefault(e => e.Id == vm.Id);
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = origSub?.CreatedAt ?? DateTime.UtcNow });
}
else
{
// update order_num if position changed
var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
if (orig is not null && orig.OrderNum != idx)
await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
}
}
}
// Added (id == "" means new)
{
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
{
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
var newId = Guid.NewGuid().ToString();
await subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
}
}
_tcs.TrySetResult(entity);
RequestClose?.Invoke();
}
[RelayCommand]
private void Cancel()
{
_tcs.TrySetResult(null);
RequestClose?.Invoke();
}
public void OnWindowClosed()
{
_tcs.TrySetResult(null);
}
public Task<TaskEntity?> ShowAndWaitAsync() => _tcs.Task;
}

View File

@@ -1,174 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Media;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskItemViewModel : ViewModelBase
{
[ObservableProperty] private string _title;
[ObservableProperty] private string _statusText;
[ObservableProperty] private string _tagsText;
[ObservableProperty] private string _commitType;
[ObservableProperty] private string? _description;
[ObservableProperty] private TaskStatus _status;
[ObservableProperty] private bool _isStarting;
[ObservableProperty] private bool _isExpanded;
[ObservableProperty] private bool _hasSubtasks;
[ObservableProperty] private int _subtaskCount;
public ObservableCollection<SubtaskItemViewModel> Subtasks { get; } = new();
public string Id { get; }
public string ListId { get; }
public TaskEntity Entity { get; private set; }
private readonly Func<string, Task>? _runNow;
private readonly Func<bool> _canRunNow;
private readonly Func<string, Task>? _toggleDone;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private bool _subtasksLoaded;
public TaskItemViewModel(TaskEntity entity, IReadOnlyList<TagEntity> tags,
Func<string, Task>? runNow, Func<bool> canRunNow,
IDbContextFactory<ClaudeDoDbContext> dbFactory, int subtaskCount,
Func<string, Task>? toggleDone = null)
{
Entity = entity;
Id = entity.Id;
ListId = entity.ListId;
_title = entity.Title;
_status = entity.Status;
_statusText = entity.Status.ToString().ToLowerInvariant();
_tagsText = string.Join(", ", tags.Select(t => t.Name));
_commitType = entity.CommitType;
_description = entity.Description;
_runNow = runNow;
_canRunNow = canRunNow;
_toggleDone = toggleDone;
_dbFactory = dbFactory;
_subtaskCount = subtaskCount;
_hasSubtasks = subtaskCount > 0;
}
public bool IsDone => Status == TaskStatus.Done;
public bool IsRunning => Status == TaskStatus.Running;
public bool CanToggleDone => Status != TaskStatus.Running && Status != TaskStatus.Failed;
public TextDecorationCollection? TitleDecorations => IsDone
? TextDecorations.Strikethrough
: null;
public IBrush TitleForeground => IsDone
? new SolidColorBrush(Color.Parse("#5a6578"))
: new SolidColorBrush(Color.Parse("#e2e8f0"));
public double RowOpacity => IsDone ? 0.6 : 1.0;
public void Refresh(TaskEntity entity, IReadOnlyList<TagEntity> tags)
{
Entity = entity;
Title = entity.Title;
Status = entity.Status;
StatusText = entity.Status.ToString().ToLowerInvariant();
TagsText = string.Join(", ", tags.Select(t => t.Name));
CommitType = entity.CommitType;
Description = entity.Description;
RunNowCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsRunning));
IsStarting = false;
OnPropertyChanged(nameof(CanToggleDone));
OnPropertyChanged(nameof(TitleDecorations));
OnPropertyChanged(nameof(TitleForeground));
OnPropertyChanged(nameof(RowOpacity));
ToggleDoneCommand.NotifyCanExecuteChanged();
}
public void SetStarting()
{
IsStarting = true;
StatusText = "starting...";
RunNowCommand.NotifyCanExecuteChanged();
}
public void ClearStarting()
{
IsStarting = false;
RunNowCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanRunNow))]
private async Task RunNowAsync()
{
if (_runNow is not null)
await _runNow(Id);
}
private bool CanRunNow() =>
_canRunNow() && Status != TaskStatus.Running && !IsStarting;
[RelayCommand(CanExecute = nameof(CanToggleDone))]
private async Task ToggleDone()
{
if (_toggleDone is not null)
await _toggleDone(Id);
}
[RelayCommand]
private async Task ToggleExpanded()
{
IsExpanded = !IsExpanded;
if (IsExpanded && !_subtasksLoaded)
await LoadSubtasksAsync();
}
private async Task LoadSubtasksAsync()
{
using var context = _dbFactory.CreateDbContext();
var repo = new SubtaskRepository(context);
var entities = await repo.GetByTaskIdAsync(Id);
Subtasks.Clear();
foreach (var e in entities)
Subtasks.Add(SubtaskItemViewModel.From(e));
_subtasksLoaded = true;
}
[RelayCommand]
private async Task ToggleSubtaskDone(string subtaskId)
{
var vm = Subtasks.FirstOrDefault(s => s.Id == subtaskId);
if (vm is null) return;
vm.Completed = !vm.Completed;
using var context = _dbFactory.CreateDbContext();
var entity = await context.Subtasks.FindAsync(subtaskId);
if (entity is not null)
{
entity.Completed = vm.Completed;
await context.SaveChangesAsync();
}
}
public async Task RefreshSubtasksAsync(int newCount)
{
SubtaskCount = newCount;
HasSubtasks = newCount > 0;
if (!HasSubtasks)
{
IsExpanded = false;
Subtasks.Clear();
_subtasksLoaded = false;
}
else if (_subtasksLoaded || IsExpanded)
{
await LoadSubtasksAsync();
}
}
}

View File

@@ -1,356 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Views;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels;
public partial class TaskListViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker;
private readonly Func<TaskEditorViewModel> _editorFactory;
private readonly Action<string> _showMessage;
public ObservableCollection<TaskItemViewModel> Tasks { get; } = new();
[ObservableProperty] private TaskItemViewModel? _selectedTask;
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddTaskCommand))] private string? _currentListId;
[ObservableProperty] private string _listName = "Tasks";
[ObservableProperty] private string _inlineAddTitle = "";
public event Action<TaskItemViewModel?>? SelectedTaskChanged;
partial void OnSelectedTaskChanged(TaskItemViewModel? value) =>
SelectedTaskChanged?.Invoke(value);
public TaskListViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker,
Func<TaskEditorViewModel> editorFactory, Action<string> showMessage)
{
_dbFactory = dbFactory;
_worker = worker;
_editorFactory = editorFactory;
_showMessage = showMessage;
worker.TaskUpdatedEvent += OnTaskUpdated;
worker.TaskFinishedEvent += (_, taskId, _, _) => OnTaskUpdated(taskId);
worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(WorkerClient.IsConnected))
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
foreach (var t in Tasks)
t.RunNowCommand.NotifyCanExecuteChanged();
});
};
worker.RunNowRequestedEvent += taskId =>
{
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
item?.SetStarting();
};
worker.TaskStartedEvent += (_, taskId, _) =>
{
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
item?.ClearStarting();
};
}
public async Task LoadAsync(string? listId)
{
CurrentListId = listId;
Tasks.Clear();
SelectedTask = null;
if (listId is not null)
{
using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(listId);
ListName = list?.Name ?? "Tasks";
}
else
{
ListName = "Tasks";
}
if (listId is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entities = await taskRepo.GetByListIdAsync(listId);
var taskIds = entities.Select(e => e.Id).ToList();
var subtaskCounts = await context.Subtasks
.Where(s => taskIds.Contains(s.TaskId))
.GroupBy(s => s.TaskId)
.ToDictionaryAsync(g => g.Key, g => g.Count());
foreach (var e in entities)
{
var tags = await taskRepo.GetEffectiveTagsAsync(e.Id);
subtaskCounts.TryGetValue(e.Id, out var count);
Tasks.Add(new TaskItemViewModel(e, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, count, ToggleDoneAsync));
}
}
catch (Exception ex)
{
_showMessage($"Error loading tasks: {ex.Message}");
}
}
private bool CanAddTask() => CurrentListId is not null;
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task InlineAdd()
{
var title = InlineAddTitle.Trim();
if (string.IsNullOrEmpty(title) || CurrentListId is null) return;
string defaultCommitType;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(CurrentListId);
defaultCommitType = list?.DefaultCommitType ?? "chore";
}
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = CurrentListId,
Title = title,
Status = TaskStatus.Manual,
CommitType = defaultCommitType,
CreatedAt = DateTime.UtcNow,
};
try
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.AddAsync(entity);
var tags = await taskRepo.GetEffectiveTagsAsync(entity.Id);
var vm = new TaskItemViewModel(entity, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync);
Tasks.Add(vm);
SelectedTask = vm;
InlineAddTitle = "";
}
catch (Exception ex)
{
_showMessage($"Error creating task: {ex.Message}");
}
}
[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTask()
{
var listId = CurrentListId;
if (listId is null) return;
string defaultCommitType;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
var list = await listRepo.GetByIdAsync(listId);
defaultCommitType = list?.DefaultCommitType ?? "chore";
}
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(listId, defaultCommitType);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var saved = await editor.ShowAndWaitAsync();
if (saved is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var tagRepo = new TagRepository(context);
await taskRepo.AddAsync(saved);
foreach (var tagName in editor.SelectedTagNames)
{
var tagId = await tagRepo.GetOrCreateAsync(tagName);
await taskRepo.AddTagAsync(saved.Id, tagId);
}
var tags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
Tasks.Add(new TaskItemViewModel(saved, tags, RunNowAsync, () => _worker.IsConnected,
_dbFactory, 0, ToggleDoneAsync));
// Auto wake-queue if agent+queued
if (saved.Status == TaskStatus.Queued &&
tags.Any(t => t.Name == "agent"))
{
try { await _worker.WakeQueueAsync(); }
catch { /* worker offline is fine */ }
}
}
catch (Exception ex)
{
_showMessage($"Error creating task: {ex.Message}");
}
}
[RelayCommand]
private async Task EditTask()
{
if (SelectedTask is null || CurrentListId is null) return;
TaskEntity? entity;
List<TagEntity> taskTags;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
entity = await taskRepo.GetByIdAsync(SelectedTask.Id);
if (entity is null) return;
taskTags = await taskRepo.GetTagsAsync(entity.Id);
}
var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
await editor.InitForEditAsync(entity, taskTags);
var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window);
var saved = await editor.ShowAndWaitAsync();
if (saved is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var tagRepo = new TagRepository(context);
await taskRepo.UpdateAsync(saved);
var existingTags = await taskRepo.GetTagsAsync(saved.Id);
foreach (var old in existingTags)
await taskRepo.RemoveTagAsync(saved.Id, old.Id);
foreach (var tagName in editor.SelectedTagNames)
{
var tagId = await tagRepo.GetOrCreateAsync(tagName);
await taskRepo.AddTagAsync(saved.Id, tagId);
}
var newTags = await taskRepo.GetEffectiveTagsAsync(saved.Id);
SelectedTask.Refresh(saved, newTags);
}
catch (Exception ex)
{
_showMessage($"Error updating task: {ex.Message}");
}
}
[RelayCommand]
private async Task DeleteTask()
{
if (SelectedTask is null) return;
try
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.DeleteAsync(SelectedTask.Id);
Tasks.Remove(SelectedTask);
SelectedTask = null;
}
catch (Exception ex)
{
_showMessage($"Error deleting task: {ex.Message}");
}
}
public async Task RefreshSingleAsync(string taskId)
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entity = await taskRepo.GetByIdAsync(taskId);
var existing = Tasks.FirstOrDefault(t => t.Id == taskId);
if (entity is null)
{
if (existing is not null) Tasks.Remove(existing);
return;
}
var tags = await taskRepo.GetEffectiveTagsAsync(taskId);
if (existing is not null)
{
existing.Refresh(entity, tags);
var subtaskCount = await context.Subtasks.CountAsync(s => s.TaskId == taskId);
await existing.RefreshSubtasksAsync(subtaskCount);
}
}
private async Task RunNowAsync(string taskId)
{
try
{
await _worker.RunNowAsync(taskId);
}
catch (Exception ex)
{
_showMessage($"RunNow failed: {ex.Message}");
}
}
private async Task ToggleDoneAsync(string taskId)
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entity = await taskRepo.GetByIdAsync(taskId);
if (entity is null) return;
entity.Status = entity.Status == TaskStatus.Done ? TaskStatus.Manual : TaskStatus.Done;
if (entity.Status == TaskStatus.Done)
entity.FinishedAt = DateTime.UtcNow;
await taskRepo.UpdateAsync(entity);
await RefreshSingleAsync(taskId);
}
private async void OnTaskUpdated(string taskId)
{
if (CurrentListId is null) return;
try
{
await RefreshSingleAsync(taskId);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TaskListViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
}
private static async Task ShowDialogAsync(Window dialog)
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
&& desktop.MainWindow is not null)
{
await dialog.ShowDialog(desktop.MainWindow);
}
else
{
dialog.Show();
}
}
}

View File

@@ -0,0 +1,160 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
x:Class="ClaudeDo.Ui.Views.Islands.AgentStripView"
x:DataType="vm:DetailsIslandViewModel">
<Border Classes="agent-strip"
Classes.running="{Binding IsRunning}"
Margin="18,8,18,0">
<StackPanel Margin="12,10" Spacing="6">
<!-- Row 1: pulsing dot · status label · model · stop button -->
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<Ellipse Grid.Column="0"
Width="8" Height="8"
Fill="{DynamicResource MossBrush}"
VerticalAlignment="Center"
Classes.status-pulse="{Binding IsRunning}"
Margin="0,0,6,0"/>
<TextBlock Grid.Column="1"
Text="{Binding AgentStatusLabel}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.2"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Grid.Column="2"
Text="{Binding Model}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding Model, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<!-- Stop button — only when running -->
<Button Grid.Column="3"
Classes="icon-btn"
Command="{Binding StopCommand}"
IsVisible="{Binding IsRunning}"
ToolTip.Tip="Stop agent"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.X}" Width="12" Height="12"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
<!-- Hand off button — only when idle -->
<Button Grid.Column="3"
Classes="btn accent"
Content="Hand off"
Command="{Binding RunNowCommand}"
IsVisible="{Binding !IsRunning}"
ToolTip.Tip="Hand task off to Claude"
VerticalAlignment="Center"
Padding="10,4"/>
</Grid>
<!-- Row 2: WORKTREE label + path + copy button -->
<Grid ColumnDefinitions="Auto,*,Auto"
IsVisible="{Binding WorktreePath, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Grid.Column="0"
Text="WORKTREE"
FontFamily="{DynamicResource MonoFont}" FontSize="9"
LetterSpacing="1.2"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Grid.Column="1"
Text="{Binding WorktreePath}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextDimBrush}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<Button Grid.Column="2"
Classes="icon-btn"
ToolTip.Tip="Copy path"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
</Button>
</Grid>
<!-- Row 3: Branch line — icon + branch ← main + commits chip -->
<StackPanel Orientation="Horizontal" Spacing="6"
IsVisible="{Binding BranchLine, Converter={x:Static ObjectConverters.IsNotNull}}">
<PathIcon Data="{StaticResource Icon.GitBranch}" Width="11" Height="11"
Foreground="{DynamicResource AccentBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding BranchLine}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"/>
<Border Classes="chip"
IsVisible="{Binding CommitsOnBranch}"
Padding="5,1" CornerRadius="4">
<TextBlock Text="{Binding CommitsOnBranch, StringFormat='{}{0}c'}"
FontFamily="{DynamicResource MonoFont}" FontSize="9"
Foreground="{DynamicResource TextFaintBrush}"/>
</Border>
</StackPanel>
<!-- Row 4: DIFF label + +add del + meter bar -->
<Grid ColumnDefinitions="Auto,Auto,Auto,*">
<TextBlock Grid.Column="0"
Text="DIFF"
FontFamily="{DynamicResource MonoFont}" FontSize="9"
LetterSpacing="1.2"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock Grid.Column="1"
Text="{Binding DiffAdditions, StringFormat='+{0}'}"
Classes="diff-add"
VerticalAlignment="Center"
Margin="0,0,4,0"/>
<TextBlock Grid.Column="2"
Text="{Binding DiffDeletions, StringFormat='{0}'}"
Classes="diff-del"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<!-- Slim 4px meter: track + fill using a Grid overlay -->
<Grid Grid.Column="3" VerticalAlignment="Center">
<Border Classes="diff-meter-track"/>
<Rectangle Classes="diff-meter-fill"
Width="{Binding DiffMeterRatio}"
HorizontalAlignment="Left"
RenderTransformOrigin="0,0.5">
<Rectangle.RenderTransform>
<ScaleTransform ScaleX="{Binding $parent[Grid].Bounds.Width}"/>
</Rectangle.RenderTransform>
</Rectangle>
</Grid>
</Grid>
<!-- Action buttons -->
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,4,0,0">
<Button Classes="btn" Content="Open diff" Command="{Binding OpenDiffCommand}"/>
<Button Classes="btn" Command="{Binding OpenWorktreeCommand}"
ToolTip.Tip="Open worktree in file explorer">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.ArrowOut}"
Width="11" Height="11"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="Worktree" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Classes="btn accent"
Content="Continue"
Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Resume the task from where it failed"
Padding="10,4"/>
<Button Classes="btn"
Content="Reset"
Command="{Binding ResetCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Discard the worktree and move the task back to Manual"
Padding="10,4"/>
</StackPanel>
</StackPanel>
</Border>
</UserControl>

View File

@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Islands;
public partial class AgentStripView : UserControl
{
public AgentStripView() { InitializeComponent(); }
}

View File

@@ -0,0 +1,157 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
x:DataType="vm:DetailsIslandViewModel">
<DockPanel>
<!-- ── Metadata footer (sticky bottom) ── -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="14,8">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Delete button -->
<Button Grid.Column="0" Classes="icon-btn"
Command="{Binding DeleteTaskCommand}"
ToolTip.Tip="Delete task"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
<!-- Created date -->
<TextBlock Grid.Column="1"
Text="{Binding Task.CreatedAtFormatted}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<!-- Close button -->
<Button Grid.Column="2" Classes="icon-btn"
Command="{Binding CloseDetailsCommand}"
ToolTip.Tip="Close"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.X}" Width="14" Height="14"/>
</Button>
</Grid>
</Border>
<!-- ── Header ── -->
<Border DockPanel.Dock="Top" Classes="island-header">
<!-- Eyebrow row -->
<StackPanel Spacing="0">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
<Ellipse Width="5" Height="5" Fill="{DynamicResource AccentBrush}"
VerticalAlignment="Center"/>
<TextBlock Classes="eyebrow" Text="LOGBOOK" VerticalAlignment="Center"/>
<TextBlock Text="{Binding TaskIdBadge}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"
Margin="8,0,0,0"/>
</StackPanel>
<!-- Editable title (reduced size) -->
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
FontSize="14" FontWeight="Medium"
BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource TextBrush}"
Padding="0"/>
</StackPanel>
</Border>
<!-- ── Task strip row: check + title display + star ── -->
<Border DockPanel.Dock="Top"
Padding="18,10,18,10"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<Ellipse Grid.Column="0"
Classes="task-check"
Classes.done="{Binding Task.Done}"
Width="18" Height="18"
VerticalAlignment="Center"
Cursor="Hand"/>
<TextBlock Grid.Column="1"
Text="{Binding EditableTitle}"
FontSize="14" FontWeight="Medium"
Foreground="{DynamicResource TextBrush}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Margin="10,0"/>
<Button Grid.Column="2"
Classes="icon-btn star-btn"
Classes.on="{Binding Task.IsStarred}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
</Button>
</Grid>
</Border>
<!-- ── Main body: agent strip (auto) · terminal (flex) · steps+notes (auto/capped) ── -->
<Grid RowDefinitions="Auto,*,Auto">
<!-- Agent strip -->
<islands:AgentStripView Grid.Row="0"/>
<!-- Session terminal — fills remaining vertical space -->
<islands:SessionTerminalView Grid.Row="1" MinHeight="220" Margin="0,0,0,0"/>
<!-- Steps + Notes in a capped scroller so they never squeeze the terminal -->
<ScrollViewer Grid.Row="2" MaxHeight="240"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="0">
<!-- Subtasks section -->
<StackPanel Margin="18,12,18,0"
IsVisible="{Binding Subtasks.Count}">
<TextBlock Classes="section-label" Text="STEPS"
Margin="0,0,0,6"/>
<ItemsControl ItemsSource="{Binding Subtasks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SubtaskRowViewModel">
<Border Classes="subtask-row"
Classes.done="{Binding Done}">
<Grid ColumnDefinitions="Auto,*">
<Ellipse Grid.Column="0"
Classes="task-check"
Classes.done="{Binding Done}"
Width="16" Height="16"
VerticalAlignment="Center"
Cursor="Hand"
Margin="0,0,8,0"/>
<TextBlock Grid.Column="1"
Classes="subtask-title"
Text="{Binding Title}"
FontSize="13"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Notes section -->
<StackPanel Margin="18,12,18,12">
<TextBlock Classes="section-label" Text="NOTES" Margin="0,0,0,6"/>
<TextBox Text="{Binding Notes, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="80"
Padding="12"
PlaceholderText="Notes..."
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="8"
LostFocus="NotesLostFocus"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,93 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands;
public partial class DetailsIslandView : UserControl
{
public DetailsIslandView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (DataContext is DetailsIslandViewModel vm)
{
vm.ShowDiffModal = async (diffVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new DiffModalView { DataContext = diffVm };
await modal.ShowDialog(owner);
};
vm.ShowWorktreeModal = async (worktreeVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new WorktreeModalView { DataContext = worktreeVm };
await modal.ShowDialog(owner);
};
vm.ConfirmAsync = ShowConfirmAsync;
}
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancel, confirm }
}
}
}
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
private void NotesLostFocus(object? sender, RoutedEventArgs e)
{
if (DataContext is DetailsIslandViewModel vm)
vm.SaveNotesCommand.Execute(null);
}
}

View File

@@ -0,0 +1,183 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:converters="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
x:DataType="vm:ListsIslandViewModel">
<UserControl.Resources>
<converters:UpperCaseConverter x:Key="UpperCase"/>
<converters:IconKeyConverter x:Key="IconKey"/>
<converters:DotBrushConverter x:Key="DotBrush"/>
</UserControl.Resources>
<DockPanel LastChildFill="True">
<!-- ── Header ── -->
<Border DockPanel.Dock="Top" Classes="island-header">
<StackPanel Margin="14,12,14,0" Spacing="4">
<TextBlock FontFamily="{DynamicResource SansFamily}" FontSize="18"
FontWeight="SemiBold" Foreground="{DynamicResource TextBrush}"
Text="Lists"/>
<!-- Search row -->
<Border Classes="search-wrap" Margin="0,8,0,12">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<PathIcon Grid.Column="0" Width="14" Height="14"
Data="{StaticResource Icon.Search}"
Foreground="{DynamicResource TextFaintBrush}"
Margin="2,0,0,0"/>
<TextBox Grid.Column="1" x:Name="SearchBox" Classes="search-inner"
PlaceholderText="Search tasks…"
Text="{Binding SearchText, Mode=TwoWay}"/>
<Border Grid.Column="2" Classes="kbd" Margin="0,0,2,0">
<TextBlock Text="Ctrl K"/>
</Border>
</Grid>
</Border>
</StackPanel>
</Border>
<!-- ── Footer ── -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0"
Padding="12,10">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<!-- Avatar circle -->
<Border Grid.Column="0" Classes="avatar-circle"
VerticalAlignment="Center">
<TextBlock Text="{Binding UserInitials}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource DeepBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<!-- Name + machine -->
<StackPanel Grid.Column="1" Margin="8,0" Spacing="1" VerticalAlignment="Center">
<TextBlock Text="{Binding UserName}"
FontSize="12" Foreground="{DynamicResource TextBrush}"/>
<TextBlock FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} / local">
<Binding Path="MachineName"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<!-- More button -->
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"
Command="{Binding OpenSettingsCommand}"
ToolTip.Tip="Settings">
<PathIcon Data="{StaticResource Icon.MoreHorizontal}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid>
</Border>
<!-- ── Scrollable body ── -->
<ScrollViewer>
<StackPanel Margin="6,0,6,4">
<!-- SMART LISTS section -->
<TextBlock Classes="list-section-label" Text="SMART LISTS"/>
<ItemsControl ItemsSource="{Binding SmartLists}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ListNavItemViewModel">
<Border Classes="list-item" Classes.active="{Binding IsActive}"
Tapped="OnItemTapped">
<Grid ColumnDefinitions="20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False">
<Border.IsVisible>
<Binding Path="IsActive"/>
</Border.IsVisible>
<Border Width="2" Height="16"
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- Icon -->
<PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
Foreground="{DynamicResource TextMuteBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
<!-- Count -->
<TextBlock Grid.Column="2"
Text="{Binding Count}"
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- MY LISTS section -->
<TextBlock Classes="list-section-label" Text="MY LISTS"/>
<ItemsControl ItemsSource="{Binding UserLists}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ListNavItemViewModel">
<Border Classes="list-item" Classes.active="{Binding IsActive}"
Tapped="OnItemTapped">
<Grid ColumnDefinitions="20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}">
<Border Width="2" Height="16"
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- Color dot (6px circle, color from DotColorKey) -->
<Ellipse Grid.Column="0"
Width="6" Height="6"
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
<!-- Count -->
<TextBlock Grid.Column="2"
Text="{Binding Count}"
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- + New list button -->
<Button Classes="new-list-btn" Margin="0,4,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="New list" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,38 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands;
public partial class ListsIslandView : UserControl
{
public ListsIslandView()
{
InitializeComponent();
DataContextChanged += (_, _) =>
{
if (DataContext is ListsIslandViewModel vm)
{
vm.FocusSearchRequested += (_, _) => SearchBox.Focus();
vm.ShowSettingsModal = ShowSettingsAsync;
}
};
}
private void OnItemTapped(object? sender, RoutedEventArgs e)
{
if (sender is Border { DataContext: ListNavItemViewModel item }
&& DataContext is ListsIslandViewModel vm)
vm.SelectCommand.Execute(item);
}
private async System.Threading.Tasks.Task ShowSettingsAsync(SettingsModalViewModel settingsVm)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new SettingsModalView { DataContext = settingsVm };
await modal.ShowDialog(owner);
}
}

View File

@@ -0,0 +1,82 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
x:Class="ClaudeDo.Ui.Views.Islands.SessionTerminalView"
x:DataType="vm:DetailsIslandViewModel">
<Border Classes="terminal" Margin="18,8,18,0">
<DockPanel LastChildFill="True">
<!-- ── Terminal header bar ── -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
Background="{DynamicResource Surface2Brush}"
Height="28">
<!-- Session label -->
<TextBlock Grid.Column="1"
Text="{Binding BranchLine, StringFormat='claude-session · {0}'}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
LetterSpacing="0.8"
Foreground="{DynamicResource TextMuteBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<!-- LIVE chip -->
<Border Grid.Column="2" Classes="live-chip pulsing"
IsVisible="{Binding IsRunning}"
Margin="0,0,8,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center"/>
<TextBlock Text="LIVE" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- DONE chip -->
<Border Grid.Column="2" Classes="live-chip done"
IsVisible="{Binding IsDone}"
Margin="0,0,8,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource MossBrush}"/>
<TextBlock Text="DONE" VerticalAlignment="Center"
Foreground="{DynamicResource MossBrush}"/>
</StackPanel>
</Border>
<!-- FAILED chip -->
<Border Grid.Column="2" Classes="live-chip failed"
IsVisible="{Binding IsFailed}"
Margin="0,0,8,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource BloodBrush}"/>
<TextBlock Text="FAILED" VerticalAlignment="Center"
Foreground="{DynamicResource BloodBrush}"/>
</StackPanel>
</Border>
</Grid>
<!-- ── Log output ── -->
<ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto"
Padding="10,8,10,4">
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,46,*" Margin="0,1">
<!-- Timestamp -->
<TextBlock Grid.Column="0"
Classes="log-ts"
Text="{Binding TimestampFormatted}"/>
<!-- Kind marker -->
<TextBlock Grid.Column="1"
Classes="log-kind"
Tag="{Binding ClassName}"
Text="{Binding KindMarker}"/>
<!-- Message text — selectable so the user can copy raw output -->
<SelectableTextBlock Grid.Column="2"
Text="{Binding Text}" Tag="{Binding ClassName}"
FontFamily="{DynamicResource MonoFont}" FontSize="11"
Foreground="{DynamicResource TextDimBrush}"
TextWrapping="Wrap"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</UserControl>

View File

@@ -0,0 +1,23 @@
using System.Collections.Specialized;
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands;
public partial class SessionTerminalView : UserControl
{
public SessionTerminalView() { InitializeComponent(); }
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is DetailsIslandViewModel vm)
vm.Log.CollectionChanged += OnLogChanged;
}
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add)
LogScroll.ScrollToEnd();
}
}

View File

@@ -0,0 +1,113 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
x:Class="ClaudeDo.Ui.Views.Islands.TaskRowView"
x:DataType="vm:TaskRowViewModel">
<Border Classes="task-row"
Classes.selected="{Binding IsSelected}"
Classes.done="{Binding Done}">
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
<!-- Left accent bar (visible when selected) -->
<Border Grid.Column="0" Classes="task-row-accent"
IsVisible="{Binding IsSelected}"/>
<!-- Done toggle -->
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top"
Margin="0,2,0,0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}"
CommandParameter="{Binding}">
<Ellipse Width="18" Height="18" Classes="task-check"
Classes.done="{Binding Done}"/>
</Button>
<!-- Title + chip row + live tail -->
<StackPanel Grid.Column="2" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="task-title"
Text="{Binding Title}" FontSize="14"
Foreground="{DynamicResource TextBrush}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
<!-- Chip row -->
<StackPanel Orientation="Horizontal" Spacing="6">
<!-- Status chip -->
<Border Classes="chip"
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
<TextBlock Text="{Binding Status}"/>
</Border>
<!-- List chip with dot -->
<Border Classes="chip chip-list">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse Width="6" Height="6"
Fill="{DynamicResource MossBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding ListName}"/>
</StackPanel>
</Border>
<!-- Branch chip -->
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.GitBranch}"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding Branch}"/>
</StackPanel>
</Border>
<!-- Diff chip -->
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
</StackPanel>
</Border>
<!-- Tag chips -->
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="6"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Classes="chip chip-tag">
<TextBlock Text="{Binding}"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Live-tail row (visible when running + has tail) -->
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
<StackPanel Spacing="3">
<TextBlock Text="{Binding LiveTail}"
TextTrimming="CharacterEllipsis" MaxLines="1"/>
<Grid Height="3" HorizontalAlignment="Stretch">
<Rectangle Fill="{DynamicResource Surface3Brush}"
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
<Rectangle Fill="{DynamicResource MossBrush}"
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
</Grid>
</StackPanel>
</Border>
</StackPanel>
<!-- Star toggle -->
<Button Grid.Column="3" Classes="icon-btn star-btn"
Classes.on="{Binding IsStarred}"
VerticalAlignment="Top" Margin="0,2,0,0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
CommandParameter="{Binding}">
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
</Button>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,33 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
namespace ClaudeDo.Ui.Views.Islands;
public partial class TaskRowView : UserControl
{
public TaskRowView() { InitializeComponent(); }
protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
RenderTransform = new TranslateTransform(0, 8);
Opacity = 0;
var anim = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromMilliseconds(300),
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 0d) } },
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 1d) } },
}
};
await anim.RunAsync(this);
Opacity = 1;
RenderTransform = null;
}
}

View File

@@ -0,0 +1,138 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:converters="using:Avalonia.Data.Converters"
x:Class="ClaudeDo.Ui.Views.Islands.TasksIslandView"
x:DataType="vm:TasksIslandViewModel">
<DockPanel LastChildFill="True">
<!-- Header -->
<Border DockPanel.Dock="Top" Classes="island-header">
<Grid ColumnDefinitions="*,Auto" Margin="18,14">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="eyebrow" Text="{Binding HeaderEyebrow}"/>
<TextBlock FontFamily="{DynamicResource SansFamily}" FontSize="24"
FontWeight="SemiBold" Foreground="{DynamicResource TextBrush}"
Text="{Binding HeaderTitle}"
TextTrimming="CharacterEllipsis"/>
<TextBlock FontFamily="{DynamicResource MonoFamily}" FontSize="11"
Foreground="{DynamicResource TextMuteBrush}"
Text="{Binding Subtitle}"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Top">
<Border Classes="kbd" VerticalAlignment="Center" Margin="0,0,6,0"
IsVisible="{Binding HasStatusPill}">
<TextBlock Text="{Binding StatusPill}"/>
</Border>
<Button Classes="icon-btn" Command="{Binding SortCommand}" ToolTip.Tip="Sort">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Sort}"/>
</Button>
<Button Classes="icon-btn" Classes.active="{Binding IsShowingCompleted}"
Command="{Binding ToggleShowCompletedCommand}"
ToolTip.Tip="Show completed">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
</Button>
<Button Classes="icon-btn" Command="{Binding MoreCommand}" ToolTip.Tip="More">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.MoreHorizontal}"/>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Add-task row -->
<Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,8">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center">
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}"
Foreground="{DynamicResource TextFaintBrush}"/>
</Border>
<TextBox Grid.Column="1" x:Name="AddTaskBox" Classes="add-task-input"
Watermark="Add a task…"
Text="{Binding NewTaskTitle, Mode=TwoWay}"
VerticalAlignment="Center"
Margin="12,0,0,0">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<Border Grid.Column="2" Classes="kbd kbd-enter" VerticalAlignment="Center"
IsVisible="{Binding #AddTaskBox.IsFocused}">
<TextBlock Text="ENTER"/>
</Border>
</Grid>
</Border>
<!-- Task list -->
<ScrollViewer>
<StackPanel Margin="10,4">
<!-- OVERDUE -->
<StackPanel IsVisible="{Binding HasOverdue}">
<TextBlock Classes="eyebrow section-label overdue"
Text="OVERDUE" Margin="14,14,14,6"/>
<ItemsControl ItemsSource="{Binding OverdueItems}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- TASKS -->
<StackPanel IsVisible="{Binding HasOpen}">
<TextBlock Classes="eyebrow section-label"
Text="TASKS" Margin="14,14,14,6"
IsVisible="{Binding ShowOpenLabel}"/>
<ItemsControl ItemsSource="{Binding OpenItems}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- COMPLETED -->
<StackPanel>
<StackPanel.IsVisible>
<MultiBinding Converter="{x:Static converters:BoolConverters.And}">
<Binding Path="HasCompleted"/>
<Binding Path="IsShowingCompleted"/>
</MultiBinding>
</StackPanel.IsVisible>
<TextBlock Classes="eyebrow section-label"
Text="{Binding CompletedHeader}" Margin="14,14,14,6"/>
<ItemsControl ItemsSource="{Binding CompletedItems}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>

View File

@@ -0,0 +1,17 @@
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands;
public partial class TasksIslandView : UserControl
{
public TasksIslandView()
{
InitializeComponent();
DataContextChanged += (_, _) =>
{
if (DataContext is TasksIslandViewModel vm)
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
};
}
}

View File

@@ -1,62 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:svc="using:ClaudeDo.Data.Models"
x:Class="ClaudeDo.Ui.Views.ListEditorView"
x:DataType="vm:ListEditorViewModel"
Title="{Binding WindowTitle}"
Width="450" Height="480"
WindowStartupLocation="CenterOwner"
CanResize="False"
Background="{StaticResource WindowBgBrush}">
<StackPanel Margin="16" Spacing="10">
<TextBlock Text="Name" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding Name}" PlaceholderText="List name..."/>
<TextBlock Text="Working Directory" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<DockPanel>
<Button DockPanel.Dock="Right" Content="Browse..." Click="OnBrowseFolder" Margin="8,0,0,0" VerticalAlignment="Center"/>
<TextBox Text="{Binding WorkingDir}" PlaceholderText="(optional) Absolute path to git repo"/>
</DockPanel>
<TextBlock Text="Default Commit Type" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding CommitTypes}"
SelectedItem="{Binding DefaultCommitType}"
MinWidth="150"/>
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
<TextBlock Text="Agent Config" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="Model" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding ModelDisplayNames}"
SelectedItem="{Binding Model}"
MinWidth="150"/>
<TextBlock Text="System Prompt" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding SystemPrompt}"
PlaceholderText="(optional) Additional system instructions..."
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"/>
<TextBlock Text="Agent File" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding AvailableAgents}"
SelectedItem="{Binding SelectedAgent}"
MinWidth="150">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:AgentInfo">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
Background="{StaticResource AccentBrush}" Foreground="{StaticResource TextPrimaryBrush}"/>
<Button Content="Cancel" Command="{Binding CancelCommand}" IsCancel="True" MinWidth="80"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -1,40 +0,0 @@
using System;
using System.IO;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
public partial class ListEditorView : Window
{
public ListEditorView()
{
InitializeComponent();
}
private async void OnBrowseFolder(object? sender, RoutedEventArgs e)
{
var vm = DataContext as ListEditorViewModel;
var startPath = !string.IsNullOrWhiteSpace(vm?.WorkingDir) && Directory.Exists(vm.WorkingDir)
? vm.WorkingDir
: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var startLocation = await StorageProvider.TryGetFolderFromPathAsync(new Uri(startPath));
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Select Working Directory",
SuggestedStartLocation = startLocation,
AllowMultiple = false,
});
if (result.Count > 0)
{
var path = result[0].TryGetLocalPath();
if (path is not null && vm is not null)
vm.WorkingDir = path;
}
}
}

View File

@@ -1,104 +1,135 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:v="using:ClaudeDo.Ui.Views"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="700"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
x:Class="ClaudeDo.Ui.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
x:DataType="vm:IslandsShellViewModel"
Title="ClaudeDo"
Icon="avares://ClaudeDo.App/Assets/ClaudeTask.ico"
MinWidth="800" MinHeight="500"
KeyDown="OnGlobalKeyDown">
Width="1280" Height="820" MinWidth="780" MinHeight="600"
Background="{DynamicResource VoidBrush}"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1">
<Window.KeyBindings>
<KeyBinding Gesture="OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
</Window.KeyBindings>
<Grid RowDefinitions="36,*,22">
<!-- Custom title bar -->
<Border Grid.Row="0"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<DockPanel Background="{StaticResource WindowBgBrush}">
<v:StatusBarView DockPanel.Dock="Bottom" DataContext="{Binding StatusBar}" />
<!-- Left: brand block -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center" Margin="14,0,0,0">
<!-- Green checkbox glyph -->
<PathIcon Classes="title-brand-icon"
Data="{StaticResource Icon.BrandCheck}"
Width="14" Height="14"
Foreground="{DynamicResource MossBrush}" />
<!-- CLAUDEDO label -->
<TextBlock Classes="title-brand-name"
Text="CLAUDEDO"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
Foreground="{DynamicResource TextBrush}"
LetterSpacing="1.4"
VerticalAlignment="Center"/>
<!-- separator dot -->
<TextBlock Text="·"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
<!-- current list name -->
<TextBlock Text="{Binding Lists.SelectedList.Name, Converter={StaticResource UpperCase}}"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
Foreground="{DynamicResource TextDimBrush}"
LetterSpacing="1.4"
VerticalAlignment="Center"/>
</StackPanel>
<Grid ColumnDefinitions="1*,2*,1.5*" Margin="8,8,8,0">
<!-- Middle: draggable strip -->
<Border Grid.Column="1" Background="Transparent"
PointerPressed="OnTitleBarPressed" />
<!-- Lists island -->
<Border Grid.Column="0" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
MinWidth="180" Margin="0,0,4,8" ClipToBounds="True">
<DockPanel>
<TextBlock DockPanel.Dock="Top"
Text="Lists" FontWeight="SemiBold" FontSize="13"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="16,14,16,10"/>
<!-- Right: window controls -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="0"
VerticalAlignment="Center" Margin="0,0,4,0">
<Button Classes="title-ctrl" Click="OnMinimize">
<PathIcon Data="{StaticResource Icon.WinMin}" Width="10" Height="10"/>
</Button>
<Button Classes="title-ctrl" Click="OnToggleMax">
<PathIcon Data="{StaticResource Icon.WinMax}" Width="10" Height="10"/>
</Button>
<Button Classes="title-ctrl close" Click="OnClose">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="10" Height="10"/>
</Button>
</StackPanel>
<Border DockPanel.Dock="Bottom" Padding="8,8"
BorderThickness="0,1,0,0" BorderBrush="{StaticResource BorderSubtleBrush}">
<Button Content="+ New List"
Command="{Binding AddListCommand}"
Background="Transparent"
Foreground="{StaticResource AccentBrush}"
BorderThickness="0"
Padding="12,8"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
FontSize="13"
Cursor="Hand"/>
</Border>
</Grid>
</Border>
<ListBox x:Name="ListsBox"
ItemsSource="{Binding Lists}"
SelectedItem="{Binding SelectedList}"
Background="Transparent"
Margin="4,0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ListItemViewModel">
<Grid ColumnDefinitions="Auto,*" Margin="8,6"
Background="Transparent"
DoubleTapped="OnListItemDoubleTapped"
PointerPressed="OnListItemPointerPressed">
<Grid.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Edit"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).EditListCommand}"/>
<MenuItem Header="Delete"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).DeleteListCommand}"/>
<Separator/>
<MenuItem Header="New Task"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).TaskList.AddTaskCommand}"/>
</MenuFlyout>
</Grid.ContextFlyout>
<!-- Background gradient layer -->
<Border Grid.Row="1">
<Border.Background>
<RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%">
<GradientStop Offset="0" Color="{StaticResource DeepColor}" />
<GradientStop Offset="1" Color="{StaticResource VoidColor}" />
</RadialGradientBrush>
</Border.Background>
</Border>
<Ellipse Grid.Column="0" Width="8" Height="8"
Fill="{Binding DotBrush}"
VerticalAlignment="Center" Margin="0,0,10,0"/>
<!-- Three islands -->
<Grid Grid.Row="1" Margin="7" ColumnDefinitions="260,*,320">
<Border Grid.Column="0" Classes="island" Margin="7">
<islands:ListsIslandView DataContext="{Binding Lists}"/>
</Border>
<Border Grid.Column="1" Classes="island" Margin="7">
<islands:TasksIslandView DataContext="{Binding Tasks}"/>
</Border>
<Border Grid.Column="2" Classes="island" Margin="7"
IsVisible="{Binding ShowDetails}">
<islands:DetailsIslandView DataContext="{Binding Details}"/>
</Border>
</Grid>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding Name}" FontWeight="Medium"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding WorkingDir}" FontSize="10"
Foreground="{StaticResource TextDimBrush}"
IsVisible="{Binding WorkingDir, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- Tasks island -->
<Border Grid.Column="1" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
Margin="4,0,4,8" ClipToBounds="True">
<DockPanel>
<TextBlock DockPanel.Dock="Top"
Text="{Binding TaskList.ListName, FallbackValue='Tasks'}"
FontWeight="SemiBold" FontSize="16"
Foreground="{StaticResource TextPrimaryBrush}"
Margin="16,14,16,10"/>
<v:TaskListView DataContext="{Binding TaskList}" />
</DockPanel>
</Border>
<!-- Detail island -->
<Border Grid.Column="2" CornerRadius="12" Background="{StaticResource IslandBgBrush}"
MinWidth="280" Margin="4,0,0,8" ClipToBounds="True">
<v:TaskDetailView DataContext="{Binding TaskDetail}" />
</Border>
</Grid>
</DockPanel>
<!-- Footer: connection status -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="7"
VerticalAlignment="Center" Margin="14,0">
<Ellipse Width="7" Height="7" Fill="#4CAF50"
IsVisible="{Binding Worker.IsConnected}"/>
<Ellipse Width="7" Height="7" Fill="#FFA726"
IsVisible="{Binding Worker.IsReconnecting}"/>
<Ellipse Width="7" Height="7" Fill="#EF5350"
IsVisible="{Binding IsOffline}"/>
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="·"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="WORKER"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</Window>

View File

@@ -1,9 +1,5 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
@@ -13,63 +9,34 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
KeyDown += OnWindowKeyDown;
}
protected override async void OnOpened(EventArgs e)
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
base.OnOpened(e);
if (DataContext is MainWindowViewModel vm)
await vm.InitializeAsync();
}
private void OnGlobalKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
var ctrl = e.KeyModifiers.HasFlag(KeyModifiers.Control);
var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
if (ctrl && shift && e.Key == Key.N)
if (e.Key == Key.Space
&& FocusManager?.GetFocusedElement() is not TextBox
&& DataContext is IslandsShellViewModel vm)
{
vm.AddListCommand.Execute(null);
e.Handled = true;
}
else if (ctrl && e.Key == Key.N)
{
var taskListView = this.GetVisualDescendants().OfType<TaskListView>().FirstOrDefault();
taskListView?.FocusInlineAdd();
e.Handled = true;
}
else if (ctrl && e.Key == Key.L)
{
this.FindControl<ListBox>("ListsBox")?.Focus();
e.Handled = true;
}
else if (ctrl && e.Key == Key.R)
{
if (vm.TaskList.SelectedTask is { } task)
{
task.RunNowCommand.Execute(null);
e.Handled = true;
}
_ = vm.ToggleSelectedDoneAsync();
}
}
private void OnListItemDoubleTapped(object? sender, TappedEventArgs e)
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
vm.EditListCommand.Execute(null);
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
private void OnMinimize(object? s, Avalonia.Interactivity.RoutedEventArgs e) =>
WindowState = WindowState.Minimized;
private void OnToggleMax(object? s, Avalonia.Interactivity.RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
private void OnClose(object? s, Avalonia.Interactivity.RoutedEventArgs e) => Close();
private void OnListItemPointerPressed(object? sender, PointerPressedEventArgs e)
protected override void OnSizeChanged(SizeChangedEventArgs e)
{
var props = e.GetCurrentPoint(this).Properties;
if (!props.IsRightButtonPressed) return;
if (sender is Grid { DataContext: ListItemViewModel item }
&& DataContext is MainWindowViewModel vm)
{
vm.SelectedList = item;
}
base.OnSizeChanged(e);
if (DataContext is IslandsShellViewModel vm) vm.WindowWidth = Bounds.Width;
}
}

View File

@@ -0,0 +1,170 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.DiffModalView"
x:DataType="vm:DiffModalViewModel"
Title="Diff"
Width="1200" Height="800"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{StaticResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<!-- diff line row tints via Tag selector (compiled-binding-friendly) -->
<Style Selector="Border.diff-line[Tag=add]">
<Setter Property="Background" Value="#1A4A6B4A"/>
</Style>
<Style Selector="Border.diff-line[Tag=del]">
<Setter Property="Background" Value="#1AC87060"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx]">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.diff-line[Tag=add] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=add] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}"/>
</Style>
</Window.Styles>
<!-- Outer container — rectangular so the OS window rectangle stays filled (no black corners) -->
<Border Background="{StaticResource SurfaceBrush}"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="1">
<Grid RowDefinitions="36,*">
<!-- Title bar / drag handle -->
<Border Grid.Row="0"
x:Name="TitleBar"
Background="{StaticResource Surface2Brush}"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="TitleBar_PointerPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="Diff" VerticalAlignment="Center"
FontFamily="{StaticResource MonoFamily}"
FontSize="12"
Foreground="{StaticResource TextDimBrush}"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CloseCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Body: sidebar + diff content -->
<Grid Grid.Row="1" ColumnDefinitions="240,*">
<!-- File sidebar -->
<Border Grid.Column="0"
BorderBrush="{StaticResource LineBrush}"
BorderThickness="0,0,1,0"
Background="{StaticResource DeepBrush}">
<ListBox ItemsSource="{Binding Files}"
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:DiffFileViewModel">
<Border Padding="10,8" Background="Transparent">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Path}"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"
Foreground="{StaticResource TextDimBrush}"
TextTrimming="LeadingEllipsis"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{StaticResource MossBrightBrush}"
FontFamily="{StaticResource MonoFamily}"
FontSize="10"
Text="{Binding Additions, StringFormat='+{0}'}"/>
</Border>
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{StaticResource BloodBrush}"
FontFamily="{StaticResource MonoFamily}"
FontSize="10"
Text="{Binding Deletions, StringFormat='\u2212{0}'}"/>
</Border>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Diff content -->
<ScrollViewer Grid.Column="1"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Background="{StaticResource VoidBrush}">
<ItemsControl ItemsSource="{Binding SelectedFile.Lines}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DiffLineViewModel">
<Border Classes="diff-line"
Tag="{Binding ClassName}"
Padding="4,1">
<Grid ColumnDefinitions="48,48,16,*">
<!-- Old line number -->
<TextBlock Grid.Column="0"
Text="{Binding OldNo}"
Classes="diff-lineno"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"
Foreground="{StaticResource TextFaintBrush}"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- New line number -->
<TextBlock Grid.Column="1"
Text="{Binding NewNo}"
Classes="diff-lineno"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"
Foreground="{StaticResource TextFaintBrush}"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- Sign -->
<TextBlock Grid.Column="2"
Classes="diff-sign"
Text="{Binding Sign}"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"/>
<!-- Line text -->
<TextBlock Grid.Column="3"
Classes="diff-text"
Text="{Binding Text}"
FontFamily="{StaticResource MonoFamily}"
FontSize="11"
TextWrapping="NoWrap"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,52 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class DiffModalView : Window
{
public DiffModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is DiffModalViewModel vm)
vm.CloseAction = Close;
}
protected override async void OnOpened(EventArgs e)
{
base.OnOpened(e);
Opacity = 0;
RenderTransform = new ScaleTransform(0.98, 0.98);
var anim = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromMilliseconds(180),
Easing = new CubicEaseOut(),
FillMode = FillMode.Forward,
Children =
{
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 0d) } },
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 1d) } },
}
};
await anim.RunAsync(this);
Opacity = 1;
RenderTransform = new ScaleTransform(1.0, 1.0);
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
}

View File

@@ -0,0 +1,253 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
x:DataType="vm:SettingsModalViewModel"
Title="Settings"
Width="580" Height="760"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="TextBlock.section-label">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="LetterSpacing" Value="1.4"/>
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}"/>
<Setter Property="Margin" Value="4,0,0,6"/>
</Style>
<Style Selector="TextBlock.field-label">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<Style Selector="TextBlock.path-mono">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
</Style>
<Style Selector="Border.section">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="14"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="{DynamicResource BloodBrush}"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</Window.Styles>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1">
<Grid RowDefinitions="36,*,52">
<!-- Title bar -->
<Border Grid.Row="0"
x:Name="TitleBar"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="TitleBar_PointerPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="SETTINGS"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CancelCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Scrollable body -->
<ScrollViewer Grid.Row="1" Padding="20,16">
<StackPanel Spacing="18">
<!-- CLAUDE DEFAULTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="CLAUDE DEFAULTS"/>
<Border Classes="section">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Default instructions"/>
<TextBox AcceptsReturn="True"
TextWrapping="Wrap"
Height="110"
Watermark="Baseline instructions applied to every task (e.g. 'speak German', 'never touch .env')"
Text="{Binding DefaultClaudeInstructions, Mode=TwoWay}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="Model"/>
<ComboBox ItemsSource="{Binding Models}"
SelectedItem="{Binding DefaultModel, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="Max turns"/>
<NumericUpDown Value="{Binding DefaultMaxTurns, Mode=TwoWay}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Classes="field-label" Text="Permission"/>
<ComboBox ItemsSource="{Binding PermissionModes}"
SelectedItem="{Binding DefaultPermissionMode, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</StackPanel>
<!-- WORKTREES -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="WORKTREES"/>
<Border Classes="section">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,12,2*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="Strategy"/>
<ComboBox ItemsSource="{Binding WorktreeStrategies}"
SelectedItem="{Binding WorktreeStrategy, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="Central worktree root"/>
<TextBox Text="{Binding CentralWorktreeRoot, Mode=TwoWay}"
Watermark="e.g. C:\worktrees"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox IsChecked="{Binding WorktreeAutoCleanupEnabled, Mode=TwoWay}"
Content="Auto-cleanup finished worktrees after"
VerticalAlignment="Center"/>
<NumericUpDown Value="{Binding WorktreeAutoCleanupDays, Mode=TwoWay}"
Width="130" Minimum="1" Maximum="365" Increment="1" FormatString="0"
IsEnabled="{Binding WorktreeAutoCleanupEnabled}"/>
<TextBlock Text="days" VerticalAlignment="Center"
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
<StackPanel Spacing="8">
<Button Content="Cleanup finished worktrees"
Command="{Binding CleanupWorktreesCommand}"
HorizontalAlignment="Left"/>
<!-- Force-remove: button vs. confirm bar -->
<StackPanel>
<Button Content="Force-remove all worktrees"
Classes="danger"
Command="{Binding RequestResetConfirmCommand}"
HorizontalAlignment="Left"
IsVisible="{Binding !ShowResetConfirm}"/>
<Border BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
CornerRadius="6" Padding="12,10"
IsVisible="{Binding ShowResetConfirm}">
<StackPanel Spacing="8">
<TextBlock Text="Remove ALL worktrees? Uncommitted work will be lost."
Foreground="{DynamicResource TextBrush}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Cancel" Command="{Binding CancelResetConfirmCommand}"/>
<Button Content="Remove All" Classes="danger"
Command="{Binding ConfirmResetAllCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- ABOUT -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="ABOUT"/>
<Border Classes="section">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="Version"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono"
Text="{Binding AppVersion}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Data"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono"
Text="{Binding DataFolderPath}" VerticalAlignment="Center"/>
<Button Grid.Row="1" Grid.Column="2" Content="Open"
Command="{Binding OpenPathCommand}"
CommandParameter="{Binding DataFolderPath}"/>
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Logs"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono"
Text="{Binding LogsFolderPath}" VerticalAlignment="Center"/>
<Button Grid.Row="2" Grid.Column="2" Content="Open"
Command="{Binding OpenPathCommand}"
CommandParameter="{Binding LogsFolderPath}"/>
<TextBlock Grid.Row="3" Grid.Column="0" Classes="field-label" Text="Config"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="3" Grid.Column="1" Classes="path-mono"
Text="{Binding WorkerConfigPath}" VerticalAlignment="Center"/>
<Button Grid.Row="3" Grid.Column="2" Content="Open"
Command="{Binding OpenPathCommand}"
CommandParameter="{Binding WorkerConfigPath}"/>
</Grid>
</Border>
</StackPanel>
<!-- Inline status / error -->
<TextBlock Text="{Binding ValidationError}"
Foreground="{DynamicResource BloodBrush}"
FontSize="11"
IsVisible="{Binding ValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding StatusMessage}"
Foreground="{DynamicResource TextDimBrush}"
FontSize="11"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary"
Command="{Binding SaveCommand}"
IsEnabled="{Binding !IsBusy}" MinWidth="90"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class SettingsModalView : Window
{
public SettingsModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is SettingsModalViewModel vm)
vm.CloseAction = Close;
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
}

View File

@@ -0,0 +1,66 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
x:DataType="vm:WorktreeModalViewModel"
Title="Worktree"
Width="640" Height="720"
WindowStartupLocation="CenterOwner"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
Background="Transparent"
CanResize="False"
TransparencyLevelHint="AcrylicBlur">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<Border Classes="island" Margin="12">
<DockPanel>
<!-- Title strip -->
<Border DockPanel.Dock="Top" Height="36"
PointerPressed="OnTitleBarPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Grid.Column="0" Text="Worktree" VerticalAlignment="Center"
FontFamily="{DynamicResource MonoFont}" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕"
Command="{Binding CloseCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Path strip -->
<Border DockPanel.Dock="Top" Padding="14,0,14,8">
<TextBlock Text="{Binding WorktreePath}"
FontFamily="{DynamicResource MonoFont}" FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
TextTrimming="CharacterEllipsis"/>
</Border>
<!-- File tree -->
<TreeView DockPanel.Dock="Top" ItemsSource="{Binding Root}"
Background="Transparent" Margin="8,0,8,8">
<TreeView.ItemTemplate>
<TreeDataTemplate DataType="vm:WorktreeNodeViewModel"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Name}"
FontFamily="{DynamicResource MonoFont}" FontSize="12"
Foreground="{DynamicResource TextBrush}"/>
<Border Tag="{Binding Status}" CornerRadius="3" Padding="4,0"
VerticalAlignment="Center"
IsVisible="{Binding Status, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Status}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
Foreground="{DynamicResource TextBrush}"/>
</Border>
</StackPanel>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</DockPanel>
</Border>
</Window>

View File

@@ -0,0 +1,52 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class WorktreeModalView : Window
{
public WorktreeModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is WorktreeModalViewModel vm)
vm.CloseAction = Close;
}
protected override async void OnOpened(EventArgs e)
{
base.OnOpened(e);
Opacity = 0;
RenderTransform = new ScaleTransform(0.98, 0.98);
var anim = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromMilliseconds(180),
Easing = new CubicEaseOut(),
FillMode = FillMode.Forward,
Children =
{
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 0d) } },
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 1d) } },
}
};
await anim.RunAsync(this);
Opacity = 1;
RenderTransform = new ScaleTransform(1.0, 1.0);
}
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
}

View File

@@ -1,22 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:conv="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.StatusBarView"
x:DataType="vm:StatusBarViewModel">
<Border Background="#222" Padding="6,3">
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6">
<Ellipse Width="10" Height="10" VerticalAlignment="Center"
Fill="{Binding ConnectionStatus, Converter={x:Static conv:ConnectionColorConverter.Instance}}"/>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="White" VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding ActiveTasksSummary}" Foreground="LightGray"
VerticalAlignment="Center" Margin="20,0,0,0" FontSize="12"/>
<TextBlock Grid.Column="3" Text="{Binding StatusMessage}" Foreground="Yellow"
VerticalAlignment="Center" FontSize="12" Margin="0,0,8,0"/>
</Grid>
</Border>
</UserControl>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views;
public partial class StatusBarView : UserControl
{
public StatusBarView()
{
InitializeComponent();
}
}

View File

@@ -1,225 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:conv="using:ClaudeDo.Ui.Converters"
xmlns:m="using:ClaudeDo.Data.Models"
x:Class="ClaudeDo.Ui.Views.TaskDetailView"
x:DataType="vm:TaskDetailViewModel">
<ScrollViewer>
<StackPanel Margin="12" Spacing="8"
IsVisible="{Binding Title, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<!-- Title (large, editable) -->
<TextBox x:Name="TitleBox"
Text="{Binding Title}"
FontWeight="Bold" FontSize="16"
Foreground="{StaticResource TextPrimaryBrush}"
BorderThickness="0" Background="Transparent"
Padding="0,4"
LostFocus="OnFieldLostFocus"/>
<!-- Status + Commit Type row -->
<Grid ColumnDefinitions="*,16,*" Margin="0,4,0,0">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Status" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding StatusChoices}"
SelectedItem="{Binding StatusChoice}"
MinWidth="100"
LostFocus="OnFieldLostFocus"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Commit Type" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding CommitTypes}"
SelectedItem="{Binding CommitType}"
MinWidth="100"
LostFocus="OnFieldLostFocus"/>
</StackPanel>
</Grid>
<!-- Tags -->
<StackPanel Spacing="4" Margin="0,8,0,0">
<TextBlock Text="Tags" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<WrapPanel Orientation="Horizontal">
<ItemsControl ItemsSource="{Binding Tags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="m:TagEntity">
<Border CornerRadius="10" Padding="8,3" Margin="0,0,4,4"
Background="{StaticResource AccentSubtleBrush}">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="{Binding Name}" FontSize="12"
Foreground="{StaticResource AccentLightBrush}"
VerticalAlignment="Center"/>
<Button Content="x" FontSize="10" Padding="2,0"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource TextMutedBrush}"
Cursor="Hand"
Command="{Binding $parent[UserControl].((vm:TaskDetailViewModel)DataContext).RemoveTagCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBox Text="{Binding NewTagInput}"
PlaceholderText="Add tag..."
Width="100" FontSize="12"
BorderThickness="0" Background="Transparent"
Padding="4,3"
KeyDown="OnTagInputKeyDown"/>
</WrapPanel>
</StackPanel>
<!-- Description (editable) -->
<TextBlock Text="Description" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,2"/>
<TextBox Text="{Binding Description}"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="60"
Foreground="{StaticResource TextPrimaryBrush}"
PlaceholderText="Add a description..."
LostFocus="OnFieldLostFocus"/>
<!-- Sub-Tasks -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<ItemsControl ItemsSource="{Binding Subtasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="220"
VerticalAlignment="Center"
LostFocus="OnSubtaskTitleLostFocus"/>
<Button Content="✕" Padding="6,2"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource TextMutedBrush}"
Command="{Binding $parent[UserControl].((vm:TaskDetailViewModel)DataContext).RemoveSubtaskCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
<!-- Agent Config (overrides) -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,8,0,4"/>
<TextBlock Text="Agent Config (overrides)" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<Grid ColumnDefinitions="*,12,*" Margin="0,4,0,0">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Model" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding ModelChoices}"
SelectedItem="{Binding ModelChoice}"
MinWidth="100"
LostFocus="OnFieldLostFocus"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Agent File" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<ComboBox ItemsSource="{Binding AvailableAgents}"
SelectedItem="{Binding SelectedAgent}"
MinWidth="100"
LostFocus="OnFieldLostFocus">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="m:AgentInfo">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
</StackPanel>
</StackPanel>
</Grid>
<TextBlock Text="System Prompt" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,2"/>
<TextBox Text="{Binding SystemPromptOverride}"
PlaceholderText="(inherits from list)"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"
LostFocus="OnFieldLostFocus"/>
<!-- === READ-ONLY ZONE === -->
<TextBlock Text="Result" FontWeight="SemiBold" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,12,0,2"/>
<TextBox Text="{Binding Result, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="60"
IsVisible="{Binding Result, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="(no result yet)" Foreground="{StaticResource TextMutedBrush}" FontStyle="Italic"
IsVisible="{Binding Result, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
<StackPanel Orientation="Horizontal" Spacing="4"
IsVisible="{Binding LogPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="Log:" FontWeight="SemiBold" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding LogPath}" FontSize="11"
Foreground="{StaticResource TextDimBrush}" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="Live Output" FontWeight="SemiBold" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,2"/>
<TextBox x:Name="LiveOutputBox"
Text="{Binding LiveText, Mode=OneWay}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="NoWrap"
FontFamily="Consolas,Courier New,monospace"
FontSize="11"
MaxHeight="300"
Foreground="{StaticResource TextPrimaryBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="6"/>
<Border IsVisible="{Binding HasWorktree}" BorderBrush="{StaticResource AccentBrush}"
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,8,0,0">
<StackPanel Spacing="6">
<TextBlock Text="Worktree" FontWeight="Bold" FontSize="14"
Foreground="{StaticResource TextPrimaryBrush}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Branch:" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding BranchName}" FontFamily="Consolas,Courier New,monospace"
Foreground="{StaticResource TextPrimaryBrush}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="State:" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBlock Text="{Binding WorktreeState}"
Foreground="{StaticResource TextPrimaryBrush}"/>
</StackPanel>
<TextBlock Text="Diff Stat:" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"
IsVisible="{Binding DiffStat, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBox Text="{Binding DiffStat, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" FontFamily="Consolas,Courier New,monospace" FontSize="11"
IsVisible="{Binding DiffStat, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<WrapPanel Orientation="Horizontal" Margin="0,4,0,0">
<Button Content="Open Worktree" Command="{Binding OpenWorktreeCommand}" Margin="0,0,4,4"/>
<Button Content="Show Diff" Command="{Binding ShowDiffCommand}" Margin="0,0,4,4"/>
<Button Content="Merge into main" Command="{Binding MergeIntoMainCommand}"
IsEnabled="{Binding CanWorktreeAction}" Margin="0,0,4,4"/>
<Button Content="Keep as branch" Command="{Binding KeepAsBranchCommand}"
IsEnabled="{Binding CanWorktreeAction}" Margin="0,0,4,4"/>
<Button Content="Discard" Command="{Binding DiscardCommand}"
IsEnabled="{Binding CanWorktreeAction}" Margin="0,0,4,4"/>
</WrapPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -1,85 +0,0 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
public partial class TaskDetailView : UserControl
{
public TaskDetailView()
{
InitializeComponent();
}
private async void OnFieldLostFocus(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskDetailViewModel vm)
await vm.SaveAsync();
}
private void OnSubtaskTitleLostFocus(object? sender, RoutedEventArgs e)
{
// Title change is handled by SubtaskItemViewModel.PropertyChanged → OnSubtaskPropertyChanged in the VM
}
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select Agent File",
AllowMultiple = false,
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
});
if (files.Count == 0) return;
var path = files[0].TryGetLocalPath();
if (path is null) return;
if (DataContext is TaskDetailViewModel vm)
{
vm.SetAgentFromPath(path);
await vm.SaveAsync();
}
}
private void OnTagInputKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Enter && DataContext is TaskDetailViewModel vm)
{
vm.AddTagCommand.Execute(null);
e.Handled = true;
}
}
public void FocusTitle()
{
this.FindControl<TextBox>("TitleBox")?.Focus();
}
private TaskDetailViewModel? _previousVm;
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (_previousVm is not null)
_previousVm.PropertyChanged -= OnViewModelPropertyChanged;
_previousVm = DataContext as TaskDetailViewModel;
if (_previousVm is not null)
_previousVm.PropertyChanged += OnViewModelPropertyChanged;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TaskDetailViewModel.LiveText))
{
var box = this.FindControl<TextBox>("LiveOutputBox");
if (box is not null)
{
box.CaretIndex = box.Text?.Length ?? 0;
}
}
}
}

View File

@@ -1,101 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:models="using:ClaudeDo.Data.Models"
x:Class="ClaudeDo.Ui.Views.TaskEditorView"
x:DataType="vm:TaskEditorViewModel"
Title="{Binding WindowTitle}"
Width="500" Height="600"
WindowStartupLocation="CenterOwner"
CanResize="False"
Background="{StaticResource WindowBgBrush}">
<StackPanel Margin="16" Spacing="10">
<TextBlock Text="Title" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding Title}" PlaceholderText="Task title..."/>
<TextBlock Text="Description" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding Description}" PlaceholderText="(optional)" AcceptsReturn="True"
TextWrapping="Wrap" MinHeight="80"/>
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Status" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding StatusChoices}"
SelectedItem="{Binding StatusChoice}"
MinWidth="120"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Commit Type" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding CommitTypes}"
SelectedItem="{Binding CommitType}"
MinWidth="120"/>
</StackPanel>
</Grid>
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
<!-- Sub-Tasks -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
<TextBlock Text="Sub-Tasks" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<ItemsControl ItemsSource="{Binding Subtasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,2,0,2">
<CheckBox IsChecked="{Binding Completed}" VerticalAlignment="Center"/>
<TextBox Text="{Binding Title}" PlaceholderText="Subtask title..." Width="320"
VerticalAlignment="Center"/>
<Button Content="✕" Padding="6,2"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource TextMutedBrush}"
Command="{Binding $parent[Window].((vm:TaskEditorViewModel)DataContext).RemoveSubtaskCommand}"
CommandParameter="{Binding}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="+ Add Sub-Task" Command="{Binding AddSubtaskCommand}"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource AccentLightBrush}" HorizontalAlignment="Left"/>
<!-- Divider -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
<TextBlock Text="Agent Config (overrides)" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="Model" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding ModelChoices}"
SelectedItem="{Binding ModelChoice}"
MinWidth="150"/>
<TextBlock Text="System Prompt" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding SystemPromptOverride}"
PlaceholderText="(inherits from list)"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"/>
<TextBlock Text="Agent File" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<ComboBox ItemsSource="{Binding AvailableAgents}"
SelectedItem="{Binding SelectedAgent}"
MinWidth="150">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:AgentInfo">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="…" Click="OnBrowseAgent" ToolTip.Tip="Browse for agent .md file"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
Background="{StaticResource AccentBrush}" Foreground="{StaticResource TextPrimaryBrush}"/>
<Button Content="Cancel" Command="{Binding CancelCommand}" IsCancel="True" MinWidth="80"/>
</StackPanel>
</StackPanel>
</Window>

View File

@@ -1,29 +0,0 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
public partial class TaskEditorView : Window
{
public TaskEditorView()
{
InitializeComponent();
}
private async void OnBrowseAgent(object? sender, RoutedEventArgs e)
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Select Agent File",
AllowMultiple = false,
FileTypeFilter = new[] { new FilePickerFileType("Agent Files") { Patterns = ["*.md"] } },
});
if (files.Count == 0) return;
var path = files[0].TryGetLocalPath();
if (path is null) return;
if (DataContext is TaskEditorViewModel vm)
vm.SetAgentFromPath(path);
}
}

View File

@@ -1,161 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:conv="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.TaskListView"
x:DataType="vm:TaskListViewModel"
x:Name="Root">
<DockPanel>
<!-- Inline add field at bottom -->
<Border DockPanel.Dock="Bottom" Padding="8,8"
BorderThickness="0,1,0,0" BorderBrush="{StaticResource BorderSubtleBrush}">
<TextBox x:Name="InlineAddBox"
Text="{Binding InlineAddTitle, Mode=TwoWay}"
PlaceholderText="+ Add a task..."
BorderThickness="1"
BorderBrush="{StaticResource BorderSubtleBrush}"
CornerRadius="8"
Padding="10,8"
FontSize="13"
KeyDown="OnInlineAddKeyDown"
GotFocus="OnInlineAddGotFocus"
LostFocus="OnInlineAddLostFocus"/>
</Border>
<!-- Task list -->
<ListBox x:Name="TaskListBox"
ItemsSource="{Binding Tasks}"
SelectedItem="{Binding SelectedTask}"
Background="Transparent"
Margin="4,0"
KeyDown="OnTaskListKeyDown">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TaskItemViewModel">
<Grid RowDefinitions="Auto,Auto"
Background="Transparent"
Opacity="{Binding RowOpacity}">
<!-- Row 0: Task row -->
<Grid Grid.Row="0" ColumnDefinitions="20,Auto,*" Margin="4,4"
DoubleTapped="OnTaskItemDoubleTapped"
PointerPressed="OnTaskItemPointerPressed">
<Grid.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Edit"
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/>
<MenuItem Header="Delete"
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).DeleteTaskCommand}"/>
<Separator/>
<MenuItem Header="Run Now"
Command="{Binding RunNowCommand}"/>
</MenuFlyout>
</Grid.ContextFlyout>
<!-- Expand/collapse chevron -->
<Button Grid.Column="0"
Command="{Binding ToggleExpandedCommand}"
IsVisible="{Binding HasSubtasks}"
Background="Transparent"
BorderThickness="0"
Padding="0"
Width="16" Height="16"
VerticalAlignment="Center"
Cursor="Hand">
<Panel>
<Canvas Width="10" Height="10"
IsVisible="{Binding !IsExpanded}">
<Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
Data="M 2,0 L 8,5 L 2,10"/>
</Canvas>
<Canvas Width="10" Height="10"
IsVisible="{Binding IsExpanded}">
<Path Stroke="{StaticResource TextDimBrush}" StrokeThickness="1.5"
Data="M 0,2 L 5,8 L 10,2"/>
</Canvas>
</Panel>
</Button>
<!-- Circular checkbox -->
<Border Grid.Column="1" Width="22" Height="22"
CornerRadius="11"
BorderThickness="2"
BorderBrush="{Binding StatusText, Converter={x:Static conv:CheckboxBorderConverter.Instance}}"
Background="Transparent"
VerticalAlignment="Center" Margin="0,0,10,0"
Cursor="Hand"
PointerPressed="OnCheckboxPressed">
<Panel>
<Canvas Width="12" Height="12"
IsVisible="{Binding IsDone}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Path Stroke="{StaticResource StatusGreenBrush}" StrokeThickness="2"
Data="M 1,6 L 4.5,9.5 L 11,3"/>
</Canvas>
<Ellipse Width="8" Height="8"
Fill="{StaticResource StatusOrangeBrush}"
IsVisible="{Binding IsRunning}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Ellipse Width="8" Height="8" Fill="#FFD700"
IsVisible="{Binding IsStarting}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Panel>
</Border>
<!-- Task content -->
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontWeight="Medium"
Foreground="{Binding TitleForeground}"
TextDecorations="{Binding TitleDecorations}"
TextTrimming="CharacterEllipsis"/>
<TextBlock FontSize="11"
Foreground="{StaticResource TextDimBrush}"
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} · {1}">
<Binding Path="TagsText"/>
<Binding Path="StatusText"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
<TextBlock Text="{Binding StatusText}" FontSize="11"
Foreground="{StaticResource TextDimBrush}"
IsVisible="{Binding TagsText, Converter={x:Static StringConverters.IsNullOrEmpty}}"/>
</StackPanel>
</Grid>
<!-- Row 1: Subtask list (visible when expanded) -->
<ItemsControl Grid.Row="1"
ItemsSource="{Binding Subtasks}"
IsVisible="{Binding IsExpanded}"
Margin="40,0,0,4">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SubtaskItemViewModel">
<Grid ColumnDefinitions="Auto,*" Margin="0,2"
PointerPressed="OnSubtaskPointerPressed">
<Grid.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Edit Task"
Command="{Binding #Root.((vm:TaskListViewModel)DataContext).EditTaskCommand}"/>
</MenuFlyout>
</Grid.ContextFlyout>
<CheckBox Grid.Column="0"
IsChecked="{Binding Completed, Mode=OneWay}"
VerticalAlignment="Center"
Margin="0,0,6,0"
MinWidth="0"
Click="OnSubtaskCheckboxClick"/>
<TextBlock Grid.Column="1"
Text="{Binding Title}"
FontSize="12"
Foreground="{StaticResource TextDimBrush}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</UserControl>

View File

@@ -1,128 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
public partial class TaskListView : UserControl
{
public TaskListView()
{
InitializeComponent();
}
private void OnInlineAddKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not TaskListViewModel vm) return;
if (e.Key == Key.Enter)
{
vm.InlineAddCommand.Execute(null);
e.Handled = true;
}
else if (e.Key == Key.Escape)
{
vm.InlineAddTitle = "";
this.FindControl<ListBox>("TaskListBox")?.Focus();
e.Handled = true;
}
}
private void OnInlineAddGotFocus(object? sender, FocusChangedEventArgs e)
{
if (sender is TextBox tb)
tb.BorderBrush = Avalonia.Application.Current?.FindResource("AccentBrush") as Avalonia.Media.IBrush;
}
private void OnInlineAddLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox tb)
tb.BorderBrush = Avalonia.Application.Current?.FindResource("BorderSubtleBrush") as Avalonia.Media.IBrush;
}
private void OnTaskListKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not TaskListViewModel vm || vm.SelectedTask is null) return;
switch (e.Key)
{
case Key.Delete:
vm.DeleteTaskCommand.Execute(null);
e.Handled = true;
break;
case Key.Space:
if (vm.SelectedTask.CanToggleDone)
{
vm.SelectedTask.ToggleDoneCommand.Execute(null);
e.Handled = true;
}
break;
case Key.Enter:
case Key.F2:
var detailView = this.GetVisualAncestors().OfType<Window>().FirstOrDefault()
?.GetVisualDescendants().OfType<TaskDetailView>().FirstOrDefault();
detailView?.FocusTitle();
e.Handled = true;
break;
}
}
private void OnCheckboxPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Border { DataContext: TaskItemViewModel task }) return;
if (task.CanToggleDone)
{
task.ToggleDoneCommand.Execute(null);
e.Handled = true;
}
}
private void OnTaskItemDoubleTapped(object? sender, TappedEventArgs e)
{
if (DataContext is TaskListViewModel vm)
vm.EditTaskCommand.Execute(null);
}
private void OnTaskItemPointerPressed(object? sender, PointerPressedEventArgs e)
{
var props = e.GetCurrentPoint(this).Properties;
if (!props.IsRightButtonPressed) return;
if (sender is Grid { DataContext: TaskItemViewModel item }
&& DataContext is TaskListViewModel vm)
{
vm.SelectedTask = item;
}
}
private void OnSubtaskPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(null).Properties.IsRightButtonPressed
&& sender is Control { DataContext: SubtaskItemViewModel subtask }
&& DataContext is TaskListViewModel vm)
{
var parent = vm.Tasks.FirstOrDefault(t => t.Subtasks.Contains(subtask));
if (parent is not null)
vm.SelectedTask = parent;
}
}
private async void OnSubtaskCheckboxClick(object? sender, RoutedEventArgs e)
{
if (sender is CheckBox { DataContext: SubtaskItemViewModel subtask }
&& DataContext is TaskListViewModel vm)
{
var parent = vm.Tasks.FirstOrDefault(t => t.Subtasks.Contains(subtask));
if (parent is not null)
await parent.ToggleSubtaskDoneCommand.ExecuteAsync(subtask.Id);
}
}
public void FocusInlineAdd()
{
this.FindControl<TextBox>("InlineAddBox")?.Focus();
}
}

Some files were not shown because too many files have changed in this diff Show More