120 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
mika kuns
2a8cd97d02 fix(installer): expand ~ in UiDbPath
All checks were successful
Release / release (push) Successful in 29s
2026-04-17 14:33:30 +02:00
mika kuns
09e8b1f10b fix(ui): init editor TCS before dialog can complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:32:46 +02:00
mika kuns
92d8d902df fix(ui): reset stale worktree state on TaskDetail reload 2026-04-17 14:31:24 +02:00
mika kuns
aa1008dcff fix(ui): capture CurrentListId before await in AddTask 2026-04-17 14:30:35 +02:00
mika kuns
5f3d41e1f6 fix(installer): make user-data deletion on uninstall opt-in
Add bool removeAppData parameter (default false) to UninstallRunner.RunAsync,
gate ~/.todo-app deletion on it, surface a checkbox in SettingsWindow, and
update the confirmation message to reflect whether data will be removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:29:35 +02:00
mika kuns
7d48f34b15 fix(installer): rollback-safe extract with .bak stash 2026-04-17 14:27:45 +02:00
mika kuns
51a1bbe6b8 fix(installer): move service start out of RegisterServiceStep 2026-04-17 14:26:34 +02:00
mika kuns
ad7c9facaf fix(worker): escape newline/tab in CLI args 2026-04-17 14:25:15 +02:00
mika kuns
11a4376da5 fix(worker): guard against same task in queue and override slot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 14:23:08 +02:00
mika kuns
f10ad69863 fix(installer): reject CurrentUser service account without password 2026-04-17 14:20:09 +02:00
mika kuns
dc4571a338 fix(ui): swallow DB errors in TaskListViewModel.OnTaskUpdated 2026-04-17 14:19:11 +02:00
mika kuns
4fb6ba6be8 fix(worker): emit RunCreated after run row exists
Remove premature RunCreated broadcast from WorkerHub.RunNow and the
duplicate calls in RunAsync retry block and ContinueAsync. RunOnceAsync
now owns the broadcast for every run, fired immediately after the row
insert so the UI never receives an event for a non-existent row.
2026-04-17 14:17:00 +02:00
mika kuns
3423919655 fix: resolve critical bugs and improve reliability across worker, data, UI
- Fix worker using wrong DB by defaulting to CurrentUser service account
  and expanding ~ to absolute paths at install time
- Fix DbContext disposed before fire-and-forget by passing taskId instead
  of TaskEntity into RunInSlotAsync, which creates its own context
- Fix ActiveTaskDto property casing mismatch between hub and client
- Move WAL mode PRAGMA before migrations to prevent concurrent lock issues
- Replace FirstAsync with FirstOrDefaultAsync + null guards in tag operations
- Add delete confirmation flow for lists
- Log fire-and-forget exceptions instead of swallowing them
- Broadcast RunCreated event from WorkerHub.RunNow
- Add IDisposable to MainWindowViewModel for event handler cleanup
- Preserve subtask CreatedAt on updates instead of overwriting
- Replace bare catch blocks with Debug.WriteLine logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:12:59 +02:00
mika kuns
fca2bdb596 Merge remote-tracking branch 'origin/main' 2026-04-16 12:26:50 +02:00
mika kuns
721f0cd903 Merge branch 'feat/subtask-tree-view' 2026-04-16 12:17:46 +02:00
mika kuns
32bb52875f feat(ui): add subtask tree view with expand/collapse in task list
Tasks with subtasks show a chevron for inline expand/collapse.
Subtask checkboxes toggle completion state directly. Also sets
Windows AppUserModelID for proper taskbar identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:16:22 +02:00
mika kuns
4f25c3dd40 docs: add subtask tree view design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:18:04 +02:00
33fedc7e26 Merge pull request 'feat/efcore-migration' (#3) from feat/efcore-migration into main
All checks were successful
Release / release (push) Successful in 30s
Reviewed-on: #3
2026-04-16 07:38:59 +00:00
mika kuns
4ca48044db chore: add design-time.db to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:10:53 +02:00
mika kuns
611454df1e fix(data): address code review findings
- Fix sort order regression in GetByListIdAsync (was descending, should be ascending)
- Restore WAL mode pragma (was silently dropped in EF migration)
- Add existing-DB compatibility shim in MigrateAndConfigure (baselines InitialCreate
  migration for databases created by the old schema.sql)
- Remove dead AddDbContext/AddScoped registrations from Worker (only IDbContextFactory
  is used by singleton consumers)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:10:35 +02:00
mika kuns
8d61b05179 docs: update CLAUDE.md files for EF Core migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:03:15 +02:00
mika kuns
7d0ca45a60 refactor(installer): switch InitDatabaseStep to EF Core migrations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:01:50 +02:00
mika kuns
36484ed45a feat(worker,ui): wire EF Core into DI and update all consumers to IDbContextFactory
Worker and App Program.cs: replace SqliteConnectionFactory+SchemaInitializer
with AddDbContextFactory<ClaudeDoDbContext> + Database.Migrate(). Repos
changed from AddSingleton to AddScoped.

All singleton services (QueueService, StaleTaskRecovery, WorktreeManager,
TaskRunner) and singleton ViewModels (MainWindowViewModel, TaskDetailViewModel,
TaskListViewModel, TaskEditorViewModel) now take IDbContextFactory<ClaudeDoDbContext>
and create short-lived contexts per operation.

Test infrastructure: DbFixture now uses EF migrations instead of SchemaInitializer;
all test classes create contexts via DbFixture.CreateContext().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:59:24 +02:00
mika kuns
b7be52a623 chore(data): remove raw ADO.NET infrastructure, add EF migration and design-time factory
Delete SqliteConnectionFactory, SchemaInitializer, and schema.sql.
Fix ValueConverter lambdas in entity configurations (no throw-expressions
in expression trees). Add IDesignTimeDbContextFactory for dotnet-ef tooling.
Generate InitialCreate migration with seed data for agent/manual tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:59:06 +02:00
mika kuns
34ca1b018f feat(data): rewrite all repositories to use EF Core ClaudeDoDbContext
Replace raw ADO.NET implementations with EF Core LINQ queries and
ExecuteUpdate/ExecuteDelete for bulk operations. TaskRepository preserves
FlipAllRunningToFailedAsync(reason) signature and keeps raw SQL for the
atomic queue claim (UPDATE...RETURNING). GetByListAsync alias kept for
backwards compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:58:57 +02:00
mika kuns
51a5dcbb73 feat(data): add ClaudeDoDbContext with Fluent API configurations 2026-04-16 08:37:37 +02:00
mika kuns
f8f13865d2 feat(data): add navigation properties to all entity models 2026-04-16 08:36:22 +02:00
mika kuns
a064865417 chore(data): swap Microsoft.Data.Sqlite for EF Core packages 2026-04-16 08:35:40 +02:00
mika kuns
9236ca6d45 docs(data): add EF Core migration implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:32:18 +02:00
mika kuns
9e1f1370bb docs(data): add EF Core migration design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:20:33 +02:00
Mika Kuns
3b1f148122 feat(branding): add app and installer icons
All checks were successful
Release / release (push) Successful in 27s
- app: ClaudeTask.ico wired as ApplicationIcon and MainWindow.Icon
  via avares URI
- installer: ClaudeTaskSetup.ico wired as ApplicationIcon and embedded
  as a WPF Resource so WizardWindow and SettingsWindow can reference it
  via pack URI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:28:15 +02:00
Mika Kuns
2b3fe02d8c fix(ui): prevent async void races and leak-on-exit
- task detail vm: LoadAsync now uses a per-call CancellationTokenSource
  so rapid TaskUpdated events can't race on _taskId / Subtasks / Tags;
  old subtask PropertyChanged handlers are torn down before Clear
- task detail vm: async void event handlers (OnTaskUpdated,
  OnWorktreeUpdated, OnSubtaskPropertyChanged) wrap work in try/catch
  so thrown exceptions can't crash the Avalonia sync context
- task detail vm: Clear cancels/disposes the load CTS so a late-arriving
  LoadAsync can't resurrect detail state after deselect
- app: DisposeAsync the ServiceProvider in a finally after the classic
  desktop lifetime ends, so WorkerClient.DisposeAsync runs and the
  SignalR connection closes cleanly instead of being abandoned

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:45 +02:00
Mika Kuns
d3b85f2234 fix(worker): address concurrency, cancellation, and resource issues
- claude process: run stdout/stderr reads without ct; rely on
  kill-on-cancel closing the pipes to unblock them — previously
  ReadLineAsync(ct) could hang, stalling task slots and shutdown
- task runner: terminal db writes (task_runs, MarkDone, MarkFailed,
  SetLogPath) now use CancellationToken.None; RunOnceAsync catches
  OCE and finalizes the run row so ContinueAsync can resume
- task repository: GetNextQueuedAgentTaskAsync is now a single
  UPDATE ... RETURNING statement — closes TOCTOU window where two
  loop iterations could dispatch the same queued task
- queue service: dispose CancellationTokenSource in slot-completion
  ContinueWith to stop leaking wait handles
- git service: register ct.Kill(processTree), drain reads without ct,
  always reap via WaitForExitAsync(None) — no more git zombies on
  cancelled worktree ops
- worktree manager: branch name uses full task id (dashes stripped)
  instead of 8-char prefix, eliminating collision risk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:18 +02:00
Mika Kuns
fc9029de97 fix(installer): wait for prior service registration to clear before create
After `sc delete`, the service stays in "marked for deletion" state until
every open handle (services.msc, Task Manager Services tab, Event Viewer,
prior sc query process) is closed. The installer used to immediately call
`sc create` and hit a silent hang / confusing "specified service has been
marked for deletion" error.

Poll `sc query` for up to 30s after delete; if the service is still
registered past that, fail with actionable guidance (close the offending
console or reboot). Also translate exit 1072 from `sc create` into the
same human-readable hint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:54:36 +02:00
Mika Kuns
1c764dae3f fix(installer): publish framework-dependent single-file
All checks were successful
Release / release (push) Successful in 27s
Self-contained single-file bundle was crashing at startup with 0xc0000005
in the apphost bootstrap (COR_E_EXECUTIONENGINE), because the Linux Gitea
runner doesn't carry the Microsoft.WindowsDesktop.App runtime pack — the
resulting bundle was missing WPF runtime bits. Disabling compression alone
didn't resolve it.

Switch to framework-dependent single-file: target machines need the .NET 8
Desktop Runtime (x64) installed but the bundle is ~2 MB instead of ~140 MB
and starts reliably. Keep IncludeAllContentForSelfExtract=true so native
deps (e_sqlite3) extract to a temp dir on first run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:11:27 +02:00
Mika Kuns
cfec3297a4 fix(installer): disable single-file compression to prevent WPF startup AV
All checks were successful
Release / release (push) Successful in 30s
Published installer was crashing at launch with 0xc0000005 (access violation)
and exit code 0x80131506 (COR_E_EXECUTIONENGINE), faulting inside the exe's
own bundle-extractor bootstrap before any managed code ran. This is a known
interaction between WPF, single-file publish, and EnableCompressionInSingleFile.

Disable compression (exe grows ~30% but startup becomes reliable).
IncludeAllContentForSelfExtract stays true — WPF still needs on-disk
extraction for XAML resources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:48:06 +02:00
Mika Kuns
6e1d64b489 Change sdk Version
All checks were successful
Release / release (push) Successful in 39s
2026-04-15 14:23:26 +02:00
Mika Kuns
f599f8d0af fix(installer,worker): service hosting, dark theme, uninstall polish
Some checks failed
Release / release (push) Failing after 0s
Worker:
- Wire UseWindowsService + Microsoft.Extensions.Hosting.WindowsServices so
  SCM's Service Control Protocol handshake succeeds. Previously the binary
  exited immediately under sc start, leaving the service registered but
  never running.

Installer:
- Pin SDK to .NET 9 (global.json) — SDK 10 dropped win-arm from its RID
  graph, breaking restore of the WPF project; .NET 9 keeps win-arm AND
  understands the .slnx solution format.
- Force SelfContained=true and default RID=win-x64 when PublishSingleFile
  is set, so Rider Publish and CLI produce the same bundle.
- Dark theme: set Background/Foreground explicitly on WizardWindow and
  SettingsWindow roots (WPF implicit styles don't cascade to derived
  Window types). Custom ComboBox template + ComboBoxItem style so
  dropdowns honour the dark palette instead of system defaults.
- Throttle download progress to one report per MB and overwrite the same
  UI line (\r prefix marker) instead of appending per chunk.
- Register ClaudeDo in HKLM\...\Uninstall so it appears in Apps & Features.
  Copy installer into InstallDir\uninstaller\ for the UninstallString, and
  schedule a cmd.exe trampoline to handle the self-delete case when
  Apps & Features launches the copy from inside the install dir.
- Treat sc.exe stop exit 1062 (ERROR_SERVICE_NOT_ACTIVE) as success.
- Delete the uninstall registry key during UninstallRunner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:19:09 +02:00
Mika Kuns
9b928c6217 fix(installer): set EnableWindowsTargeting so Linux Gitea runners can publish
All checks were successful
Release / release (push) Successful in 40s
The release workflow runs on a Linux container; building net8.0-windows +
UseWPF=true requires this opt-in property since .NET 8. No-op on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:39:23 +02:00
c9e38aef88 Merge pull request 'feat/ui-improvements' (#1) from feat/ui-improvements into main
Some checks failed
Release / release (push) Failing after 19s
Reviewed-on: #1
2026-04-15 09:28:58 +00:00
66843d242b Merge branch 'main' into feat/ui-improvements 2026-04-15 09:28:18 +00:00
6afe5959ca Merge pull request 'feat/release-workflow' (#2) from feat/release-workflow into main
Reviewed-on: #2
2026-04-15 09:27:53 +00:00
b623651a5d Merge branch 'main' into feat/release-workflow 2026-04-15 09:27:21 +00:00
Mika Kuns
6b1b920149 feat(installer): download-mode rewrite + Gitea Releases pipeline
Rewrites ClaudeDo.Installer to fetch prebuilt binaries from
git.kuns.dev/releases/ClaudeDo instead of building from source.

- Async InstallModeDetector: FreshInstall / Update / Config
- DownloadAndExtractStep with SHA256 verify + scratch-dir extract
- UninstallRunner: stop-service / delete / full ~/.todo-app removal
  with path guard + partial-failure reporting
- Config view: Save / Repair / Uninstall buttons
- Self-contained single-file publish for the installer itself
- 29 xUnit tests in new ClaudeDo.Installer.Tests project

Spec: docs/superpowers/specs/2026-04-15-installer-download-mode-design.md
Plan: docs/superpowers/plans/2026-04-15-installer-download-mode.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:22:01 +02:00
Mika Kuns
9a407bde83 feat(ui): agent config inline in detail panel, file picker, subtask UI
TaskDetailView now edits Model / SystemPrompt / Agent inline (LostFocus
save), matching the modal editor. Both TaskEditorView and TaskDetailView
gain a Browse button that opens a .md file picker — external agent
paths are preserved on reload via a synthetic AgentInfo entry. Both
views also render the per-task subtask checklist (CheckBox + TextBox +
remove), with diff-on-save in the editor and inline-save in the detail
panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:20:17 +02:00
Mika Kuns
8c051d8f62 feat(data): add subtasks table, repository and prompt integration
Per-task checklist backend: subtasks table with CASCADE delete,
SubtaskEntity + SubtaskRepository (connection-per-op, async), DI
registration in App and Worker, TaskRunner composes a '## Sub-Tasks'
markdown block into the Claude prompt when subtasks exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:19:54 +02:00
Mika Kuns
8577c55685 feat(ui): remove MaxWidth on main columns to use full window width
Lists (320px) and Detail (500px) borders no longer cap the 3-column
grid — star-sizing (1*:2*:1.5*) now fills the window, reducing the
dead whitespace between columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:19:41 +02:00
7119c8474e ci(release): use system zip now that it's installed on the runner 2026-04-15 06:33:50 +00:00
aea09098e6 feat(ci): add Gitea Actions release workflow
Builds App + Worker + Installer for win-x64 self-contained on v* tag push,
bundles into ClaudeDo-<version>-win-x64.zip (app + worker),
renames installer to ClaudeDo.Installer-<version>.exe,
writes sha256 checksums.txt, then creates a Gitea Release on
releases/ClaudeDo and attaches all three assets.

Uses the workflow-scoped GITEA_TOKEN; no PAT required.
Host-mode runner (ubuntu-latest:host) with installed .NET 8 at
/home/mika/.dotnet. Uses python3 -m zipfile because the host
runner has no zip CLI, and git clone instead of actions/checkout
because DEFAULT_ACTIONS_URL=self has no local checkout mirror.
2026-04-15 06:31:50 +00:00
178 changed files with 19475 additions and 3166 deletions

View File

@@ -0,0 +1,174 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
env:
DOTNET_ROOT: /home/mika/.dotnet
GITEA_API: https://git.kuns.dev/api/v1
REPO: releases/ClaudeDo
steps:
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building version: $VERSION (tag: $TAG)"
- name: Prepare workspace
id: ws
run: |
set -euo pipefail
WORK="$(mktemp -d -t claudedo-release-XXXXXX)"
echo "dir=$WORK" >> "$GITHUB_OUTPUT"
echo "Workspace: $WORK"
- name: Checkout tag
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
git clone --depth 1 --branch "$TAG" \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
"$WORK/src"
git -C "$WORK/src" log -1 --oneline
- name: Publish ClaudeDo.App (win-x64, self-contained)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/app
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/worker
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
# Framework-dependent — WPF runtime pack isn't distributed on Linux SDK;
# the previous self-contained bundle crashed at startup (apphost AV).
# Target machines need .NET 8 Desktop Runtime (x64).
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
-c Release -r win-x64 --self-contained false \
/p:Version=$VERSION /p:PublishSingleFile=true \
-o out/installer
- name: Package assets
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
cd "$WORK/src"
mkdir -p assets
# 1) App + Worker bundle (top-level dirs /app and /worker)
rm -rf bundle
mkdir -p bundle
cp -r out/app bundle/app
cp -r out/worker bundle/worker
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
# 2) Installer single-file exe (renamed)
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
if [ -z "$INSTALLER_EXE" ]; then
echo "::error::No .exe produced by installer publish" >&2
exit 1
fi
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
# 3) Checksums (sha256, relative filenames)
( cd assets && sha256sum \
"ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \
> checksums.txt )
echo "--- assets ---"
ls -la assets
- name: Create Gitea Release
id: release
env:
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
BODY=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${GITEA_API}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "::error::Release creation failed" >&2
echo "$RESP" >&2
exit 1
fi
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
echo "Created release id=$RELEASE_ID for tag=$TAG"
- name: Upload release assets
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
cd "$WORK/src/assets"
for f in \
"ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \
"checksums.txt"
do
echo "Uploading: $f"
curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${f}" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
> /dev/null
done
echo "All assets uploaded."
- name: Cleanup workspace
if: always()
env:
WORK: ${{ steps.ws.outputs.dir }}
run: |
rm -rf "$WORK" || true

1
.gitignore vendored
View File

@@ -61,3 +61,4 @@ Desktop.ini
*.log *.log
*.tmp *.tmp
*.bak *.bak
design-time.db

View File

@@ -15,7 +15,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
## Tech Stack ## Tech Stack
- .NET 8.0, Avalonia 12.0.0 (Fluent theme) - .NET 8.0, Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM - SQLite (WAL mode) via Entity Framework Core (Microsoft.EntityFrameworkCore.Sqlite)
- SignalR for real-time IPC - SignalR for real-time IPC
- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`) - CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
- Git worktrees for task isolation - Git worktrees for task isolation
@@ -27,12 +27,14 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- Worker config: `~/.todo-app/worker.config.json` - Worker config: `~/.todo-app/worker.config.json`
- Logs: `~/.todo-app/logs/` - Logs: `~/.todo-app/logs/`
- Worktrees: configured per worker (sibling or central strategy) - Worktrees: configured per worker (sibling or central strategy)
- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
## Conventions ## Conventions
- Repository pattern — each entity has its own async repository - Repository pattern — each entity has its own async repository
- All data operations are async with CancellationToken support - All data operations are async with CancellationToken support
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Manual | Queued -> Running -> Done | Failed - Task status flow: Manual | Queued -> Running -> Done | Failed
- Worktree state flow: Active -> Merged | Discarded | Kept - Worktree state flow: Active -> Merged | Discarded | Kept
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup - Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup

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

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,253 @@
# EF Core Migration Design
Replace the raw ADO.NET / Microsoft.Data.Sqlite data layer with Entity Framework Core and LINQ queries.
## Motivation
- Developer ergonomics: raw SQL is tedious to write and maintain; LINQ enables faster iteration.
- Maintainability: the ad-hoc migration approach (ALTER TABLE with error-code catching) and manual DBNull/enum mapping are a liability as the schema grows. EF Core provides proper migration versioning, value converters, and change tracking.
## Decision Summary
| Decision | Choice |
|---|---|
| Approach | Big bang — rewrite all 6 repositories at once |
| Migration strategy | Fresh start — EF Core owns the schema, drop schema.sql |
| DbContext sharing | Single shared `ClaudeDoDbContext` in ClaudeDo.Data |
| Configuration style | Fluent API only, clean POCO models |
| Atomic queue claim | Kept as `FromSqlRaw` — not expressible in LINQ |
---
## 1. DbContext and Entity Configuration
### ClaudeDoDbContext
A single `ClaudeDoDbContext` in `ClaudeDo.Data` with DbSets for all entities:
```csharp
public class ClaudeDoDbContext : DbContext
{
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
}
```
### Entity-to-Table Mapping
| Entity | Table | Key | Notes |
|---|---|---|---|
| `TaskEntity` | `tasks` | `Id` (TEXT) | Nav to List, Tags, Worktree, Runs, Subtasks |
| `ListEntity` | `lists` | `Id` (TEXT) | Nav to Tasks, Tags, Config |
| `TagEntity` | `tags` | `Id` (INTEGER auto) | Nav to Lists, Tasks (both M:N) |
| `ListConfigEntity` | `list_config` | `ListId` (TEXT) | 1:1 owned by List |
| `WorktreeEntity` | `worktrees` | `TaskId` (TEXT) | 1:1 owned by Task |
| `TaskRunEntity` | `task_runs` | `Id` (TEXT) | FK to Task |
| `SubtaskEntity` | `subtasks` | `Id` (TEXT) | FK to Task |
### Navigation Properties Added to Models
```csharp
// TaskEntity gains:
public ListEntity List { get; set; }
public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; }
public ICollection<TaskRunEntity> Runs { get; set; }
public ICollection<SubtaskEntity> Subtasks { get; set; }
// ListEntity gains:
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
public ICollection<TagEntity> Tags { get; set; }
// TagEntity gains:
public ICollection<ListEntity> Lists { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
```
### Enum Handling
EF Core `ValueConverter<TEnum, string>` for `TaskStatus` and `WorktreeState`, storing the same lowercase strings (`"manual"`, `"active"`, etc.) for database compatibility. The `ToDb`/`FromDb` methods in repositories are removed.
### Junction Tables
`list_tags` and `task_tags` are configured as implicit join tables via `.UsingEntity()` in Fluent API — no explicit junction entity classes needed.
### Fluent Configuration
Each entity gets its own `IEntityTypeConfiguration<T>` class in a `Configuration/` folder within `ClaudeDo.Data`.
---
## 2. Migration Strategy
### Fresh Start
- `schema.sql` and `SchemaInitializer` are deleted.
- An initial EF Core migration (`InitialCreate`) is generated from the DbContext model, producing the full schema (all 8 tables, indexes, foreign keys, check constraints).
- EF's `__EFMigrationsHistory` table tracks applied migrations.
### Startup
Both App and Worker call `context.Database.Migrate()` at startup instead of `SchemaInitializer.Apply()`. This is idempotent.
### Existing Database Compatibility
For users who already have a database created by `schema.sql`, the initial migration must handle the schema already existing. On startup, if the `lists` table exists but `__EFMigrationsHistory` does not, insert the initial migration record into `__EFMigrationsHistory` so EF skips it.
### Seed Data
The `"agent"` and `"manual"` tags move into `OnModelCreating` via `HasData()`:
```csharp
modelBuilder.Entity<TagEntity>().HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
```
### Ad-hoc Migrations Removed
The 3 manual `ALTER TABLE` statements (model, system_prompt, agent_path on tasks) become part of the initial migration since they're already in the model. The manual `ApplyMigrations()` method is deleted.
---
## 3. Repository Rewrite
All 6 repositories are rewritten to use `ClaudeDoDbContext` and LINQ.
### Per-Repository Changes
| Repository | After EF Core |
|---|---|
| `TagRepository` | LINQ queries. `GetOrCreateAsync` uses `FirstOrDefaultAsync` + `Add` + `SaveChangesAsync`. Static `SqliteConnection` overload removed. |
| `SubtaskRepository` | Straightforward LINQ CRUD, `.OrderBy(s => s.OrderNum)`. |
| `WorktreeRepository` | LINQ CRUD. State update becomes property set + `SaveChangesAsync`. |
| `ListRepository` | LINQ CRUD. Tag management via `.Tags` navigation property. Config upsert via `List.Config` navigation. |
| `TaskRunRepository` | LINQ CRUD. Latest = `.OrderByDescending(r => r.RunNumber).FirstOrDefaultAsync()`. |
| `TaskRepository` | See special cases below. |
### TaskRepository Special Cases
**Atomic queue claim** (`GetNextQueuedAgentTaskAsync`): kept as `FromSqlRaw` / `ExecuteSqlRawAsync`. The `UPDATE ... WHERE id = (SELECT ...) RETURNING` is not expressible in LINQ and the atomicity guarantee matters.
**Effective tags** (`GetEffectiveTagsAsync`): LINQ via navigation properties:
```csharp
var taskTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags);
var listTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.List.Tags);
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
```
**FlipAllRunningToFailed**: EF Core 7+ bulk update:
```csharp
await context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Failed), ct);
```
**Status transitions** (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`): property updates + `SaveChangesAsync`.
### Removed Code
- `SqliteConnectionFactory.cs`
- `SchemaInitializer.cs`
- `schema/schema.sql`
- All `ToDb`/`FromDb` enum mapping methods
- All manual `DBNull.Value` handling
- `BindTask` helper methods
---
## 4. Package Changes and DI Registration
### ClaudeDo.Data.csproj
- Remove: `Microsoft.Data.Sqlite`
- Remove: embedded resource for `schema.sql`
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
- Add: `Microsoft.EntityFrameworkCore.Design` (`PrivateAssets="all"`)
### ClaudeDo.Worker.Tests.csproj
- Remove: `Microsoft.Data.Sqlite`
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
### App DI (Program.cs)
```csharp
// Replace SqliteConnectionFactory + singleton repos with:
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));
sc.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
sc.AddScoped<ListRepository>();
sc.AddScoped<TaskRepository>();
sc.AddScoped<SubtaskRepository>();
sc.AddScoped<TagRepository>();
sc.AddScoped<WorktreeRepository>();
sc.AddScoped<TaskRunRepository>();
// Migrate at startup:
using var initScope = services.CreateScope();
initScope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
```
ViewModels are singletons that currently take repositories as constructor parameters. Since repositories become scoped, ViewModels switch to taking `IDbContextFactory<ClaudeDoDbContext>` and create a fresh context (+ repositories) per operation. Each ViewModel method that touches data does: `using var context = _factory.CreateDbContext();` then constructs or resolves the needed repository with that context. This mirrors the current connection-per-call pattern.
### Worker DI (Program.cs)
```csharp
builder.Services.AddDbContext<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<SubtaskRepository>();
builder.Services.AddScoped<TagRepository>();
builder.Services.AddScoped<WorktreeRepository>();
builder.Services.AddScoped<TaskRunRepository>();
// Migrate at startup after build:
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
```
Worker has request scopes via SignalR hub invocations, so scoped registration works naturally.
---
## 5. Test Infrastructure
### DbFixture
`DbFixture` is rewritten as an EF Core fixture:
- Creates a temp SQLite file per test class.
- Builds `DbContextOptions<ClaudeDoDbContext>` with `UseSqlite`.
- Calls `context.Database.Migrate()` to apply the schema (also tests that migrations work).
- Exposes a `CreateContext()` method so each test gets a fresh context instance (avoids change-tracker bleed).
Tests construct repositories by passing in a fresh context from the fixture.
No mocking — tests keep hitting real SQLite, same philosophy as today.
---
## 6. Risk and Mitigation
| Risk | Mitigation |
|---|---|
| Big-bang rewrite touches nearly every file in ClaudeDo.Data | Existing tests are the safety net — all must pass after migration |
| Existing databases with schema from schema.sql | Compatibility shim: detect existing tables, mark initial migration as applied |
| Atomic queue claim semantics change | Kept as raw SQL via `FromSqlRaw` |
| Scoped lifetime vs. singleton ViewModels | `IDbContextFactory` provides on-demand contexts |
| EF change tracker overhead vs. raw ADO.NET | Negligible for this workload size; use `AsNoTracking()` for read-only queries |

View File

@@ -0,0 +1,118 @@
# Subtask Tree View in Task List
## Problem
Subtasks are invisible in the task list — users only see them after opening the detail pane or editor modal. This makes it hard to get an overview of task progress without clicking into each task individually.
## Solution
Show subtasks indented below their parent task in the task list, with expand/collapse. Tasks start collapsed with a visual indicator when subtasks exist.
## Scope
Pure UI/ViewModel change. No data model changes, no new migrations, no repository schema changes.
## Design
### ViewModel Changes
**TaskItemViewModel** — add:
- `ObservableCollection<SubtaskItemViewModel> Subtasks` — populated on first expand
- `bool IsExpanded` — observable, default `false`; toggles subtask visibility
- `bool HasSubtasks` — observable, set during initial load from a count query
- `int SubtaskCount` — observable, used for the indicator
- `ToggleExpandedCommand` — flips `IsExpanded`; on first expand, loads subtasks from `SubtaskRepository.GetByTaskIdAsync`
- `ToggleSubtaskDoneCommand(string subtaskId)` — toggles a subtask's `Completed` and persists via `SubtaskRepository.UpdateAsync`
Constructor gains `SubtaskRepository` and initial `subtaskCount` parameter.
**TaskListViewModel.LoadAsync** — after fetching tasks, run a single batch query to get subtask counts per task. Pass counts into each `TaskItemViewModel`. This avoids N+1 queries on load.
**TaskListViewModel.RefreshSingleAsync** — if the refreshed task's `IsExpanded` is true, also reload its subtasks from DB and update the collection.
### Repository Changes
**SubtaskRepository** — add one method:
```csharp
Task<Dictionary<string, int>> GetCountsByTaskIdsAsync(IEnumerable<string> taskIds, CancellationToken ct = default)
```
Single query: `SELECT task_id, COUNT(*) FROM subtasks WHERE task_id IN (...) GROUP BY task_id`. Returns a map of taskId -> count. Tasks with no subtasks won't appear in the result (count defaults to 0).
### XAML Changes
**TaskListView.axaml** — the `DataTemplate` for `TaskItemViewModel` becomes a 2-row grid:
```
Row 0: [ExpandChevron] [StatusCircle] [Title + Tags/Status subtitle]
Row 1: [SubtaskItemsControl, margin-left ~40px, visible when IsExpanded]
```
**Row 0 — Expand chevron:**
- Column 0 gets a small chevron button (12x12 `Path` data) before the status circle
- Right-pointing when collapsed, down-pointing when expanded
- Bound to `ToggleExpandedCommand`
- Only visible when `HasSubtasks` is true (via `IsVisible` binding)
- When `HasSubtasks` is false, the space is empty but reserved (fixed-width column) so all titles align
**Row 1 — Subtask list:**
- `ItemsControl` bound to `Subtasks`
- `IsVisible` bound to `IsExpanded`
- Left margin ~40px for visual indentation
- Each subtask item: `CheckBox` (bound to `Completed`) + `TextBlock` (bound to `Title`)
- Subtask row has its own context menu flyout with "Edit Task" (opens parent task's editor modal via `EditTaskCommand` on root `TaskListViewModel`)
- Checkbox toggle calls `ToggleSubtaskDoneCommand` on the parent `TaskItemViewModel`
**Column layout change:** The existing 2-column `Grid` (`Auto, *`) gets a third column prepended: `Auto, Auto, *`. The chevron goes in column 0, status circle in column 1, title stack in column 2. Row 1 spans all 3 columns.
### Subtask Checkbox Interaction
When a subtask checkbox is toggled in the list:
1. Update the `SubtaskItemViewModel.Completed` property
2. Call `SubtaskRepository.UpdateAsync` with the updated entity (same auto-save pattern as `TaskDetailView`)
3. No need to refresh the parent task — subtask completion doesn't affect task status
### Subtask Context Menu
Right-click on a subtask row shows:
- "Edit Task" — opens the parent task's editor modal (same flow as `EditTaskCommand`)
This reuses the existing editor which already has full subtask editing (add/remove/reorder/rename).
### Real-time Updates
When `RefreshSingleAsync` fires (via SignalR `TaskUpdatedEvent`):
1. Reload subtask count, update `HasSubtasks` and `SubtaskCount`
2. If `IsExpanded`, reload subtask list from DB and reconcile with the observable collection
### Detail Pane Sync
When the user edits subtasks in `TaskDetailView` (auto-save) or `TaskEditorView` (batch-save), the list view's subtask state may become stale. Two options:
**Chosen approach:** The detail pane and editor already trigger `TaskUpdatedEvent` (or the editor's save path calls `RefreshSingleAsync` via `SelectedTask.Refresh`). Extend `Refresh` on `TaskItemViewModel` to also reload subtasks if expanded, and update `HasSubtasks`/`SubtaskCount`.
### Visual Style
- Chevron: 10x10 path, `TextDimBrush` color, no background, cursor=Hand
- Subtask rows: smaller font (12px), `TextDimBrush` for unchecked title, strikethrough + dimmed for completed
- Subtask checkbox: standard Avalonia `CheckBox` (no custom circular border), small size
- Subtask row vertical padding: 2px (compact)
- Indent: 40px left margin on the subtask `ItemsControl`
## Files to Modify
1. `src/ClaudeDo.Data/Repositories/SubtaskRepository.cs` — add `GetCountsByTaskIdsAsync`
2. `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — add subtask collection, expand/collapse, toggle done
3. `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — batch-load counts, pass SubtaskRepository, extend refresh
4. `src/ClaudeDo.Ui/Views/TaskListView.axaml` — restructure item template with chevron + nested ItemsControl
5. `src/ClaudeDo.Ui/Views/TaskListView.axaml.cs` — handle subtask context menu pointer-pressed if needed
6. `src/ClaudeDo.App/Program.cs` — pass SubtaskRepository to TaskListViewModel (if not already available via DI)
## Out of Scope
- Drag-to-reorder subtasks in the list view
- Add subtask directly from the list view
- Subtask progress indicator (e.g., "2/5 done") on collapsed tasks
- Recursive task nesting (tasks containing tasks)

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.

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.418",
"rollForward": "latestFeature"
}
}

View File

@@ -1,90 +0,0 @@
-- ClaudeDo SQLite schema (single source of truth, 3NF)
-- Applied by Worker on first startup. WAL mode is set via PRAGMA after open.
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
working_dir TEXT NULL,
default_commit_type TEXT NOT NULL DEFAULT 'chore'
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NULL,
status TEXT NOT NULL CHECK (status IN ('manual','queued','running','done','failed')),
scheduled_for TIMESTAMP NULL,
result TEXT NULL,
log_path TEXT NULL,
created_at TIMESTAMP NOT NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
commit_type TEXT NOT NULL DEFAULT 'chore'
);
CREATE INDEX IF NOT EXISTS idx_tasks_list_id ON tasks(list_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS list_tags (
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, tag_id)
);
CREATE TABLE IF NOT EXISTS task_tags (
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);
CREATE TABLE IF NOT EXISTS list_config (
list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
model TEXT NULL,
system_prompt TEXT NULL,
agent_path TEXT NULL
);
CREATE TABLE IF NOT EXISTS worktrees (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
path TEXT NOT NULL,
branch_name TEXT NOT NULL,
base_commit TEXT NOT NULL,
head_commit TEXT NULL,
diff_stat TEXT NULL,
state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','discarded','kept')),
created_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS task_runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
run_number INTEGER NOT NULL,
session_id TEXT NULL,
is_retry INTEGER NOT NULL DEFAULT 0,
prompt TEXT NOT NULL,
result_markdown TEXT NULL,
structured_output TEXT NULL,
error_markdown TEXT NULL,
exit_code INTEGER NULL,
turn_count INTEGER NULL,
tokens_in INTEGER NULL,
tokens_out INTEGER NULL,
log_path TEXT NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
-- Seed: minimal tag set (ignored if already present)
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
INSERT OR IGNORE INTO tags (name) VALUES ('manual');

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\ClaudeTask.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>

View File

@@ -1,29 +1,51 @@
using Avalonia; using Avalonia;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui; using ClaudeDo.Ui;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Runtime.InteropServices;
namespace ClaudeDo.App; namespace ClaudeDo.App;
sealed class Program sealed class Program
{ {
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SetCurrentProcessExplicitAppUserModelID(string appId);
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
var services = BuildServices(); var services = BuildServices();
App.Services = services; App.Services = services;
// Ensure DB schema exists using (var scope = services.CreateScope())
var factory = services.GetRequiredService<SqliteConnectionFactory>(); {
SchemaInitializer.Apply(factory); var db = scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>();
ClaudeDoDbContext.MigrateAndConfigure(db);
}
BuildAvaloniaApp() try
.StartWithClassicDesktopLifetime(args); {
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
finally
{
// Dispose the container so WorkerClient.DisposeAsync runs —
// cancels the retry loop and closes the SignalR connection cleanly
// instead of abandoning it.
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
catch { /* best effort on shutdown */ }
}
} }
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
@@ -44,51 +66,32 @@ sealed class Program
// Infrastructure // Infrastructure
sc.AddSingleton(settings); sc.AddSingleton(settings);
sc.AddSingleton(new SqliteConnectionFactory(dbPath)); sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));
// Repositories sc.AddScoped<ClaudeDoDbContext>(sp =>
sc.AddSingleton<ListRepository>(); sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
sc.AddSingleton<TaskRepository>();
sc.AddSingleton<TagRepository>();
sc.AddSingleton<WorktreeRepository>();
// Services // Services
sc.AddSingleton<GitService>(); sc.AddSingleton<GitService>();
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl)); sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
// ViewModels // ViewModels
sc.AddTransient<ListEditorViewModel>(); sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<TaskEditorViewModel>(); sc.AddTransient<SettingsModalViewModel>();
sc.AddSingleton<StatusBarViewModel>();
sc.AddSingleton<TaskDetailViewModel>(sp => new TaskDetailViewModel( // Islands shell VMs
sp.GetRequiredService<TaskRepository>(), sc.AddSingleton<ListsIslandViewModel>(sp =>
sp.GetRequiredService<WorktreeRepository>(), new ListsIslandViewModel(
sp.GetRequiredService<ListRepository>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<GitService>(), sp));
sp.GetRequiredService<WorkerClient>(), sc.AddSingleton<TasksIslandViewModel>(sp =>
sp.GetRequiredService<TagRepository>())); new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
sc.AddSingleton<TaskListViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>
{ new DetailsIslandViewModel(
var taskRepo = sp.GetRequiredService<TaskRepository>(); sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
var tagRepo = sp.GetRequiredService<TagRepository>();
var listRepo = sp.GetRequiredService<ListRepository>();
var worker = sp.GetRequiredService<WorkerClient>();
var statusBar = sp.GetRequiredService<StatusBarViewModel>();
return new TaskListViewModel(
taskRepo, tagRepo, listRepo, worker,
() => sp.GetRequiredService<TaskEditorViewModel>(),
msg => statusBar.ShowMessage(msg));
});
sc.AddSingleton<MainWindowViewModel>(sp =>
{
return new MainWindowViewModel(
sp.GetRequiredService<ListRepository>(),
sp.GetRequiredService<WorkerClient>(), sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TaskListViewModel>(), sp));
sp.GetRequiredService<TaskDetailViewModel>(), sc.AddSingleton<IslandsShellViewModel>();
sp.GetRequiredService<StatusBarViewModel>(),
() => sp.GetRequiredService<ListEditorViewModel>());
});
return sc.BuildServiceProvider(); return sc.BuildServiceProvider();
} }

View File

@@ -11,7 +11,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Repositories ## Repositories
All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each method opens its own connection — no Unit of Work. All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync` - **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`
- **ListRepository** — CRUD, tag junction management - **ListRepository** — CRUD, tag junction management
@@ -20,8 +20,8 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
## Infrastructure ## Infrastructure
- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA - **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options
- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE) - **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app` - **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl) - **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
@@ -31,11 +31,11 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
## Schema ## Schema
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. See `schema/schema.sql`. Seed data: tags "agent" and "manual". 6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual".
## Conventions ## Conventions
- Enum <-> string mapping via explicit `ToDb()`/`FromDb()` static methods on each enum - Enum <-> string mapping via EF Core `ValueConverter` (configured in `IEntityTypeConfiguration<T>`)
- Entity configurations live in the `Configuration/` folder
- Primary keys are `init`-only strings (GUIDs assigned at creation) - Primary keys are `init`-only strings (GUIDs assigned at creation)
- Nullable fields use `DBNull.Value` checks
- All methods are async with CancellationToken where applicable - All methods are async with CancellationToken where applicable

View File

@@ -7,11 +7,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
</ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<ItemGroup> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<EmbeddedResource Include="..\..\schema\schema.sql" Link="schema.sql" LogicalName="ClaudeDo.Data.schema.sql" /> </PackageReference>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,80 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Seeding;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
namespace ClaudeDo.Data;
public class ClaudeDoDbContext : DbContext
{
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly);
}
/// <summary>
/// Applies EF Core migrations and sets WAL mode. Safe for both fresh and existing databases.
/// Existing databases (created by the old schema.sql) have their tables but no
/// __EFMigrationsHistory — this method detects that case and baselines the initial
/// migration so EF skips re-creating tables that already exist.
/// </summary>
public static void MigrateAndConfigure(ClaudeDoDbContext db)
{
var conn = db.Database.GetDbConnection();
try
{
conn.Open();
// Set WAL FIRST, before migrations — prevents write-lock contention
// when UI and Worker start simultaneously.
using (var walCmd = conn.CreateCommand())
{
walCmd.CommandText = "PRAGMA journal_mode=wal;";
walCmd.ExecuteNonQuery();
}
// If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration.
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
if (hasLists && !hasHistory)
{
cmd.CommandText = """
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260416064948_InitialCreate', '8.0.11');
""";
cmd.ExecuteNonQuery();
}
}
}
finally
{
conn.Close();
}
db.Database.Migrate();
DefaultListsSeeder.SeedAsync(db).GetAwaiter().GetResult();
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace ClaudeDo.Data;
public sealed class ClaudeDoDbContextFactory : IDesignTimeDbContextFactory<ClaudeDoDbContext>
{
public ClaudeDoDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite("Data Source=design-time.db")
.Options;
return new ClaudeDoDbContext(options);
}
}

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

@@ -0,0 +1,19 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfigEntity>
{
public void Configure(EntityTypeBuilder<ListConfigEntity> builder)
{
builder.ToTable("list_config");
builder.HasKey(c => c.ListId);
builder.Property(c => c.ListId).HasColumnName("list_id");
builder.Property(c => c.Model).HasColumnName("model");
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
}
}

View File

@@ -0,0 +1,36 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
{
public void Configure(EntityTypeBuilder<ListEntity> builder)
{
builder.ToTable("lists");
builder.HasKey(l => l.Id);
builder.Property(l => l.Id).HasColumnName("id");
builder.Property(l => l.Name).HasColumnName("name").IsRequired();
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.HasOne(l => l.Config)
.WithOne(c => c.List)
.HasForeignKey<ListConfigEntity>(c => c.ListId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(l => l.Tags)
.WithMany(tag => tag.Lists)
.UsingEntity("list_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("list_id", "tag_id");
j.ToTable("list_tags");
});
}
}

View File

@@ -0,0 +1,28 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class SubtaskEntityConfiguration : IEntityTypeConfiguration<SubtaskEntity>
{
public void Configure(EntityTypeBuilder<SubtaskEntity> builder)
{
builder.ToTable("subtasks");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id");
builder.Property(s => s.TaskId).HasColumnName("task_id").IsRequired();
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
builder.Property(s => s.Completed).HasColumnName("completed").IsRequired().HasDefaultValue(false);
builder.Property(s => s.OrderNum).HasColumnName("order_num").IsRequired();
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(s => s.Task)
.WithMany(t => t.Subtasks)
.HasForeignKey(s => s.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(s => s.TaskId).HasDatabaseName("idx_subtasks_task_id");
}
}

View File

@@ -0,0 +1,22 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
{
public void Configure(EntityTypeBuilder<TagEntity> builder)
{
builder.ToTable("tags");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd();
builder.Property(t => t.Name).HasColumnName("name").IsRequired();
builder.HasIndex(t => t.Name).IsUnique();
builder.HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
}
}

View File

@@ -0,0 +1,78 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Configuration;
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
{
private static string StatusToString(TaskStatus v)
=> v == TaskStatus.Manual ? "manual"
: v == TaskStatus.Queued ? "queued"
: v == TaskStatus.Running ? "running"
: v == TaskStatus.Done ? "done"
: v == TaskStatus.Failed ? "failed"
: throw new ArgumentOutOfRangeException(nameof(v));
private static TaskStatus StatusFromString(string v)
=> v == "manual" ? TaskStatus.Manual
: v == "queued" ? TaskStatus.Queued
: v == "running" ? TaskStatus.Running
: v == "done" ? TaskStatus.Done
: v == "failed" ? TaskStatus.Failed
: throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
new(v => StatusToString(v), v => StatusFromString(v));
public void Configure(EntityTypeBuilder<TaskEntity> builder)
{
builder.ToTable("tasks");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id");
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
builder.Property(t => t.Description).HasColumnName("description");
builder.Property(t => t.Status).HasColumnName("status").IsRequired()
.HasConversion(StatusConverter);
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.LogPath).HasColumnName("log_path");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(t => t.StartedAt).HasColumnName("started_at");
builder.Property(t => t.FinishedAt).HasColumnName("finished_at");
builder.Property(t => t.CommitType).HasColumnName("commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(t => t.Model).HasColumnName("model");
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
builder.Property(t => t.AgentPath).HasColumnName("agent_path");
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
builder.Property(t => t.Notes).HasColumnName("notes");
builder.HasOne(t => t.List)
.WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(t => t.Worktree)
.WithOne(w => w.Task)
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
builder.HasMany(t => t.Tags)
.WithMany(tag => tag.Tasks)
.UsingEntity("task_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("task_id", "tag_id");
j.ToTable("task_tags");
});
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
}
}

View File

@@ -0,0 +1,38 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity>
{
public void Configure(EntityTypeBuilder<TaskRunEntity> builder)
{
builder.ToTable("task_runs");
builder.HasKey(r => r.Id);
builder.Property(r => r.Id).HasColumnName("id");
builder.Property(r => r.TaskId).HasColumnName("task_id").IsRequired();
builder.Property(r => r.RunNumber).HasColumnName("run_number").IsRequired();
builder.Property(r => r.SessionId).HasColumnName("session_id");
builder.Property(r => r.IsRetry).HasColumnName("is_retry").IsRequired().HasDefaultValue(false);
builder.Property(r => r.Prompt).HasColumnName("prompt").IsRequired();
builder.Property(r => r.ResultMarkdown).HasColumnName("result_markdown");
builder.Property(r => r.StructuredOutputJson).HasColumnName("structured_output");
builder.Property(r => r.ErrorMarkdown).HasColumnName("error_markdown");
builder.Property(r => r.ExitCode).HasColumnName("exit_code");
builder.Property(r => r.TurnCount).HasColumnName("turn_count");
builder.Property(r => r.TokensIn).HasColumnName("tokens_in");
builder.Property(r => r.TokensOut).HasColumnName("tokens_out");
builder.Property(r => r.LogPath).HasColumnName("log_path");
builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
builder.HasOne(r => r.Task)
.WithMany(t => t.Runs)
.HasForeignKey(r => r.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(r => r.TaskId).HasDatabaseName("idx_task_runs_task_id");
}
}

View File

@@ -0,0 +1,43 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ClaudeDo.Data.Configuration;
public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEntity>
{
private static string StateToString(WorktreeState v)
=> v == WorktreeState.Active ? "active"
: v == WorktreeState.Merged ? "merged"
: v == WorktreeState.Discarded ? "discarded"
: v == WorktreeState.Kept ? "kept"
: throw new ArgumentOutOfRangeException(nameof(v));
private static WorktreeState StateFromString(string v)
=> v == "active" ? WorktreeState.Active
: v == "merged" ? WorktreeState.Merged
: v == "discarded" ? WorktreeState.Discarded
: v == "kept" ? WorktreeState.Kept
: throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<WorktreeState, string> StateConverter =
new(v => StateToString(v), v => StateFromString(v));
public void Configure(EntityTypeBuilder<WorktreeEntity> builder)
{
builder.ToTable("worktrees");
builder.HasKey(w => w.TaskId);
builder.Property(w => w.TaskId).HasColumnName("task_id");
builder.Property(w => w.Path).HasColumnName("path").IsRequired();
builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired();
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
builder.Property(w => w.State).HasColumnName("state").IsRequired()
.HasDefaultValue(WorktreeState.Active)
.HasConversion(StateConverter);
builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired();
}
}

View File

@@ -27,6 +27,14 @@ public sealed class GitService
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
} }
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct); var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
@@ -50,6 +58,36 @@ public sealed class GitService
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
} }
public async Task<string> GetDiffAsync(string worktreePath, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "HEAD"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
// If nothing staged vs HEAD, try the index (untracked is never in diff)
if (string.IsNullOrWhiteSpace(stdout))
{
var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct);
if (e2 == 0) return s2;
}
return stdout;
}
/// <summary>
/// Full diff between <paramref name="baseRef"/> and the current working tree
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
/// task's total impact relative to where the branch started.
/// </summary>
public async Task<string> GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(worktreePath,
["diff", baseRef], ct);
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
return stdout;
// Fallback: whatever the worktree has vs HEAD (uncommitted only).
return await GetDiffAsync(worktreePath, ct);
}
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default) public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
@@ -70,6 +108,41 @@ public sealed class GitService
throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git worktree remove failed (exit {exitCode}): {stderr}");
} }
public async Task<List<string>> ListWorktreePathsForBranchAsync(string repoDir, string branchName, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct);
if (exitCode != 0) return new();
var target = $"refs/heads/{branchName}";
var paths = new List<string>();
string? currentPath = null;
foreach (var raw in stdout.Split('\n'))
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("worktree ", StringComparison.Ordinal))
{
currentPath = line["worktree ".Length..].Trim();
}
else if (line.StartsWith("branch ", StringComparison.Ordinal))
{
var b = line["branch ".Length..].Trim();
if (b == target && currentPath is not null) paths.Add(currentPath);
}
else if (string.IsNullOrWhiteSpace(line))
{
currentPath = null;
}
}
return paths;
}
public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git worktree prune failed (exit {exitCode}): {stderr}");
}
public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default) public async Task BranchDeleteAsync(string repoDir, string branchName, bool force = false, CancellationToken ct = default)
{ {
var flag = force ? "-D" : "-d"; var flag = force ? "-D" : "-d";
@@ -104,20 +177,34 @@ public sealed class GitService
using var proc = new Process { StartInfo = psi }; using var proc = new Process { StartInfo = psi };
proc.Start(); proc.Start();
// On cancellation: kill the git process tree. Killing closes the
// redirected pipes, which unblocks the ReadToEndAsync calls below
// and lets WaitForExitAsync return so the process is reaped.
// Without this, cancelling mid-git leaves zombie processes.
await using var ctr = ct.Register(() =>
{
try { proc.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
if (stdinData is not null) if (stdinData is not null)
{ {
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct); await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
proc.StandardInput.Close(); proc.StandardInput.Close();
} }
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); // Drain output without ct — pipes close when the process exits
var stderrTask = proc.StandardError.ReadToEndAsync(ct); // (whether naturally or via Kill above), so these always complete.
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
var stderrTask = proc.StandardError.ReadToEndAsync();
await proc.WaitForExitAsync(ct); await proc.WaitForExitAsync(CancellationToken.None);
var stdout = await stdoutTask; var stdout = await stdoutTask;
var stderr = await stderrTask; var stderr = await stderrTask;
ct.ThrowIfCancellationRequested();
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd()); return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
} }
} }

View File

@@ -0,0 +1,298 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "lists",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
working_dir = table.Column<string>(type: "TEXT", nullable: true),
default_commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore")
},
constraints: table =>
{
table.PrimaryKey("PK_lists", x => x.id);
});
migrationBuilder.CreateTable(
name: "tags",
columns: table => new
{
id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tags", x => x.id);
});
migrationBuilder.CreateTable(
name: "list_config",
columns: table => new
{
list_id = table.Column<string>(type: "TEXT", nullable: false),
model = table.Column<string>(type: "TEXT", nullable: true),
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
agent_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_list_config", x => x.list_id);
table.ForeignKey(
name: "FK_list_config_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tasks",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
list_id = table.Column<string>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
description = table.Column<string>(type: "TEXT", nullable: true),
status = table.Column<string>(type: "TEXT", nullable: false),
scheduled_for = table.Column<DateTime>(type: "TEXT", nullable: true),
result = table.Column<string>(type: "TEXT", nullable: true),
log_path = table.Column<string>(type: "TEXT", nullable: true),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true),
commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore"),
model = table.Column<string>(type: "TEXT", nullable: true),
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
agent_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_tasks", x => x.id);
table.ForeignKey(
name: "FK_tasks_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "list_tags",
columns: table => new
{
list_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
table.ForeignKey(
name: "FK_list_tags_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_list_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subtasks",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
completed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
order_num = table.Column<int>(type: "INTEGER", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subtasks", x => x.id);
table.ForeignKey(
name: "FK_subtasks_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "task_runs",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
run_number = table.Column<int>(type: "INTEGER", nullable: false),
session_id = table.Column<string>(type: "TEXT", nullable: true),
is_retry = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
prompt = table.Column<string>(type: "TEXT", nullable: false),
result_markdown = table.Column<string>(type: "TEXT", nullable: true),
structured_output = table.Column<string>(type: "TEXT", nullable: true),
error_markdown = table.Column<string>(type: "TEXT", nullable: true),
exit_code = table.Column<int>(type: "INTEGER", nullable: true),
turn_count = table.Column<int>(type: "INTEGER", nullable: true),
tokens_in = table.Column<int>(type: "INTEGER", nullable: true),
tokens_out = table.Column<int>(type: "INTEGER", nullable: true),
log_path = table.Column<string>(type: "TEXT", nullable: true),
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_task_runs", x => x.id);
table.ForeignKey(
name: "FK_task_runs_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "task_tags",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
table.ForeignKey(
name: "FK_task_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_task_tags_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "worktrees",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
path = table.Column<string>(type: "TEXT", nullable: false),
branch_name = table.Column<string>(type: "TEXT", nullable: false),
base_commit = table.Column<string>(type: "TEXT", nullable: false),
head_commit = table.Column<string>(type: "TEXT", nullable: true),
diff_stat = table.Column<string>(type: "TEXT", nullable: true),
state = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "active"),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_worktrees", x => x.task_id);
table.ForeignKey(
name: "FK_worktrees_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "tags",
columns: new[] { "id", "name" },
values: new object[,]
{
{ 1L, "agent" },
{ 2L, "manual" }
});
migrationBuilder.CreateIndex(
name: "IX_list_tags_tag_id",
table: "list_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "idx_subtasks_task_id",
table: "subtasks",
column: "task_id");
migrationBuilder.CreateIndex(
name: "IX_tags_name",
table: "tags",
column: "name",
unique: true);
migrationBuilder.CreateIndex(
name: "idx_task_runs_task_id",
table: "task_runs",
column: "task_id");
migrationBuilder.CreateIndex(
name: "IX_task_tags_tag_id",
table: "task_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "idx_tasks_list_id",
table: "tasks",
column: "list_id");
migrationBuilder.CreateIndex(
name: "idx_tasks_status",
table: "tasks",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "list_config");
migrationBuilder.DropTable(
name: "list_tags");
migrationBuilder.DropTable(
name: "subtasks");
migrationBuilder.DropTable(
name: "task_runs");
migrationBuilder.DropTable(
name: "task_tags");
migrationBuilder.DropTable(
name: "worktrees");
migrationBuilder.DropTable(
name: "tags");
migrationBuilder.DropTable(
name: "tasks");
migrationBuilder.DropTable(
name: "lists");
}
}
}

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

@@ -0,0 +1,569 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
partial class ClaudeDoDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 30,
DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

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

@@ -6,4 +6,7 @@ public sealed class ListConfigEntity
public string? Model { get; set; } public string? Model { get; set; }
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; } public string? AgentPath { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
} }

View File

@@ -7,4 +7,9 @@ public sealed class ListEntity
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
public string? WorkingDir { get; set; } public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = "chore"; public string DefaultCommitType { get; set; } = "chore";
// Navigation properties
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
} }

View File

@@ -0,0 +1,14 @@
namespace ClaudeDo.Data.Models;
public sealed class SubtaskEntity
{
public required string Id { get; init; }
public required string TaskId { get; init; }
public required string Title { get; set; }
public bool Completed { get; set; }
public int OrderNum { get; set; }
public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
}

View File

@@ -4,4 +4,8 @@ public sealed class TagEntity
{ {
public long Id { get; init; } public long Id { get; init; }
public required string Name { get; set; } public required string Name { get; set; }
// Navigation properties
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
} }

View File

@@ -26,4 +26,14 @@ public sealed class TaskEntity
public string? Model { get; set; } public string? Model { get; set; }
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; } public string? AgentPath { get; set; }
public bool IsStarred { get; set; }
public bool IsMyDay { get; set; }
public string? Notes { get; set; }
// Navigation properties
public ListEntity List { get; set; } = null!;
public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
} }

View File

@@ -18,4 +18,7 @@ public sealed class TaskRunEntity
public string? LogPath { get; set; } public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; } public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; } public DateTime? FinishedAt { get; set; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
} }

View File

@@ -18,4 +18,7 @@ public sealed class WorktreeEntity
public string? DiffStat { get; set; } public string? DiffStat { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active; public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { 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

@@ -1,157 +1,91 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository public sealed class ListRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public ListRepository(SqliteConnectionFactory factory) => _factory = factory; public ListRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(ListEntity entity, CancellationToken ct = default) public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Lists.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
INSERT INTO lists (id, name, created_at, working_dir, default_commit_type)
VALUES (@id, @name, @created_at, @working_dir, @default_commit_type)
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@name", entity.Name);
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default) public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Lists.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
UPDATE lists SET name = @name, working_dir = @working_dir,
default_commit_type = @default_commit_type
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@name", entity.Name);
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string listId, CancellationToken ct = default) public async Task DeleteAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM lists WHERE id = @id";
cmd.Parameters.AddWithValue("@id", listId);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default) public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists WHERE id = @id";
cmd.Parameters.AddWithValue("@id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadList(reader);
} }
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default) public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists ORDER BY created_at";
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<ListEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadList(reader));
return result;
} }
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default) public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists
await using var cmd = conn.CreateCommand(); .Where(l => l.Id == listId)
cmd.CommandText = """ .SelectMany(l => l.Tags)
SELECT t.id, t.name FROM tags t .ToListAsync(ct);
JOIN list_tags lt ON lt.tag_id = t.id
WHERE lt.list_id = @list_id
""";
cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
} }
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
await using var cmd = conn.CreateCommand(); if (list is null) return;
cmd.CommandText = "INSERT OR IGNORE INTO list_tags (list_id, tag_id) VALUES (@list_id, @tag_id)"; var tag = await _context.Tags.FindAsync([tagId], ct);
cmd.Parameters.AddWithValue("@list_id", listId); if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
cmd.Parameters.AddWithValue("@tag_id", tagId); {
await cmd.ExecuteNonQueryAsync(ct); list.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
} }
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var list = await _context.Lists.Include(l => l.Tags).FirstOrDefaultAsync(l => l.Id == listId, ct);
await using var cmd = conn.CreateCommand(); if (list is null) return;
cmd.CommandText = "DELETE FROM list_tags WHERE list_id = @list_id AND tag_id = @tag_id"; var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
cmd.Parameters.AddWithValue("@list_id", listId); if (tag is not null)
cmd.Parameters.AddWithValue("@tag_id", tagId); {
await cmd.ExecuteNonQueryAsync(ct); list.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
}
} }
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default) public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
await using var cmd = conn.CreateCommand(); }
cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id";
cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct); public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default)
if (!await reader.ReadAsync(ct)) return null; {
return new ListConfigEntity var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct);
if (existing is null)
{ {
ListId = reader.GetString(0), _context.ListConfigs.Add(config);
Model = reader.IsDBNull(1) ? null : reader.GetString(1), }
SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2), else
AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3), {
}; existing.Model = config.Model;
existing.SystemPrompt = config.SystemPrompt;
existing.AgentPath = config.AgentPath;
}
await _context.SaveChangesAsync(ct);
} }
public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path)
VALUES (@list_id, @model, @system_prompt, @agent_path)
""";
cmd.Parameters.AddWithValue("@list_id", entity.ListId);
cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
private static ListEntity ReadList(SqliteDataReader reader) => new()
{
Id = reader.GetString(0),
Name = reader.GetString(1),
CreatedAt = DateTime.Parse(reader.GetString(2)),
WorkingDir = reader.IsDBNull(3) ? null : reader.GetString(3),
DefaultCommitType = reader.GetString(4),
};
} }

View File

@@ -0,0 +1,41 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class SubtaskRepository
{
private readonly ClaudeDoDbContext _context;
public SubtaskRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
{
_context.Subtasks.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
return await _context.Subtasks
.Where(s => s.TaskId == taskId)
.OrderBy(s => s.OrderNum)
.ToListAsync(ct);
}
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
{
_context.Subtasks.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string subtaskId, CancellationToken ct = default)
{
await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct);
}
public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct);
}
}

View File

@@ -1,47 +1,28 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class TagRepository public sealed class TagRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public TagRepository(SqliteConnectionFactory factory) => _factory = factory; public TagRepository(ClaudeDoDbContext context) => _context = context;
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default) public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name FROM tags ORDER BY id";
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
} }
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default) public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
return await GetOrCreateAsync(conn, name, ct);
}
public static async Task<long> GetOrCreateAsync(SqliteConnection conn, string name, CancellationToken ct = default)
{
await using var sel = conn.CreateCommand();
sel.CommandText = "SELECT id FROM tags WHERE name = @name";
sel.Parameters.AddWithValue("@name", name);
var existing = await sel.ExecuteScalarAsync(ct);
if (existing is not null) if (existing is not null)
return (long)existing; return existing.Id;
await using var ins = conn.CreateCommand(); var tag = new TagEntity { Name = name };
ins.CommandText = "INSERT INTO tags (name) VALUES (@name) RETURNING id"; _context.Tags.Add(tag);
ins.Parameters.AddWithValue("@name", name); await _context.SaveChangesAsync(ct);
return tag.Id;
return (long)(await ins.ExecuteScalarAsync(ct))!;
} }
} }

View File

@@ -1,171 +1,159 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class TaskRepository public sealed class TaskRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public TaskRepository(SqliteConnectionFactory factory) => _factory = factory; public TaskRepository(ClaudeDoDbContext context) => _context = context;
#region Status mapping
private static string ToDb(TaskStatus s) => s switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
TaskStatus.Running => "running",
TaskStatus.Done => "done",
TaskStatus.Failed => "failed",
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
private static TaskStatus FromDb(string s) => s switch
{
"manual" => TaskStatus.Manual,
"queued" => TaskStatus.Queued,
"running" => TaskStatus.Running,
"done" => TaskStatus.Done,
"failed" => TaskStatus.Failed,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
#endregion
#region CRUD #region CRUD
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Tasks.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
result, log_path, created_at, started_at, finished_at, commit_type,
model, system_prompt, agent_path)
VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
@result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
@model, @system_prompt, @agent_path)
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Tasks.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
status = @status, scheduled_for = @scheduled_for, result = @result,
log_path = @log_path, started_at = @started_at,
finished_at = @finished_at, commit_type = @commit_type,
model = @model, system_prompt = @system_prompt, agent_path = @agent_path
WHERE id = @id
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string taskId, CancellationToken ct = default) public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default) public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
} }
public async Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default) public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tasks
await using var cmd = conn.CreateCommand(); .Where(t => t.ListId == listId)
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE list_id = @list_id ORDER BY created_at"; .OrderBy(t => t.CreatedAt)
cmd.Parameters.AddWithValue("@list_id", listId); .ToListAsync(ct);
}
await using var reader = await cmd.ExecuteReaderAsync(ct); // Kept for backwards-compatibility with callers using the old name.
var result = new List<TaskEntity>(); public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
while (await reader.ReadAsync(ct)) => GetByListIdAsync(listId, ct);
result.Add(ReadTask(reader));
return result; #endregion
#region Status transitions
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Running)
.SetProperty(t => t.StartedAt, startedAt), ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Done)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
var resultText = "[stale] " + reason;
var now = DateTime.UtcNow;
return await _context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, now)
.SetProperty(t => t.Result, resultText), ct);
}
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 #endregion
#region Tag junction #region Tags
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.name FROM tags t
JOIN task_tags tt ON tt.tag_id = t.id
WHERE tt.task_id = @task_id
""";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
}
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
await using var cmd = conn.CreateCommand(); if (task is null) return;
cmd.CommandText = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (@task_id, @tag_id)"; var tag = await _context.Tags.FindAsync([tagId], ct);
cmd.Parameters.AddWithValue("@task_id", taskId); if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
cmd.Parameters.AddWithValue("@tag_id", tagId); {
await cmd.ExecuteNonQueryAsync(ct); task.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
} }
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
await using var cmd = conn.CreateCommand(); if (task is null) return;
cmd.CommandText = "DELETE FROM task_tags WHERE task_id = @task_id AND tag_id = @tag_id"; var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
cmd.Parameters.AddWithValue("@task_id", taskId); if (tag is not null)
cmd.Parameters.AddWithValue("@tag_id", tagId); {
await cmd.ExecuteNonQueryAsync(ct); task.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags)
.ToListAsync(ct);
} }
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default) public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var taskTags = _context.Tasks
await using var cmd = conn.CreateCommand(); .Where(t => t.Id == taskId)
cmd.CommandText = """ .SelectMany(t => t.Tags);
SELECT DISTINCT t.id, t.name FROM tags t var listTags = _context.Tasks
WHERE t.id IN ( .Where(t => t.Id == taskId)
SELECT tag_id FROM task_tags WHERE task_id = @task_id .SelectMany(t => t.List.Tags);
UNION return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
SELECT lt.tag_id FROM list_tags lt
JOIN tasks tk ON tk.list_id = lt.list_id
WHERE tk.id = @task_id
)
""";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
} }
#endregion #endregion
@@ -174,136 +162,38 @@ public sealed class TaskRepository
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); // Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
await using var cmd = conn.CreateCommand(); // Uses raw SQL because EF cannot express UPDATE...RETURNING.
cmd.CommandText = """ // Includes both task-level and list-level "agent" tag so lists tagged "agent"
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for, // automatically enqueue all their tasks without per-task tagging.
t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type, // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
t.model, t.system_prompt, t.agent_path var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
FROM tasks t var result = await _context.Tasks.FromSqlRaw("""
WHERE t.status = 'queued' UPDATE tasks SET status = 'running'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now) WHERE id = (
AND EXISTS ( SELECT t.id FROM tasks t
SELECT 1 FROM task_tags tt WHERE t.status = 'queued'
JOIN tags tg ON tg.id = tt.tag_id AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
WHERE tt.task_id = t.id AND tg.name = 'agent' AND (
UNION EXISTS (
SELECT 1 FROM list_tags lt SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = lt.tag_id JOIN tags tg ON tg.id = tt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent' WHERE tt.task_id = t.id AND tg.name = 'agent'
) )
ORDER BY t.created_at ASC OR EXISTS (
LIMIT 1 SELECT 1 FROM list_tags lt
"""; JOIN tags tg ON tg.id = lt.tag_id
cmd.Parameters.AddWithValue("@now", now.ToString("o")); WHERE lt.list_id = t.list_id AND tg.name = 'agent'
)
)
ORDER BY t.created_at ASC
LIMIT 1
)
RETURNING *
""", nowStr).ToListAsync(ct);
await using var reader = await cmd.ExecuteReaderAsync(ct); return result.FirstOrDefault();
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
} }
#endregion #endregion
#region Transitions
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET log_path = @log_path WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@log_path", logPath);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'running', started_at = @started_at WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@started_at", startedAt.ToString("o"));
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'done', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)result ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? errorMarkdown, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'failed', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)errorMarkdown ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE tasks SET status = 'failed',
finished_at = @now,
result = '[stale] ' || @reason
WHERE status = 'running'
""";
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
cmd.Parameters.AddWithValue("@reason", reason);
return await cmd.ExecuteNonQueryAsync(ct);
}
#endregion
#region Helpers
private static void BindTask(SqliteCommand cmd, TaskEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@list_id", e.ListId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
}
private static TaskEntity ReadTask(SqliteDataReader r) => new()
{
Id = r.GetString(0),
ListId = r.GetString(1),
Title = r.GetString(2),
Description = r.IsDBNull(3) ? null : r.GetString(3),
Status = FromDb(r.GetString(4)),
ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
Result = r.IsDBNull(6) ? null : r.GetString(6),
LogPath = r.IsDBNull(7) ? null : r.GetString(7),
CreatedAt = DateTime.Parse(r.GetString(8)),
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
CommitType = r.GetString(11),
Model = r.IsDBNull(12) ? null : r.GetString(12),
SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
};
#endregion
} }

View File

@@ -1,139 +1,44 @@
using System.Globalization;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class TaskRunRepository public sealed class TaskRunRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory; public TaskRunRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default) public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.TaskRuns.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
result_markdown, structured_output, error_markdown, exit_code,
turn_count, tokens_in, tokens_out, log_path, started_at, finished_at)
VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
@result_markdown, @structured_output, @error_markdown, @exit_code,
@turn_count, @tokens_in, @tokens_out, @log_path, @started_at, @finished_at)
""";
BindRun(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default) public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.TaskRuns.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
UPDATE task_runs SET session_id = @session_id,
result_markdown = @result_markdown,
structured_output = @structured_output,
error_markdown = @error_markdown,
exit_code = @exit_code,
turn_count = @turn_count,
tokens_in = @tokens_in,
tokens_out = @tokens_out,
finished_at = @finished_at
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<TaskRunEntity?> GetByIdAsync(string runId, CancellationToken ct = default) public async Task<TaskRunEntity?> GetByIdAsync(string id, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id";
cmd.Parameters.AddWithValue("@id", runId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadRun(reader);
} }
public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default) public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.TaskRuns
await using var cmd = conn.CreateCommand(); .Where(r => r.TaskId == taskId)
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number"; .OrderBy(r => r.RunNumber)
cmd.Parameters.AddWithValue("@task_id", taskId); .ToListAsync(ct);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TaskRunEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadRun(reader));
return result;
} }
public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default) public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.TaskRuns
await using var cmd = conn.CreateCommand(); .Where(r => r.TaskId == taskId)
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1"; .OrderByDescending(r => r.RunNumber)
cmd.Parameters.AddWithValue("@task_id", taskId); .FirstOrDefaultAsync(ct);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadRun(reader);
} }
#region Helpers
private static void BindRun(SqliteCommand cmd, TaskRunEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
cmd.Parameters.AddWithValue("@run_number", e.RunNumber);
cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0);
cmd.Parameters.AddWithValue("@prompt", e.Prompt);
cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
}
private static TaskRunEntity ReadRun(SqliteDataReader r) => new()
{
Id = r.GetString(0),
TaskId = r.GetString(1),
RunNumber = r.GetInt32(2),
SessionId = r.IsDBNull(3) ? null : r.GetString(3),
IsRetry = r.GetInt32(4) != 0,
Prompt = r.GetString(5),
ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6),
StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7),
ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8),
ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9),
TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10),
TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11),
TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12),
LogPath = r.IsDBNull(13) ? null : r.GetString(13),
StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14), null, DateTimeStyles.RoundtripKind),
FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15), null, DateTimeStyles.RoundtripKind),
};
#endregion
} }

View File

@@ -1,102 +1,56 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class WorktreeRepository public sealed class WorktreeRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public WorktreeRepository(SqliteConnectionFactory factory) => _factory = factory; public WorktreeRepository(ClaudeDoDbContext context) => _context = context;
private static string ToDb(WorktreeState s) => s switch
{
WorktreeState.Active => "active",
WorktreeState.Merged => "merged",
WorktreeState.Discarded => "discarded",
WorktreeState.Kept => "kept",
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
private static WorktreeState FromDb(string s) => s switch
{
"active" => WorktreeState.Active,
"merged" => WorktreeState.Merged,
"discarded" => WorktreeState.Discarded,
"kept" => WorktreeState.Kept,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default) public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Worktrees.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
INSERT INTO worktrees (task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at)
VALUES (@task_id, @path, @branch_name, @base_commit, @head_commit, @diff_stat, @state, @created_at)
""";
cmd.Parameters.AddWithValue("@task_id", entity.TaskId);
cmd.Parameters.AddWithValue("@path", entity.Path);
cmd.Parameters.AddWithValue("@branch_name", entity.BranchName);
cmd.Parameters.AddWithValue("@base_commit", entity.BaseCommit);
cmd.Parameters.AddWithValue("@head_commit", (object?)entity.HeadCommit ?? DBNull.Value);
cmd.Parameters.AddWithValue("@diff_stat", (object?)entity.DiffStat ?? DBNull.Value);
cmd.Parameters.AddWithValue("@state", ToDb(entity.State));
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default) public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at FROM worktrees WHERE task_id = @task_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadWorktree(reader);
} }
public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default) public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees
await using var cmd = conn.CreateCommand(); .Where(w => w.TaskId == taskId)
cmd.CommandText = "UPDATE worktrees SET head_commit = @head_commit, diff_stat = @diff_stat WHERE task_id = @task_id"; .ExecuteUpdateAsync(s => s
cmd.Parameters.AddWithValue("@task_id", taskId); .SetProperty(w => w.HeadCommit, headCommit)
cmd.Parameters.AddWithValue("@head_commit", headCommit); .SetProperty(w => w.DiffStat, diffStat), ct);
cmd.Parameters.AddWithValue("@diff_stat", (object?)diffStat ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default) public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees
await using var cmd = conn.CreateCommand(); .Where(w => w.TaskId == taskId)
cmd.CommandText = "UPDATE worktrees SET state = @state WHERE task_id = @task_id"; .ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct);
cmd.Parameters.AddWithValue("@task_id", taskId);
cmd.Parameters.AddWithValue("@state", ToDb(state));
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string taskId, CancellationToken ct = default) public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM worktrees WHERE task_id = @task_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
await cmd.ExecuteNonQueryAsync(ct);
} }
private static WorktreeEntity ReadWorktree(SqliteDataReader r) => new() public async Task<List<WorktreeEntity>> GetAllAsync(CancellationToken ct = default)
{ {
TaskId = r.GetString(0), return await _context.Worktrees.AsNoTracking().ToListAsync(ct);
Path = r.GetString(1), }
BranchName = r.GetString(2),
BaseCommit = r.GetString(3), public async Task<List<WorktreeEntity>> GetByStatesAsync(
HeadCommit = r.IsDBNull(4) ? null : r.GetString(4), IReadOnlyCollection<WorktreeState> states, CancellationToken ct = default)
DiffStat = r.IsDBNull(5) ? null : r.GetString(5), {
State = FromDb(r.GetString(6)), return await _context.Worktrees.AsNoTracking()
CreatedAt = DateTime.Parse(r.GetString(7)), .Where(w => states.Contains(w.State))
}; .ToListAsync(ct);
}
} }

View File

@@ -1,67 +0,0 @@
using System.Reflection;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
/// IF NOT EXISTS / INSERT OR IGNORE.
/// </summary>
public static class SchemaInitializer
{
private const string ResourceName = "ClaudeDo.Data.schema.sql";
public static void Apply(SqliteConnectionFactory factory)
{
using var conn = factory.Open();
ApplyTo(conn);
}
public static void ApplyTo(SqliteConnection conn)
{
var sql = LoadScript();
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
tx.Commit();
ApplyMigrations(conn);
}
private static void ApplyMigrations(SqliteConnection conn)
{
string[] alterStatements =
[
"ALTER TABLE tasks ADD COLUMN model TEXT NULL",
"ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
"ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
];
foreach (var sql in alterStatements)
{
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1)
{
// Column already exists — safe to ignore.
}
}
}
private static string LoadScript()
{
var asm = typeof(SchemaInitializer).Assembly;
using var stream = asm.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException(
$"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " +
$"Available: {string.Join(", ", asm.GetManifestResourceNames())}");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}

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);
}
}

View File

@@ -1,48 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Opens <see cref="SqliteConnection"/> instances pointed at <see cref="DbPath"/>.
/// First call ensures the parent directory exists, enables WAL and foreign keys.
/// </summary>
public sealed class SqliteConnectionFactory
{
public string DbPath { get; }
private readonly string _connectionString;
private int _walApplied;
public SqliteConnectionFactory(string dbPath)
{
DbPath = Paths.Expand(dbPath);
Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = DbPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
}.ToString();
}
public SqliteConnection Open()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
// WAL is a persistent DB-level setting; applying it once per process is enough,
// but idempotent so we do it defensively on the first connection we hand out.
if (Interlocked.Exchange(ref _walApplied, 1) == 0)
{
using var pragma = conn.CreateCommand();
pragma.CommandText = "PRAGMA journal_mode=WAL;";
pragma.ExecuteNonQuery();
}
using var fk = conn.CreateCommand();
fk.CommandText = "PRAGMA foreign_keys=ON;";
fk.ExecuteNonQuery();
return conn;
}
}

View File

@@ -110,13 +110,16 @@ public partial class App : Application
sc.AddSingleton<IInstallStep, WriteConfigStep>(); sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>(); sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>(); sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartServiceStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>(); sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
sc.AddSingleton<WriteInstallManifestStep>(); sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>()); sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline). // Stop — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
// Pulled by Update flow + Repair/Uninstall. // Pulled by Update flow + Repair/Uninstall.
sc.AddSingleton<StopServiceStep>(); sc.AddSingleton<StopServiceStep>();
// StartServiceStep is also registered as IInstallStep above (fresh-install pipeline).
sc.AddSingleton<StartServiceStep>(); sc.AddSingleton<StartServiceStep>();
// Runners // Runners

View File

@@ -6,8 +6,16 @@
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>ClaudeTaskSetup.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- Embed icon so it is available via pack URI in WPF windows. -->
<Resource Include="ClaudeTaskSetup.ico" />
</ItemGroup>
<!-- Debug: asInvoker so Rider/VS can debug without elevation --> <!-- Debug: asInvoker so Rider/VS can debug without elevation -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'"> <PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<ApplicationManifest>app.debug.manifest</ApplicationManifest> <ApplicationManifest>app.debug.manifest</ApplicationManifest>
@@ -19,10 +27,15 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'"> <PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
<!-- Framework-dependent: the WPF runtime pack isn't distributed for cross-compile
on Linux CI, which made self-contained bundles crash on startup with AV in the
apphost. Target machines already have the .NET 8 Desktop Runtime. -->
<SelfContained>false</SelfContained>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<PublishTrimmed>false</PublishTrimmed> <PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract> <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile> <EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -22,7 +22,7 @@ public sealed class InstallContext
public int SignalRPort { get; set; } = 47_821; public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000; public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude"; public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "LocalSystem"; public string ServiceAccount { get; set; } = "CurrentUser";
public bool AutoStart { get; set; } = true; public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000; public int RestartDelayMs { get; set; } = 5000;

View File

@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.IO; using System.IO;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Installer.Steps; using ClaudeDo.Installer.Steps;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Core;
@@ -15,7 +17,7 @@ public sealed class UninstallRunner
_stopService = stopService; _stopService = stopService;
} }
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct) public async Task<StepResult> RunAsync(bool removeAppData, IProgress<string> progress, CancellationToken ct)
{ {
// 1) Validate install dir up front — refuse obviously unsafe paths. // 1) Validate install dir up front — refuse obviously unsafe paths.
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\. // Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
@@ -36,6 +38,17 @@ public sealed class UninstallRunner
progress.Report("Unregistering service..."); progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct); await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
// 3b) Remove Apps & Features registry entry (best-effort).
progress.Report("Removing Add/Remove Programs entry...");
try
{
Registry.LocalMachine.DeleteSubKeyTree(WriteUninstallRegistryStep.UninstallKeyPath, throwOnMissingSubKey: false);
}
catch (Exception ex)
{
progress.Report($"Warning: could not delete uninstall registry key: {ex.Message}");
}
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest). // 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
progress.Report("Removing shortcuts..."); progress.Report("Removing shortcuts...");
TryDeleteFile(Path.Combine( TryDeleteFile(Path.Combine(
@@ -54,13 +67,31 @@ public sealed class UninstallRunner
failures.Add($"install dir ({_context.InstallDirectory}): {err}"); failures.Add($"install dir ({_context.InstallDirectory}): {err}");
} }
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal. // 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in.
var appData = Paths.AppDataRoot(); if (removeAppData)
if (Directory.Exists(appData))
{ {
progress.Report($"Deleting {appData}..."); var appData = Paths.AppDataRoot();
if (!TryDeleteDir(appData, out var err)) if (Directory.Exists(appData))
failures.Add($"app data ({appData}): {err}"); {
progress.Report($"Deleting {appData}...");
if (!TryDeleteDir(appData, out var err))
failures.Add($"app data ({appData}): {err}");
}
}
// 7) If we were launched from inside the install dir (Apps & Features case),
// our own exe is still locked — schedule a cmd.exe trampoline to finish
// the deletion after this process exits. Best-effort: if this fails the
// user is left with an empty <uninstaller> folder which is harmless.
var runningExe = Environment.ProcessPath;
if (runningExe is not null
&& IsInsideDirectory(runningExe, _context.InstallDirectory)
&& Directory.Exists(_context.InstallDirectory))
{
progress.Report("Scheduling final cleanup after exit...");
TryScheduleTrampolineDelete(_context.InstallDirectory);
// The trampoline will finish the job — clear the residual failure entry for the install dir.
failures.RemoveAll(f => f.StartsWith("install dir"));
} }
if (failures.Count > 0) if (failures.Count > 0)
@@ -74,6 +105,37 @@ public sealed class UninstallRunner
return StepResult.Ok(); return StepResult.Ok();
} }
private static bool IsInsideDirectory(string filePath, string directory)
{
try
{
var full = Path.GetFullPath(filePath);
var dir = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(dir, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
private static void TryScheduleTrampolineDelete(string installDir)
{
try
{
var pid = Environment.ProcessId;
// Wait for this process to exit, then recursively remove the install dir.
// /B timeout avoids a visible window; ping as a portable sleep; rmdir /S /Q is silent.
var cmd = $"/C start \"\" /MIN cmd /C \"ping 127.0.0.1 -n 3 >nul & rmdir /S /Q \"\"{installDir}\"\"\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
});
}
catch { /* best effort */ }
}
/// <summary> /// <summary>
/// Guards against catastrophic recursive-delete paths. The install dir must be /// Guards against catastrophic recursive-delete paths. The install dir must be
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/"). /// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").

View File

@@ -54,6 +54,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
Steps.Add(new StepViewModel("Initialize Database")); Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service")); Steps.Add(new StepViewModel("Register Windows Service"));
Steps.Add(new StepViewModel("Create Shortcuts")); Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest")); Steps.Add(new StepViewModel("Write Install Manifest"));
} }
return Task.CompletedTask; return Task.CompletedTask;
@@ -82,7 +83,21 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
step.Status = p.Status; step.Status = p.Status;
if (p.Message is not null) if (p.Message is not null)
step.Messages.Add(p.Message); {
// Messages starting with "\r" overwrite the previous line (live progress).
if (p.Message.StartsWith('\r'))
{
var line = p.Message[1..];
if (step.Messages.Count > 0 && step.Messages[^1].StartsWith(" "))
step.Messages[^1] = line;
else
step.Messages.Add(line);
}
else
{
step.Messages.Add(p.Message);
}
}
if (p.Status is StepStatus.Running && !step.IsExpanded) if (p.Status is StepStatus.Running && !step.IsExpanded)
step.IsExpanded = true; step.IsExpanded = true;

View File

@@ -44,9 +44,18 @@ public sealed class DownloadAndExtractStep : IInstallStep
var zipPath = Path.Combine(scratchDir, zipAsset.Name); var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt"); var checksumPath = Path.Combine(scratchDir, "checksums.txt");
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)..."); var totalMb = zipAsset.Size / (1024 * 1024);
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
long lastReportedMb = -1;
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath, await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")), new Progress<long>(b =>
{
var mb = b / (1024 * 1024);
if (mb == lastReportedMb) return;
lastReportedMb = mb;
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
progress.Report($"\r {mb} / {totalMb} MB downloaded");
}),
ct); ct);
progress.Report("Downloading checksums..."); progress.Report("Downloading checksums...");
@@ -61,11 +70,16 @@ public sealed class DownloadAndExtractStep : IInstallStep
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with."); return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
// Only after verification do we touch the install directory. // Only after verification do we touch the install directory.
progress.Report("Clearing previous app/worker binaries..."); progress.Report("Stashing previous app/worker binaries...");
var appDest = Path.Combine(ctx.InstallDirectory, "app"); var appDest = Path.Combine(ctx.InstallDirectory, "app");
var workerDest = Path.Combine(ctx.InstallDirectory, "worker"); var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true); var appBak = appDest + ".bak";
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true); var workerBak = workerDest + ".bak";
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
if (Directory.Exists(appDest)) Directory.Move(appDest, appBak);
if (Directory.Exists(workerDest)) Directory.Move(workerDest, workerBak);
progress.Report("Extracting..."); progress.Report("Extracting...");
Directory.CreateDirectory(ctx.InstallDirectory); Directory.CreateDirectory(ctx.InstallDirectory);
@@ -75,11 +89,19 @@ public sealed class DownloadAndExtractStep : IInstallStep
} }
catch (Exception ex) catch (Exception ex)
{ {
// Roll back to previous binaries.
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
if (Directory.Exists(appBak)) Directory.Move(appBak, appDest);
if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest);
return StepResult.Fail( return StepResult.Fail(
$"Extraction failed after old binaries were removed: {ex.Message}. " + $"Extraction failed; previous binaries have been restored: {ex.Message}.");
"Your install directory may be incomplete. Re-run the installer to retry.");
} }
// Success — drop stash.
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V'); ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok(); return StepResult.Ok();
} }

View File

@@ -1,5 +1,6 @@
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Installer.Steps; namespace ClaudeDo.Installer.Steps;
@@ -14,8 +15,11 @@ public sealed class InitDatabaseStep : IInstallStep
var expandedPath = Paths.Expand(ctx.DbPath); var expandedPath = Paths.Expand(ctx.DbPath);
progress.Report($"Initializing database at {expandedPath}"); progress.Report($"Initializing database at {expandedPath}");
var factory = new SqliteConnectionFactory(expandedPath); var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
SchemaInitializer.Apply(factory); .UseSqlite($"Data Source={expandedPath}")
.Options;
using var context = new ClaudeDoDbContext(options);
ClaudeDoDbContext.MigrateAndConfigure(context);
progress.Report("Schema applied successfully"); progress.Report("Schema applied successfully");
return Task.FromResult(StepResult.Ok()); return Task.FromResult(StepResult.Ok());

View File

@@ -23,18 +23,39 @@ public sealed class RegisterServiceStep : IInstallStep
progress.Report("Removing existing service registration (if any)..."); progress.Report("Removing existing service registration (if any)...");
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true); await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Wait for the service to actually disappear from SCM. `sc delete` returns
// immediately but the service stays "marked for deletion" until every open
// handle (services.msc, Task Manager, a prior sc query process) is closed.
// Poll up to 30s — then fail with actionable guidance if it's still there.
progress.Report("Waiting for prior service registration to clear...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
if (queryExit != 0) break; // service no longer registered — good
if (i == 29)
return StepResult.Fail(
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
"Close any open Services console (services.msc), Task Manager Services tab, or " +
"Event Viewer showing the service, then retry. A reboot will also clear it.");
await Task.Delay(1000, ct);
}
// Create service // Create service
var startType = ctx.AutoStart ? "auto" : "demand"; var startType = ctx.AutoStart ? "auto" : "demand";
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}"; var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
if (ctx.ServiceAccount == "CurrentUser") if (ctx.ServiceAccount == "CurrentUser")
{ return StepResult.Fail(
var username = Environment.UserName; "Service cannot run as Current User without a password. " +
createArgs += $" obj= \".\\{username}\""; "Select 'Local System' or extend ServicePage to capture a password.");
}
progress.Report("Creating service..."); progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct); var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode == 1072)
return StepResult.Fail(
$"Service '{ServiceName}' is still marked for deletion. " +
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
if (exitCode != 0) if (exitCode != 0)
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}"); return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
@@ -46,15 +67,6 @@ public sealed class RegisterServiceStep : IInstallStep
if (failExit != 0) if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})"); progress.Report($"Warning: failed to set restart policy (exit {failExit})");
// Start service if auto-start
if (ctx.AutoStart)
{
progress.Report("Starting service...");
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
if (startExit != 0)
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
}
return StepResult.Ok(); return StepResult.Ok();
} }

View File

@@ -27,6 +27,12 @@ public sealed class StopServiceStep : IInstallStep
} }
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct); var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
if (stopExit == 1062)
{
progress.Report("Service was registered but not running.");
return StepResult.Ok();
}
if (stopExit != 0) if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}"); return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps; namespace ClaudeDo.Installer.Steps;
@@ -10,13 +11,15 @@ public sealed class WriteConfigStep : IInstallStep
{ {
try try
{ {
// Expand ~ to the installing user's absolute path so the worker
// service always finds the correct DB regardless of service account.
var workerCfg = new InstallerWorkerConfig var workerCfg = new InstallerWorkerConfig
{ {
DbPath = ctx.DbPath, DbPath = Paths.Expand(ctx.DbPath),
SandboxRoot = ctx.SandboxRoot, SandboxRoot = Paths.Expand(ctx.SandboxRoot),
LogRoot = ctx.LogRoot, LogRoot = Paths.Expand(ctx.LogRoot),
WorktreeRootStrategy = ctx.WorktreeRootStrategy, WorktreeRootStrategy = ctx.WorktreeRootStrategy,
CentralWorktreeRoot = ctx.CentralWorktreeRoot, CentralWorktreeRoot = Paths.Expand(ctx.CentralWorktreeRoot),
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs, QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
SignalRPort = ctx.SignalRPort, SignalRPort = ctx.SignalRPort,
ClaudeBin = ctx.ClaudeBin, ClaudeBin = ctx.ClaudeBin,
@@ -26,7 +29,7 @@ public sealed class WriteConfigStep : IInstallStep
var uiCfg = new InstallerAppSettings var uiCfg = new InstallerAppSettings
{ {
DbPath = ctx.UiDbPath, DbPath = Paths.Expand(ctx.UiDbPath),
SignalRUrl = ctx.SignalRUrl, SignalRUrl = ctx.SignalRUrl,
}; };
uiCfg.Save(); uiCfg.Save();

View File

@@ -0,0 +1,81 @@
using System.IO;
using ClaudeDo.Installer.Core;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Steps;
/// <summary>
/// Registers ClaudeDo under <c>HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo</c>
/// so it shows up in Windows "Apps &amp; Features" / "Programs and Features".
/// Also copies the running installer into the install directory so there is an exe
/// for UninstallString to reference after the temp-extracted single-file bundle is gone.
/// </summary>
public sealed class WriteUninstallRegistryStep : IInstallStep
{
internal const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo";
public string Name => "Register in Add/Remove Programs";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var uninstallDir = Path.Combine(ctx.InstallDirectory, "uninstaller");
Directory.CreateDirectory(uninstallDir);
var targetExe = Path.Combine(uninstallDir, "ClaudeDo.Installer.exe");
// Copy the running installer so Apps & Features has a stable exe to launch —
// the single-file temp extract is gone once this process exits.
var sourceExe = Environment.ProcessPath
?? throw new InvalidOperationException("Cannot resolve running installer path.");
try
{
progress.Report("Copying uninstaller binary...");
File.Copy(sourceExe, targetExe, overwrite: true);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
}
progress.Report("Writing Add/Remove Programs entry...");
try
{
using var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath, writable: true);
if (key is null)
return StepResult.Fail("Could not open uninstall registry key (permission denied?).");
key.SetValue("DisplayName", "ClaudeDo", RegistryValueKind.String);
key.SetValue("DisplayVersion", ctx.InstallerVersion ?? "0.0.0", RegistryValueKind.String);
key.SetValue("Publisher", "Mika Kuns", RegistryValueKind.String);
key.SetValue("InstallLocation", ctx.InstallDirectory, RegistryValueKind.String);
key.SetValue("UninstallString", $"\"{targetExe}\"", RegistryValueKind.String);
key.SetValue("DisplayIcon", targetExe, RegistryValueKind.String);
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
// Best-effort install size (KB) — scan install dir.
try
{
var sizeKb = (int)(DirectorySizeBytes(ctx.InstallDirectory) / 1024);
key.SetValue("EstimatedSize", sizeKb, RegistryValueKind.DWord);
}
catch { /* best-effort only */ }
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to write uninstall registry: {ex.Message}");
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static long DirectorySizeBytes(string path)
{
long total = 0;
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(file).Length; } catch { /* ignore */ }
}
return total;
}
}

View File

@@ -184,6 +184,34 @@
</Setter> </Setter>
</Style> </Style>
<!-- ComboBox toggle button (dropdown arrow chrome) -->
<ControlTemplate x:Key="ComboBoxToggleButtonTemplate" TargetType="ToggleButton">
<Border x:Name="Bd"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<Path Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
Fill="{StaticResource TextSecondaryBrush}"
Data="M 0 0 L 4 4 L 8 0 Z"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- ComboBox --> <!-- ComboBox -->
<Style TargetType="ComboBox"> <Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/> <Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
@@ -191,6 +219,71 @@
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/> <Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/> <Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/> <Setter Property="Padding" Value="8,6"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="ToggleButton"
Template="{StaticResource ComboBoxToggleButtonTemplate}"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
ClickMode="Press"/>
<ContentPresenter x:Name="ContentSite"
IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" HorizontalAlignment="Left"
TextElement.Foreground="{StaticResource TextPrimaryBrush}"/>
<Popup x:Name="Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False"
PopupAnimation="Slide">
<Border x:Name="DropDownBorder"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="4"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<ScrollViewer SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBoxItem — dark dropdown rows -->
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style> </Style>
<!-- CheckBox --> <!-- CheckBox -->

View File

@@ -29,6 +29,9 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string _versionLabel = ""; private string _versionLabel = "";
[ObservableProperty]
private bool _removeAppData;
public SettingsViewModel( public SettingsViewModel(
PageResolver resolver, PageResolver resolver,
InstallContext context, InstallContext context,
@@ -133,8 +136,12 @@ public partial class SettingsViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private async Task Uninstall() private async Task Uninstall()
{ {
var dataNote = RemoveAppData
? "This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?"
: "This will remove ClaudeDo. Your tasks, configuration, and database in ~/.todo-app will be kept.\n\nContinue?";
var confirm = MessageBox.Show( var confirm = MessageBox.Show(
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?", dataNote,
"Uninstall ClaudeDo", "Uninstall ClaudeDo",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Warning); MessageBoxImage.Warning);
@@ -142,7 +149,7 @@ public partial class SettingsViewModel : ObservableObject
if (confirm != MessageBoxResult.Yes) return; if (confirm != MessageBoxResult.Yes) return;
var progress = new Progress<string>(msg => StatusMessage = msg); var progress = new Progress<string>(msg => StatusMessage = msg);
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None); var r = await _uninstallRunner.RunAsync(RemoveAppData, progress, CancellationToken.None);
if (!r.Success) if (!r.Success)
{ {

View File

@@ -3,9 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views" xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Settings" Title="ClaudeDo Settings"
Icon="/ClaudeTaskSetup.ico"
Width="720" Height="520" Width="720" Height="520"
MinWidth="620" MinHeight="460" MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{StaticResource WindowBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="Segoe UI"
FontSize="13"
d:DataContext="{d:DesignInstance views:SettingsViewModel}" d:DataContext="{d:DesignInstance views:SettingsViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -64,6 +69,7 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Status message / version label --> <!-- Status message / version label -->
@@ -83,14 +89,17 @@
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0" <CheckBox Grid.Column="1" IsChecked="{Binding RemoveAppData}"
Content="Remove user data (tasks, logs, configs in ~/.todo-app)"
Margin="0,0,12,0" VerticalAlignment="Center"/>
<Button Grid.Column="2" Content="Uninstall" Margin="0,0,8,0"
Command="{Binding UninstallCommand}"/> Command="{Binding UninstallCommand}"/>
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0" <Button Grid.Column="3" Content="Repair" Margin="0,0,8,0"
Command="{Binding RepairCommand}"/> Command="{Binding RepairCommand}"/>
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0" <Button Grid.Column="4" Content="Save" Margin="0,0,8,0"
Command="{Binding SaveCommand}" Command="{Binding SaveCommand}"
Style="{StaticResource AccentButton}"/> Style="{StaticResource AccentButton}"/>
<Button Grid.Column="4" Content="Close" <Button Grid.Column="5" Content="Close"
Command="{Binding CloseCommand}"/> Command="{Binding CloseCommand}"/>
</Grid> </Grid>
</Border> </Border>

View File

@@ -3,9 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views" xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Installer" Title="ClaudeDo Installer"
Icon="/ClaudeTaskSetup.ico"
Width="720" Height="520" Width="720" Height="520"
MinWidth="620" MinHeight="460" MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{StaticResource WindowBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="Segoe UI"
FontSize="13"
d:DataContext="{d:DesignInstance views:WizardViewModel}" d:DataContext="{d:DesignInstance views:WizardViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"

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 ## 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 ## Converters

View File

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

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using Avalonia.Threading; using Avalonia.Threading;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
@@ -168,6 +169,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("RunNow", taskId); await _hub.InvokeAsync("RunNow", taskId);
} }
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
{
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
}
public async Task ResetTaskAsync(string taskId)
{
await _hub.InvokeAsync("ResetTask", taskId);
}
public async Task CancelTaskAsync(string taskId) public async Task CancelTaskAsync(string taskId)
{ {
await _hub.InvokeAsync("CancelTask", taskId); await _hub.InvokeAsync("CancelTask", taskId);
@@ -208,9 +219,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt)); ActiveTasks.Add(new ActiveTask(a.Slot, a.TaskId, a.StartedAt));
}); });
} }
catch catch (HubException)
{ {
// Worker might not support GetActive yet // Expected: worker doesn't support GetActive yet
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"SeedActiveTasksAsync failed: {ex}");
} }
} }
@@ -221,6 +236,47 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.DisposeAsync(); await _hub.DisposeAsync();
} }
public async Task<AppSettingsDto?> GetAppSettingsAsync()
{
try
{
return await _hub.InvokeAsync<AppSettingsDto>("GetAppSettings");
}
catch
{
return null;
}
}
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
{
await _hub.InvokeAsync("UpdateAppSettings", dto);
}
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
{
try
{
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees");
}
catch
{
return null;
}
}
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
{
try
{
return await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
}
catch
{
return null;
}
}
// DTOs for deserializing hub responses // DTOs for deserializing hub responses
private sealed class ActiveTaskDto private sealed class ActiveTaskDto
{ {
@@ -229,3 +285,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public DateTime StartedAt { get; set; } public DateTime StartedAt { get; set; }
} }
} }
public sealed record AppSettingsDto(
string DefaultClaudeInstructions,
string DefaultModel,
int DefaultMaxTurns,
string DefaultPermissionMode,
string WorktreeStrategy,
string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled,
int WorktreeAutoCleanupDays);
public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);

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

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