148 Commits

Author SHA1 Message Date
mika kuns
14cc9fb891 feat(settings): default permission mode to auto and surface it in UI
Replaces "bypassPermissions" with "auto" as the default for new installs
and adds "auto" as the first option in the settings dropdown. Existing
rows keep their stored value; ClaudeArgsBuilder still maps the legacy
"bypassPermissions" -> "auto" at dispatch time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:11:02 +02:00
mika kuns
7f96ae9508 feat(prompts): add editable system/planning/agent prompt files
Introduces ~/.todo-app/prompts/{system,planning,agent}.md as the canonical
location for prompt content. The settings modal exposes "Open in editor"
shortcuts for each, and TaskRunner merges system.md (always) and agent.md
(for "agent"-tagged tasks) into the effective system prompt alongside the
existing global/list/task layers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:10:50 +02:00
mika kuns
6c54759aa0 feat(ui): add Run interactively action to task context menu
Spawns Windows Terminal in the list working directory running
`claude --permission-mode auto` with the task title and description
prefilled as the initial prompt. Reuses the planning launcher
infrastructure but skips worktree, system prompt, and MCP setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:02:21 +02:00
mika kuns
615c1da665 docs: add planning UX spec/plan and prompts/mailbox proposals
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:37:32 +02:00
mika kuns
e192285f5d feat(ui): make island layout user-resizable with grid splitters
Replaces fixed 260/*/320 columns with two GridSplitters between Lists/Tasks/Details. Min widths preserved (200/320/280). Right splitter hides when ShowDetails is false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:37:25 +02:00
mika kuns
a6ca1c0108 feat(ui): add MarkdownView control and editable description in details island
New MarkdownView UserControl renders a markdown preview. Details island gains an editable Description section with edit/preview toggle, collapsible header, copy-to-clipboard, and debounced auto-save (400ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:37:14 +02:00
mika kuns
8f94dddbc5 feat(ui): queue planning subtasks sequentially and surface waiting status
Adds a "Queue subtasks sequentially" context-menu entry on rows with planning children, wires it to WorkerHub.QueuePlanningSubtasksAsync via IWorkerClient. TaskRowViewModel exposes IsWaiting/StatusChipClass for the new Waiting status, and HasPlanningChildren keeps parents expandable after they leave the planning state. TasksIslandViewModel auto-collapses parents whose every child is Done and includes Waiting children in the queued virtual list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:37:04 +02:00
mika kuns
45320427e8 feat(worker): add external MCP endpoint with API-key auth
A second WebApplication runs the external MCP server on its own port (default 47822) so it can expose a different tool set under different auth than the internal /mcp endpoint. Shared singletons (config, broadcaster, queue, db factory) are injected by instance so both apps share runtime state. ExternalMcpAuthMiddleware enforces an optional X-ClaudeDo-Key header; loopback-only trust when no key is configured.

Tools: ListTaskLists, ListTasks, GetTask, AddTask, UpdateTaskStatus, RunTaskNow, CancelTask.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:36:46 +02:00
mika kuns
16e1ddd129 feat(worker): add PlanningChainCoordinator for sequential subtask execution
Coordinates Waiting -> Queued transitions between sibling subtasks: when a child finishes Done, the next Waiting sibling is promoted to Queued. WorkerHub.QueuePlanningSubtasksAsync exposes this to the UI; TaskRunner advances the chain on completion. Also tightens the planning-session prompt: planner must use MCP tools, not direct edits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:36:01 +02:00
mika kuns
288d2ece8b feat(data): add Waiting task status and CreatedBy column
- New TaskStatus.Waiting for sequential subtask chains.
- New TaskEntity.CreatedBy column with migration AddTaskCreatedBy.
- TaskRepository.GetByCreatorAsync for filtering by creator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:35:15 +02:00
mika kuns
2ad6f20258 chore: bump default max turns from 30 to 100
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:35:01 +02:00
mika kuns
b2eb5fcfa4 refactor(worker): use --permission-mode auto instead of --dangerously-skip-permissions
Map legacy "bypassPermissions" config to "auto" at dispatch time; pass-through other modes (acceptEdits, plan, default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:34:48 +02:00
mika kuns
8e9f09a8e6 feat(worker): run planning agent in plan permission mode and enforce brainstorming skill
Adds --permission-mode plan to both launch paths (start and resume) so the
planning agent cannot perform file-modifying actions during the planning
conversation. Also appends instructions to the system prompt telling the
agent to always invoke the superpowers:brainstorming skill before creating
any child tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:38:09 +02:00
mika kuns
ce23f64dc3 fix(worker): emit PlanningMergeAborted (not Conflict) on non-conflict merge failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:32:52 +02:00
mika kuns
3008c36921 feat(worker): register planning services and add Merge-all hub methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:28:38 +02:00
mika kuns
e58cac24e1 feat(worker): add pre-flight checks and idempotent restart to PlanningMergeOrchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:24:41 +02:00
mika kuns
b9896399fa feat(worker): add PlanningMergeOrchestrator.AbortAsync 2026-04-24 18:18:49 +02:00
mika kuns
7d87c03cfa feat(worker): add PlanningMergeOrchestrator.ContinueAsync to resume merge after conflict
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:15:19 +02:00
mika kuns
ef070ddab5 fix(worker): prevent PlanningMergeOrchestrator double-drain race and orphaned state 2026-04-24 18:12:21 +02:00
mika kuns
3142ba203f feat(worker): add PlanningMergeOrchestrator happy path with merge event broadcasts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:08:58 +02:00
mika kuns
bc788e1e0f feat(ui): add conflict resolution dialog for planning merge-all
Opens a modal when PlanningMergeConflict fires, listing conflicted files
with options to open in VS Code, continue, or abort the merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:08:45 +02:00
mika kuns
a6ebff3f34 feat(ui): add aggregated diff viewer for planning tasks
Implements Task 14: PlanningDiffView (Window), PlanningDiffViewModel,
ShowPlanningDiffModal callback wired in DetailsIslandView, and 5 xUnit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:39:38 +02:00
mika kuns
389d9045d5 feat(worker): add PlanningAggregator.CleanupIntegrationBranchAsync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:34:25 +02:00
mika kuns
1aead9dad0 refactor(ui): test planning detail pane via real ViewModel and restore merge-all IsEnabled binding
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:31:54 +02:00
mika kuns
9d04d1d9f6 fix(worker): reorder PlanningAggregator checkout/delete and kill git on cancel
Also stub new IWorkerClient planning members in FakeWorkerClient to restore build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:24:24 +02:00
mika kuns
4c6fd9f024 feat(ui): add merge-target dropdown and merge-all controls to planning detail
- Add SubtaskDiffDto and CombinedDiffResultDto to PlanningDtos.cs
- Extend IWorkerClient with 5 planning merge methods and 5 events
- Implement methods and hub subscriptions on WorkerClient
- Add Status and WorktreeState to SubtaskRowViewModel
- Add MergeTargetBranches, SelectedMergeTarget, CanMergeAll,
  MergeAllDisabledReason, MergeAllError, RecomputeCanMergeAll,
  MergeAllCommand, ReviewCombinedDiffCommand (Task 14 TODO)
  to DetailsIslandViewModel
- Add planning merge section to DetailsIslandView.axaml
  (merge target ComboBox + buttons + error label), gated on
  Task.IsPlanningParent
- Add 4 xUnit tests covering CanMergeAll logic and DTO shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:22:27 +02:00
mika kuns
2cab33d708 feat(worker): add PlanningAggregator.BuildIntegrationBranchAsync 2026-04-24 16:18:45 +02:00
mika kuns
a1727b647c feat(worker): add PlanningAggregator.GetAggregatedDiffAsync
Returns per-subtask diff entries (title, branch, base/head commit, DiffStat, unified diff) for all children of a Planning task in SortOrder order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:08:56 +02:00
mika kuns
6bdfa73150 fix(ui): align virtual list semantics and complete planning roll-up coverage 2026-04-24 16:03:27 +02:00
mika kuns
ada4d9fd9b fix(worker): wrap MergeAbortAsync in AbortMergeAsync for consistent error handling 2026-04-24 15:51:40 +02:00
mika kuns
6d460ea996 fix(ui): planning parents roll up child status; children stay nested until parent Done 2026-04-24 15:47:47 +02:00
mika kuns
bc0f1e3122 feat(worker): add AbortMergeAsync to cancel a conflicted merge 2026-04-24 15:42:15 +02:00
mika kuns
63759ee7dc fix(worker): tighten ContinueMergeAsync guards and commit error handling 2026-04-24 15:22:52 +02:00
mika kuns
62106ff644 feat(worker): add ContinueMergeAsync to resume a conflicted merge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:17:57 +02:00
mika kuns
e77ba35b0e feat(worker): add leaveConflictsInTree option to TaskMergeService.MergeAsync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:06:33 +02:00
mika kuns
8afbf20613 docs(planning): add spec and plan for planning merge-all feature
Covers subtask visibility fix, aggregated diff viewer, and single
Merge-all action with VS-Code-assisted conflict resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:55:11 +02:00
mika kuns
5a03dc8430 feat(worker): broadcast child TaskUpdated events on planning CRUD
So the UI refreshes individual child rows alongside the parent during
create/update/delete/finalize from the planning MCP service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:54:46 +02:00
mika kuns
e62485db3b fix(worker): derive planning MCP URL from configured SignalRPort
Hard-coded 47821 meant .mcp.json pointed at the wrong port for any
worker running on a custom signalr_port (e.g. 37821), causing
"Unable to connect" auth failures in the planning session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:07:44 +02:00
mika kuns
a5ebfd12f8 test(worker): clean up non-git test tmp dir and assert self-heal setup exit code
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:24:01 +02:00
mika kuns
2262ab0e13 test(worker): cover planning worktree lifecycle and self-heal
Adds four tests to PlanningSessionManagerTests: worktree removal on
discard, error on non-git working dir, self-heal when branch already
exists, and resume returning the correct token and session id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:20:29 +02:00
mika kuns
0da527dbbc test(worker): adapt planning tests to git-backed worktree flow
Update constructor calls (6-arg), seed AppSettings with sibling strategy,
git-init working dirs via GitRepoFixture.InitRepoWithInitialCommit, and
replace McpConfigPath assertions with worktree-path / .mcp.json checks.
Also fixes PlanningHubTests which had the same 3-arg constructor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:14:46 +02:00
mika kuns
9beda55681 chore(worker): wire GitService and WorkerConfig into PlanningSessionManager DI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:52:20 +02:00
mika kuns
6800852ae4 feat(worker): launcher passes planning token via env, drops --mcp-config
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:47:35 +02:00
mika kuns
48899b3df8 feat(worker): cleanup planning worktree and branch on finalize/discard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:43:53 +02:00
mika kuns
fce91bcf86 feat(worker): create ephemeral worktree and write .mcp.json in StartAsync
Rewrites StartAsync to provision a git worktree before transitioning the
task to Planning state, writes .mcp.json and .claude/settings.local.json
into the worktree, and fixes ResumeAsync to supply the updated
PlanningSessionResumeContext fields (Token, WorktreePath).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:37:42 +02:00
mika kuns
975e1ce50c refactor(worker): switch MCP config to env-var token expansion
BuildMcpConfigJson drops the token argument; the literal
\${CLAUDEDO_PLANNING_TOKEN} placeholder is written to mcp.json so
claude expands it from the spawned process environment at load time.
Also declares SettingsLocalJson constant for use in later tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:30:11 +02:00
mika kuns
1d61df8160 refactor(worker): add worktree path and token file helpers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:27:35 +02:00
mika kuns
1370bf3dcc refactor(worker): inject GitService and WorkerConfig into PlanningSessionManager
Adds AppSettingsRepository to the test constructor, GitService and
WorkerConfig to both constructors, and updates CreateRepos() and all
its call-sites to expose the new settings tuple element.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:24:28 +02:00
mika kuns
f2db5f4ad0 refactor(worker): drop McpConfigPath from PlanningSessionFiles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:20:58 +02:00
mika kuns
fd2ac4842f refactor(worker): extend planning contexts with token and worktree
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:17:28 +02:00
mika kuns
4de2deaebe docs(planning): add worktree-isolated MCP session design and plan
Design: run each planning session in an ephemeral git worktree so .mcp.json
and .claude/settings.local.json can be placed without touching the user's
working dir. Plan breaks the change into 12 TDD tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:12:40 +02:00
mika kuns
b7c60f5838 feat(ui): live task updates from worker events + planning polish
Wire TasksIslandViewModel to TaskUpdated/WorktreeUpdated/TaskMessage worker
events so rows refresh without a full reload; add ForegroundHelper to permit
wt.exe to take foreground on planning launch; misc UI polish on lists, task
rows and settings modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:12:27 +02:00
e455d85578 Merge pull request 'feat/planning-sessions-worker' (#7) from feat/planning-sessions-worker into main
Reviewed-on: #7
2026-04-24 06:02:49 +00:00
mika kuns
0782ba574b test(worker): planning session end-to-end 2026-04-23 23:31:01 +02:00
mika kuns
7b67e35720 feat(worker): SignalR hub endpoints for planning sessions 2026-04-23 23:26:12 +02:00
mika kuns
c048264b95 fix(worker): register TaskRepository in DI and guard null WorkingDir 2026-04-23 23:17:30 +02:00
mika kuns
6cb20a9213 feat(worker): map MCP HTTP endpoint and broadcast TaskUpdated
- Add PlanningMcpContextAccessor (Option A) to read PlanningMcpContext
  from HttpContext.Items set by PlanningTokenAuthMiddleware
- Annotate PlanningMcpService with [McpServerToolType]/[McpServerTool]
  and remove PlanningMcpContext ctx parameter from all tool methods
- Broadcast TaskUpdated(parentTaskId) via HubBroadcaster after every
  mutation in PlanningMcpService
- Refactor PlanningSessionManager to accept IDbContextFactory for
  singleton-safe use in DI; keep direct-repo ctor for tests
- Register PlanningSessionManager (singleton), IPlanningTerminalLauncher,
  PlanningMcpContextAccessor, PlanningMcpService, and MCP server in
  Program.cs; wire PlanningTokenAuthMiddleware and MapMcp("/mcp")
- Update PlanningMcpServiceTests with fake HttpContext accessor and
  no-op HubBroadcaster (avoids Moq dependency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 23:12:24 +02:00
mika kuns
99c6a71e4c feat(worker): MCP tools update_planning_task and finalize 2026-04-23 23:03:42 +02:00
mika kuns
0088d6e0e0 feat(worker): MCP tools for child-task CRUD 2026-04-23 22:57:27 +02:00
mika kuns
b115a4c512 feat(worker): MCP bearer-token auth middleware 2026-04-23 21:15:38 +02:00
mika kuns
9e09ae6b4e fix(worker): planning launcher — avoid cmd shell to prevent prompt injection 2026-04-23 21:13:23 +02:00
mika kuns
43a3740980 feat(worker): WindowsTerminalPlanningLauncher with pre-flight checks 2026-04-23 21:08:15 +02:00
mika kuns
d28164caf4 feat(worker): PlanningSessionManager.GetPendingDraftCountAsync 2026-04-23 21:04:06 +02:00
mika kuns
77f7cf1423 feat(worker): PlanningSessionManager.FinalizeAsync 2026-04-23 21:01:22 +02:00
mika kuns
84e6c2d5fc feat(worker): PlanningSessionManager.DiscardAsync 2026-04-23 20:58:55 +02:00
mika kuns
84b0ba8670 feat(worker): PlanningSessionManager.ResumeAsync 2026-04-23 20:55:01 +02:00
mika kuns
b6bec1e63c feat(worker): PlanningSessionManager.StartAsync
Add PlanningSessionFiles, PlanningSessionStartContext/ResumeContext DTOs,
PlanningSessionManager.StartAsync (file scaffolding + status transition),
and integration tests. Also fix migration discovery by adding [DbContext]
attribute to all migration classes and switch DbFixture to EnsureCreated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:49:41 +02:00
mika kuns
b32621a4e5 chore(worker): add ModelContextProtocol package 2026-04-23 20:49:41 +02:00
993851009b Merge pull request 'feat(ui): planning sessions UI (Plan C)' (#5) from feat/planning-sessions-ui into main
Reviewed-on: #5
2026-04-23 17:38:08 +00:00
mika kuns
450e685580 docs(open): add planning-session manual verification checklist 2026-04-23 19:32:34 +02:00
mika kuns
0e116bec7b feat(ui): friendly error when deleting task with children 2026-04-23 19:22:28 +02:00
mika kuns
47b49743c0 feat(ui): unfinished planning session dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:19:16 +02:00
mika kuns
506caa2c53 feat(ui): draft and planning badge styles 2026-04-23 19:04:26 +02:00
mika kuns
388a8c1fae feat(ui): planning entries in task context menu 2026-04-23 19:02:06 +02:00
mika kuns
42b208ff28 feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:58:08 +02:00
mika kuns
309f84b388 feat(ui): planning commands and expand/collapse in TasksIslandViewModel
- Add IWorkerClient interface; WorkerClient implements it
- TasksIslandViewModel accepts IWorkerClient? and gains OpenPlanningSession,
  ResumePlanningSession, DiscardPlanningSession, FinalizePlanningSession,
  and ToggleExpand commands
- Regroup() is hierarchy-aware: children of collapsed planning parents are hidden
- InternalsVisibleTo ClaudeDo.Worker.Tests for Regroup()
- 4 new unit tests covering collapse/expand and guard logic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:51:22 +02:00
mika kuns
00608401aa feat(ui): WorkerClient planning-session methods 2026-04-23 18:41:04 +02:00
mika kuns
229d4bbb2b feat(ui): TaskRowViewModel gains planning hierarchy flags
Adds ParentTaskId, IsExpanded, IsChild, IsPlanningParent, IsDraft, and
PlanningBadge to TaskRowViewModel with property-changed notifications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:39:44 +02:00
845359b885 feat: planning sessions foundation (Plan A) (#4)
Merges Plan A: schema + repos + auto-parent-completion hook.
2026-04-23 16:31:37 +00:00
mika kuns
d4a46420c9 feat(worker): hook TryCompleteParentAsync after MarkDone/MarkFailed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:18:50 +02:00
mika kuns
f704244b84 test(data): parent delete with children is restricted 2026-04-23 18:15:12 +02:00
mika kuns
782110604b fix(data): enable foreign_keys pragma in MigrateAndConfigure 2026-04-23 18:15:06 +02:00
mika kuns
19bf032a2e test(data): queue skips Planning/Planned/Draft 2026-04-23 18:09:29 +02:00
mika kuns
b7464c9a11 feat(data): TaskRepository.TryCompleteParentAsync 2026-04-23 18:08:14 +02:00
mika kuns
524aaf85af feat(data): TaskRepository.DiscardPlanningAsync 2026-04-23 18:04:40 +02:00
mika kuns
a9e7479326 feat(data): TaskRepository.FinalizePlanningAsync 2026-04-23 18:03:10 +02:00
mika kuns
2e80cc606e feat(data): TaskRepository.FindByPlanningTokenAsync 2026-04-23 17:59:42 +02:00
mika kuns
d099138487 feat(data): TaskRepository.UpdatePlanningSessionIdAsync 2026-04-23 17:58:28 +02:00
mika kuns
2278d97b7e feat(data): TaskRepository.SetPlanningStartedAsync 2026-04-23 17:56:19 +02:00
mika kuns
74255ddc82 feat(data): TaskRepository.CreateChildAsync 2026-04-23 17:54:43 +02:00
mika kuns
b466246c1b feat(data): TaskRepository.GetChildrenAsync 2026-04-23 17:52:51 +02:00
mika kuns
b3eb39a28b feat(data): migration AddPlanningSupport 2026-04-23 17:48:10 +02:00
mika kuns
253e6f05e0 feat(data): configure planning columns and self-ref FK with Restrict 2026-04-23 17:45:31 +02:00
mika kuns
042a1b47c2 feat(data): add planning columns and self-ref navigations to TaskEntity 2026-04-23 17:44:55 +02:00
mika kuns
7a20534e7c feat(data): add Planning, Planned, Draft task statuses 2026-04-23 17:44:29 +02:00
mika kuns
ee2cbc92ef feat(ui): move list-settings access from lists pane to tasks header
The gear button on list rows became noisy and overlapped with row
selection. Moves it into the tasks island header where it targets the
currently selected list. Lists pane regains a cleaner row layout.
Also: swallow GetListConfig errors on fresh lists that have no row yet.
2026-04-23 17:40:27 +02:00
mika kuns
373f04a034 build: manage version via MinVer with AssemblyInformationalVersion
Uses MinVer to derive the version from git tags (prefix "v"), stored in
AssemblyInformationalVersion. Program.cs reads that attribute (stripping
the "+sha" build metadata) so the update-check compares against a clean
semver. CI overrides the tag via /p:MinVerVersionOverride=$VERSION.
2026-04-23 17:40:01 +02:00
mika kuns
43d517dcfc docs(plans): add planning sessions implementation plans A, B, C
- Plan A (Foundation): schema, enum, repos, auto-status hook
- Plan B (Worker MCP + Launcher): MCP server, SignalR endpoints, wt.exe launcher
- Plan C (UI): context menu, hierarchy rendering, dialog, client methods

Plans B and C depend on Plan A merging first (marker: migration file
AddPlanningSupport). B and C can run in parallel after A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:36:02 +02:00
mika kuns
8891d48af2 docs(spec): add planning sessions design
Interactive "Open planning Session" context menu: launches a scoped
MCP-backed Claude CLI session in Windows Terminal, letting the user
brainstorm and Claude break a rough task into concrete child-tasks
under a flat parent-child hierarchy. Includes schema, MCP tool surface,
terminal launch, UI changes, lifecycle, testing, and a three-plan
phasing (A foundation, then B worker + C UI in parallel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:24 +02:00
mika kuns
0b72c0fb53 Merge branch 'feat/self-update'
Self-update for app and installer. Integrates cleanly with the
worker-log-footer feature that landed on main in parallel — the
shell VM now carries both worker-log state and update-check state,
and MainWindow hosts both the update banner and the footer log line.

Conflict resolved in IslandsShellViewModel.cs: kept nullable property
types from main's test-only parameterless constructor work, and added
the UpdateCheck property exposing the injected service.
2026-04-23 15:24:07 +02:00
mika kuns
a41e5b5b2d docs(open): add self-update manual verification checklist 2026-04-23 15:11:39 +02:00
mika kuns
00c62178e1 feat(ui): add update banner and Help menu to MainWindow 2026-04-23 15:10:43 +02:00
mika kuns
bbe7d73de2 feat(ui): wire update-check state and commands into shell VM 2026-04-23 15:05:56 +02:00
mika kuns
0934b294c2 feat(app): register UpdateCheckService and InstallerLocator in DI 2026-04-23 15:03:28 +02:00
mika kuns
b28d8f2f4a feat(ui): show worker log line in footer 2026-04-23 14:59:28 +02:00
mika kuns
ec4ec44603 feat(ui): add worker log state and 30s timer to shell VM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:56:58 +02:00
mika kuns
ee09706811 feat(ui): add InstallerLocator 2026-04-23 14:56:57 +02:00
mika kuns
c06d1d6afb feat(ui): add UpdateCheckService 2026-04-23 14:53:20 +02:00
mika kuns
f906e7086c feat(ui): add WorkerLogLevelToBrushConverter with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:53:03 +02:00
mika kuns
caf900b02d feat(installer): self-update pre-flight before wizard 2026-04-23 14:49:48 +02:00
mika kuns
e80e3fccc0 feat(ui): subscribe to WorkerLog SignalR event 2026-04-23 14:49:43 +02:00
mika kuns
e8056553fd feat(worker): emit WorkerLog for merge, discard, reset 2026-04-23 14:47:45 +02:00
mika kuns
ea4d2d7c0c feat(worker): emit WorkerLog events from TaskRunner 2026-04-23 14:46:10 +02:00
mika kuns
98c188a5da feat(releases): add SelfUpdater.DownloadAndVerifyAsync 2026-04-23 14:45:13 +02:00
mika kuns
0c3dcb0052 feat(releases): add SelfUpdater.HandleReplaceSelfAsync 2026-04-23 14:42:41 +02:00
mika kuns
e017d66023 feat(releases): add SelfUpdater.DecideUpdateAsync 2026-04-23 14:40:45 +02:00
mika kuns
ba0b38b4f1 feat(releases): add SelfUpdater installer-asset matching 2026-04-23 14:38:20 +02:00
mika kuns
5b4cdd366e refactor(installer): use shared VersionComparer in InstallModeDetector 2026-04-23 14:36:45 +02:00
mika kuns
7c0f8d8408 feat(releases): add VersionComparer 2026-04-23 14:21:25 +02:00
mika kuns
0a7fcae137 feat(worker): add WorkerLog SignalR event 2026-04-23 14:19:04 +02:00
mika kuns
5346737e2b test(releases): port ReleaseClient + ChecksumVerifier tests to new project
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:18:17 +02:00
mika kuns
80f6669585 feat(data): add WorkerLogLevel enum 2026-04-23 14:17:31 +02:00
mika kuns
27054e6715 chore: ignore .worktrees/ for local dev worktrees 2026-04-23 14:15:53 +02:00
mika kuns
ea7694566d docs(superpowers): add worker-log footer implementation plan 2026-04-23 14:11:39 +02:00
mika kuns
46e01aefed refactor(releases): move release-API + checksum types to ClaudeDo.Releases 2026-04-23 14:09:40 +02:00
mika kuns
41e0bea162 docs(superpowers): add worker-log footer implementation plan 2026-04-23 14:03:09 +02:00
mika kuns
86012e02b9 feat(releases): add empty ClaudeDo.Releases library 2026-04-23 13:59:45 +02:00
mika kuns
da19eb807b docs(superpowers): add worker-log footer design spec 2026-04-23 13:56:09 +02:00
mika kuns
0d37473575 docs(self-update): add implementation plan 2026-04-23 13:52:36 +02:00
mika kuns
6a4bf676ff docs(self-update): add design spec for app + installer self-update 2026-04-23 13:44:52 +02:00
mika kuns
a135485339 docs(superpowers): add default-agents plan and design spec 2026-04-23 13:08:23 +02:00
mika kuns
3c420acd54 style(ui): polish list sidebar, kbd chips, and session terminal
- Introduce .list-count style for sidebar badges (brighter on active row).
- Bind list header More button to the correct OpenSettingsCommand.
- Give the per-list gear the standard icon-btn look.
- Center-align kbd chip content and title-bar/icon button content.
- Drop the kind-marker column in SessionTerminal and always show the
  scrollbar so the terminal feels like one.
2026-04-23 13:08:17 +02:00
mika kuns
5ced1b97a6 refactor(ui): redesign list settings and merge modals with custom chrome
Both modals now use SystemDecorations=None with a draggable title bar,
sectioned layout matching the rest of the island shell, Escape-to-cancel,
and themed brushes instead of hard-coded colours. ListSettings adds a
Browse... button that reads agent frontmatter from arbitrary .md files.
2026-04-23 13:08:09 +02:00
mika kuns
1344beba56 fix(ui): select task on left-click even when reorder is disabled
The tunnel pointer handler returned early when CanReorder was false,
so clicking a row in smart/virtual lists never updated the details
pane. Select first, then bail out of the drag path; also skip drag
initialisation on nested buttons so the done-toggle click still fires.
2026-04-23 13:08:02 +02:00
mika kuns
c8c8bb4a47 feat(ui): replay persisted task log when selecting a task
Read the task's LogPath on selection and feed each line through the
live-stream parser so Claude output stays visible across app restarts.
Tail-caps at 2000 lines to avoid flooding the UI.
2026-04-23 13:07:54 +02:00
mika kuns
6f725d12f5 feat(ui): add queueing and scheduling from task row context menu
- Right-click on a task row exposes Send to queue / Remove from queue
  and Schedule for... / Clear schedule actions.
- New virtual:queued list in the sidebar with live count.
- Sidebar counts are now computed (open per list, running, queued,
  review) and refreshed on task- and worker-side events.
- Sending a task to the queue wakes the worker so it starts immediately.
2026-04-23 13:07:48 +02:00
mika kuns
9952ff98f2 feat(ui): use ClaudeTask icon for window and taskbar
Expose the .ico as an Avalonia resource, set it on MainWindow, and
swap the custom SystemDecorations=None for BorderOnly so the icon
appears on the taskbar and window can be resized normally.
2026-04-23 13:07:38 +02:00
mika kuns
4a6d96b90e feat(installer): show version info and offer worker restart in settings
- Surface Latest version and flag unparseable pre-release tags in
  VersionLabel so users know why auto-update was skipped.
- Prompt to stop/start the worker service after Save, since the
  worker only reads its config at process start.
2026-04-23 13:07:31 +02:00
mika kuns
2690332d13 feat(installer): record data directory in install manifest
Write the resolved DbPath parent into the manifest so UninstallRunner
can honour customised data locations instead of always assuming
~/.todo-app. Older manifests fall back to the default path.
2026-04-23 13:07:23 +02:00
mika kuns
31218fc205 feat(installer): harden database init and service setup steps
- InitDatabaseStep: create DbPath parent directory so custom paths work
- RegisterServiceStep: pass obj= argument so ServiceAccount is honoured
- StartServiceStep: poll for RUNNING state so downstream steps don't race
2026-04-23 13:07:16 +02:00
mika kuns
cc01871407 chore(settings): allow context7 MCP tools 2026-04-23 13:07:07 +02:00
mika kuns
e70ae7f6ce feat(ui): add Restore default agents button to Settings modal 2026-04-23 12:21:02 +02:00
mika kuns
1830273a9d feat(ui): add RestoreDefaultAgentsAsync to WorkerClient 2026-04-23 12:19:44 +02:00
mika kuns
1a10e6fa09 feat(worker): expose RestoreDefaultAgents hub method 2026-04-23 12:18:49 +02:00
mika kuns
df57c2bc05 feat(worker): seed default agents on startup 2026-04-23 12:15:28 +02:00
mika kuns
990be09bd7 feat(worker): add DefaultAgentSeeder for first-launch agent seeding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:12:55 +02:00
mika kuns
e275f67a5e build(worker): ship DefaultAgents folder in build output 2026-04-23 12:09:13 +02:00
mika kuns
ff3de1d100 feat(worker): add bundled default agent definitions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:08:16 +02:00
177 changed files with 23323 additions and 447 deletions

View File

@@ -4,7 +4,12 @@
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)", "Bash(git commit:*)",
"mcp__plugin_context-mode_context-mode__batch_execute", "mcp__plugin_context-mode_context-mode__batch_execute",
"mcp__plugin_context-mode_context-mode__execute" "mcp__plugin_context-mode_context-mode__execute",
"mcp__plugin_context7_context7__query-docs",
"mcp__plugin_context-mode_context-mode__search",
"Bash(git fetch *)",
"PowerShell(cmdkey *)",
"mcp__plugin_context7_context7__resolve-library-id"
] ]
} }
} }

View File

@@ -53,7 +53,7 @@ jobs:
cd "$WORK/src" cd "$WORK/src"
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \ dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
-c Release -r win-x64 --self-contained true \ -c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/app /p:MinVerVersionOverride=$VERSION -o out/app
- name: Publish ClaudeDo.Worker (win-x64, self-contained) - name: Publish ClaudeDo.Worker (win-x64, self-contained)
env: env:
@@ -65,7 +65,7 @@ jobs:
cd "$WORK/src" cd "$WORK/src"
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \ dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
-c Release -r win-x64 --self-contained true \ -c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/worker /p:MinVerVersionOverride=$VERSION -o out/worker
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent) - name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
env: env:
@@ -80,7 +80,7 @@ jobs:
# Target machines need .NET 8 Desktop Runtime (x64). # Target machines need .NET 8 Desktop Runtime (x64).
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \ dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
-c Release -r win-x64 --self-contained false \ -c Release -r win-x64 --self-contained false \
/p:Version=$VERSION /p:PublishSingleFile=true \ /p:MinVerVersionOverride=$VERSION /p:PublishSingleFile=true \
-o out/installer -o out/installer
- name: Package assets - name: Package assets

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# Local dev worktrees (created by using-git-worktrees skill)
.worktrees/
# .NET build output # .NET build output
bin/ bin/
obj/ obj/

View File

@@ -5,10 +5,12 @@
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" /> <Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" /> <Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" /> <Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" /> <Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" /> <Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" /> <Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

8
Directory.Build.props Normal file
View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<MinVerTagPrefix>v</MinVerTagPrefix>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
</ItemGroup>
</Project>

98
docs/mailbox-proposal.md Normal file
View File

@@ -0,0 +1,98 @@
# Task Mailbox — Push Messages Into Running Sessions
**Status:** proposal
**Context:** the user runs parallel Claude sessions (e.g. backend + frontend) and wants to push messages into a session while it's busy inside a subagent. A shared folder works for one-offs; this turns it into a first-class ClaudeDo feature so every future parallel-session project gets it for free.
## Problem
Claude CLI processes one turn at a time. While a subagent (or any long tool) runs, no new user input can be injected. The harness offers no mid-execution interrupt. The workable window is *between* tool calls — so we need a cheap "inbox check" the agent can poll at natural checkpoints, plus a UI affordance and a cross-session sender.
## Design
### 1. Data
New table `task_messages`:
| col | type | notes |
|---|---|---|
| `id` | INTEGER PK | |
| `task_id` | TEXT FK → tasks.id | recipient |
| `sender` | TEXT | `'user'` \| `'task:<id>'` (for cross-session) |
| `body` | TEXT | markdown |
| `created_at` | TEXT | ISO |
| `delivered_at` | TEXT NULL | set when inbox pulls it |
EF Core migration + repository. Async, CancellationToken, matches existing conventions.
### 2. Worker MCP tools (extend existing `mcp__claudedo__*` server)
- **`check_inbox(task_id)`** → returns undelivered messages for this task and marks them delivered. Idempotent. Empty array if nothing pending.
- **`send_to_task(task_id, body)`** → inserts a row. Callable from *any* session — this is how the frontend session tells the backend session something.
- **`inbox_status(task_id)`** → `{ pending: int }` for a cheap "is there anything?" poll.
All three run in-proc in the Worker, go through the existing repository layer.
### 3. SignalR additions on `WorkerHub`
Server methods (UI → Worker):
- `SendTaskMessage(taskId, body)` — UI calls this; worker inserts the row and fires `TaskMessageQueued`.
Client events (Worker → UI):
- `TaskMessageQueued(taskId, pendingCount)` — so the UI can show an unread badge.
- `TaskMessageDelivered(taskId, pendingCount)` — when the agent pulls it, badge clears.
### 4. UI
On every `Running` task row + detail pane:
- "Send to session" textarea + Enter to submit → `SendTaskMessage`.
- Unread badge showing `pendingCount`.
- Read-only message timeline (who sent what, when delivered).
### 5. Agent-side poll discipline
Two complementary mechanisms so it's robust whether or not the agent remembers:
**a) CLAUDE.md instruction** (seeded by worker into each worktree's `CLAUDE.md`):
> After every subagent completes and before starting the next step, call `mcp__claudedo__check_inbox`. Treat returned messages as user input with priority over the current plan.
**b) PostToolUse hook on `Agent`** (written into the worktree's `.claude/settings.json` by the Worker when it creates the tree):
- Runs `mcp__claudedo__inbox_status` via a tiny CLI shim the worker ships.
- If `pending > 0`, the hook emits a system reminder: "Inbox has N pending messages — call `mcp__claudedo__check_inbox` now."
- Keeps the burden off the agent's memory. Belt + suspenders.
### 6. Cross-session pattern
Backend session and frontend session are just two tasks with known IDs. Either can call `send_to_task(other_id, body)` via the MCP server. No shared folder needed — the DB is already the shared channel.
To make this ergonomic:
- A "linked tasks" concept: tag two tasks as peers at creation time. The Worker exposes `send_to_peer(body)` as sugar around `send_to_task` so neither session needs to hardcode the other's UUID.
## Limits (honest)
- Messages arrive *between* tool calls, not mid-tool. A 20-minute subagent still blocks 20 minutes. Splitting work into shorter subagents is still the right discipline.
- If the agent ignores the CLAUDE.md instruction, the hook catches it next tool call — but we can't force immediate consumption.
- `-p` (print) mode with stdin prompt is one-shot and can't be extended. This design targets *interactive* sessions (Planning Sessions already use this mode). For queued `-p` runs, the mailbox is effectively a post-run instruction carrier.
## Why this is the repeatable "Grundgerüst"
Once this lands in ClaudeDo, the workflow becomes:
1. Create two linked tasks (`backend`, `frontend`) with `working_dir` set.
2. Start each — each gets its own worktree, its own Planning Session terminal, its own inbox with `check_inbox` + `send_to_peer`.
3. Push messages from the UI or from the other session. No per-project scaffolding, no custom hooks, no shared folder.
Every future parallel-session project inherits the mailbox.
## Build order (suggested)
1. Migration + repo + model. Tests first.
2. MCP tools (`check_inbox`, `send_to_task`, `inbox_status`) + unit tests.
3. SignalR method + events + UI textarea/badge.
4. Worker writes CLAUDE.md addendum + `.claude/settings.json` hook into each new worktree.
5. Linked-tasks sugar (`send_to_peer`).
6. Manual verification: queue a long subagent, send a message, confirm it's picked up at the next tool boundary.
## Open questions
- Should messages be deleted or soft-kept after delivery? Leaning soft-kept for the timeline UI.
- Priority / interrupt semantics — do we want a "high priority" flag that the agent should surface immediately vs. batch?
- Should `send_to_peer` also work when the peer is `Queued` (i.e. not yet running)? Probably yes — deliver on start.

View File

@@ -191,3 +191,38 @@ Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` ma
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird. 9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
Punkte 13 sind ein realistischer Block für eine Session. Punkte 13 sind ein realistischer Block für eine Session.
---
## Self-Update — Manual Verification
Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with three assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, and `checksums.txt` listing both.
1. Install a baseline version (e.g. `0.2.x`) normally.
2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums.
3. Launch the app — confirm the banner appears: `Update available: v0.2.x → v0.3.0`.
4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker.
5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date (v0.3.0)".
6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** → running exe is replaced and the wizard opens on the new version.
7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
8. Repeat step 6 with **Cancel** → installer exits without any action.
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
---
## Planning Sessions — Manual Verification (Plan C UI)
Requires Plan B (worker hub endpoints) merged. Until then, only the UI structure/styling checks are meaningful.
1. Create a Manual task with a title and a TODO-ish description.
2. Right-click the task → **Open planning Session** — Windows Terminal opens with Claude CLI running (Plan B).
3. Ask Claude to create two child tasks via `mcp__claudedo__create_child_task`.
4. Watch the UI: drafts appear indented under the parent, italic, reduced opacity, with a `DRAFT` badge.
5. The parent shows a `PLANNING` badge. Click the chevron → children collapse; click again → children expand.
6. Ask Claude to `finalize` — drafts flip to Manual/Queued children; parent flips to `PLANNED` badge.
7. In a new planning task, close the terminal without finalize. Right-click the Planning task → the unfinished-session modal opens with Resume / Finalize now / Discard.
8. Attempt to delete a parent with children via the details panel — confirm the friendly error dialog appears and the task is NOT deleted.
**Known followups (non-blocking):**
- `Border.badge.planned` style (blue) is defined in `IslandStyles.axaml` but never applied — `TaskRowView` keeps the `planning` class for both Planning and Planned, so Planned gets the amber badge. Either make the view swap `Classes.planned` when status is Planned, or remove the unused style + brush.
- Dead `Instance` statics on `BoolToItalicConverter` and `BoolToDraftOpacityConverter` — App.axaml registers instances via the resource dictionary; the static members can be removed.

198
docs/prompts-inventory.md Normal file
View File

@@ -0,0 +1,198 @@
# ClaudeDo — Prompt & CLI Inventory
Snapshot of every string ClaudeDo sends to Claude CLI, plus the CLI-flag surface that shapes each run. Intended as a working doc for tomorrow's prompt-tuning pass.
Date: 2026-04-24
---
## 1. Task-execution prompts (agent-tagged tasks → Claude CLI)
Used for every "agent" task that the queue picks up or that `RunNow` dispatches.
Orchestration lives in `src/ClaudeDo.Worker/Runner/TaskRunner.cs` and `ClaudeArgsBuilder.cs`.
### 1.1 User prompt (stdin) — `TaskRunner.RunAsync` ~L101L110
Plain text, no template around it:
```
{task.Title}
{task.Description?.Trim()} ← only if non-empty
## Sub-Tasks ← only if subtasks exist
- [ ] {subtask.Title} ← "[x]" if completed
...
```
Notes
- Title is included verbatim — no leading `#` heading.
- No role tags, no XML, no delimiters between title and description — just blank lines.
- Sub-Tasks section uses markdown checkboxes. This is the only structural scaffolding.
- No context about the project, working dir, or git state is added here.
### 1.2 Retry prompt (on failure, when a session ID exists) — `TaskRunner` ~L126
```
The previous attempt failed with:
{result.ErrorMarkdown}
Try again and fix the issues.
```
Fired once per task via `--resume <session_id>`; if the retry also fails, the task is marked Failed.
### 1.3 Follow-up prompt (multi-turn `ContinueAsync`) — `TaskRunner.ContinueAsync` L159
The UI/hub supplies `followUpPrompt` as-is; no wrapping. The session is resumed via `--resume`. So the effective "prompt template" is whatever the user types in the Continue textbox.
### 1.4 System prompt — merged in `TaskRunner` ~L413L418
Built by `TaskRunner.MergeInstructions(global, list, task)` which concatenates three optional strings with `\n\n`:
1. `AppSettings.DefaultClaudeInstructions` (global, set in Settings modal, default `""`)
2. `list_config.SystemPrompt` (per-list override)
3. `task.SystemPrompt` (per-task override)
The merged string is passed as `--append-system-prompt <instructions>` to the CLI. Empty/whitespace → flag is omitted entirely.
**Currently the global `DefaultClaudeInstructions` ships as empty string** (see `AppSettingsEntity.cs` L9). Anything in the system prompt today is whatever the user typed into Settings / List-Settings / Task-Settings.
### 1.5 CLI args — `ClaudeArgsBuilder.Build` (`ClaudeArgsBuilder.cs`)
Always on:
- `-p`
- `--output-format stream-json`
- `--verbose`
- `--permission-mode {auto|acceptEdits|plan|default}` (legacy `bypassPermissions``auto`)
Conditional:
- `--model {sonnet|opus|haiku|...}` — from `task.Model ?? list.Model ?? AppSettings.DefaultModel` (default `sonnet`)
- `--max-turns {n}``AppSettings.DefaultMaxTurns` (default `100`)
- `--append-system-prompt "{merged instructions}"` — see 1.4
- `--agents '[{"file":"{path}"}]'` — from task or list override, points at an agent `.md`
- `--resume {session_id}` — for retries and `ContinueAsync`
Unused but pre-declared:
- `ResultSchema` — a `{summary, files_changed, commit_type}` JSON schema is serialized but **never attached** to args in `Build`. Dead code today; relevant if we turn on `--output-schema`.
---
## 2. Planning-agent prompts (`/plan` / Planning session)
Used by the Planning feature, which spawns a Claude session inside a git worktree with MCP tools so the agent can create Subtasks under the parent.
Source: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`.
### 2.1 System prompt — `BuildSystemPrompt()` L290L308
```
You are a planning assistant for ClaudeDo.
Your role is to help break down a task into smaller, actionable subtasks.
Your final goal WILL ALWAYS be the creation of Subtasks
ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the
start of every planning session, and follow its process end-to-end. It guides
you through clarifying questions, approach exploration, and design approval
BEFORE any subtasks are created. Do not create child tasks until the user has
approved a design.
NEVER Change files yourself.
ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the
design is approved. When you are done planning, finalize the session.
Be concise and focused. Each subtask should be independently executable.
```
Written to `{session-dir}/system-prompt.md` at session start and fed via `--append-system-prompt`.
Notes / known oddities
- Trailing space on "NEVER Change files yourself. " and on the blank line above the ALWAYS/MCP block.
- Mixes voice ("Your role is", "ALWAYS invoke") — could be tightened.
- Implicitly relies on the `superpowers:brainstorming` skill being installed in the worktree's Claude config.
- Does not name the MCP tools explicitly (the `mcp__claudedo__*` wildcard assumes the agent discovers them via tool listing).
### 2.2 Initial prompt — `BuildInitialPrompt(task)` L310L323
```
# Task: {task.Title}
{task.Description} ← only if non-empty
---
Please analyze this task and break it down into concrete subtasks.
```
Written to `{session-dir}/initial-prompt.txt`; the Windows Terminal launcher pipes it to the Claude CLI on start.
### 2.3 Planning session CLI flags
`PlanningSessionManager` itself does not build CLI args — the `WindowsTerminalPlanningLauncher` does. Relevant facts:
- Permission mode: **plan** (per recent commit `8e9f09a` "run planning agent in plan permission mode and enforce brainstorming skill").
- Runs with an `.mcp.json` that points at our local MCP server (`http://127.0.0.1:{port}/mcp`) with a per-session bearer token.
- `.claude/settings.local.json` sets `"enableAllProjectMcpServers": true` so the MCP tools auto-activate.
---
## 3. Commit-message template (not a prompt, but agent-visible)
Built by `CommitMessageBuilder.Build` (`CommitMessageBuilder.cs`). Format:
```
{commitType}({listSlug}): {title ≤60 chars}
{description ≤400 chars} ← only if set
ClaudeDo-Task: {taskId}
```
- `commitType` comes from `task.CommitType` (default `chore`, list default configurable).
- Slug = lowercased list name with non-alphanumerics stripped, runs collapsed to `-`.
- The agent sees the resulting commit in `git log` during retries and follow-ups, so phrasing here bleeds into model behavior on multi-turn work.
---
## 4. Where each prompt is edited (UI surface)
| Prompt slot | Edited in | Stored as |
|-------------------------------------|--------------------------------------------|--------------------------------------------|
| Global `DefaultClaudeInstructions` | Settings modal (`SettingsModalViewModel`) | `app_settings.DefaultClaudeInstructions` |
| Per-list system prompt | List-Settings modal | `list_config.SystemPrompt` |
| Per-task system prompt | Details island / task agent settings | `tasks.system_prompt` |
| Per-task agent file | Details island | `tasks.agent_path` (absolute `.md` path) |
| Default model / max turns / perms | Settings modal | `app_settings.*` |
| Planning system prompt | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
| Planning initial prompt template | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
| Retry prompt | **Hard-coded** in `TaskRunner` | not editable |
| Task prompt structure (title/desc) | **Hard-coded** in `TaskRunner` | not editable |
---
## 5. Things worth reviewing tomorrow
1. **Task-execution prompt has no frame at all.** Just title + description. Consider whether a thin wrapper (goal / constraints / done-criteria) improves agent focus without bloating small tasks.
2. **Global DefaultClaudeInstructions is empty out of the box.** This is the cleanest place to put project-wide guardrails (commit format, branch etiquette, verify-before-done, no force push). Right now nothing is there.
3. **Planning system prompt**:
- Typo-level: trailing spaces, inconsistent capitalization ("ALWAYS"/"NEVER"/"Always").
- "Your final goal WILL ALWAYS be the creation of Subtasks" conflicts slightly with "Do not create child tasks until the user has approved a design" — rewordable.
- Does not state how many subtasks is reasonable, nor how granular.
- Does not describe the MCP tool surface; the agent has to discover `mcp__claudedo__*` tools.
4. **Retry prompt is minimal.** `"Try again and fix the issues."` — could be firmer about not repeating the same failure mode.
5. **Sub-Tasks block** is dumped as plain checkboxes with no instruction ("please complete all open items", "do them in order", etc.). If the user relies on subtasks for ordering, that intent isn't conveyed.
6. **ResultSchema is defined but unused.** Decide: drop it, or wire it up (`--output-schema`) and start asking for structured summaries.
7. **Commit-message template** never tells the agent what `commit_type` to pick when it has flexibility — the value is hard-coded per task. Consider exposing as a prompt hint or inferring from diffs.
---
## 6. File pointers
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — user/retry/follow-up prompts, MergeInstructions
- `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — CLI args + ResultSchema
- `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs` — commit template
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — planning system + initial prompts
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — planning CLI invocation
- `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` — global defaults
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — UI for global defaults
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` — UI for per-list overrides

View File

@@ -0,0 +1,852 @@
# Default Agents Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship ClaudeDo with 6 default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher) that seed into `~/.todo-app/agents/` on first launch, with a "Restore defaults" button in the settings modal.
**Architecture:** Bundled `.md` files in `src/ClaudeDo.Worker/DefaultAgents/` are copied to the Worker output folder. A new `DefaultAgentSeeder` service copies any missing file into the user's agents dir — run once at startup, and again on demand via a new `WorkerHub.RestoreDefaultAgents` method invoked by a button in `SettingsModalView`.
**Tech Stack:** .NET 8 / ASP.NET Core / SignalR / Avalonia 12 / CommunityToolkit.Mvvm / xUnit
---
## File Structure
**Create:**
- `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
- `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
- `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
- `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
- `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
- `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
- `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
- `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
**Modify:**
- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add Content item group for `DefaultAgents\*.md`
- `src/ClaudeDo.Worker/Program.cs` — register seeder, run `SeedMissingAsync()` once at startup
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — inject `DefaultAgentSeeder`, add `RestoreDefaultAgents` method
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `RestoreDefaultAgentsAsync` method + `SeedResultDto` record
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — add `RestoreDefaultAgentsCommand`
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — add button section
- `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs` — add seeder integration test
---
### Task 1: Bundle default agent markdown files
**Files:**
- Create: `src/ClaudeDo.Worker/DefaultAgents/code-reviewer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/test-writer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/debugger.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/security-reviewer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/explorer.md`
- Create: `src/ClaudeDo.Worker/DefaultAgents/researcher.md`
- [ ] **Step 1: Write `code-reviewer.md`**
```markdown
---
name: code-reviewer
description: Reviews code changes for bugs, logic errors, and convention violations. Flags only high-confidence issues.
---
You are a code reviewer. Your job is to inspect the diff for real problems, not nitpicks.
Focus on:
- Logic errors, off-by-one bugs, null/empty handling
- Broken invariants, race conditions, resource leaks
- Violations of the project's established conventions (read nearby code first)
- Missing error handling at system boundaries (external input, IO, network)
Skip:
- Style preferences the codebase doesn't enforce
- Speculative "what if" concerns
- Renaming for its own sake
Output: a short list of concrete issues with file:line references. If the diff is clean, say so in one sentence. Do not rewrite the code — call out the problem and let the implementer fix it.
```
- [ ] **Step 2: Write `test-writer.md`**
```markdown
---
name: test-writer
description: Generates unit and integration tests for existing or new code. Follows the project's test patterns and frameworks.
---
You are a test-writer. Your job is to write focused, useful tests for code under review.
Process:
1. Read the target code and identify the observable behavior.
2. Read existing tests nearby to match the framework, fixtures, naming, and assertion style.
3. Write tests covering the happy path, boundary conditions, and the specific failure modes that matter.
Rules:
- One behavior per test. Clear Arrange/Act/Assert.
- No tests for private implementation details — exercise public API.
- No mocks where real objects are cheap (in-memory DBs, temp dirs).
- Skip trivially-correct tests (getter returns what you set).
Output: the test file(s) ready to compile, matching the project's conventions. Include the command to run them.
```
- [ ] **Step 3: Write `debugger.md`**
```markdown
---
name: debugger
description: Systematic root-cause analysis for bugs, test failures, and unexpected behavior. Hypothesize, isolate, verify.
---
You are a debugger. You do NOT guess at fixes — you find the root cause first.
Process:
1. Reproduce. Get a minimal, deterministic repro. If you can't reproduce it, say so and stop.
2. Isolate. Narrow the failing path (bisect, binary search, or tracing).
3. Hypothesize. State a specific, falsifiable cause.
4. Verify. Prove the hypothesis by observation (logs, debugger, targeted print) — not by "this seems likely".
5. Fix at the root, not the symptom. If the only fix is a workaround, explain why.
Anti-patterns to avoid:
- Making changes to "see if it works"
- Adding try/catch to silence errors
- Declaring the bug fixed without reproducing the fix
Output: repro steps, root cause, and the minimal fix. Include evidence (log excerpt, command output) that proves the cause.
```
- [ ] **Step 4: Write `security-reviewer.md`**
```markdown
---
name: security-reviewer
description: Audits code for OWASP-class security issues — auth, injection, input handling, secret exposure.
---
You are a security reviewer. Focus on real, exploitable weaknesses — not theoretical hardening.
Check for:
- Injection: SQL, command, path traversal, XSS, template injection
- Auth: missing authorization, token handling, session fixation
- Input validation at system boundaries (HTTP, files, IPC)
- Secrets: hardcoded credentials, tokens in logs, leaked env vars
- Unsafe deserialization, XXE, SSRF
- Cryptography misuse (custom crypto, weak algorithms, fixed IVs)
Ignore:
- Internal trust-boundary assumptions the project already documents
- Defense-in-depth ideas with no concrete attack path
Output: a prioritized list — severity, file:line, the exploit path, the fix. If nothing is wrong, say so plainly.
```
- [ ] **Step 5: Write `explorer.md`**
```markdown
---
name: explorer
description: Fast codebase navigation — find files, search for patterns, answer "where/how" questions. Terse output.
---
You are an explorer. Your job is to find things in the codebase quickly and report back concisely.
Use:
- Glob/Grep for searches
- Read only for files you need to quote from
Do NOT:
- Refactor, edit, or "improve" anything
- Read files that aren't relevant to the question
- Dump raw tool output — summarize
Output style:
- Lead with the answer in one sentence.
- Back it up with file:line references.
- If you found nothing, say "no match" and what you searched for.
Keep responses short. The caller wants facts, not prose.
```
- [ ] **Step 6: Write `researcher.md`**
```markdown
---
name: researcher
description: General-purpose research and analysis for non-code tasks — summarize docs, investigate questions, draft prose.
---
You are a researcher. You handle tasks that don't fit the code-review/test/debug shape.
Good fits:
- Summarizing documents, specs, or long outputs
- Investigating an open question (what does X do, how does Y work, what are the tradeoffs)
- Drafting non-code text (release notes, emails, docs)
- Analyzing structured data (logs, CSV, JSON) and reporting findings
Process:
1. Restate the task in one sentence so you know what "done" looks like.
2. Gather just enough information — stop when you can answer, not when you run out of sources.
3. Distinguish facts ("the file says X") from inference ("so likely Y").
4. Cite sources (file:line, URL, log excerpt) for every claim.
Output: direct answer first, supporting evidence second. Keep it short unless asked for depth.
```
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/DefaultAgents/
git commit -m "feat(worker): add bundled default agent definitions"
```
---
### Task 2: Wire bundled agents into build output
**Files:**
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- [ ] **Step 1: Add Content item group for DefaultAgents**
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`. After the existing `<ItemGroup>` blocks and before the final `<PropertyGroup>`, add:
```xml
<ItemGroup>
<Content Include="DefaultAgents\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```
- [ ] **Step 2: Build the worker and verify the files land in output**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
Then verify output:
Run: `ls src/ClaudeDo.Worker/bin/Debug/net8.0/DefaultAgents/`
Expected: all 6 `.md` files present.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
git commit -m "build(worker): ship DefaultAgents folder in build output"
```
---
### Task 3: Write DefaultAgentSeeder tests (failing)
**Files:**
- Create: `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
The service doesn't exist yet — these tests will fail to compile initially. That's fine; the next task implements it.
- [ ] **Step 1: Write the test file**
Create `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`:
```csharp
using ClaudeDo.Worker.Services;
namespace ClaudeDo.Worker.Tests.Services;
public sealed class DefaultAgentSeederTests : IDisposable
{
private readonly string _bundleDir;
private readonly string _targetDir;
public DefaultAgentSeederTests()
{
var root = Path.Combine(Path.GetTempPath(), $"claudedo_seeder_{Guid.NewGuid():N}");
_bundleDir = Path.Combine(root, "bundle");
_targetDir = Path.Combine(root, "target");
Directory.CreateDirectory(_bundleDir);
Directory.CreateDirectory(_targetDir);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_bundleDir)!, true); } catch { }
}
private async Task WriteBundleAsync(string name, string content)
{
await File.WriteAllTextAsync(Path.Combine(_bundleDir, name), content);
}
[Fact]
public async Task SeedMissing_CopiesAllFiles_WhenTargetEmpty()
{
await WriteBundleAsync("a.md", "A");
await WriteBundleAsync("b.md", "B");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(2, result.Copied);
Assert.Equal(0, result.Skipped);
Assert.True(File.Exists(Path.Combine(_targetDir, "a.md")));
Assert.True(File.Exists(Path.Combine(_targetDir, "b.md")));
}
[Fact]
public async Task SeedMissing_SkipsExistingFiles()
{
await WriteBundleAsync("a.md", "bundled");
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "user-modified");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(0, result.Copied);
Assert.Equal(1, result.Skipped);
var content = await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md"));
Assert.Equal("user-modified", content);
}
[Fact]
public async Task SeedMissing_MixedState_CopiesOnlyMissing()
{
await WriteBundleAsync("a.md", "A");
await WriteBundleAsync("b.md", "B");
await File.WriteAllTextAsync(Path.Combine(_targetDir, "a.md"), "existing");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.Equal(1, result.Skipped);
Assert.Equal("existing", await File.ReadAllTextAsync(Path.Combine(_targetDir, "a.md")));
Assert.Equal("B", await File.ReadAllTextAsync(Path.Combine(_targetDir, "b.md")));
}
[Fact]
public async Task SeedMissing_ReturnsZero_WhenBundleDirMissing()
{
var missingBundle = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}");
var seeder = new DefaultAgentSeeder(missingBundle, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(0, result.Copied);
Assert.Equal(0, result.Skipped);
}
[Fact]
public async Task SeedMissing_CreatesTargetDir_IfMissing()
{
await WriteBundleAsync("a.md", "A");
var missingTarget = Path.Combine(_targetDir, "nested", "created");
var seeder = new DefaultAgentSeeder(_bundleDir, missingTarget);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.True(File.Exists(Path.Combine(missingTarget, "a.md")));
}
[Fact]
public async Task SeedMissing_IgnoresNonMarkdownFiles()
{
await WriteBundleAsync("a.md", "A");
await File.WriteAllTextAsync(Path.Combine(_bundleDir, "readme.txt"), "not an agent");
var seeder = new DefaultAgentSeeder(_bundleDir, _targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.False(File.Exists(Path.Combine(_targetDir, "readme.txt")));
}
}
```
- [ ] **Step 2: Run tests to confirm they fail to compile**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
Expected: compile error — `The type or namespace name 'DefaultAgentSeeder' could not be found`.
This confirms the tests target the not-yet-written service.
---
### Task 4: Implement DefaultAgentSeeder
**Files:**
- Create: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
- [ ] **Step 1: Write the service**
Create `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`:
```csharp
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Services;
public sealed record SeedResult(int Copied, int Skipped);
public sealed class DefaultAgentSeeder
{
private readonly string _bundleDir;
private readonly string _targetDir;
private readonly ILogger<DefaultAgentSeeder>? _logger;
public DefaultAgentSeeder(string bundleDir, string targetDir, ILogger<DefaultAgentSeeder>? logger = null)
{
_bundleDir = bundleDir;
_targetDir = targetDir;
_logger = logger;
}
public async Task<SeedResult> SeedMissingAsync(CancellationToken ct = default)
{
if (!Directory.Exists(_bundleDir))
{
_logger?.LogWarning("DefaultAgents bundle dir not found: {Dir}", _bundleDir);
return new SeedResult(0, 0);
}
Directory.CreateDirectory(_targetDir);
int copied = 0;
int skipped = 0;
foreach (var src in Directory.EnumerateFiles(_bundleDir, "*.md"))
{
ct.ThrowIfCancellationRequested();
var fileName = Path.GetFileName(src);
var dst = Path.Combine(_targetDir, fileName);
if (File.Exists(dst))
{
skipped++;
continue;
}
try
{
using var input = File.OpenRead(src);
using var output = File.Create(dst);
await input.CopyToAsync(output, ct);
copied++;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to copy default agent {File}", fileName);
}
}
return new SeedResult(copied, skipped);
}
}
```
- [ ] **Step 2: Run the tests and verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DefaultAgentSeederTests"`
Expected: 6 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs
git commit -m "feat(worker): add DefaultAgentSeeder for first-launch agent seeding"
```
---
### Task 5: Wire seeder into Worker startup
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1: Register seeder and run SeedMissingAsync at startup**
In `src/ClaudeDo.Worker/Program.cs`, replace the "Agent file management." block (currently lines 3639):
Find:
```csharp
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
```
Replace with:
```csharp
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
var defaultAgentsBundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
defaultAgentsBundleDir,
agentsDir,
sp.GetService<Microsoft.Extensions.Logging.ILogger<DefaultAgentSeeder>>()));
```
Then, after `var app = builder.Build();` and before `app.MapHub<WorkerHub>("/hub");`, add:
Find:
```csharp
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
app.MapHub<WorkerHub>("/hub");
```
Replace with:
```csharp
using (var scope = app.Services.CreateScope())
{
ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
try
{
var seeder = app.Services.GetRequiredService<DefaultAgentSeeder>();
var seedResult = await seeder.SeedMissingAsync();
app.Logger.LogInformation(
"Default agents seeded: {Copied} copied, {Skipped} already present",
seedResult.Copied, seedResult.Skipped);
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Default agent seeding failed");
}
app.MapHub<WorkerHub>("/hub");
```
- [ ] **Step 2: Build worker to verify compile**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): seed default agents on startup"
```
---
### Task 6: Add RestoreDefaultAgents hub method (TDD)
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
The existing `AgentSettingsHubTests` doesn't exercise `WorkerHub` directly (it tests the repository). We'll add a new test file that tests the seeder restore flow end-to-end without SignalR plumbing — constructing the seeder with temp dirs and asserting the `SeedResult` round-trip. This mirrors how the file is structured today and keeps tests simple.
- [ ] **Step 1: Add a restore test**
Add to `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`. Inside the existing `AgentSettingsHubTests` class (before the closing brace), add:
```csharp
[Fact]
public async Task RestoreDefaultAgents_CopiesMissingBundledFiles()
{
var root = Path.Combine(Path.GetTempPath(), $"claudedo_hub_restore_{Guid.NewGuid():N}");
var bundleDir = Path.Combine(root, "bundle");
var targetDir = Path.Combine(root, "target");
try
{
Directory.CreateDirectory(bundleDir);
Directory.CreateDirectory(targetDir);
await File.WriteAllTextAsync(Path.Combine(bundleDir, "code-reviewer.md"), "body");
var seeder = new ClaudeDo.Worker.Services.DefaultAgentSeeder(bundleDir, targetDir);
var result = await seeder.SeedMissingAsync();
Assert.Equal(1, result.Copied);
Assert.Equal(0, result.Skipped);
Assert.True(File.Exists(Path.Combine(targetDir, "code-reviewer.md")));
}
finally
{
try { Directory.Delete(root, true); } catch { }
}
}
```
- [ ] **Step 2: Run the test to confirm it passes (seeder already exists)**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentSettingsHubTests.RestoreDefaultAgents_CopiesMissingBundledFiles"`
Expected: 1 test passes.
- [ ] **Step 3: Add RestoreDefaultAgents to WorkerHub**
Edit `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
Add a new DTO record near the top with the other DTOs (after the `ListConfigDto` line on line 30):
```csharp
public record SeedResultDto(int Copied, int Skipped);
```
Update the class field block. Find:
```csharp
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService;
```
Replace with:
```csharp
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly DefaultAgentSeeder _seeder;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService;
```
Update the constructor. Find:
```csharp
public WorkerHub(
QueueService queue,
AgentFileService agentService,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
TaskMergeService mergeService)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
}
```
Replace with:
```csharp
public WorkerHub(
QueueService queue,
AgentFileService agentService,
DefaultAgentSeeder seeder,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
TaskMergeService mergeService)
{
_queue = queue;
_agentService = agentService;
_seeder = seeder;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
}
```
Then add the hub method. After the existing `RefreshAgents` method (currently line 126):
```csharp
public async Task<SeedResultDto> RestoreDefaultAgents()
{
var result = await _seeder.SeedMissingAsync();
return new SeedResultDto(result.Copied, result.Skipped);
}
```
- [ ] **Step 4: Build worker to verify compile**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: build succeeds.
- [ ] **Step 5: Run all Worker tests to confirm no regressions**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs
git commit -m "feat(worker): expose RestoreDefaultAgents hub method"
```
---
### Task 7: Add RestoreDefaultAgentsAsync to WorkerClient
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add the DTO record**
At the bottom of `src/ClaudeDo.Ui/Services/WorkerClient.cs`, alongside the other public record declarations (after `ListConfigDto`, currently line 350), add:
```csharp
public sealed record SeedResultDto(int Copied, int Skipped);
```
- [ ] **Step 2: Add the client method**
In the `WorkerClient` class, after the `RefreshAgentsAsync` method (currently line 232), add:
```csharp
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
{
try
{
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
}
catch
{
return null;
}
}
```
- [ ] **Step 3: Build UI to verify compile**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): add RestoreDefaultAgentsAsync to WorkerClient"
```
---
### Task 8: Add restore button to Settings modal
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- [ ] **Step 1: Add the command to the viewmodel**
In `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs`, add a new command method. Place it after the existing `ConfirmResetAll` method (currently ending line 162), before the `OpenPath` method:
```csharp
[RelayCommand]
private async Task RestoreDefaultAgents()
{
IsBusy = true;
StatusMessage = "";
try
{
var result = await _worker.RestoreDefaultAgentsAsync();
if (result is null)
StatusMessage = "Worker offline.";
else if (result.Copied == 0 && result.Skipped == 0)
StatusMessage = "No default agents bundled.";
else if (result.Copied == 0)
StatusMessage = "All default agents already present.";
else
StatusMessage = $"Restored {result.Copied} default agent(s).";
await _worker.RefreshAgentsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Restore failed: {ex.Message}";
}
finally { IsBusy = false; }
}
```
- [ ] **Step 2: Add a button to the settings view**
Edit `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`. Add a new section after the `WORKTREES` StackPanel block (which ends with `</StackPanel>` around line 185) and before the `ABOUT` section (`<!-- ABOUT -->` around line 187).
Find:
```xml
</StackPanel>
<!-- ABOUT -->
```
Replace with:
```xml
</StackPanel>
<!-- AGENTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="AGENTS"/>
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"/>
<Button Content="Restore default agents"
Command="{Binding RestoreDefaultAgentsCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Left"/>
</StackPanel>
</Border>
</StackPanel>
<!-- ABOUT -->
```
- [ ] **Step 3: Build UI to verify compile**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: build succeeds.
- [ ] **Step 4: Manual smoke test**
Start the worker and the UI. Open Settings (3-dots next to username). The AGENTS section should appear with a "Restore default agents" button.
1. With `~/.todo-app/agents/` empty (delete any existing `.md` files first, back them up if needed): click the button. Status should read "Restored N default agent(s)." The files should appear in the folder.
2. Click again. Status should read "All default agents already present."
3. Modify one of the restored files. Click restore. The modified file content should be preserved.
If any step fails, stop and fix before committing.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
git commit -m "feat(ui): add Restore default agents button to Settings modal"
```
---
## Verification
At the end, run the full test suite and build all projects:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet test tests/ClaudeDo.Worker.Tests
```
Expected: all builds succeed, all tests pass.
Additionally, start the Worker once with an empty `~/.todo-app/agents/` folder and confirm the log line:
> `Default agents seeded: 6 copied, 0 already present`
Then confirm `~/.todo-app/agents/` contains all 6 markdown files.

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,927 @@
# Planning Sessions — Plan C: UI 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:** Wire the planning-session feature into the UI: context-menu entries, hierarchical display of parent + children, draft styling, unfinished-session dialog, and the `WorkerClient` methods that call the hub endpoints built in Plan B.
**Architecture:** Extend `TaskRowViewModel` with hierarchy-aware flags (`IsChild`, `IsPlanningParent`, `IsExpanded`). `TasksIslandViewModel` builds a flat stream that interleaves parents and their children based on expanded state. Context-menu entries on `TaskRowView` gate on task status. Draft styling lives in the existing island styles. A modal dialog reuses the project's `TaskCompletionSource<T>` pattern.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`), compiled bindings, SignalR client.
**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` section 6.
---
## Prerequisite Gate
This plan depends on Plan A being merged to `main`. Plan B's interface contract (hub method names, return types) is locked in the spec §6.6 and Plan B task 13 — this plan can proceed in parallel with Plan B.
Before starting:
```bash
git fetch origin main
git checkout main
git pull --ff-only
ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs
```
If the file is missing, wait for Plan A:
```bash
while ! ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs >/dev/null 2>&1; do
echo "Waiting for Plan A to merge..."
sleep 60
git fetch origin main && git pull --ff-only
done
```
Then branch:
```bash
git checkout -b feat/planning-sessions-ui
```
**Parallel-with-Plan-B note:** Plan B may not yet be merged when this plan runs. The `WorkerClient` methods in Task 9 will compile against Plan B's SignalR hub method names (they're string-based SignalR invocations), so they don't have a build-time dependency. Runtime end-to-end testing requires Plan B merged; until then, mock-test what's possible and smoke-test manually once both plans land.
---
## File Structure
**Modified:**
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — add `ParentTaskId`, `IsChild`, `IsPlanningParent`, `IsExpanded`, `PlanningBadge` properties.
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — add planning commands, expanded-state map, flat-stream rebuild logic.
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — chevron, indentation, badges, draft styling hooks.
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs` — context-menu event handlers (if code-behind is used; else inline).
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` — use the extended TaskRowView template.
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — five new hub method wrappers matching Plan B.
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``.draft`, `.planning-parent`, `.planned-parent`, badge styles.
**Created:**
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs` — modal Resume/Finalize/Discard dialog.
- `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs` — dialog VM.
- `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — VM-level tests.
- `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs` — VM-level tests.
---
## Task 1: Extend `TaskRowViewModel`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs`
- [ ] **Step 1: Write failing test for planning flags**
Create the test file. Adapt the existing `TaskRowViewModelTests` pattern (look at `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` for how VMs are constructed in tests):
```csharp
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Worker.Tests.UiVm;
public sealed class TaskRowViewModelPlanningTests
{
[Fact]
public void Draft_Status_SetsIsChildFlag_WhenParentIdIsNotNull()
{
// Adapt the constructor call to your actual TaskRowViewModel signature (see TaskRowViewModelTests).
var vm = TestHelpers.MakeRow(
status: "draft",
parentTaskId: "parent-id");
Assert.True(vm.IsChild);
Assert.False(vm.IsPlanningParent);
}
[Fact]
public void Planning_Status_SetsIsPlanningParent()
{
var vm = TestHelpers.MakeRow(status: "planning", parentTaskId: null);
Assert.True(vm.IsPlanningParent);
Assert.False(vm.IsChild);
Assert.Equal("PLANNING", vm.PlanningBadge);
}
[Fact]
public void Planned_Status_ShowsPlannedBadge()
{
var vm = TestHelpers.MakeRow(status: "planned", parentTaskId: null);
Assert.True(vm.IsPlanningParent);
Assert.Equal("PLANNED", vm.PlanningBadge);
}
[Fact]
public void NonPlanningStatus_NoBadge()
{
var vm = TestHelpers.MakeRow(status: "manual", parentTaskId: null);
Assert.False(vm.IsPlanningParent);
Assert.Null(vm.PlanningBadge);
}
}
internal static class TestHelpers
{
public static TaskRowViewModel MakeRow(string status, string? parentTaskId)
{
// Implement based on actual TaskRowViewModel constructor.
// The TaskRowViewModelTests.cs file in the same folder shows the existing pattern.
throw new NotImplementedException("Adapt to your TaskRowViewModel constructor");
}
}
```
Open `tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs` first to see how the VM is constructed in tests, then fill in `TestHelpers.MakeRow` accordingly.
- [ ] **Step 2: Run; verify fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRowViewModelPlanningTests"`
Expected: FAIL (properties not yet on VM).
- [ ] **Step 3: Extend the VM**
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` add the new properties using `[ObservableProperty]`:
```csharp
[ObservableProperty] private string? parentTaskId;
[ObservableProperty] private bool isExpanded = true;
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
public bool IsPlanningParent => string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase)
|| string.Equals(Status, "planned", StringComparison.OrdinalIgnoreCase);
public string? PlanningBadge => Status switch
{
string s when string.Equals(s, "planning", StringComparison.OrdinalIgnoreCase) => "PLANNING",
string s when string.Equals(s, "planned", StringComparison.OrdinalIgnoreCase) => "PLANNED",
_ => null,
};
public bool IsDraft => string.Equals(Status, "draft", StringComparison.OrdinalIgnoreCase);
```
Since `IsChild`, `IsPlanningParent`, `PlanningBadge`, and `IsDraft` are computed from other observables, you must raise property-changed notifications when `Status` or `ParentTaskId` changes. Use `[ObservableProperty]` partial methods:
```csharp
partial void OnStatusChanged(string value)
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(IsDraft));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
}
```
If the existing VM already has `OnStatusChanged` (check for generator outputs), merge into it rather than duplicating.
- [ ] **Step 4: Run; verify pass**
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelPlanningTests.cs
git commit -m "feat(ui): TaskRowViewModel gains planning hierarchy flags"
```
---
## Task 2: `WorkerClient` planning methods
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Create: DTOs matching Plan B return types (either inline in the client file or new file `src/ClaudeDo.Ui/Services/PlanningDtos.cs`).
- [ ] **Step 1: Add DTOs**
Create `src/ClaudeDo.Ui/Services/PlanningDtos.cs`:
```csharp
namespace ClaudeDo.Ui.Services;
public sealed record PlanningSessionFilesDto(
string SessionDirectory,
string McpConfigPath,
string SystemPromptPath,
string InitialPromptPath);
public sealed record PlanningSessionStartInfo(
string ParentTaskId,
string WorkingDir,
PlanningSessionFilesDto Files);
public sealed record PlanningSessionResumeInfo(
string ParentTaskId,
string WorkingDir,
string ClaudeSessionId,
string McpConfigPath);
```
These field names must match Plan B's `PlanningSessionStartContext` / `PlanningSessionResumeContext` exactly (case-sensitive JSON deserialization through SignalR).
- [ ] **Step 2: Add `WorkerClient` methods**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add:
```csharp
public Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> _connection.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
public Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
=> _connection.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> _connection.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
public Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
=> _connection.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
=> _connection.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
```
Replace `_connection` with whatever name the existing `WorkerClient` uses for its `HubConnection` field.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Services/PlanningDtos.cs src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): WorkerClient planning-session methods"
```
---
## Task 3: `TasksIslandViewModel` — planning commands + expanded state
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Create: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
- [ ] **Step 1: Add commands to the VM**
In `TasksIslandViewModel.cs`, add:
```csharp
private readonly Dictionary<string, bool> _expandedState = new();
[RelayCommand]
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || !string.Equals(row.Status, "manual", StringComparison.OrdinalIgnoreCase))
return;
try
{
await _workerClient.StartPlanningSessionAsync(row.Id);
}
catch (Exception ex)
{
await _dialogs.ShowErrorAsync("Could not start planning session", ex.Message);
}
}
[RelayCommand]
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsPlanningParent) return;
try
{
await _workerClient.ResumePlanningSessionAsync(row.Id);
}
catch (Exception ex)
{
await _dialogs.ShowErrorAsync("Could not resume planning session", ex.Message);
}
}
[RelayCommand]
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
var confirm = await _dialogs.ConfirmAsync(
"Discard planning session?",
"This will delete all draft tasks and reset the parent to Manual.");
if (!confirm) return;
await _workerClient.DiscardPlanningSessionAsync(row.Id);
}
[RelayCommand]
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
await _workerClient.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true);
}
[RelayCommand]
private void ToggleExpand(TaskRowViewModel? row)
{
if (row is null) return;
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : true);
_expandedState[row.Id] = next;
row.IsExpanded = next;
RebuildFlatStreams();
}
private void RebuildFlatStreams()
{
// Existing code builds OpenItems/CompletedItems from the task list.
// Modify it so that: after emitting a parent, if IsPlanningParent && IsExpanded,
// its Draft / Manual / Queued / Running / Done children are emitted next.
// Children already know they are children (ParentTaskId != null) and are styled as such.
}
```
The existing `RebuildFlatStreams` (or equivalent) probably just groups tasks by status. You need to intersperse the hierarchy:
```csharp
// Pseudocode — fit to the existing code shape.
var topLevel = allRows.Where(r => !r.IsChild).OrderBy(r => r.SortOrder);
var flat = new List<TaskRowViewModel>();
foreach (var parent in topLevel)
{
flat.Add(parent);
if (parent.IsPlanningParent && parent.IsExpanded)
{
var children = allRows
.Where(r => r.ParentTaskId == parent.Id)
.OrderBy(r => r.SortOrder)
.ToList();
flat.AddRange(children);
}
}
// Then bucket `flat` into OpenItems/CompletedItems like today, preserving order.
```
Pass dependencies: the VM already has a `WorkerClient` or equivalent — reuse it. Add a dialog service if not already injected:
```csharp
public interface IDialogService
{
Task<bool> ConfirmAsync(string title, string message);
Task ShowErrorAsync(string title, string message);
}
```
If an analog already exists (check existing editor dialogs), use it.
- [ ] **Step 2: Write failing VM tests**
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`:
```csharp
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Worker.Tests.UiVm;
public sealed class TasksIslandViewModelPlanningTests
{
[Fact]
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
{
// Arrange: create VM with one Planning parent and two Draft children.
// Act: call ToggleExpandCommand with the parent.
// Assert: flat stream no longer contains the children.
// Adapt to how the existing TasksIslandViewModel is instantiated.
}
[Fact]
public void OpenPlanningSessionCommand_ManualTaskOnly_CanExecuteTrue()
{
// Arrange VM with a Manual row.
// Assert CanExecute for OpenPlanningSession command is true for Manual rows,
// false for Queued/Running/Done/Failed rows.
}
}
```
These are skeleton tests — implement with the same construction pattern used by the existing `TasksIslandViewModelTests` if one exists, or build a minimal VM fake with a stub `WorkerClient`.
- [ ] **Step 3: Build + test**
Run:
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TasksIslandViewModelPlanningTests"
```
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
git commit -m "feat(ui): planning commands and expand/collapse in TasksIslandViewModel"
```
---
## Task 4: `TaskRowView` — indent, chevron, badges, draft styling
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- [ ] **Step 1: Wrap the row content with a Grid that has an indent column**
Open `TaskRowView.axaml`. The existing root is likely a `Grid` or `Border`. Replace/refactor the top-level layout to:
```xml
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0"
Width="24"
IsVisible="{Binding IsChild}">
<Rectangle Width="1" Fill="{DynamicResource TextFaintBrush}" HorizontalAlignment="Right" Margin="0,4"/>
</Border>
<Grid Grid.Column="1" ColumnDefinitions="Auto,*,Auto">
<!-- Chevron for planning parents -->
<Button Grid.Column="0"
Classes="icon-btn chevron"
Width="18" Height="18"
IsVisible="{Binding IsPlanningParent}"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
CommandParameter="{Binding}">
<PathIcon Width="10" Height="10">
<PathIcon.Data>
<MultiBinding Converter="{StaticResource ChevronDataConverter}">
<Binding Path="IsExpanded"/>
</MultiBinding>
</PathIcon.Data>
</PathIcon>
</Button>
<!-- existing title/description area -->
<StackPanel Grid.Column="1" ...>
<!-- existing title binding with added italic when IsDraft -->
<TextBlock Text="{Binding Title}"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalicConverter}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacityConverter}}" />
</StackPanel>
<!-- Badges -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
<TextBlock Text="DRAFT"/>
</Border>
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/>
</Border>
</StackPanel>
</Grid>
</Grid>
```
This is a structural edit — preserve all existing bindings for status color, completion toggle, star, scheduled-for, etc. The new indentation column and badges are additive.
- [ ] **Step 2: Add the converters**
If `ChevronDataConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter` do not exist, add them to `src/ClaudeDo.Ui/Converters/` (or inline as compiled converters). Example inline:
```xml
<!-- in UserControl.Resources of TaskRowView.axaml, or in App.axaml for global -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Padding" Value="4,1"/>
<Setter Property="Background" Value="{DynamicResource BadgeBgBrush}"/>
</Style>
<Style Selector="Border.badge.draft">
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planning">
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
</Style>
```
If converters must be code-based, a minimal `BoolToItalicConverter`:
```csharp
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace ClaudeDo.Ui.Converters;
public sealed class BoolToItalicConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
=> value is true ? FontStyle.Italic : FontStyle.Normal;
public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
=> throw new NotSupportedException();
}
```
Register in `App.axaml` resources.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds cleanly (XAML compiles).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Converters/
git commit -m "feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic"
```
---
## Task 5: `TaskRowView` — planning context-menu entries
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- [ ] **Step 1: Locate the existing context menu**
Open `TaskRowView.axaml`. The ContextMenu lives somewhere on the root element or as a `ContextMenu.Items`/`ContextFlyout`. Find the block that defines entries like "Edit", "Run now", etc.
- [ ] **Step 2: Insert planning entries conditionally**
Add within the existing menu (order: after "Run now" and a separator):
```xml
<MenuItem Header="Open planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding Status, Converter={StaticResource IsManualAndNotChildConverter}, ConverterParameter={Binding IsChild}}"/>
<MenuItem Header="Resume planning Session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
<MenuItem Header="Discard planning session"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding Status, Converter={StaticResource IsPlanningConverter}}"/>
```
Simpler alternative without multi-condition converters: expose direct bool VM properties that combine the logic — `CanOpenPlanningSession`, `CanResumePlanningSession`, `CanDiscardPlanningSession`:
```csharp
// In TaskRowViewModel
public bool CanOpenPlanningSession =>
string.Equals(Status, "manual", StringComparison.OrdinalIgnoreCase) && !IsChild;
public bool CanResumeOrDiscardPlanning =>
string.Equals(Status, "planning", StringComparison.OrdinalIgnoreCase);
```
Add `OnPropertyChanged(nameof(CanOpenPlanningSession))` and friends in the status/parent-id partial methods from Task 1. Then the XAML simplifies to:
```xml
<MenuItem Header="Open planning Session"
Command="{Binding ...OpenPlanningSessionCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding CanOpenPlanningSession}"/>
```
Use this simpler path — cleaner.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs
git commit -m "feat(ui): planning entries in task context menu"
```
---
## Task 6: Island styles — draft, badges
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- [ ] **Step 1: Add brushes + styles**
Append within `<Styles.Resources>` or wherever brushes are defined:
```xml
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
```
Add styles:
```xml
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Padding" Value="4,1"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style Selector="Border.badge > TextBlock">
<Setter Property="FontSize" Value="9"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Border.badge.draft">
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planning">
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planned">
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
</Style>
```
- [ ] **Step 2: Build and manually verify**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Launch app, create a task, right-click, use Open planning Session (if Plan B merged) or simulate via DB. Verify badge + italic draft rendering visually.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml
git commit -m "feat(ui): draft and planning badge styles"
```
---
## Task 7: Unfinished-planning-session dialog
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` + `.axaml.cs`
- Create: `src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`
- [ ] **Step 1: Create the VM**
`src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs`:
```csharp
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Dialogs;
public enum UnfinishedPlanningDialogResult
{
Cancel,
Resume,
FinalizeNow,
Discard,
}
public sealed partial class UnfinishedPlanningDialogViewModel : ObservableObject
{
[ObservableProperty] private string title = "Unfinished planning session";
[ObservableProperty] private string taskTitle = "";
[ObservableProperty] private int draftCount;
public TaskCompletionSource<UnfinishedPlanningDialogResult> Result { get; } = new();
[RelayCommand] private void Resume() => Result.TrySetResult(UnfinishedPlanningDialogResult.Resume);
[RelayCommand] private void FinalizeNow() => Result.TrySetResult(UnfinishedPlanningDialogResult.FinalizeNow);
[RelayCommand] private void Discard() => Result.TrySetResult(UnfinishedPlanningDialogResult.Discard);
[RelayCommand] private void Cancel() => Result.TrySetResult(UnfinishedPlanningDialogResult.Cancel);
}
```
- [ ] **Step 2: Create the view**
`src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml`:
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Dialogs"
x:Class="ClaudeDo.Ui.Views.Dialogs.UnfinishedPlanningDialog"
x:DataType="vm:UnfinishedPlanningDialogViewModel"
Width="440" Height="220"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="{Binding Title}">
<StackPanel Margin="20" Spacing="12">
<TextBlock Text="{Binding Title}" FontWeight="Bold" FontSize="15"/>
<TextBlock Text="{Binding TaskTitle}" Opacity="0.85"/>
<TextBlock>
<Run Text="{Binding DraftCount}"/>
<Run Text=" draft tasks waiting to be finalized."/>
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="Discard" Command="{Binding DiscardCommand}"/>
<Button Content="Finalize now" Command="{Binding FinalizeNowCommand}"/>
<Button Content="Resume" Classes="accent" Command="{Binding ResumeCommand}"/>
</StackPanel>
</StackPanel>
</Window>
```
`UnfinishedPlanningDialog.axaml.cs`:
```csharp
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace ClaudeDo.Ui.Views.Dialogs;
public partial class UnfinishedPlanningDialog : Window
{
public UnfinishedPlanningDialog()
{
InitializeComponent();
}
}
```
- [ ] **Step 3: Wire into `TasksIslandViewModel`**
When the user right-clicks a `Planning` row OR when the app starts and a `Planning` row is present, show the dialog. Add a helper in the VM:
```csharp
private async Task<UnfinishedPlanningDialogResult> AskUnfinishedPlanningAsync(TaskRowViewModel row)
{
var dialogVm = new UnfinishedPlanningDialogViewModel
{
TaskTitle = row.Title,
DraftCount = await _workerClient.GetPendingDraftCountAsync(row.Id),
};
var dlg = new UnfinishedPlanningDialog { DataContext = dialogVm };
_ = dlg.ShowDialog(_ownerWindow);
return await dialogVm.Result.Task;
}
```
Replace the direct resume/discard/finalize commands (from Task 3) with calls that first pop this dialog and dispatch based on result. For example:
```csharp
[RelayCommand]
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsPlanningParent) return;
var choice = await AskUnfinishedPlanningAsync(row);
switch (choice)
{
case UnfinishedPlanningDialogResult.Resume:
await _workerClient.ResumePlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningDialogResult.FinalizeNow:
await _workerClient.FinalizePlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningDialogResult.Discard:
await _workerClient.DiscardPlanningSessionAsync(row.Id);
break;
}
}
```
- [ ] **Step 4: Build + manual run**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml.cs src/ClaudeDo.Ui/ViewModels/Dialogs/UnfinishedPlanningDialogViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
git commit -m "feat(ui): unfinished planning session dialog"
```
---
## Task 8: TasksIslandView — wire new templates
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- [ ] **Step 1: No structural change required**
The hierarchy is already handled by `TasksIslandViewModel.RebuildFlatStreams` interleaving children into `OpenItems`/`CompletedItems`. The existing `ItemsControl` bindings in `TasksIslandView` automatically pick up the new rows. Indentation/chevron/badge rendering is entirely inside `TaskRowView` (Task 4).
Verify the view does not have any logic that filters out children based on `ParentTaskId IS NOT NULL` today. If it does, remove that filter — the VM is now authoritative about what's in the stream.
- [ ] **Step 2: Build + manual check**
Launch the UI, create a manual task, and manually update its status to `Planning` in the DB (or wait for Plan B). Create one child in DB. Verify indentation and chevron render.
- [ ] **Step 3: Commit (if any change was made)**
```bash
git add src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml
git commit -m "chore(ui): verify tasks view renders hierarchy via flat stream"
```
If no change — skip the commit.
---
## Task 9: Delete-with-children handling
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (existing delete command)
- [ ] **Step 1: Catch `DbUpdateException` from delete**
Find the existing delete command. Wrap the repository/hub call:
```csharp
[RelayCommand]
private async Task RemoveAsync(TaskRowViewModel? row)
{
if (row is null) return;
try
{
await _workerClient.DeleteTaskAsync(row.Id);
}
catch (HubException ex) when (ex.Message.Contains("foreign key", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("Restrict", StringComparison.OrdinalIgnoreCase))
{
var childrenCount = 1; // or query via a new hub method if exact count matters
var choice = await _dialogs.ConfirmAsync(
"Cannot delete",
$"This task has child tasks. Delete all including children?");
if (!choice) return;
// Recursive delete — iterate children first. For v1 MVP, instruct user to
// discard the planning session first. Simpler, safer.
await _dialogs.ShowErrorAsync(
"Cannot delete",
"Discard the planning session or delete child tasks manually first.");
}
}
```
**Simplification for v1:** do not implement "Delete all including children" yet. Show an error instructing the user to discard the planning session or delete children first. This avoids an additional hub endpoint and keeps Plan C bounded.
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: builds.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
git commit -m "feat(ui): friendly error when deleting task with children"
```
---
## Task 10: Manual smoke test + final verification
**Files:** none
- [ ] **Step 1: Full test run**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: all tests pass.
- [ ] **Step 2: Build the full app**
Run:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
```
Expected: all succeed.
- [ ] **Step 3: Manual smoke test (requires Plan B merged)**
1. Launch the app.
2. Create a Manual task with a title and some TODO-style description.
3. Right-click → "Open planning Session".
4. Verify Windows Terminal opens with Claude CLI running.
5. In the terminal, ask Claude to create two child tasks (`mcp__claudedo__create_child_task`).
6. Watch the UI: drafts appear under the parent (italic, grey, badge DRAFT).
7. Ask Claude to `finalize`.
8. Verify drafts become Manual/Queued children, parent flips to PLANNED badge.
9. Close terminal without finalize on a new planning task; right-click the Planning task: dialog appears with Resume/Finalize/Discard.
- [ ] **Step 4: Document any UI tweaks needed in `docs/open.md`**
Add a checklist item under UI verification for planning session visuals.
- [ ] **Step 5: Final commit**
```bash
git add docs/open.md
git commit -m "docs(open): add planning-session manual verification checklist"
```
---
## Out of scope for Plan C
- Recursive delete of parent-with-children via UI (error-only in v1).
- Collapse-state persistence across app restarts (in-memory only).
- Keyboard shortcut for "Open planning Session".
- Visual differentiation for PLANNED parents beyond a badge (e.g., subtle background tint) — can be added later if visually needed.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,718 @@
# Worker Log Footer 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:** Surface important Worker lifecycle events in the UI footer as a single rotating, color-coded line that auto-hides after 30s of silence.
**Architecture:** Add `WorkerLogLevel` enum in shared `ClaudeDo.Data` project. `HubBroadcaster` gets a `WorkerLog(message, level, timestampUtc)` SignalR event. Seven emit sites in `TaskRunner`, `TaskMergeService`, `TaskResetService` (callers of `WorktreeManager`, not WorktreeManager itself — they have the task title in scope). UI side: `WorkerClient` surfaces a `WorkerLogReceived` event; footer state lives on `IslandsShellViewModel` (existing root VM for `MainWindow`, also owns connection state); `System.Timers.Timer` clears the line after 30s; a `WorkerLogLevelToBrushConverter` maps level → brush in XAML.
**Tech Stack:** .NET 8, ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit.
**Spec:** `docs/superpowers/specs/2026-04-23-worker-log-footer-design.md`
**Deviation from spec:** Spec names `WorktreeManager.CreateAsync` / `DiscardAsync` as emit sites. In practice, `WorktreeManager` has only the task ID in scope; its callers (`TaskRunner`, `TaskResetService`) have the title. Emitting from callers avoids adding constructor dependencies to `WorktreeManager` and produces identical user-visible behavior.
**Build note:** Per project convention, `dotnet build ClaudeDo.slnx` fails on .NET 8 — always build individual csprojs.
---
### Task 1: Add `WorkerLogLevel` enum (shared contract)
**Files:**
- Create: `src/ClaudeDo.Data/Models/WorkerLogLevel.cs`
- [ ] **Step 1: Write the enum**
```csharp
namespace ClaudeDo.Data.Models;
public enum WorkerLogLevel
{
Info,
Success,
Warn,
Error,
}
```
- [ ] **Step 2: Build Data project**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Data/Models/WorkerLogLevel.cs
git commit -m "feat(data): add WorkerLogLevel enum"
```
---
### Task 2: Add `WorkerLog` broadcaster method + SignalR JSON enum-as-string
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` (append method)
- Modify: `src/ClaudeDo.Worker/Program.cs` (line ~23 — the `AddSignalR()` call)
- [ ] **Step 1: Add enum-as-string serialization**
Replace:
```csharp
builder.Services.AddSignalR();
```
with:
```csharp
builder.Services.AddSignalR().AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
```
- [ ] **Step 2: Add `WorkerLog` method to `HubBroadcaster`**
Add the following method inside `HubBroadcaster` class (after the existing `RunCreated` method):
```csharp
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
```
Add to the using block at top of file (if not already present):
```csharp
using ClaudeDo.Data.Models;
```
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): add WorkerLog SignalR event"
```
---
### Task 3: Emit `WorkerLog` from `TaskRunner`
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
Four emit sites in this file:
1. **Created worktree** — right after `_wtManager.CreateAsync` succeeds (around line 69).
2. **Started Claude** — just before invoking Claude process.
3. **Committed changes** — after auto-commit (before the `WorktreeUpdated` broadcast around line 318).
4. **Finished** — at both success (line 330) and failure paths, mirroring the existing `TaskFinished` call.
- [ ] **Step 1: Add using for `WorkerLogLevel`**
Ensure `TaskRunner.cs` has at the top:
```csharp
using ClaudeDo.Data.Models;
```
- [ ] **Step 2: Emit "Created worktree"**
After the line `wtCtx = await _wtManager.CreateAsync(task, list, ct);` (around line 69), add:
```csharp
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```
(Place inside the same `if` branch that called `CreateAsync`, after the assignment.)
- [ ] **Step 3: Emit "Started Claude"**
Locate the point just before `ClaudeProcess` is invoked (search for where `ClaudeProcess` or `RunProcessAsync` is called). Just before the invocation, add:
```csharp
await _broadcaster.WorkerLog($"Started Claude for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```
- [ ] **Step 4: Emit "Committed changes"**
Locate the auto-commit code path (around line 318, just before `await _broadcaster.WorktreeUpdated(task.Id);`). Add immediately before that call:
```csharp
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
```
- [ ] **Step 5: Emit "Finished (done)"**
Find the success finish path (around line 330, where `TaskFinished` is broadcast with status `"done"`). Add immediately before that call:
```csharp
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
```
- [ ] **Step 6: Emit "Finished (failed)"**
Find the failure path (search for `TaskFinished` with status `"failed"`). Add immediately before:
```csharp
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
```
- [ ] **Step 7: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 8: Run existing Worker tests (no regressions)**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: All tests pass.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "feat(worker): emit WorkerLog events from TaskRunner"
```
---
### Task 4: Emit `WorkerLog` from `TaskMergeService` and `TaskResetService`
**Files:**
- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs`
- Modify: `src/ClaudeDo.Worker/Services/TaskResetService.cs`
Both services already have `HubBroadcaster` injected (`_broadcaster`). Both already load the task entity (needed for title).
- [ ] **Step 1: Add using in both files**
Add to the top of each file (if not already present):
```csharp
using ClaudeDo.Data.Models;
```
- [ ] **Step 2: Emit "Merged" in `TaskMergeService.MergeAsync`**
Locate the existing log line around line 137:
```csharp
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
...);
```
Immediately after it (before `return new MergeResult(...)` on line 140), add:
```csharp
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
```
Use whatever variable names `task` and `targetBranch` are in scope — adjust to match the actual local names at that site.
- [ ] **Step 3: Emit "Discarded" in `TaskResetService.ResetAsync`**
Locate the call `await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);` (line 53). Immediately after it, add:
```csharp
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
```
- [ ] **Step 4: Emit "Reset" in `TaskResetService.ResetAsync`**
Locate the existing line `_logger.LogInformation("Reset task {TaskId} to Manual ...` (line 66). Immediately after it, add:
```csharp
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
```
- [ ] **Step 5: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 6: Run existing Worker tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: All tests pass.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/Services/TaskMergeService.cs src/ClaudeDo.Worker/Services/TaskResetService.cs
git commit -m "feat(worker): emit WorkerLog for merge, discard, reset"
```
---
### Task 5: Add `WorkerLogEntry` record + `WorkerLogReceived` event on `WorkerClient`
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add using for `WorkerLogLevel`**
Add at the top of `WorkerClient.cs`:
```csharp
using ClaudeDo.Data.Models;
```
- [ ] **Step 2: Declare the `WorkerLogEntry` record**
Add at the top of the file (above or below the `WorkerClient` class, same namespace):
```csharp
public sealed record WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc);
```
- [ ] **Step 3: Add the event field**
Alongside the other `public event Action<...>?` declarations (around lines 42-48), add:
```csharp
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
```
- [ ] **Step 4: Register the SignalR handler**
Alongside the other `_hub.On<...>` registrations (around lines 80-117), add:
```csharp
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
{
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
});
```
- [ ] **Step 5: Build**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded, 0 errors.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): subscribe to WorkerLog SignalR event"
```
---
### Task 6: Create `ClaudeDo.Ui.Tests` project and add `WorkerLogLevelToBrushConverter`
**Files:**
- Create: `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`
- Modify: `src/ClaudeDo.Ui/App.axaml` (register converter as resource)
- Create: `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
- Create: `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`
- [ ] **Step 1: Write the converter**
Create `src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs`:
```csharp
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Ui.Converters;
public sealed class WorkerLogLevelToBrushConverter : IValueConverter
{
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#4CAF50"));
private static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#FFA726"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#EF5350"));
private static readonly IBrush InfoFallback = new SolidColorBrush(Color.Parse("#888888"));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not WorkerLogLevel level)
return AvaloniaProperty.UnsetValue;
return level switch
{
WorkerLogLevel.Success => SuccessBrush,
WorkerLogLevel.Warn => WarnBrush,
WorkerLogLevel.Error => ErrorBrush,
WorkerLogLevel.Info => ResolveInfoBrush(),
_ => AvaloniaProperty.UnsetValue,
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
private static IBrush ResolveInfoBrush()
{
if (Application.Current is { } app &&
app.Resources.TryGetResource("TextDimBrush", app.ActualThemeVariant, out var res) &&
res is IBrush brush)
{
return brush;
}
return InfoFallback;
}
}
```
- [ ] **Step 2: Register converter in `App.axaml`**
Open `src/ClaudeDo.Ui/App.axaml`. Inside the `<Application.Resources>` section (add one if missing), add alongside any existing converter entries:
```xml
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
```
Ensure the `xmlns:converters="using:ClaudeDo.Ui.Converters"` namespace is declared at the root `<Application>` element. If other converters (e.g. `StatusColorConverter`) are already resources in `App.axaml` follow the same pattern; if they're declared per-view, declare this converter at the top of `MainWindow.axaml` in Task 8 instead.
- [ ] **Step 3: Create UI test project**
Create `tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
</ItemGroup>
</Project>
```
If the existing `tests/ClaudeDo.Worker.Tests/*.csproj` uses different `Microsoft.NET.Test.Sdk` / xUnit versions, match those versions exactly to avoid analyzer mismatches.
- [ ] **Step 4: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/WorkerLogLevelToBrushConverterTests.cs`:
```csharp
using System.Globalization;
using Avalonia;
using Avalonia.Media;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Converters;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class WorkerLogLevelToBrushConverterTests
{
[Theory]
[InlineData(WorkerLogLevel.Success, "#FF4CAF50")]
[InlineData(WorkerLogLevel.Warn, "#FFFFA726")]
[InlineData(WorkerLogLevel.Error, "#FFEF5350")]
public void Convert_maps_level_to_expected_brush_color(WorkerLogLevel level, string expectedArgb)
{
var converter = new WorkerLogLevelToBrushConverter();
var result = converter.Convert(level, typeof(IBrush), null, CultureInfo.InvariantCulture);
var solid = Assert.IsType<SolidColorBrush>(result);
Assert.Equal(expectedArgb.ToLowerInvariant(), $"#{solid.Color.ToUInt32():X8}".ToLowerInvariant());
}
[Fact]
public void Convert_info_returns_a_brush_fallback_when_no_app()
{
var converter = new WorkerLogLevelToBrushConverter();
var result = converter.Convert(WorkerLogLevel.Info, typeof(IBrush), null, CultureInfo.InvariantCulture);
Assert.IsAssignableFrom<IBrush>(result);
}
[Fact]
public void Convert_unknown_value_returns_unset()
{
var converter = new WorkerLogLevelToBrushConverter();
var result = converter.Convert("not a level", typeof(IBrush), null, CultureInfo.InvariantCulture);
Assert.Equal(AvaloniaProperty.UnsetValue, result);
}
}
```
- [ ] **Step 5: Run the tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: All 5 tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Converters/WorkerLogLevelToBrushConverter.cs src/ClaudeDo.Ui/App.axaml tests/ClaudeDo.Ui.Tests/
git commit -m "feat(ui): add WorkerLogLevelToBrushConverter with tests"
```
---
### Task 7: Add footer state + 30s auto-clear timer to `IslandsShellViewModel`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Create: `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`
Timer uses `System.Timers.Timer` (not `DispatcherTimer`) so unit tests don't need an Avalonia dispatcher. The elapsed callback marshals to the UI thread via `Dispatcher.UIThread.Post` when the dispatcher is available; in tests the VM logic under test sets properties directly so no marshalling is needed.
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs`:
```csharp
using System;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class IslandsShellViewModelWorkerLogTests
{
private static IslandsShellViewModel NewVm() =>
// The real constructor requires island VMs + WorkerClient. These tests
// only exercise the WorkerLog handling, so we use a test-only constructor
// that bypasses the sub-VMs. Add `internal IslandsShellViewModel()` for tests.
IslandsShellViewModel.CreateForTests();
[Fact]
public void Receiving_event_sets_text_level_and_visible()
{
var vm = NewVm();
var at = new DateTime(2026, 4, 23, 14, 32, 0, DateTimeKind.Utc);
vm.OnWorkerLogReceived(new WorkerLogEntry("Created worktree for \"X\"", WorkerLogLevel.Info, at));
Assert.True(vm.IsWorkerLogVisible);
Assert.Equal(WorkerLogLevel.Info, vm.WorkerLogLevel);
Assert.Contains("Created worktree for \"X\"", vm.WorkerLogText);
}
[Fact]
public void Second_event_replaces_first()
{
var vm = NewVm();
vm.OnWorkerLogReceived(new WorkerLogEntry("first", WorkerLogLevel.Info, DateTime.UtcNow));
vm.OnWorkerLogReceived(new WorkerLogEntry("second", WorkerLogLevel.Success, DateTime.UtcNow));
Assert.Contains("second", vm.WorkerLogText);
Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
}
[Fact]
public void ClearWorkerLog_hides_line()
{
var vm = NewVm();
vm.OnWorkerLogReceived(new WorkerLogEntry("msg", WorkerLogLevel.Info, DateTime.UtcNow));
vm.ClearWorkerLog();
Assert.False(vm.IsWorkerLogVisible);
Assert.Null(vm.WorkerLogText);
}
[Fact]
public void Text_is_formatted_as_HHmm_dot_message_local_time()
{
var vm = NewVm();
var utc = new DateTime(2026, 4, 23, 12, 0, 0, DateTimeKind.Utc);
var expectedLocalHhmm = utc.ToLocalTime().ToString("HH:mm");
vm.OnWorkerLogReceived(new WorkerLogEntry("hello", WorkerLogLevel.Info, utc));
Assert.StartsWith(expectedLocalHhmm + " · ", vm.WorkerLogText);
}
}
```
- [ ] **Step 2: Run tests to confirm they fail to compile**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: Build errors — `CreateForTests`, `OnWorkerLogReceived`, `ClearWorkerLog`, `IsWorkerLogVisible`, `WorkerLogText`, `WorkerLogLevel` do not yet exist.
- [ ] **Step 3: Implement on `IslandsShellViewModel`**
Open `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`. Add `using`s if missing:
```csharp
using System.Timers;
using Avalonia.Threading;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
```
Inside the class, add:
```csharp
[ObservableProperty] private string? workerLogText;
[ObservableProperty] private WorkerLogLevel workerLogLevel;
[ObservableProperty] private bool isWorkerLogVisible;
private readonly Timer _workerLogTimer = new(TimeSpan.FromSeconds(30).TotalMilliseconds)
{
AutoReset = false,
};
internal static IslandsShellViewModel CreateForTests() =>
(IslandsShellViewModel)System.Runtime.Serialization.FormatterServices
.GetUninitializedObject(typeof(IslandsShellViewModel));
```
(If `FormatterServices` is unavailable under `net8.0`, instead add a parameterless `internal IslandsShellViewModel() {}` constructor guarded for tests only.)
In the existing real constructor, wire up subscription (after the line `Worker.PropertyChanged += ...` block, around line 63-70):
```csharp
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
_workerLogTimer.Elapsed += (_, _) =>
{
if (Dispatcher.UIThread.CheckAccess()) ClearWorkerLog();
else Dispatcher.UIThread.Post(ClearWorkerLog);
};
```
Add the methods the tests call:
```csharp
public void OnWorkerLogReceived(WorkerLogEntry entry)
{
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
WorkerLogText = $"{hhmm} · {entry.Message}";
WorkerLogLevel = entry.Level;
IsWorkerLogVisible = true;
_workerLogTimer.Stop();
_workerLogTimer.Start();
}
public void ClearWorkerLog()
{
IsWorkerLogVisible = false;
WorkerLogText = null;
}
```
- [ ] **Step 4: Run tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj`
Expected: All tests pass (5 converter + 4 VM = 9 tests).
- [ ] **Step 5: Build the UI project as a sanity check**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/IslandsShellViewModelWorkerLogTests.cs
git commit -m "feat(ui): add worker log state and 30s timer to shell VM"
```
---
### Task 8: Update `MainWindow.axaml` footer — dock log line right
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 104-135 — the footer `Border`)
- [ ] **Step 1: Add the converter resource to the window (if not already in App.axaml)**
If Task 6 declared the converter in `App.axaml`, skip this step. Otherwise, add a `<Window.Resources>` block near the top of `MainWindow.axaml`:
```xml
<Window.Resources>
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
</Window.Resources>
```
Ensure `xmlns:converters="using:ClaudeDo.Ui.Converters"` is declared on the root `<Window>`.
- [ ] **Step 2: Replace the footer body**
Replace the existing footer `<Border Grid.Row="2" ...>` inner contents (the `<StackPanel>` at lines 109-134) with:
```xml
<DockPanel LastChildFill="True" Margin="14,0">
<!-- Left: connection pill -->
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="7"
VerticalAlignment="Center">
<Ellipse Width="7" Height="7" Fill="#4CAF50"
IsVisible="{Binding Worker.IsConnected}"/>
<Ellipse Width="7" Height="7" Fill="#FFA726"
IsVisible="{Binding Worker.IsReconnecting}"/>
<Ellipse Width="7" Height="7" Fill="#EF5350"
IsVisible="{Binding IsOffline}"/>
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Right: worker log line -->
<TextBlock DockPanel.Dock="Right"
Text="{Binding WorkerLogText}"
IsVisible="{Binding IsWorkerLogVisible}"
Foreground="{Binding WorkerLogLevel, Converter={StaticResource WorkerLogLevelToBrush}}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
<!-- Spacer (fills remaining space between pill and log) -->
<Panel/>
</DockPanel>
```
- [ ] **Step 3: Build the UI**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded, 0 errors. No XAML compilation errors.
- [ ] **Step 4: Build the full app (entry point)**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 5: Manual smoke test**
Start the Worker and the App (two separate processes per CLAUDE.md).
Exercise each event and confirm the footer line appears with the expected color and copy:
1. Start a task → expect `HH:MM · Created worktree for "<title>"` (dim/info).
2. Observe while Claude runs → expect `HH:MM · Started Claude for "<title>"` (dim/info).
3. Task commits → expect `HH:MM · Committed changes in "<title>"` (dim/info).
4. Task finishes successfully → expect `HH:MM · Finished "<title>" (done)` (green).
5. Trigger a failing task → expect `HH:MM · Finished "<title>" (failed)` (red).
6. Reset a failed task → expect `HH:MM · Discarded worktree for "<title>"` (amber) followed by `HH:MM · Reset "<title>"` (amber).
7. Merge a completed task → expect `HH:MM · Merged "<title>" into <branch>` (green).
8. Wait 30s with no new events → footer log line disappears (connection pill remains).
9. Trigger a burst of 3 events in quick succession → only the most recent is shown; timer resets on each.
10. Long task title (≥60 chars) → line is ellipsized, connection pill on the left remains fully visible.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
git commit -m "feat(ui): show worker log line in footer"
```
---
## Self-Review
- **Spec coverage:**
- Enum `WorkerLogLevel` in `ClaudeDo.Data` — Task 1 ✓
- SignalR enum-as-string — Task 2 ✓
- `HubBroadcaster.WorkerLog` — Task 2 ✓
- 7 emit sites with correct level mapping — Tasks 3, 4 ✓
- `WorkerClient.WorkerLogReceived` event + `WorkerLogEntry` record — Task 5 ✓
- `WorkerLogLevelToBrushConverter` with unit tests — Task 6 ✓
- Footer VM state + 30s timer + tests — Task 7 ✓
- Footer XAML (DockPanel, connection left, log right, level-based color, ellipsis) — Task 8 ✓
- Out-of-scope items (history drawer, filtering, persistence) — correctly omitted ✓
- **Placeholder scan:** No "TBD" / "handle edge cases" / "similar to Task N". All code is inline.
- **Type consistency:** `WorkerLogEntry(Message, Level, TimestampUtc)` — same signature used in Task 5 (declaration), Task 7 (consumer tests + VM). `WorkerLog(message, level, timestampUtc)` — same signature in Task 2 (broadcaster) and Tasks 3-4 (callers). `OnWorkerLogReceived` / `ClearWorkerLog` / `IsWorkerLogVisible` / `WorkerLogText` / `WorkerLogLevel` — consistent between Task 7 test and Task 7 implementation.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,799 @@
# Planning UX Polish + Sequential Subtask Queue — 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 sequential execution of planning subtasks (new `Waiting` status, context-menu trigger, worker-side chain advancement) plus three small UX changes (auto-collapse done planning parents in the task list, collapsible Description in the Details pane, narrower island GridSplitters).
**Architecture:** Foundation first — add the new `Waiting` enum value and its surface in the UI (chip, virtual-queued filter, row plumbing). Then ship the three UI polish items independently. Finally build the worker-side chain coordinator behind TDD and wire up the SignalR method + context-menu entry.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core (Sqlite), SignalR, xUnit.
**Spec:** `docs/superpowers/specs/2026-04-24-planning-ux-and-sequential-subtasks-design.md`
---
## Task 1: Add `Waiting` status to the enum
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` (TaskStatus enum)
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` (chip class switch)
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (virtual-queued match predicate)
- [ ] **Step 1: Add `Waiting` to the enum**
Append `Waiting` as the last value (keeps existing numeric slots stable for any int-serialized rows).
`src/ClaudeDo.Data/Models/TaskEntity.cs`:
```csharp
public enum TaskStatus
{
Manual,
Queued,
Running,
Done,
Failed,
Planning,
Planned,
Draft,
Waiting,
}
```
- [ ] **Step 2: Extend `StatusChipClass` switch in TaskRowViewModel**
`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — update the switch:
```csharp
public string StatusChipClass => Status switch
{
TaskStatus.Running => "running",
TaskStatus.Failed => "error",
TaskStatus.Done => "review",
TaskStatus.Queued => "queued",
TaskStatus.Waiting => "waiting",
_ => "idle",
};
```
- [ ] **Step 3: Add `IsWaiting` and include it in virtual-queued matching**
In the same `TaskRowViewModel.cs`, add alongside `IsQueued`:
```csharp
public bool IsWaiting => Status == TaskStatus.Waiting;
```
In `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`, find the `TaskMatchesList` static method and update the `virtual:queued` branch so tasks in `Waiting` also match. Locate the existing match for `ListKind.Virtual when list.Id == "virtual:queued"` and change it to match `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`. If the existing line reads `t.Status == TaskStatus.Queued` exactly, replace it with `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`.
- [ ] **Step 4: Build**
Run:
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: both build with 0 errors. Existing warnings OK.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/TaskEntity.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
git commit -m "feat(data): add Waiting task status and include it in virtual:queued"
```
---
## Task 2: Narrower island GridSplitters
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 158 and 170)
- [ ] **Step 1: Halve the splitter width**
Both `GridSplitter` elements currently use `Width="5"`. Change both to `Width="3"`. Leave all other attributes untouched.
- [ ] **Step 2: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
git commit -m "style(ui): narrow island GridSplitters from 5 to 3"
```
---
## Task 3: Collapsible Description section in Details pane
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- [ ] **Step 1: Add observable flag + toggle command**
In `DetailsIslandViewModel.cs`, add beside the existing editable fields:
```csharp
[ObservableProperty] private bool _isDescriptionExpanded = true;
[RelayCommand]
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
```
- [ ] **Step 2: Reset flag when a new task is loaded**
Find the method that handles a new `Task` being bound (the existing `OnTaskChanged` / `Bind` path — it's the spot that already sets `EditableTitle`, `EditableDescription`, etc.). At the start of the load path where fields get reset, add:
```csharp
IsDescriptionExpanded = true;
```
(If the reset is scattered, put it next to the `EditableDescription = ""` assignment.)
- [ ] **Step 3: Wrap the description TextBox in a collapsible section**
In `DetailsIslandView.axaml`, locate the description TextBox. Wrap it so it looks like:
```xml
<StackPanel Spacing="4">
<Button Classes="flat"
Command="{Binding ToggleDescriptionExpandedCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="0">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding IsDescriptionExpanded}"/>
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding !IsDescriptionExpanded}"/>
<TextBlock Classes="eyebrow" Text="DESCRIPTION"/>
</StackPanel>
</Button>
<!-- existing description TextBox goes here unchanged, but add: -->
<TextBox ...existing attributes...
IsVisible="{Binding IsDescriptionExpanded}"/>
</StackPanel>
```
If the existing `Icon.ChevronDown` / `Icon.ChevronRight` static resources don't exist, inspect `App.axaml` (or wherever `StaticResource Icon.*` icons live) and pick the closest existing chevron pair. If only one direction exists, use a simple `▾` / `▸` TextBlock substitute:
```xml
<TextBlock Text="▾" IsVisible="{Binding IsDescriptionExpanded}"/>
<TextBlock Text="▸" IsVisible="{Binding !IsDescriptionExpanded}"/>
```
- [ ] **Step 4: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 5: Manual verify**
Launch the app (`dotnet run --project src/ClaudeDo.App`), open a task with a description, click the chevron. Verify the body collapses/expands; verify opening a different task restores the expanded default.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
git commit -m "feat(ui): collapsible description section in details pane"
```
---
## Task 4: Auto-collapse done planning parents in task list
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- [ ] **Step 1: Add expansion state + "all children done" flag to `TaskRowViewModel`**
In `TaskRowViewModel.cs`, add below the existing observable properties:
```csharp
[ObservableProperty] private bool _areChildrenExpanded = true;
[ObservableProperty] private bool _allChildrenDone;
partial void OnAllChildrenDoneChanged(bool value)
{
// Default children to collapsed once the planning parent is fully done.
if (value) AreChildrenExpanded = false;
}
[RelayCommand]
private void ToggleChildrenExpanded() => AreChildrenExpanded = !AreChildrenExpanded;
```
- [ ] **Step 2: Compute `AllChildrenDone` during Regroup in `TasksIslandViewModel`**
In `TasksIslandViewModel.cs`, locate the `Regroup()` method (the one that clears and repopulates `OverdueItems`/`OpenItems`/`CompletedItems`). Before it distributes rows, build a lookup of children by parent id:
```csharp
var childrenByParent = Items
.Where(r => r.IsChild && r.ParentTaskId is not null)
.GroupBy(r => r.ParentTaskId!)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild))
{
if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
parent.AllChildrenDone = kids.All(c => c.Status == TaskStatus.Done);
else
parent.AllChildrenDone = false;
}
```
Then inside the existing distribution loop, skip child rows whose parent row has `AreChildrenExpanded == false`:
```csharp
foreach (var row in Items)
{
if (row.IsChild && row.ParentTaskId is not null)
{
var parentRow = Items.FirstOrDefault(p => p.Id == row.ParentTaskId);
if (parentRow is not null && !parentRow.AreChildrenExpanded) continue;
}
// ... existing distribution into Overdue/Open/Completed ...
}
```
If `Regroup()` currently uses LINQ expressions instead of a loop, split them out into explicit foreach so the skip is clear. Keep the overdue/completed logic intact — children of a collapsed parent are excluded from every bucket.
- [ ] **Step 3: Re-run Regroup when a row's expansion flag toggles**
In `TasksIslandViewModel.cs`, in the constructor (after `Items` is created), subscribe to changes so toggling one row triggers a regroup:
```csharp
Items.CollectionChanged += (_, e) =>
{
if (e.NewItems is not null)
foreach (TaskRowViewModel r in e.NewItems)
r.PropertyChanged += OnItemPropertyChanged;
if (e.OldItems is not null)
foreach (TaskRowViewModel r in e.OldItems)
r.PropertyChanged -= OnItemPropertyChanged;
};
```
Add the handler:
```csharp
private void OnItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TaskRowViewModel.AreChildrenExpanded))
Regroup();
}
```
- [ ] **Step 4: Add chevron toggle button to the planning-parent row**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the main task card where the title/eyebrow row lives (co-located with `PlanningBadge`), add a chevron button visible only when `IsPlanningParent && HasPlanningChildren`:
```xml
<Button Classes="flat"
Command="{Binding ToggleChildrenExpandedCommand}"
IsVisible="{Binding HasPlanningChildren}"
Padding="0" Margin="0,0,6,0"
VerticalAlignment="Center">
<TextBlock FontSize="10"
Text="▾"
IsVisible="{Binding AreChildrenExpanded}"/>
<TextBlock FontSize="10"
Text="▸"
IsVisible="{Binding !AreChildrenExpanded}"/>
</Button>
```
Place it immediately before the title TextBlock in the parent-row layout. Leave child rows untouched.
- [ ] **Step 5: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 6: Manual verify**
Create a planning parent with ≥2 children. Mark both children `Done` (manually via DB if needed, or via a full planning run). Reload the list — the children should be hidden by default. Click the chevron on the parent — children appear. Click again — collapse.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
git commit -m "feat(ui): auto-collapse done planning parents in task list"
```
---
## Task 5: PlanningChainCoordinator — worker-side chain advancement (TDD)
**Files:**
- Create: `src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`
- [ ] **Step 1: Write the first failing test — queueing sets first child Queued, rest Waiting**
Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`:
```csharp
using System.Threading.Tasks;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Planning;
using Microsoft.EntityFrameworkCore;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Planning;
public class PlanningChainCoordinatorTests
{
private static DbContextOptions<ClaudeDoDbContext> InMemoryOptions() =>
new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite("DataSource=:memory:;Cache=Shared")
.Options;
private static async Task<(ClaudeDoDbContext ctx, TaskRepository repo)> NewDbAsync()
{
var ctx = new ClaudeDoDbContext(InMemoryOptions());
await ctx.Database.OpenConnectionAsync();
await ctx.Database.EnsureCreatedAsync();
return (ctx, new TaskRepository(ctx));
}
private static async Task SeedPlanningFamily(TaskRepository repo, string parentId, int childCount)
{
await repo.AddAsync(new TaskEntity
{
Id = parentId, ListId = "L1", Title = "Parent",
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Planned,
});
for (int i = 0; i < childCount; i++)
{
await repo.AddAsync(new TaskEntity
{
Id = $"{parentId}-c{i}", ListId = "L1", Title = $"Child {i}",
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Manual,
ParentTaskId = parentId, SortOrder = i,
});
}
}
[Fact]
public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Queued, kids[0].Status);
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
}
```
- [ ] **Step 2: Run the test — expect failure (class doesn't exist)**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: compile error "PlanningChainCoordinator not found".
- [ ] **Step 3: Create the coordinator with the minimum to pass**
`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
namespace ClaudeDo.Worker.Planning;
public sealed class PlanningChainCoordinator
{
private readonly TaskRepository _tasks;
public PlanningChainCoordinator(TaskRepository tasks) => _tasks = tasks;
public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct)
{
var parent = await _tasks.GetByIdAsync(parentTaskId, ct)
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
var children = (await _tasks.GetChildrenAsync(parentTaskId, ct))
.OrderBy(t => t.SortOrder)
.ToList();
if (children.Count == 0)
throw new InvalidOperationException("Parent has no subtasks.");
var bad = children.FirstOrDefault(c => c.Status is not (TaskStatus.Manual or TaskStatus.Planned));
if (bad is not null)
throw new InvalidOperationException($"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned.");
for (int i = 0; i < children.Count; i++)
{
children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting;
await _tasks.UpdateAsync(children[i], ct);
}
}
}
```
If `TaskRepository.GetChildrenAsync` does not yet exist, add it:
```csharp
// in src/ClaudeDo.Data/Repositories/TaskRepository.cs
public Task<List<TaskEntity>> GetChildrenAsync(string parentTaskId, CancellationToken ct = default) =>
_ctx.Tasks.Where(t => t.ParentTaskId == parentTaskId).ToListAsync(ct);
```
(If the repo uses `AsNoTracking()` elsewhere for reads, match that pattern. For this method we want tracked entities so `UpdateAsync` works without extra attach.)
- [ ] **Step 4: Run the test — expect pass**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 1 passed.
- [ ] **Step 5: Add failing test — on child Done, next Waiting sibling flips to Queued**
Append to `PlanningChainCoordinatorTests.cs`:
```csharp
[Fact]
public async Task OnChildDone_FlipsNextWaitingToQueued()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
// Simulate first child finishing Done.
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Done;
await ctx.SaveChangesAsync();
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
Assert.Equal("P-c1", advanced);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Done, kids[0].Status);
Assert.Equal(TaskStatus.Queued, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
```
- [ ] **Step 6: Run — expect failure**
Expected: compile error "OnChildFinishedAsync does not exist".
- [ ] **Step 7: Implement `OnChildFinishedAsync`**
In `PlanningChainCoordinator.cs`:
```csharp
/// <summary>
/// Call after a child task transitions to a terminal status.
/// Returns the id of the newly-queued sibling (if any), else null.
/// </summary>
public async Task<string?> OnChildFinishedAsync(string childTaskId, TaskStatus finalStatus, CancellationToken ct)
{
if (finalStatus != TaskStatus.Done) return null;
var child = await _tasks.GetByIdAsync(childTaskId, ct);
if (child?.ParentTaskId is null) return null;
var siblings = (await _tasks.GetChildrenAsync(child.ParentTaskId, ct))
.OrderBy(t => t.SortOrder)
.ToList();
var next = siblings
.Where(s => s.SortOrder > child.SortOrder && s.Status == TaskStatus.Waiting)
.FirstOrDefault();
if (next is null) return null;
next.Status = TaskStatus.Queued;
await _tasks.UpdateAsync(next, ct);
return next.Id;
}
```
- [ ] **Step 8: Run — expect pass**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 2 passed.
- [ ] **Step 9: Add failing test — on Failed, chain stops**
```csharp
[Fact]
public async Task OnChildFailed_DoesNotAdvanceChain()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Failed;
await ctx.SaveChangesAsync();
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
Assert.Null(advanced);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Failed, kids[0].Status);
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
```
- [ ] **Step 10: Run — expect pass (existing guard handles it)**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 3 passed.
- [ ] **Step 11: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs \
src/ClaudeDo.Data/Repositories/TaskRepository.cs \
tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs
git commit -m "feat(worker): add PlanningChainCoordinator with sequential subtask advancement"
```
---
## Task 6: Hook chain advancement into TaskRunner finish path
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
- Modify: `src/ClaudeDo.Worker/Program.cs` (DI registration)
- [ ] **Step 1: Register `PlanningChainCoordinator` in DI**
Locate `src/ClaudeDo.Worker/Program.cs` where other services are registered (look for `services.AddSingleton<PlanningSessionManager>` or similar). Add:
```csharp
services.AddScoped<PlanningChainCoordinator>();
```
Use `AddScoped` if `TaskRepository` is scoped (check how it's registered — match its lifetime). If `TaskRepository` is constructed ad-hoc inside the worker, add a constructor overload on `PlanningChainCoordinator` that takes `IDbContextFactory<ClaudeDoDbContext>` and builds its own `TaskRepository` per call, then register as Singleton. Mirror the pattern used by `PlanningSessionManager`.
- [ ] **Step 2: Inject coordinator into `TaskRunner`**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add `PlanningChainCoordinator` to the constructor parameter list and store it in a readonly field (match the style used for `_broadcaster`).
If `TaskRunner` is not a good fit for direct injection (e.g., it's used in contexts without DI), instead inject `IServiceProvider` / `IDbContextFactory<ClaudeDoDbContext>` and new-up a coordinator inside the finish handler. Pick whichever matches existing `TaskRunner` patterns.
- [ ] **Step 3: Call coordinator after Done/Failed emission**
Immediately after each `await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);` on line ~338 and the two failed emissions on lines ~355 and ~372, add:
```csharp
if (task.ParentTaskId is not null)
{
var advancedId = await _chainCoordinator.OnChildFinishedAsync(
task.Id,
/* Done or Failed based on path */,
CancellationToken.None);
if (advancedId is not null)
await _broadcaster.TaskUpdated(advancedId);
}
```
Use `TaskStatus.Done` in the done-path call site and `TaskStatus.Failed` in the failed-path call sites. For the failed paths that use `justFailed` rather than `task`, read `justFailed?.ParentTaskId` and `justFailed?.Id` to stay consistent with the surrounding code.
After this call the existing queue-pickup loop will see the newly-Queued sibling and dispatch it on its next tick.
- [ ] **Step 4: Build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: 0 errors.
- [ ] **Step 5: Run full test suite**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
```
Expected: all pre-existing tests + 3 new ones pass.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs \
src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): advance planning subtask chain on child finish"
```
---
## Task 7: Hub method + client + context menu entry
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Ui/Services/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`
- [ ] **Step 1: Add hub method**
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, add (match the style of other planning methods):
```csharp
public async Task QueuePlanningSubtasks(string parentTaskId)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(ctx);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync(parentTaskId, CancellationToken.None);
// Broadcast updates for the parent and all its children so the UI refreshes.
var children = await ctx.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.Select(t => t.Id)
.ToListAsync();
await _broadcaster.TaskUpdated(parentTaskId);
foreach (var id in children)
await _broadcaster.TaskUpdated(id);
// Make sure the queue picks up the now-Queued first child immediately.
_queueSignal.Wake();
}
```
If the existing hub constructs `PlanningSessionManager` via DI directly, inject `PlanningChainCoordinator` the same way and call `_chainCoordinator.QueueSubtasksSequentiallyAsync(...)` instead of newing one up. If the hub exposes a queue-wakeup via a different name than `_queueSignal`, use that (search the file for `WakeQueue` or `.Wake()`).
- [ ] **Step 2: Add method to `IWorkerClient`**
In `src/ClaudeDo.Ui/Services/IWorkerClient.cs`, add next to the other planning methods:
```csharp
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
```
- [ ] **Step 3: Implement in `WorkerClient`**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add (match the pattern of `StartPlanningSessionAsync` etc.):
```csharp
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) =>
_connection.InvokeAsync("QueuePlanningSubtasks", parentTaskId, ct);
```
- [ ] **Step 4: Add `CanQueueSubtasksSequentially` + `HasPlanningChildren` observable to `TaskRowViewModel`**
Confirm `HasPlanningChildren` exists (it's referenced in the spec). If not, add it as `[ObservableProperty] bool _hasPlanningChildren;` and ensure `TasksIslandViewModel.Regroup()` already sets it (there should be a parent-side "has children" pass similar to the `AllChildrenDone` one added in Task 4 — if not, set it there).
Then add:
```csharp
public bool CanQueueSubtasksSequentially =>
IsPlanningParent && HasPlanningChildren && !IsChild;
```
Add `OnPropertyChanged(nameof(CanQueueSubtasksSequentially))` inside `OnStatusChanged` and `OnHasPlanningChildrenChanged` so the flag refreshes when status or children change.
- [ ] **Step 5: Add context-menu entry**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the existing `<ContextMenu>`, directly after the "Discard planning session" item:
```xml
<Separator IsVisible="{Binding CanQueueSubtasksSequentially}"/>
<MenuItem Header="Queue subtasks sequentially"
IsVisible="{Binding CanQueueSubtasksSequentially}"
Click="OnQueueSubtasksSequentiallyClick"/>
```
- [ ] **Step 6: Add click handler in code-behind**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`, add (match the other `On*Click` handlers — they pull the `TaskRowViewModel` from `DataContext` and call the shell / worker):
```csharp
private async void OnQueueSubtasksSequentiallyClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel row) return;
var worker = App.Services.GetRequiredService<IWorkerClient>();
try
{
await worker.QueuePlanningSubtasksAsync(row.Id);
}
catch (Exception ex)
{
// Match the toast/log pattern used by OnSendToQueueClick et al.
System.Diagnostics.Debug.WriteLine($"QueuePlanningSubtasks failed: {ex}");
}
}
```
Use the same `App.Services` / `IWorkerClient` lookup pattern as `OnSendToQueueClick` — do not introduce a new DI pattern. If the existing handlers use a shell/mediator indirection, use that instead.
- [ ] **Step 7: Build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 8: Manual verify end-to-end**
1. Launch app: `dotnet run --project src/ClaudeDo.App`.
2. Open a planning task with ≥2 subtasks (all in `Manual`/`Planned`).
3. Right-click parent → **Queue subtasks sequentially**.
4. Confirm in the task list: first child shows `Queued` chip, others show `Waiting` chip.
5. Let the first run to completion (or, for a quick smoke test, edit the DB to mark it `Done` and emit `TaskUpdated` via a restart).
6. Confirm the next child's status flips `Waiting → Queued` without user interaction.
7. Force-fail a child (cancel it mid-run) — confirm remaining `Waiting` children stay `Waiting`.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs \
src/ClaudeDo.Ui/Services/IWorkerClient.cs \
src/ClaudeDo.Ui/Services/WorkerClient.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
git commit -m "feat(ui+worker): context menu to queue planning subtasks sequentially"
```
---
## Self-review checklist (for the plan author before handing off)
- All four spec items mapped: auto-collapse (Task 4), collapsible description (Task 3), narrower splitters (Task 2), sequential subtask queue (Tasks 1, 5, 6, 7).
- `Waiting` enum touches: enum, chip class, virtual:queued filter — covered in Task 1.
- TDD applied where it pays off (the coordinator); UI tasks rely on manual verification (correct for this codebase).
- No placeholders. Every code step shows the code to paste.
- Type names consistent: `PlanningChainCoordinator`, `QueueSubtasksSequentiallyAsync`, `OnChildFinishedAsync`, `QueuePlanningSubtasksAsync`, `AreChildrenExpanded`, `AllChildrenDone`, `IsDescriptionExpanded` — used the same across tasks.
- Commits are small and conventional.

View File

@@ -0,0 +1,999 @@
# Planning Session Worktree 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:** Make `mcp__claudedo__*` tools available inside planning sessions by running each session in an ephemeral git worktree that holds a project-scope `.mcp.json` and a settings override that auto-trusts project MCP servers.
**Architecture:** `PlanningSessionManager` creates a short-lived git worktree from `HEAD` of the list's working directory on `StartAsync`, writes `.mcp.json` (with env-var expansion for the bearer token) and `.claude/settings.local.json` into it, and returns the worktree path as the spawn directory. `WindowsTerminalPlanningLauncher` passes the token via env var (`CLAUDEDO_PLANNING_TOKEN`) and stops passing `--mcp-config`. Finalize/Discard force-remove the worktree and branch.
**Tech Stack:** .NET 8, xUnit, real SQLite (DbFixture), real git worktrees via `ClaudeDo.Data.Git.GitService`.
**Spec:** `docs/superpowers/specs/2026-04-24-planning-worktree-design.md`
---
## File Structure
**Modify:**
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` — add `Token`, `WorktreePath`, `BranchName` to start context; add `Token` and rename `McpConfigPath``WorktreePath` on resume context
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` — drop `McpConfigPath` field
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — worktree create/cleanup, token persistence, new ctor deps
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — env var, drop `--mcp-config`
- `src/ClaudeDo.Worker/Program.cs` — DI wiring for new ctor signature
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` — add git init, update existing assertions
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` — add git init in setup
- `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs` — assert env var, no `--mcp-config`
Each file has one clear responsibility; no new files needed.
---
## Task 1: Extend context records with token and worktree info
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs`
- [ ] **Step 1: Edit the records**
Replace the full file content with:
```csharp
namespace ClaudeDo.Worker.Planning;
public sealed record PlanningSessionStartContext(
string ParentTaskId,
string WorkingDir,
string Token,
string WorktreePath,
string BranchName,
PlanningSessionFiles Files);
public sealed record PlanningSessionResumeContext(
string ParentTaskId,
string WorkingDir,
string ClaudeSessionId,
string Token,
string WorktreePath);
```
Note: `WorkingDir` on both records now points at the worktree (callers that used it as "spawn dir" remain correct; callers that needed "list working dir" must be updated separately — no such callers exist today).
- [ ] **Step 2: Build to see breakage**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: FAIL — `PlanningSessionManager` and `WindowsTerminalPlanningLauncher` no longer match these signatures.
- [ ] **Step 3: Commit stub**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
git commit -m "refactor(worker): extend planning contexts with token and worktree"
```
---
## Task 2: Drop `McpConfigPath` from `PlanningSessionFiles`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs`
- [ ] **Step 1: Edit the record**
Replace the full file content with:
```csharp
namespace ClaudeDo.Worker.Planning;
public sealed record PlanningSessionFiles(
string SessionDirectory,
string SystemPromptPath,
string InitialPromptPath);
```
- [ ] **Step 2: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
git commit -m "refactor(worker): drop McpConfigPath from PlanningSessionFiles"
```
---
## Task 3: Extend `PlanningSessionManager` constructors
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (fields + constructors only)
- [ ] **Step 1: Add using directives**
At the top of `PlanningSessionManager.cs`, add these imports alongside the existing ones:
```csharp
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Config;
```
- [ ] **Step 2: Replace fields and constructors**
Replace the block from `private const string McpServerUrl` down to the end of `CreateRepos()` with:
```csharp
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
private readonly TaskRepository? _tasksOverride;
private readonly ListRepository? _listsOverride;
private readonly AppSettingsRepository? _settingsOverride;
private readonly GitService _git;
private readonly WorkerConfig _cfg;
private readonly string _rootDirectory;
// DI constructor.
public PlanningSessionManager(
IDbContextFactory<ClaudeDoDbContext> factory,
GitService git,
WorkerConfig cfg,
string rootDirectory)
{
_factory = factory;
_git = git;
_cfg = cfg;
_rootDirectory = rootDirectory;
}
// Test constructor.
public PlanningSessionManager(
TaskRepository tasks,
ListRepository lists,
AppSettingsRepository settings,
GitService git,
WorkerConfig cfg,
string rootDirectory)
{
_tasksOverride = tasks;
_listsOverride = lists;
_settingsOverride = settings;
_git = git;
_cfg = cfg;
_rootDirectory = rootDirectory;
}
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
{
if (_tasksOverride is not null)
return (_tasksOverride, _listsOverride!, _settingsOverride!, null);
var ctx = _factory!.CreateDbContext();
return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx);
}
```
- [ ] **Step 3: Update all `CreateRepos()` call-sites in this file**
Every call currently binds `(tasks, lists, ctx)`. Change each to `(tasks, lists, settings, ctx)` (search the file for `= CreateRepos();`).
The `_` and `__` discard patterns on the returned `ctx` (lines like `await using var _ = ctx;`) remain valid.
- [ ] **Step 4: Build — expect test breakage**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS (production code compiles).
Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: FAIL — test ctor calls don't match. Will be fixed in Task 10.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): inject GitService and WorkerConfig into PlanningSessionManager"
```
---
## Task 4: Add a worktree-path helper and the token-file helpers
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (add private helpers)
- [ ] **Step 1: Add three private helpers at the bottom of the class (before the closing `}`)**
```csharp
private static string BranchNameFor(string taskId) =>
$"claudedo/planning/{taskId.Replace("-", "")}";
private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir)
{
var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride)
? centralRootOverride!
: _cfg.CentralWorktreeRoot;
var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
? Path.Combine(centralRoot, "planning", taskId)
: Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId);
return Path.GetFullPath(raw);
}
private static string TokenFilePathFor(string sessionDir) =>
Path.Combine(sessionDir, "token");
private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct)
{
await File.WriteAllTextAsync(path, token, ct);
// Best-effort current-user-only ACL on Windows. On non-Windows the inherited
// perms from the parent dir apply; acceptable because sessionDir is already
// under the user's home (~/.todo-app/sessions/).
if (OperatingSystem.IsWindows())
{
try
{
var fi = new FileInfo(path);
var ac = fi.GetAccessControl();
ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!;
ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
me,
System.Security.AccessControl.FileSystemRights.FullControl,
System.Security.AccessControl.AccessControlType.Allow));
fi.SetAccessControl(ac);
}
catch { /* ACL hardening is best-effort */ }
}
}
private static async Task<string> ReadTokenFileAsync(string path, CancellationToken ct)
{
if (!File.Exists(path))
throw new InvalidOperationException($"Token file missing: {path}");
return (await File.ReadAllTextAsync(path, ct)).Trim();
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): add worktree path and token file helpers"
```
---
## Task 5: Rewrite `BuildMcpConfigJson` to use env-var expansion
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
- [ ] **Step 1: Replace `BuildMcpConfigJson` body**
Find the existing `private static string BuildMcpConfigJson(string token)` method. Replace with:
```csharp
private static string BuildMcpConfigJson()
{
var payload = new
{
mcpServers = new
{
claudedo = new
{
type = "http",
url = McpServerUrl,
headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
}
}
}
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
}
```
(The token argument is dropped — claude expands `${CLAUDEDO_PLANNING_TOKEN}` at load time from the spawned process environment.)
- [ ] **Step 2: Also add settings override builder below it**
```csharp
private const string SettingsLocalJson = """
{
"enableAllProjectMcpServers": true
}
""";
```
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): switch MCP config to env-var token expansion"
```
---
## Task 6: Rewrite `StartAsync` to create the worktree
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (body of `StartAsync` only)
- [ ] **Step 1: Replace `StartAsync` body (keep signature)**
Replace the entire method body with:
```csharp
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var _ = ctx;
var task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.ParentTaskId is not null)
throw new InvalidOperationException("Cannot start a planning session on a child task.");
if (task.Status != TaskStatus.Manual)
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
var list = await lists.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException($"List {task.ListId} not found.");
var listWorkingDir = list.WorkingDir
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
var branchName = BranchNameFor(taskId);
var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct);
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
try
{
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
// Self-heal: remove phantom worktrees, prune, delete branch, retry once.
var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct);
foreach (var stale in stalePaths)
{
try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { }
}
try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { }
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
}
// Write .mcp.json and .claude/settings.local.json into the worktree.
var mcpPath = Path.Combine(worktreePath, ".mcp.json");
await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct);
var claudeDir = Path.Combine(worktreePath, ".claude");
Directory.CreateDirectory(claudeDir);
await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct);
// Session dir + token + prompt files.
var token = GenerateToken();
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
?? throw new InvalidOperationException("Failed to transition task to Planning.");
var sessionDir = Path.Combine(_rootDirectory, taskId);
Directory.CreateDirectory(sessionDir);
var files = new PlanningSessionFiles(
sessionDir,
Path.Combine(sessionDir, "system-prompt.md"),
Path.Combine(sessionDir, "initial-prompt.txt"));
await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct);
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
return new PlanningSessionStartContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
Token: token,
WorktreePath: worktreePath,
BranchName: branchName,
Files: files);
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "feat(worker): create ephemeral worktree and write .mcp.json in StartAsync"
```
---
## Task 7: Rewrite `ResumeAsync` and add cleanup to `FinalizeAsync` / `DiscardAsync`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (three methods)
- [ ] **Step 1: Replace `ResumeAsync` body**
```csharp
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var _ = ctx;
var task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Planning)
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
if (string.IsNullOrEmpty(task.PlanningSessionId))
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (!Directory.Exists(sessionDir))
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
var list = await lists.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException($"List {task.ListId} not found.");
var listWorkingDir = list.WorkingDir
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
if (!Directory.Exists(worktreePath))
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
return new PlanningSessionResumeContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
ClaudeSessionId: task.PlanningSessionId,
Token: token,
WorktreePath: worktreePath);
}
```
- [ ] **Step 2: Extend `FinalizeAsync` to clean up worktree + branch**
Replace the existing `FinalizeAsync` body with:
```csharp
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var __ = ctx;
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
// Best-effort cleanup — don't block finalization on git state.
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (Directory.Exists(sessionDir))
{
try { Directory.Delete(sessionDir, recursive: true); } catch { }
}
return count;
}
```
- [ ] **Step 3: Extend `DiscardAsync` with the same cleanup**
Replace the body of `DiscardAsync` with:
```csharp
public async Task DiscardAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var __ = ctx;
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (Directory.Exists(sessionDir))
{
try { Directory.Delete(sessionDir, recursive: true); } catch { }
}
if (!ok)
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
}
```
- [ ] **Step 4: Add the `TryCleanupWorktreeAsync` helper**
Add this private method near the other helpers:
```csharp
private async Task TryCleanupWorktreeAsync(
string taskId,
ListRepository lists,
AppSettingsRepository settings,
CancellationToken ct)
{
try
{
var (tasks, _, _, ctx2) = CreateRepos();
await using var __ = ctx2;
var task = await tasks.GetByIdAsync(taskId, ct);
if (task is null) return;
var list = await lists.GetByIdAsync(task.ListId, ct);
var listWorkingDir = list?.WorkingDir;
if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return;
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
var branchName = BranchNameFor(taskId);
if (Directory.Exists(worktreePath))
{
try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); }
catch { /* best effort */ }
}
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
}
catch { /* best effort — never block finalize/discard */ }
}
```
- [ ] **Step 5: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "feat(worker): cleanup planning worktree and branch on finalize/discard"
```
---
## Task 8: Update `WindowsTerminalPlanningLauncher`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
- [ ] **Step 1: Rewrite `LaunchStartAsync`**
Replace the full method body with:
```csharp
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
{
if (!Directory.Exists(ctx.WorkingDir))
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
if (!File.Exists(ctx.Files.SystemPromptPath))
throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}");
if (!File.Exists(ctx.Files.InitialPromptPath))
throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}");
var resolvedWt = Resolve(_wtPath);
if (resolvedWt is null)
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
var resolvedClaude = Resolve(_claudePath);
if (resolvedClaude is null)
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
var psi = new ProcessStartInfo
{
FileName = resolvedWt,
UseShellExecute = false,
CreateNoWindow = false,
};
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
// Arg order: --allowedTools is variadic (space-separated). The positional
// prompt must follow a single-value flag, or it will be swallowed.
// --append-system-prompt-file serves as that buffer.
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(ctx.WorkingDir);
psi.ArgumentList.Add(resolvedClaude);
psi.ArgumentList.Add("--model");
psi.ArgumentList.Add(Model);
psi.ArgumentList.Add("--allowedTools");
psi.ArgumentList.Add(AllowedTools);
psi.ArgumentList.Add("--append-system-prompt-file");
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
var proc = Process.Start(psi)
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
return Task.CompletedTask;
}
```
- [ ] **Step 2: Rewrite `LaunchResumeAsync`**
Replace the full method body with:
```csharp
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
{
if (!Directory.Exists(ctx.WorkingDir))
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
var resolvedWt = Resolve(_wtPath);
if (resolvedWt is null)
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
var resolvedClaude = Resolve(_claudePath);
if (resolvedClaude is null)
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
var psi = new ProcessStartInfo
{
FileName = resolvedWt,
UseShellExecute = false,
CreateNoWindow = false,
};
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(ctx.WorkingDir);
psi.ArgumentList.Add(resolvedClaude);
psi.ArgumentList.Add("--resume");
psi.ArgumentList.Add(ctx.ClaudeSessionId);
var proc = Process.Start(psi)
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
return Task.CompletedTask;
}
```
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
git commit -m "feat(worker): launcher passes planning token via env, drops --mcp-config"
```
---
## Task 9: Update DI wiring in `Program.cs`
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs` (around line 5962)
- [ ] **Step 1: Update the registration**
Find:
```csharp
builder.Services.AddSingleton(sp =>
new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
planningSessionsDir));
```
Replace with:
```csharp
builder.Services.AddSingleton(sp =>
new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<GitService>(),
cfg,
planningSessionsDir));
```
- [ ] **Step 2: Build full worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "chore(worker): wire GitService and WorkerConfig into PlanningSessionManager DI"
```
---
## Task 10: Fix existing tests (add git init, update constructor calls, drop McpConfigPath assertions)
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs`
- [ ] **Step 1: Add a shared git-init helper**
Create `tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs`:
```csharp
using System.Diagnostics;
namespace ClaudeDo.Worker.Tests.Infrastructure;
public static class GitRepoFixture
{
public static void InitRepoWithInitialCommit(string dir)
{
Directory.CreateDirectory(dir);
Run(dir, "init", "-b", "main");
Run(dir, "config", "user.email", "test@claudedo.local");
Run(dir, "config", "user.name", "test");
File.WriteAllText(Path.Combine(dir, "README.md"), "seed\n");
Run(dir, "add", "-A");
Run(dir, "commit", "-m", "chore: seed");
}
private static void Run(string cwd, params string[] args)
{
var psi = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var a in args) psi.ArgumentList.Add(a);
var p = Process.Start(psi)!;
p.WaitForExit();
if (p.ExitCode != 0)
throw new InvalidOperationException($"git {string.Join(" ", args)} failed: {p.StandardError.ReadToEnd()}");
}
}
```
- [ ] **Step 2: Update `PlanningSessionManagerTests` constructor and seed helper**
In `PlanningSessionManagerTests.cs`, find the constructor and add after `_rootDir = …;`:
```csharp
_git = new ClaudeDo.Data.Git.GitService();
_cfg = new ClaudeDo.Worker.Config.WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
_settingsRepo = new ClaudeDo.Data.Repositories.AppSettingsRepository(_ctx);
// Seed settings row so the manager can read strategy.
_settingsRepo.UpsertAsync(new ClaudeDo.Data.Models.AppSettingsEntity { Id = 1, WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir);
```
Add three private fields to the class:
```csharp
private readonly ClaudeDo.Data.Git.GitService _git;
private readonly ClaudeDo.Worker.Config.WorkerConfig _cfg;
private readonly ClaudeDo.Data.Repositories.AppSettingsRepository _settingsRepo;
```
Change `SeedListAsync` to init a git repo:
```csharp
private async Task<(string listId, string workingDir)> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
ClaudeDo.Worker.Tests.Infrastructure.GitRepoFixture.InitRepoWithInitialCommit(wd);
await _lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test",
WorkingDir = wd,
CreatedAt = DateTime.UtcNow,
});
return (listId, wd);
}
```
- [ ] **Step 3: Update assertions in the existing `StartAsync_…` test**
The old test asserts `ctx.Files.McpConfigPath`. Replace with worktree-based assertions:
```csharp
Assert.Equal(parent.Id, ctx.ParentTaskId);
Assert.Equal(ctx.WorktreePath, ctx.WorkingDir);
Assert.True(Directory.Exists(ctx.WorktreePath));
var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json");
Assert.True(File.Exists(mcpPath));
Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json")));
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
var mcp = await File.ReadAllTextAsync(mcpPath);
Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp);
Assert.DoesNotContain(ctx.Token, mcp);
```
- [ ] **Step 4: Update `PlanningEndToEndTests` SUT construction similarly**
Add the same fields + ctor arguments. Replace any `new PlanningSessionManager(tasks, lists, rootDir)` with `new PlanningSessionManager(tasks, lists, settingsRepo, git, cfg, rootDir)` and ensure the seeded working directory is git-initialized.
- [ ] **Step 5: Update `WindowsTerminalPlanningLauncherTests`**
If the existing tests construct `PlanningSessionStartContext` manually, update to supply the new `Token`, `WorktreePath`, `BranchName` fields. Add an assertion that the test observes (via a fake `IPlanningTerminalLauncher`-level check or by verifying the psi after a refactor seam) that the env var is set.
If the existing launcher test only verifies behavior that's no longer directly testable (it spawns wt.exe), leave those tests as-is but ensure they still compile with the new ctor shape.
- [ ] **Step 6: Run all planning tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
Expected: PASS for all tests that previously passed.
- [ ] **Step 7: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/Planning/ tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
git commit -m "test(worker): adapt planning tests to git-backed worktree flow"
```
---
## Task 11: New tests — worktree creation, cleanup, self-heal, resume
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` (append new tests)
- [ ] **Step 1: Write the failing "worktree is removed on discard" test**
Append to the test class:
```csharp
[Fact]
public async Task DiscardAsync_RemovesWorktreeAndBranch()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
Assert.True(Directory.Exists(ctx.WorktreePath));
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
Assert.False(Directory.Exists(ctx.WorktreePath));
// branch deleted
var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName);
Assert.Empty(paths);
}
```
- [ ] **Step 2: Run — expect PASS**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "DiscardAsync_RemovesWorktreeAndBranch"`
Expected: PASS.
- [ ] **Step 3: Add "non-git working dir errors" test**
```csharp
[Fact]
public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}");
Directory.CreateDirectory(wd);
await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
var t = await SeedManualTaskAsync(listId);
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartAsync(t.Id, CancellationToken.None));
}
```
Run and expect PASS.
- [ ] **Step 4: Add self-heal test**
```csharp
[Fact]
public async Task StartAsync_SelfHealsWhenBranchAlreadyExists()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
// Pre-create a colliding branch.
var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}";
var head = await _git.RevParseHeadAsync(wd);
var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd };
procInfo.ArgumentList.Add("branch");
procInfo.ArgumentList.Add(branch);
procInfo.ArgumentList.Add(head);
var p = System.Diagnostics.Process.Start(procInfo)!;
p.WaitForExit();
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
Assert.True(Directory.Exists(ctx.WorktreePath));
}
```
Run and expect PASS.
- [ ] **Step 5: Add resume test**
```csharp
[Fact]
public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
// Simulate the claude session capturing its session id.
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc", CancellationToken.None);
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
Assert.Equal(startCtx.Token, resumeCtx.Token);
Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath);
Assert.Equal("session-abc", resumeCtx.ClaudeSessionId);
}
```
Run and expect PASS. If `UpdatePlanningSessionIdAsync` doesn't exist, use whatever repository method captures the Claude session id in this codebase (search the repo for the existing pattern) and substitute; do **not** skip this step.
- [ ] **Step 6: Run all planning tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
Expected: all PASS.
- [ ] **Step 7: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "test(worker): cover planning worktree lifecycle and self-heal"
```
---
## Task 12: Manual end-to-end verification
**Files:** none (manual)
- [ ] **Step 1: Build all projects**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 2: Start Worker + UI, create a manual task on a list whose WorkingDir is a real git repo, hit "Start planning"**
Expected:
- A Windows Terminal opens with `claude` running in a worktree under `<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>` (or the central root if strategy=central).
- No trust prompt appears for the `claudedo` MCP server.
- Inside claude, `/mcp` lists `claudedo` as connected.
- Asking claude "create a subtask" invokes `mcp__claudedo__*` tools and the new child task appears in the UI.
- [ ] **Step 3: Click Discard**
Expected:
- The worktree directory is gone; `git branch --list claudedo/planning/*` returns nothing; `~/.todo-app/sessions/<taskId>` is gone.
- [ ] **Step 4: Repeat with Finalize** — same expected cleanup.
- [ ] **Step 5: Close Windows Terminal mid-session, then "Resume"** — same worktree opens again with `--resume`.
---
## Deferred / follow-up
- **Defensive startup cleanup of orphaned planning worktrees.** Enumerate `.claudedo-worktrees/planning/*` (both sibling and central) and GC any whose session dir no longer exists. Ship as a follow-up plan if orphans become a real problem in practice.
---
## Self-Review Notes
- **Spec coverage:** Every section in `docs/superpowers/specs/2026-04-24-planning-worktree-design.md` maps to a task above (data flow → Task 6; launcher → Task 8; cleanup → Task 7; self-heal → Task 6 + Task 11.4; non-git error → Task 11.3; resume → Task 7 + Task 11.5; trust prompt bypass → Task 5 + Task 6). The one spec item deferred is the defensive startup cleanup.
- **Placeholder scan:** One conditional in Task 11.5 ("use whatever repository method captures the Claude session id") — this is deliberate: the existing codebase has an accessor whose exact name depends on local conventions and it's faster for the engineer to grep than for me to guess wrong. Every other step has full code.
- **Type consistency:** `PlanningSessionStartContext.WorktreePath` and `ResumeContext.WorktreePath` both `string`. `BranchName` only on Start (Resume recomputes via `BranchNameFor`). `Token` on both. `Files.McpConfigPath` removed everywhere.

View File

@@ -37,7 +37,7 @@ Fields:
|---|---|---| |---|---|---|
| `DefaultClaudeInstructions` | text | `""` | | `DefaultClaudeInstructions` | text | `""` |
| `DefaultModel` | string | `sonnet` | | `DefaultModel` | string | `sonnet` |
| `DefaultMaxTurns` | int | `30` | | `DefaultMaxTurns` | int | `100` |
| `DefaultPermissionMode` | string | `acceptEdits` | | `DefaultPermissionMode` | string | `acceptEdits` |
| `WorktreeStrategy` | string | `sibling` | | `WorktreeStrategy` | string | `sibling` |
| `CentralWorktreeRoot` | string? | `null` | | `CentralWorktreeRoot` | string? | `null` |

View File

@@ -0,0 +1,177 @@
# Default Agents — Design
**Date:** 2026-04-23
**Status:** Approved
## Goal
Ship ClaudeDo with a curated set of default agents so that users have useful agents available on first launch, without losing the file-based ownership model (user-editable, user-deletable). Provide a "Restore defaults" action to recover missing defaults on demand.
## Agents to Ship
Six markdown agents covering the common stages of task execution plus one general-purpose agent:
| File | Focus |
|---|---|
| `code-reviewer.md` | Review diff for bugs, logic errors, convention adherence. Flags only high-confidence issues. |
| `test-writer.md` | Generate unit/integration tests for changed code. Follows existing test patterns. |
| `debugger.md` | Systematic root-cause analysis — reproduce, isolate, hypothesize, verify. |
| `security-reviewer.md` | OWASP-style audit focused on auth, SQL injection, input handling, secret exposure. |
| `explorer.md` | Fast codebase navigation and answering "where/how" questions. Terse output. |
| `researcher.md` | General-purpose research, doc summarization, analysis, investigation. Non-code. |
Each file uses Claude Code's standard agent frontmatter:
```markdown
---
name: <agent name>
description: <one-line description>
---
<system prompt body>
```
Content target: ~2040 lines per file. Style matches the existing Claude Code agent conventions.
## Behavior
**Seed on first launch, restore on demand:**
1. Bundled agents live alongside the Worker binary at `<AppContext.BaseDirectory>/DefaultAgents/*.md`.
2. At Worker startup, for each bundled file: if `~/.todo-app/agents/<name>.md` does NOT exist, copy it in. If it exists, leave it alone — the user owns their copy.
3. A "Restore default agents" button in the settings modal re-runs the same check, restoring any that the user has deleted.
The seed path and the restore path are the same code — only the invocation differs (startup vs. hub call).
## Components
### `DefaultAgents/*.md` (bundled content)
Location: `src/ClaudeDo.Worker/DefaultAgents/`
Packaging: `<Content Include="DefaultAgents\*.md" CopyToOutputDirectory="PreserveNewest" />` in `ClaudeDo.Worker.csproj`.
At runtime they land at `<AppContext.BaseDirectory>/DefaultAgents/*.md` next to the executable.
### `DefaultAgentSeeder` (new service)
Location: `src/ClaudeDo.Worker/Services/DefaultAgentSeeder.cs`
```
public sealed class DefaultAgentSeeder
{
public DefaultAgentSeeder(string bundleDir, string targetDir);
public Task<SeedResult> SeedMissingAsync(CancellationToken ct = default);
}
public sealed record SeedResult(int Copied, int Skipped);
```
Behavior of `SeedMissingAsync`:
- If `bundleDir` doesn't exist, log warning and return `(0, 0)`.
- Enumerate `*.md` in `bundleDir`.
- For each file, if the target path (`targetDir/<filename>`) is missing, copy it; else increment `Skipped`.
- Create `targetDir` if missing (consistent with existing `AgentFileService.WriteAsync`).
- Per-file exceptions are caught and logged; the seeder continues with the next file. The method itself does not throw for individual file failures.
### `Program.cs` wiring
After `AgentFileService` registration and before `app.Run()`:
```csharp
var bundleDir = Path.Combine(AppContext.BaseDirectory, "DefaultAgents");
var seeder = new DefaultAgentSeeder(bundleDir, agentsDir);
await seeder.SeedMissingAsync();
builder.Services.AddSingleton(seeder);
```
The seeder is also registered as a singleton so the hub can invoke it for the restore flow.
### `WorkerHub.RestoreDefaultAgents`
Location: add method to existing `src/ClaudeDo.Worker/Hub/WorkerHub.cs`.
Signature: `public async Task<SeedResult> RestoreDefaultAgents()`
Behavior:
- Calls `DefaultAgentSeeder.SeedMissingAsync()`.
- Returns the `SeedResult` to the caller.
- No separate broadcast — the UI will call `GetAgents` after the restore returns, reusing the existing refresh path.
### UI — Settings Modal
Location: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` + `SettingsModalViewModel`.
Add a "Restore default agents" button to the modal. On click:
1. Disable the button, show a spinner label.
2. Call `WorkerClient.RestoreDefaultAgentsAsync()`.
3. Show a brief inline confirmation: `"Restored {Copied} agent(s)"` or `"All defaults already present"`.
4. Trigger the existing agent list refresh so the new files appear immediately in the rest of the UI.
### `WorkerClient` method
Add `Task<SeedResult> RestoreDefaultAgentsAsync(CancellationToken ct = default)` to `WorkerClient` — thin wrapper that invokes the hub method.
## Data Flow
**Startup:**
```
Worker starts → DefaultAgentSeeder.SeedMissingAsync()
→ copies missing files into ~/.todo-app/agents/
AgentFileService.ScanAsync() (on first GetAgents call) → sees the seeded files
```
**User restores:**
```
Settings modal button click
→ WorkerClient.RestoreDefaultAgentsAsync()
→ WorkerHub.RestoreDefaultAgents()
→ DefaultAgentSeeder.SeedMissingAsync()
→ returns SeedResult(copied, skipped)
UI shows confirmation, triggers GetAgents refresh
```
## Error Handling
| Failure | Behavior |
|---|---|
| Missing `DefaultAgents/` bundle dir | Log warning, return `(0, 0)`. Startup proceeds. |
| Individual file copy failure (disk, permissions) | Catch per-file, log, continue with the remaining files. |
| Corrupt bundled markdown (no valid frontmatter) | Copied anyway — the `AgentFileService` frontmatter parser already falls back to filename-as-name. |
| Startup seeder exception (unexpected) | Log as warning, do not crash the Worker. Agents can still be restored via the button. |
| Hub `RestoreDefaultAgents` exception | Propagate to client as SignalR error; UI shows a generic "Restore failed" message. |
## Testing
**Unit:** `tests/ClaudeDo.Worker.Tests/Services/DefaultAgentSeederTests.cs`
- Seeds all files when target dir is empty.
- Skips files that already exist.
- Preserves existing user-modified files (file mtime / content unchanged).
- Returns accurate `SeedResult` counts.
- Handles missing bundle dir gracefully (returns `(0, 0)`, no throw).
- Creates target dir if it doesn't exist.
**Integration:** extend `tests/ClaudeDo.Worker.Tests/Hub/AgentSettingsHubTests.cs`
- `RestoreDefaultAgents` invokes the seeder and returns the count.
**No UI tests.** The project has no UI test harness; settings modal behavior is exercised manually.
## Build / Packaging
`src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` gains:
```xml
<ItemGroup>
<Content Include="DefaultAgents\*.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```
No new NuGet dependencies.
## Out of Scope
- Editing bundled agents in-place (user edits their copy under `~/.todo-app/agents/`; bundle is read-only by convention).
- Versioning / updating user copies when the bundled version changes. If a bundled agent is improved in a later release, the user's copy is not overwritten. A future release may add a "diff / reset to bundled" flow, but not now.
- Packaging as embedded resources. Content files copied to output are simpler, inspectable on disk, and consistent with the file-based agent model.

View File

@@ -0,0 +1,468 @@
# Planning Sessions — Design
**Status:** Approved for implementation
**Date:** 2026-04-23
**Scope:** Feature — "Open planning Session" context menu on tasks that spawns an interactive Windows Terminal with Claude (Sonnet 4.6, medium thinking) and a scoped MCP server, letting the user brainstorm and have Claude break a rough task into concrete executable child-tasks.
---
## 1. Goal
Allow a user to take a vague task (a title plus some TODO-style notes) and convert it — via interactive dialogue with Claude in a terminal — into a structured set of concrete, executable child-tasks that the worker queue can pick up and run.
The interaction is driven by Claude calling MCP tools against a scoped server running inside the existing `ClaudeDo.Worker` process. The parent task becomes a "Planning" container that holds its children as a flat (single-level) hierarchy.
---
## 2. Status Flow
**Parent (new statuses `Planning`, `Planned`):**
```
Manual ──[Open planning Session]──▶ Planning ──[finalize]──▶ Planned
│ │
│ (all children reach terminal state)
│ ▼
│ Done
│ or
│ Failed (if any child Failed)
[Discard] ──▶ Manual
```
**Child (new status `Draft`):**
```
Draft ──[finalize]──▶ Manual | Queued (if "agent" tag) ──▶ Running ──▶ Done | Failed
```
**Rules:**
- Parent with status `Planning` or `Planned` is **never** picked up by the queue.
- Children with status `Draft` are **never** picked up by the queue.
- Hierarchy is strictly **one level deep**: a child task cannot itself become a planning parent (enforced app-side: Plan menu item hidden/disabled if `ParentTaskId IS NOT NULL`).
- One planning session per parent task at a time (`StartPlanningSessionAsync` errors if parent is already `Planning`; use Resume instead).
- Parent auto-status on child completion (evaluated after any child reaches `Done` or `Failed`):
- At least one child `Failed` and no children still in non-terminal states → Parent `Failed`.
- All children `Done` → Parent `Done`.
- Any child still `Manual`/`Queued`/`Running`/`Draft` → Parent stays `Planned`.
- Worktree state (`Merged`/`Discarded`/`Kept`) is orthogonal; only `Task.Status` determines completion.
---
## 3. Data Model
### 3.1 Schema changes to `Tasks` table
| Column | Type | Nullable | Purpose |
|---|---|---|---|
| `ParentTaskId` | `string` (FK → `Tasks.Id`, `DeleteBehavior.Restrict`) | yes | When set, row is a child of a planning parent. NULL = top-level task. |
| `PlanningSessionId` | `string` | yes | Claude CLI session ID captured after first run; used with `--resume`. Only set on planning parents. |
| `PlanningSessionToken` | `string` | yes | Random 32-byte Base64 token generated per session; acts as bearer for MCP calls. NULL when no active session. |
| `PlanningFinalizedAt` | `DateTime` | yes | Timestamp when `finalize` was called. NULL until finalized. |
Index: `(ParentTaskId)` for fast children lookup.
### 3.2 Status enum additions
`ClaudeDo.Data.Models.TaskStatus` gains:
- `Planning` — parent, session active or paused, drafts may exist.
- `Planned` — parent, finalized, children are real tasks (may still be running).
- `Draft` — child, created during session, not yet finalized.
Existing values unchanged: `Manual | Queued | Running | Done | Failed`. Persisted via `ValueConverter` to string (existing convention — confirmed via `TaskEntity.cs`).
### 3.3 Navigation properties
On `TaskEntity`:
```csharp
public string? ParentTaskId { get; set; }
public TaskEntity? Parent { get; set; }
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
public string? PlanningSessionId { get; set; }
public string? PlanningSessionToken { get; set; }
public DateTime? PlanningFinalizedAt { get; set; }
```
In `TaskEntityConfiguration`:
```csharp
.HasOne(t => t.Parent)
.WithMany(t => t.Children)
.HasForeignKey(t => t.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
```
**Rationale for `Restrict`:** cascade delete would orphan worktrees of in-flight child tasks. UI must handle the `DbUpdateException` and prompt the user to discard children first.
### 3.4 Repository additions
`ITaskRepository` gains:
- `Task<IReadOnlyList<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct)`
- `Task<TaskEntity> CreateChildAsync(string parentId, string title, string? description, IReadOnlyList<string>? tagNames, string? commitType, CancellationToken ct)` — creates with `Status = Draft`, `ParentTaskId = parentId`.
- `Task<int> FinalizePlanningAsync(string parentId, bool queueAgentTasks, CancellationToken ct)` — transactional: all Drafts → `Manual` (or `Queued` if tagged "agent" and `queueAgentTasks=true`), parent → `Planned`, set `PlanningFinalizedAt`, clear `PlanningSessionToken`. Returns count of finalized children.
- `Task<bool> DiscardPlanningAsync(string parentId, CancellationToken ct)` — deletes all Drafts, parent → `Manual`, clears `PlanningSessionId/Token/FinalizedAt`.
- `Task<TaskEntity?> SetPlanningStartedAsync(string taskId, string sessionToken, CancellationToken ct)` — sets parent `Status = Planning`, stores token; returns null if parent not in `Manual` state.
- `Task UpdatePlanningSessionIdAsync(string parentId, string sessionId, CancellationToken ct)` — captures Claude CLI session ID after launch.
- `Task<TaskEntity?> FindByPlanningTokenAsync(string token, CancellationToken ct)` — used by MCP auth handler.
`GetNextQueuedAgentTaskAsync` — verify the existing query filters on `Status = Queued`; no additional filter needed since Planning/Planned/Draft are different statuses. Add explicit regression test.
### 3.5 Auto-status hook
After every `MarkDoneAsync`/`MarkFailedAsync` on a task with `ParentTaskId != null`, check parent children. If all in terminal state:
- Any `Failed` → parent `Failed` with `FinishedAt = now()`.
- All `Done` (or worktrees `Discarded`) → parent `Done` with `FinishedAt = now()`.
Implemented as a private helper `TryCompleteParentAsync(string parentId, CancellationToken ct)` called at the end of the two Mark methods.
### 3.6 Migration
`dotnet ef migrations add AddPlanningSupport` — adds four columns and the `(ParentTaskId)` index. No data migration needed (new columns all nullable).
---
## 4. MCP Server Surface
### 4.1 Transport
**HTTP (streamable) inside the existing Worker Kestrel host.** Mount on `/mcp` alongside the existing SignalR hub at `127.0.0.1:47821`. No separate process, no stdio proxy.
Library: `ModelContextProtocol` (official C# MCP SDK).
### 4.2 Authentication
Per-session bearer token:
1. `StartPlanningSessionAsync` generates a 32-byte random token, persists to `Tasks.PlanningSessionToken`.
2. Token is written into the session's `mcp.json` as `Authorization: Bearer <token>`.
3. Every MCP request passes through an auth filter that looks up the token via `FindByPlanningTokenAsync`. If found, the parent task ID is stored in the request context. If not, 401.
4. Token is invalidated (set NULL) on `finalize` or `discard`.
### 4.3 Tools
All tools are scoped to the parent task resolved from the request's token. `parent_id` is never an argument.
| Tool | Params | Returns | Effect |
|---|---|---|---|
| `create_child_task` | `title: string`, `description?: string`, `tags?: string[]`, `commit_type?: string` | `{ task_id, status: "Draft" }` | Creates a Draft child under this session's parent. |
| `list_child_tasks` | — | `[{ task_id, title, description, status, tags }]` | Lists children of this parent (in session context, always Drafts). |
| `update_child_task` | `task_id: string`, optional: `title`, `description`, `tags`, `commit_type` | `{ task }` | Errors if target is not a Draft or not a child of this parent. |
| `delete_child_task` | `task_id: string` | `{ ok: true }` | Errors if target is not a Draft or not a child of this parent. |
| `update_planning_task` | `title?: string`, `description?: string` | `{ task }` | Only title/description on the parent itself. |
| `finalize` | `queue_agent_tasks?: bool = true` | `{ finalized_count: int }` | Calls `FinalizePlanningAsync`. Token invalidated. |
### 4.4 Real-time UI
After each successful tool call, the MCP handler fires a `TaskUpdated` event on the Worker's SignalR hub. The UI subscribes as it already does; drafts appear/update live in the tasks list while the user chats with Claude in the terminal.
### 4.5 Errors
- 401 for missing/invalid token.
- MCP error `-32602` "task not found or not a child of this planning session" for cross-parent access attempts.
- MCP error `-32602` "cannot modify finalized task" for `update/delete` on non-Draft.
- Token validation short-circuits before tool dispatch.
---
## 5. Terminal Launch & Claude CLI Invocation
### 5.1 Launcher service
New interface `IPlanningTerminalLauncher` in the UI or App layer:
```csharp
Task LaunchAsync(PlanningSessionStart info, CancellationToken ct);
Task LaunchResumeAsync(PlanningSessionResume info, CancellationToken ct);
```
`PlanningSessionStart` contains: `WorkingDir`, `McpConfigPath`, `InitialPromptPath`, `SystemPromptPath`.
`PlanningSessionResume` contains: `WorkingDir`, `McpConfigPath`, `ClaudeSessionId`.
### 5.2 Per-session files
Path: `~/.todo-app/planning-sessions/<parentTaskId>/`
- `mcp.json` — MCP config referencing the HTTP endpoint with bearer token.
- `system-prompt.md` — planning-mode system prompt (append, not replace).
- `initial-prompt.txt` — first user-visible message (title + description + short instructions).
Cleanup:
- `Discard` → remove directory.
- `Finalize` → keep directory (for audit; prune on app start if older than N days, optional).
### 5.3 `mcp.json` format
```json
{
"mcpServers": {
"claudedo": {
"type": "http",
"url": "http://127.0.0.1:47821/mcp",
"headers": { "Authorization": "Bearer <PlanningSessionToken>" }
}
}
}
```
### 5.4 Claude CLI invocation (new session)
```
wt.exe -d "<list.WorkingDir>" cmd /k ^
claude ^
--model claude-sonnet-4-6 ^
--append-system-prompt "<contents of system-prompt.md>" ^
--mcp-config "<mcp.json absolute path>" ^
--allowedTools "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill" ^
"<contents of initial-prompt.txt>"
```
### 5.5 Claude CLI invocation (resume)
```
wt.exe -d "<list.WorkingDir>" cmd /k ^
claude --resume <PlanningSessionId> --mcp-config "<mcp.json>"
```
Resume inherits model, system prompt, and allowed tools from the original session.
### 5.6 System prompt (draft, refined in Plan B)
> You are in a ClaudeDo planning session for a task. Your job is to brainstorm with the user, then break their rough intent into concrete, independently-executable child-tasks. Each child-task should be something a single automated agent can pick up and complete autonomously. Use the `mcp__claudedo__*` tools to create/update/delete drafts in real time. You may read the repository for context (Read/Grep/Glob) but must not modify any files. When the user is satisfied, call `finalize`. Skills you may find useful: `superpowers:writing-plans`, `superpowers:writing-clearly-and-concisely`.
### 5.7 Initial prompt (template)
```
<Parent task title>
<Parent task description, if any>
---
We're planning this task together. Brainstorm with me, then create concrete child-tasks via the MCP tools. I'll call `finalize` when we're done.
```
### 5.8 Unknowns to resolve during Plan B implementation
These are left **open** in this spec; they'll be pinned down during implementation via `mcp__plugin_context7_context7__query-docs` for the Claude Code CLI:
1. Exact flag for thinking budget (`--thinking-budget medium`? model suffix `claude-sonnet-4-6-thinking`? something else?).
2. Exact casing of tool names in `--allowedTools` (`Read`/`read`, `WebFetch`/`web_fetch`, `Skill`).
3. Whether `--append-system-prompt` accepts a file reference (`@path`) or requires inline string.
4. Whether Claude CLI supports a `--session-id` flag for pre-assigning the session ID, or whether we must read it back from `~/.claude/projects/<hash>/sessions/` after the process starts.
If (4) resolves to "read back", strategy:
- Poll `~/.claude/projects/<hash>/sessions/` directory modtimes shortly after launch; newest session file after launch timestamp is ours.
- Cache the result on the parent task via `UpdatePlanningSessionIdAsync`.
- If session ID can't be captured, Resume falls back to `claude --continue` (last session in that project).
### 5.9 Pre-flight checks
On `LaunchAsync`:
- `wt.exe` resolvable in PATH → else throw `PlanningLaunchException("Windows Terminal not found")`, UI shows install hint.
- `claude` resolvable in PATH → else `PlanningLaunchException("Claude CLI not installed")`.
- `list.WorkingDir` exists → else `PlanningLaunchException("Working directory not found: <path>")`.
---
## 6. UI Changes
### 6.1 Context menu (`TaskRowView.axaml`)
New entries, conditional on status:
- `Manual` + `ParentTaskId IS NULL`**"Open planning Session"**
- `Planning`**"Resume planning Session"** and **"Discard planning session"**
- `Planned` / `Done` / `Failed` (parent) → no planning-related entries (7c: no re-planning).
- Children (`ParentTaskId IS NOT NULL`) → never show planning entries.
### 6.2 Hierarchy rendering (`TasksIslandView.axaml`)
Approach: **flat stream with indentation**, not a `TreeView`.
- `TasksIslandViewModel` builds `OpenItems`/`CompletedItems`/etc. as flat `ObservableCollection<TaskRowViewModel>` with parents followed by their children if expanded.
- `TaskRowViewModel` gets `IsChild: bool` and `IsPlanningParent: bool` and `IsExpanded: bool`.
- `TaskRowView` indents 24px when `IsChild`, shows a thin left border in `TextFaintBrush`.
- Parents with `IsPlanningParent` render a chevron (▸/▾) that toggles `IsExpanded`; collapsed parents hide their children from the flat stream.
- Expanded-state map kept in the VM (`Dictionary<string, bool>`, default `true`).
### 6.3 Draft and planning styling (`TaskRowView`)
- `Status = Draft` → row italic, 70% opacity, small left-aligned badge "DRAFT".
- Parent `Status = Planning` → badge "PLANNING" (accent: warning-amber).
- Parent `Status = Planned` → badge "PLANNED" (accent: neutral-blue).
### 6.4 Unfinished-session dialog
Trigger: on app start **and** on any context-menu click against a `Planning` parent.
Modal (built with existing `TaskCompletionSource<T>` dialog pattern):
```
Unfinished planning session
"<Parent title>"
<N> draft tasks waiting to be finalized.
[Resume] [Finalize now] [Discard]
```
- Resume → `ResumePlanningSessionAsync`, opens terminal with `--resume`.
- Finalize now → `FinalizePlanningSessionAsync` (server-side, no terminal). Useful when the user is confident drafts are good.
- Discard → `DiscardPlanningSessionAsync`.
### 6.5 TasksIslandViewModel commands
- `[RelayCommand] OpenPlanningSessionAsync(TaskRowViewModel? row)`
- `[RelayCommand] ResumePlanningSessionAsync(TaskRowViewModel? row)`
- `[RelayCommand] DiscardPlanningSessionAsync(TaskRowViewModel? row)`
- `[RelayCommand] FinalizePlanningSessionAsync(TaskRowViewModel? row)`
- `[RelayCommand] ToggleExpand(TaskRowViewModel parentRow)`
### 6.6 WorkerClient additions (`ClaudeDo.Ui/Services/WorkerClient.cs`)
- `Task<PlanningSessionLaunchInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, InitialPromptPath, SystemPromptPath }`.
- `Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct)` — returns `{ WorkingDir, McpConfigPath, ClaudeSessionId }`.
- `Task<int> FinalizePlanningSessionAsync(string taskId, CancellationToken ct)` — returns finalized count.
- `Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct)`.
- `Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)` — for the unfinished-session dialog.
Existing `TaskUpdated` event covers live draft updates; no new event needed.
### 6.7 Delete handling
When the user tries to delete a parent with children:
- Repository throws `DbUpdateException` (FK Restrict).
- UI catches, shows: "This task has N child tasks. Discard drafts and delete? / Delete all including children? / Cancel."
- "Delete all including children" → UI iterates children and deletes them first, then the parent.
- "Discard drafts" option only appears if parent status is `Planning` (drafts exist to discard).
---
## 7. Lifecycle & Error Handling
### 7.1 Worker queue isolation
`GetNextQueuedAgentTaskAsync` filters on `Status = Queued` — Planning/Planned/Draft are excluded by status. Add explicit regression test to lock this in.
### 7.2 Parent auto-completion (repeat of 2, for implementation reference)
After `MarkDoneAsync`/`MarkFailedAsync`:
```csharp
if (task.ParentTaskId is not null)
await TryCompleteParentAsync(task.ParentTaskId, ct);
```
where `TryCompleteParentAsync` loads children, checks terminal status, sets parent accordingly.
### 7.3 Session-start errors
Table in §5.9 Pre-flight checks. UI receives typed exceptions, shows appropriate dialog.
### 7.4 Session-runtime errors
- Terminal crashes → drafts + token persist. Resume via dialog (§6.4).
- Worker restart → drafts + token persist. Resume rebuilds HTTP connection.
- MCP call fails transiently → Claude CLI retries or the model reports the error to the user in terminal; drafts remain in whatever state the last successful call left them.
- No session timeout — brainstorming may be long.
### 7.5 Concurrency
- Different parents → independent sessions, one token per parent.
- Same parent launched twice → `StartPlanningSessionAsync` throws; UI says "Already planning; use Resume".
- Cleanup on app exit: nothing — planning state is fully persisted in DB and files.
---
## 8. Testing
### 8.1 Automated (in `ClaudeDo.Worker.Tests`)
**Schema & repository:**
- Migration applies cleanly on fresh DB.
- `GetChildrenAsync` returns only direct children, sorted.
- `CreateChildAsync` sets Status=Draft, ParentTaskId correctly.
- `FinalizePlanningAsync` transactionally transitions drafts to Manual/Queued, sets parent to Planned, sets timestamp, clears token. On simulated DB error, rolls back fully.
- `DiscardPlanningAsync` removes drafts, resets parent.
- `GetNextQueuedAgentTaskAsync` ignores Drafts, Planning parents, Planned parents.
- `Restrict` cascade: delete parent with children throws `DbUpdateException`.
**Auto-status hook (§7.2):**
- All children Done → parent Done.
- Mix: some Done, at least one Failed, rest in terminal state → parent Failed.
- Mix with one still Running → parent stays Planned.
- Parent stays Planned while any Draft exists (defensive — finalize should have cleared them).
**MCP handlers (against SQLite + in-process HTTP):**
- Valid token → tool executes.
- Missing/invalid token → 401.
- `create_child_task` → creates Draft, emits TaskUpdated event.
- `update_child_task` on non-Draft → MCP error.
- `delete_child_task` on non-Draft → MCP error.
- `finalize` called twice: first succeeds, second errors because token is invalidated.
- Cross-parent access: tool with `task_id` belonging to another parent's session → MCP error.
**SignalR endpoints (integration with Worker host):**
- Start → token generated, session directory + files created, `mcp.json` contains token.
- Start on already-`Planning` parent → error.
- Resume → no new token, reads `PlanningSessionId` from DB.
- Discard → drafts gone, directory removed, token NULL, parent back to Manual.
### 8.2 Manual (added to `docs/open.md` checklist)
- Windows Terminal spawn with real `wt.exe`.
- Real Claude CLI end-to-end session (requires `ANTHROPIC_API_KEY`).
- Avalonia hierarchy rendering (chevron, indentation, draft styling, badges).
- Session-ID capture from `~/.claude/projects/...` (timing-sensitive, platform-specific).
---
## 9. Phasing
Work is delivered in **three sequential-then-parallel** plans. Plan A must merge before B and C can merge.
### 9.1 Plan A — Foundation
Schema migration, enum additions, repository methods, auto-status hook, delete-Restrict, regression test for queue filter. No UI-visible changes (other than delete-with-children now failing with a generic error until Plan C handles it).
Scope files (approximate):
- `src/ClaudeDo.Data/Models/TaskEntity.cs`
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
- `src/ClaudeDo.Data/Migrations/<new>_AddPlanningSupport.cs`
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (+ `ITaskRepository`)
- `src/ClaudeDo.Worker/...` auto-status hook call-site updates.
- `tests/ClaudeDo.Worker.Tests/...` new test classes.
### 9.2 Plan B — Worker MCP + SignalR + Launcher (starts after A merges)
MCP service with HTTP transport, token auth, six tools. New SignalR hub endpoints for Start/Resume/Discard/Finalize/GetPendingDraftCount. Session directory management. `IPlanningTerminalLauncher` implementation for `wt.exe`. Resolves the four unknowns from §5.8.
Scope files (approximate):
- `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs` (new)
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (new)
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (extend)
- `src/ClaudeDo.Worker/Program.cs` (DI + endpoint mapping)
- `src/ClaudeDo.App/` or `src/ClaudeDo.Ui/Services/``IPlanningTerminalLauncher` + `WindowsTerminalPlanningLauncher`.
- `tests/ClaudeDo.Worker.Tests/Planning/...`
### 9.3 Plan C — UI (parallel to B after A merges)
Context menu entries, hierarchy rendering, draft styling, unfinished-session dialog, WorkerClient extensions, delete-with-children handling. During parallel development, mocks the WorkerClient against Plan B's interface contract.
Scope files (approximate):
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (draft/badge styling)
- `src/ClaudeDo.Ui/Views/Dialogs/UnfinishedPlanningDialog.axaml` (new)
### 9.4 Integration points between B and C
Interface contract locked before parallel work begins:
- SignalR method names, parameters, return DTOs (listed in §6.6).
- `TaskUpdated` event payload unchanged; carries the task's new parent-id and status so the UI can re-bucket.
- Session directory path shape: `~/.todo-app/planning-sessions/<parentTaskId>/`.
- `mcp.json` and session-file formats are internal to Plan B; UI never reads them.
---
## 10. Out of scope (for now)
- Nested planning (children of children). Explicitly one level.
- Cross-list planning (parent in list A, children in list B).
- Multi-user collaboration on the same planning session.
- Session timeouts / auto-discard.
- Planning-session history / audit UI. Directory is kept on finalize but not surfaced.
- Re-planning a finalized parent (7c: no).

View File

@@ -0,0 +1,230 @@
# Self-Update for App and Installer — Design
**Date:** 2026-04-23
**Status:** Approved
## Goals
Give ClaudeDo two update paths:
- **A — App-side update check:** the Avalonia UI checks Gitea for a newer release on startup (and via a manual menu action) and surfaces a dismissible banner. Clicking **Update now** launches the locally installed installer in Update mode and closes the UI.
- **B — Installer self-update:** the WPF installer checks for a newer installer binary on launch and offers to replace itself before continuing. After replacement, it proceeds with its normal wizard.
Non-goals:
- No silent/background auto-apply of updates. The user always initiates the final update action.
- No periodic in-app polling (startup + manual only).
- No changes to the release pipeline — release assets (`ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`) stay as they are.
## Architecture Overview
A new shared library, `ClaudeDo.Releases`, hosts the release-API client, version comparison, checksum verification, and installer self-update logic. Both `ClaudeDo.Installer` and `ClaudeDo.Ui` reference it. This removes the existing duplication between the installer's release plumbing and what the app needs, and keeps a single asset-matching / version-parsing code path.
```
ClaudeDo.Releases (new, netstandard2.0 or net8.0)
├── ReleaseClient.cs (moved from Installer/Core)
├── IReleaseClient.cs (moved)
├── ChecksumVerifier.cs (moved)
├── VersionComparer.cs (new)
└── SelfUpdater.cs (new — installer self-update mechanism)
ClaudeDo.Installer (WPF, consumes ClaudeDo.Releases)
├── App.xaml.cs (modified — SelfUpdater + --replace-self arg)
└── Core/InstallModeDetector.cs (modified — now uses VersionComparer)
ClaudeDo.Ui (Avalonia, consumes ClaudeDo.Releases)
├── Services/UpdateCheckService.cs (new)
├── Services/InstallerLocator.cs (new)
├── ViewModels/MainViewModel.cs (modified — banner + Help menu state)
└── Views/MainWindow.axaml (modified — banner + Help dropdown)
```
## Part A — App-Side Update Check
### `ClaudeDo.Ui/Services/UpdateCheckService.cs`
Responsibilities:
- Read the current app version from `Assembly.GetExecutingAssembly().GetName().Version`.
- Call `IReleaseClient.GetLatestReleaseAsync` to fetch the Gitea release.
- Use `VersionComparer` to decide whether the latest is newer.
- Expose observable properties: `IsUpdateAvailable`, `LatestVersion`, `CurrentVersion`, `IsChecking`, `LastCheckStatus` (`UpToDate | UpdateAvailable | CheckFailed | NeverChecked`).
- Expose a `CheckNowAsync` method for the manual Help menu action.
Lifecycle:
- Registered as a singleton in DI.
- Startup check is fired from `MainViewModel` once the main window is shown. It runs fire-and-forget on a background `Task`; UI never blocks on it.
- Manual check is awaited by its command and briefly shows a status message (see UI).
Error handling:
- Network / API errors → log to `~/.todo-app/logs/`, set `LastCheckStatus = CheckFailed`, do not surface a banner. Manual check shows a small inline status only.
### `ClaudeDo.Ui/Services/InstallerLocator.cs`
Responsibilities:
- Resolve the path to the installed `ClaudeDo.Installer.exe` so the UI can launch it.
Discovery strategy (first hit wins):
1. Walk up from `AppContext.BaseDirectory` looking for a sibling `install.json`. The installer is at `{installDir}/uninstaller/ClaudeDo.Installer.exe`.
2. Fall back to reading `HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo\InstallLocation` (written by the existing `WriteUninstallRegistryStep`).
If neither yields a valid path, the banner's **Update now** button is disabled with a tooltip explaining the installer could not be located.
### UI changes
**Banner** at the top of `MainView` (above content, below custom chrome):
- Visible when `UpdateCheckService.IsUpdateAvailable == true` and the user has not dismissed this session.
- Text: `Update available: v{CurrentVersion} → v{LatestVersion}`.
- Actions: `Update now` (primary), `Dismiss` (sets a transient `IsBannerDismissed` flag that resets on app restart — intentionally not persisted so the banner returns next launch if still relevant).
- Styled to match existing chrome/accent conventions (compact, dismissible, non-modal).
**Help dropdown** in the custom titlebar:
- New menu: `Help`.
- First item: `Check for updates` → binds to `MainViewModel.CheckForUpdatesCommand`, which calls `UpdateCheckService.CheckNowAsync`.
- When a check completes:
- `UpdateAvailable` → banner appears (no separate dialog).
- `UpToDate` → a brief inline status in the banner area: `You're up to date (v{CurrentVersion})`, auto-hides after ~3 seconds.
- `CheckFailed``Could not check for updates` inline message, auto-hides after ~3 seconds.
- Leaves room for future items (`About`, `Documentation`, etc.).
### Update action flow
1. User clicks **Update now** in the banner.
2. `MainViewModel` resolves the installer path via `InstallerLocator`.
3. UI spawns `ClaudeDo.Installer.exe` with no arguments. The installer's existing `InstallModeDetector` reads `install.json` alongside it, hits Gitea, and enters `Update` mode.
4. UI closes itself immediately after spawning the process.
5. The installer performs its standard update flow: `StopServiceStep``DownloadAndExtractStep``StartServiceStep`.
6. When the user clicks Finish the installer exits. The user re-launches the app via their existing shortcut.
No IPC between UI and installer is needed — the installer is already self-sufficient once launched against an existing install directory.
## Part B — Installer Self-Update
### `ClaudeDo.Releases/SelfUpdater.cs`
Runs from `ClaudeDo.Installer/App.xaml.cs` before any window is shown.
Flow:
1. **Handle `--replace-self` argument first.** If the installer was launched with `--replace-self "<old-path>"`:
- Wait up to 5 seconds for the old process to exit (poll for file lock release).
- Delete `<old-path>`.
- Copy own exe to `<old-path>`.
- Start a new process at `<old-path>` with no args, then exit the current (temp) process. This ensures the user's shortcut or Apps & Features entry now points at the updated binary.
- If any step fails, fall through to the normal wizard (the user still has a working installer, just in a temp location).
2. **Check for a newer installer.** If no `--replace-self` arg:
- Parse own assembly version.
- Fetch latest release.
- Find the installer asset matching `ClaudeDo.Installer-<version>.exe` (regex: `^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$`).
- Compare via `VersionComparer`.
- If not newer, or if check fails, proceed to the normal wizard (existing Config-mode fallback behavior).
3. **Prompt if newer.** Show a small modal dialog (plain WPF `Window`, reusing the installer's titlebar/accent styles):
> *"A newer installer is available: v{latest}. Update before continuing?"*
> `[Update] [Continue anyway] [Cancel]`
- **Cancel** → `Application.Current.Shutdown(0)`.
- **Continue anyway** → proceed to normal wizard.
- **Update** → run relaunch sequence.
4. **Relaunch sequence:**
- Download to `%TEMP%\ClaudeDo.Installer-<version>.exe` (show a minimal inline progress UI; no separate window).
- Verify against `checksums.txt` from the release via `ChecksumVerifier`. On failure, show error with `[Continue with current installer]` action → proceed to wizard.
- `Process.Start` new exe with args `--replace-self "<current-exe-path>"`.
- Exit current process.
### Why `--replace-self` rather than a shell script
A child process holding a handle to the new exe is reliable cross-Windows-version. Relying on a `.bat` or `cmd /c` helper leaves a file that we would need to clean up, and behaves badly when the installer was launched from a mounted share or non-ASCII path. The `--replace-self` approach keeps everything in managed code and uses a single exe throughout.
### Edge case: running from `uninstaller/` copy
When the installer runs from `{installDir}/uninstaller/ClaudeDo.Installer.exe` (via the app's **Update now** or from Apps & Features), the self-update flow is identical. It is desirable for the uninstaller copy to be kept current — stale uninstaller binaries would otherwise drift behind and could have bugs the new app release expects fixed.
## Version Comparison (`VersionComparer`)
Centralizes the logic currently in `InstallModeDetector.IsNewer`:
- Parses both versions as `System.Version` after trimming a leading `v` / `V`.
- Returns `(bool isNewer, bool unparseable)`.
- Unparseable (e.g. `0.2.0-beta`) → treated as not newer; callers can surface a hint if desired.
Both `InstallModeDetector` (existing behavior) and `SelfUpdater` / `UpdateCheckService` (new callers) share this logic.
## Error Handling Summary
| Scenario | App behavior | Installer behavior |
|---|---|---|
| Gitea unreachable | Silent; log to file; no banner | Silent; skip self-update; proceed to wizard |
| JSON parse error | Same as unreachable | Same as unreachable |
| Version unparseable | No banner; log a hint | No prompt; proceed |
| Installer exe not found on disk | `Update now` button disabled with tooltip | N/A |
| Download fails | N/A (app delegates to installer) | Error dialog with `[Continue with current installer]` |
| Checksum mismatch | N/A | Error dialog with `[Continue with current installer]` |
| Relaunch fails | N/A | Error dialog; user keeps temp exe and current exe both |
## Testing
**New test project: `tests/ClaudeDo.Releases.Tests`**
- `ReleaseClientTests` — move existing installer tests covering `GetLatestReleaseAsync` and `DownloadAsync`.
- `VersionComparerTests` — boundary cases (equal, newer, older, unparseable, mixed `v`-prefix).
- `SelfUpdaterTests`:
- Asset-name regex correctly isolates version from `ClaudeDo.Installer-0.3.0.exe` and ignores `ClaudeDo-0.3.0-win-x64.zip`.
- Decision logic given mocked `IReleaseClient` responses.
- `--replace-self` handler: given a temp dummy file, the handler waits, deletes, copies — verified with a mock filesystem / temp dir.
**Existing project: `tests/ClaudeDo.Installer.Tests`**
- `SelfUpdateIntegrationTest`: build the installer, invoke it with `--replace-self <dummy>` pointing at a temp file, assert the dummy is replaced by a copy of the test installer, and the process exits cleanly. Run only on Windows CI.
**App tests (`tests/ClaudeDo.Ui.Tests` — add if absent):**
- `UpdateCheckServiceTests` — stubbed `IReleaseClient`, assert state transitions for each status.
- `InstallerLocatorTests` — fake filesystem, verify walk-up and registry-fallback discovery.
**Manual verification** (add to `docs/open.md`):
1. Build `v0.2.x` installer and upload to a test Gitea release.
2. Tag `v0.3.0` with new installer asset.
3. Install `v0.2.x`, run the `v0.2.x` installer again — confirm self-update prompt appears and replaces the binary in place.
4. With `v0.2.x` installed and a v0.3.0 release published, launch the app — confirm banner appears, **Update now** launches installer, update completes, app relaunches at v0.3.0.
5. Pull the network during check in both places — confirm silent fallback, no user-visible errors.
## Files Summary
**New:**
- `src/ClaudeDo.Releases/ClaudeDo.Releases.csproj`
- `src/ClaudeDo.Releases/ReleaseClient.cs` (moved)
- `src/ClaudeDo.Releases/IReleaseClient.cs` (moved)
- `src/ClaudeDo.Releases/ChecksumVerifier.cs` (moved)
- `src/ClaudeDo.Releases/VersionComparer.cs` (new)
- `src/ClaudeDo.Releases/SelfUpdater.cs` (new)
- `src/ClaudeDo.Ui/Services/UpdateCheckService.cs`
- `src/ClaudeDo.Ui/Services/InstallerLocator.cs`
- `tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj`
**Modified:**
- `src/ClaudeDo.Installer/App.xaml.cs` — self-update run + `--replace-self` handling before wizard.
- `src/ClaudeDo.Installer/Core/InstallModeDetector.cs` — use shared `VersionComparer`; drop now-moved types.
- `src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — reference `ClaudeDo.Releases`.
- `src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` — reference `ClaudeDo.Releases`.
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — banner + Help menu dropdown.
- `src/ClaudeDo.Ui/ViewModels/MainViewModel.cs` — banner state, `CheckForUpdatesCommand`, wiring to `UpdateCheckService`.
- `src/ClaudeDo.App/Program.cs` (or existing DI composition root) — register `UpdateCheckService`, `InstallerLocator`, `IReleaseClient`, `HttpClient`.
- `ClaudeDo.slnx` — add new projects.
- `docs/open.md` — add manual verification checklist.
## Open Decisions Deferred to Implementation
- Exact Avalonia styling/layout of the banner is left to implementation to match the existing chrome polish pass from commit `3c420ac`.
- The Help dropdown control type (Avalonia `MenuItem` inside a `Menu`, or a custom flyout) is chosen during implementation based on what fits the current custom titlebar.

View File

@@ -0,0 +1,121 @@
# Worker Log Footer — Design
Date: 2026-04-23
## Goal
Surface important Worker lifecycle events (worktree created, Claude started, merged, etc.) in the UI footer as a single rotating, color-coded line. Gives the user ambient awareness of what the Worker just did without opening task details.
## Non-Goals
- No log history, drawer, or scrollback
- No filtering or user-configurable verbosity
- No persistence across UI restarts
- No replay of events missed while UI was disconnected
## UX
Footer (`MainWindow.axaml`, row 2) layout changes from `StackPanel` to `DockPanel`:
- **Docked left:** existing connection pill (ellipse + `ONLINE/OFFLINE/RECONNECTING` text). The static `· WORKER` label is removed; the rotating log line replaces its purpose.
- **Docked right:** rotating worker-log line.
Line format: `14:32 · <message>`, rendered in the mono font at size 10 (matches existing footer typography). `TextTrimming="CharacterEllipsis"` so long task titles don't push out the connection pill.
The line is hidden when no event has been received within the last 30 seconds. Each new event replaces the current text and resets the 30-second timer. Timestamp is local time, `HH:mm`.
### Color mapping
Level is rendered via a `WorkerLogLevelToBrushConverter` (mirrors existing `StatusColorConverter` pattern):
| Level | Brush / color | Events |
|-----------|------------------------|-----------------------------------------------------|
| `Info` | `TextDimBrush` (dim) | Created worktree, Started Claude, Committed changes |
| `Success` | `#4CAF50` green | Merged, Finished (done) |
| `Warn` | `#FFA726` amber | Discarded worktree, Reset |
| `Error` | `#EF5350` red | Finished (failed) |
## Event Catalog
Seven emit sites. Each is added alongside the existing `_logger.LogInformation(...)` call — no log-sink plumbing, no central event bus.
| Site | Level | Message |
|-------------------------------------------|-----------|-----------------------------------------|
| `WorktreeManager.CreateAsync` | `Info` | `Created worktree for "<title>"` |
| `WorktreeManager.DiscardAsync` | `Warn` | `Discarded worktree for "<title>"` |
| `TaskMergeService.MergeAsync` | `Success` | `Merged "<title>" into <target>` |
| `TaskResetService.ResetAsync` | `Warn` | `Reset "<title>"` |
| `TaskRunner` — Claude launch | `Info` | `Started Claude for "<title>"` |
| `TaskRunner` — auto-commit | `Info` | `Committed changes in "<title>"` |
| `TaskRunner` — task finished | `Success` / `Error` | `Finished "<title>" (<status>)` |
`<title>` is the task's display title; `<target>` is the merge target branch; `<status>` is `done` or `failed`.
## Architecture
### Shared contract (`ClaudeDo.Data`)
New enum:
```csharp
namespace ClaudeDo.Data.Models;
public enum WorkerLogLevel
{
Info,
Success,
Warn,
Error,
}
```
SignalR is configured to serialize enums as strings via `JsonStringEnumConverter` (added to the hub's JSON options in `Program.cs`). The UI client deserializes back to the same enum.
### Server side (`ClaudeDo.Worker`)
`HubBroadcaster` gets a new method:
```csharp
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
```
`HubBroadcaster` is already injected into `TaskRunner`. For `WorktreeManager`, `TaskMergeService`, and `TaskResetService`, add constructor injection where it isn't already present. Each emit site calls `_broadcaster.WorkerLog(...)` with `DateTime.UtcNow` next to the existing `_logger.LogInformation(...)`.
### Client side (`ClaudeDo.Ui`)
**`WorkerClient`** — register a `HubConnection.On<string, WorkerLogLevel, DateTime>("WorkerLog", ...)` handler and expose a `WorkerLogReceived` event with a small `WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc)` record.
**Footer VM**`StatusBarViewModel` already exists; extend it (or introduce a small `FooterViewModel` if `StatusBarViewModel` turns out to be scoped elsewhere — confirm during implementation). Add:
- `[ObservableProperty] string? currentEventText`
- `[ObservableProperty] WorkerLogLevel currentEventLevel`
- `[ObservableProperty] bool isEventVisible`
- A `DispatcherTimer` with a 30-second interval. On each `WorkerLogReceived`:
1. Format `HH:mm · <message>` from the event's local time.
2. Set `CurrentEventText`, `CurrentEventLevel`, `IsEventVisible = true`.
3. Stop and restart the timer.
- On timer tick: `IsEventVisible = false`, `CurrentEventText = null`.
**XAML**`MainWindow.axaml` footer `StackPanel` becomes a `DockPanel`. Existing ellipses + connection text dock left in a horizontal `StackPanel`. A new `TextBlock` docks right, bound to `CurrentEventText` with `Foreground="{Binding CurrentEventLevel, Converter={StaticResource WorkerLogLevelToBrush}}"`, `IsVisible="{Binding IsEventVisible}"`, and `TextTrimming="CharacterEllipsis"`. Same mono font / size 10 as the rest of the footer.
**Converter**`WorkerLogLevelToBrushConverter` in `Converters/` returns a brush per enum value, resolving theme brushes via `Application.Current.Resources` for `Info` (to honor theme swaps) and hard-coding the success/warn/error hex values (those are already hard-coded in the current footer).
## Testing
- Unit test `WorkerLogLevelToBrushConverter` with each enum value.
- Unit test the footer VM: receiving an event sets text/level/visibility and schedules a clear; a second event within 30s replaces and resets the timer; after 30s of silence the line hides.
- Manual smoke: run a task end-to-end and confirm each of the seven events surfaces with the expected color and copy.
## Edge Cases
- **UI disconnected during an event:** event is lost. Acceptable — reconnect resumes receiving new events.
- **Burst of events:** each replaces the previous; only the most recent is shown.
- **Long task title:** ellipsized by the TextBlock; connection pill on the left stays fully visible.
- **Clock skew between Worker and UI:** timestamp is formatted in UI's local time from the wire-format `DateTime` (sent as UTC). Minor skew is cosmetic; no correctness impact.
## Out of Scope / Future
- Click-to-expand history drawer
- Per-list or per-task event filtering
- Persisting the most recent N events across restarts

View File

@@ -0,0 +1,197 @@
# Planning Merge-All & Subtask Visibility — Design
**Date:** 2026-04-24
**Status:** Approved design, ready for implementation planning
## Problem
Three concrete issues with the current Planning feature:
1. **Queued subtasks are not visible in the Queue List.** When a planning session finalizes, its subtasks transition to `Queued`, but the Queue List's hierarchy rules only show children when their Planning parent is expanded. A collapsed (or already-`Planned`) parent effectively hides the subtasks.
2. **Completed subtasks vanish from view.** Once a subtask becomes `Done`, the regroup logic moves it to the "Completed" bucket. Users expect subtasks to remain visible under their Planning parent until the Planning task itself is marked Done.
3. **No aggregated view or bulk merge.** Each subtask must be merged individually through its worktree. There is no way to see a combined diff of all changes produced by a Planning session, and no "merge everything" action.
## Goals
- Treat Planning subtasks as belonging to their Planning parent for visibility and lifecycle purposes.
- Provide a single aggregated diff view that shows all changes produced by a Planning session.
- Provide a single "Merge all" action that sequentially merges all subtasks, with a usable conflict-resolution flow.
- Auto-complete the Planning task when all merges succeed.
## Non-goals
- Building a full-featured in-app diff editor. Textual unified diff is acceptable for now; conflict *editing* happens in VS Code.
- Persisting Merge-all progress across worker restarts. Restart clears in-memory orchestration state; user re-starts Merge-all (already-merged subtasks are skipped because their worktrees are `Merged`).
- Modifying how individual subtasks are created, executed, or finalized.
## Design
### 1. Visibility model
Planning subtasks are exclusively children of their Planning parent until the Planning task transitions to `Done`. The Planning parent acts as a roll-up in the Queue List.
- Tasks with a non-null `ParentTaskId` are excluded from all virtual lists (`virtual:queued`, `virtual:running`, `CompletedItems`, etc.) as separate rows.
- A Planning/Planned task is included in `virtual:queued` if **any** child is `Queued`, and in `virtual:running` if any child is `Running`.
- Children are always attached under their parent in the task tree; expansion purely controls visual collapse.
- When Merge-all completes successfully, the Planning task is set to `Done` and the entire subtree moves to Completed together.
- Status badge on the Planning row summarizes children (e.g., `3/5 queued`, `2 running`, `1 failed`).
### 2. Planning detail panel
Extends the existing task detail view. New elements when the selected task is a Planning task:
- **Subtasks list.** Grouped by status badge (Queued / Running / Done / Failed). Each row preserves existing per-subtask actions (view logs, open worktree, individual merge).
- **Merge target dropdown.** Single target branch that applies to all subtasks in Merge-all. Defaults to the branch that was current when the Planning session started.
- **`[Review combined diff]` button.** Opens the Aggregated Diff Viewer. Enabled as soon as any subtask has produced a diff.
- **`[Merge all subtasks]` button.** Orchestrates sequential merge + auto-Done. Disabled until every subtask is `Done` and every worktree is `Active` or `Merged` (no `Discarded` / `Kept`). Tooltip explains why when disabled (e.g., "2 subtasks still running", "1 subtask failed — resolve first", "1 worktree was discarded").
- Existing per-subtask merge action remains available; Merge-all is additive.
### 3. Aggregated diff viewer
New Avalonia view `PlanningDiffView` + `PlanningDiffViewModel`, opened as a modal or dedicated tab.
**Default — grouped by subtask:**
- Left pane: subtask list in creation order with `title • +added deleted • N files`.
- Right pane: selected subtask's diff. Reuse any existing diff-rendering control; if none exists, render unified diff text with basic syntax coloring (monospace, minimal decoration).
- Summary stats come from `WorktreeEntity.DiffStat`. Raw diff comes from `git diff <base>..<head>` executed in each subtask's worktree via `GitService`. Cached in memory per subtask until the subtask's HEAD moves.
**Toggle — "Preview combined diff":**
- Calls `PlanningAggregator.BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)`:
1. Create/reset branch `planning/<slug>-integration` off the current merge target.
2. Merge each subtask's branch sequentially with `--no-ff`.
3. On conflict during preview: abort the merge, reset the integration branch, surface a warning identifying which two subtasks conflict. Grouped view remains available.
4. On success: compute `git diff <merge-target>..planning/<slug>-integration` and render as a single flat unified diff.
- Toggle flips back to grouped mode.
**Integration-branch lifecycle:** scratch artifact, rebuilt on every preview (deleted + recreated). Cleaned up when the Planning task is marked `Done` or when the Planning session is discarded.
### 4. Merge-all orchestration
**Happy path (`PlanningMergeOrchestrator.StartAsync`):**
1. Pre-flight checks — fail fast with a clear message on any:
- Every subtask is `Done`.
- Every subtask's worktree is `Active` or `Merged` (no `Discarded` / `Kept`). `Merged` worktrees are allowed so that an interrupted Merge-all can be restarted.
- Repo working tree is clean.
- No mid-merge in progress in the target repo.
2. For each subtask in creation order, skip if its worktree is already `Merged` (idempotent restart). Otherwise call `TaskMergeService.MergeAsync` with `removeWorktree: true` and `leaveConflictsInTree: true`. Each success flips the worktree to `Merged`.
3. After the last successful merge:
- Set Planning task `Status = Done`.
- Call `PlanningAggregator.CleanupIntegrationBranchAsync` if the integration branch exists.
- Emit `PlanningCompleted` so the UI removes the row from the Queue List.
**Conflict path:**
1. `MergeAsync` with `leaveConflictsInTree: true` reports a conflict, leaves the repo in a mid-merge state, and returns the conflicted file paths (`git diff --name-only --diff-filter=U`).
2. Orchestrator halts the loop, stores the in-progress state (remaining subtasks, target branch, current subtask id) in memory, and emits `PlanningMergeConflict(planningTaskId, subtaskId, conflictedFiles)`.
3. The UI opens the **Conflict Resolution dialog** — see §5.
4. On `ContinueAsync`: calls `TaskMergeService.ContinueMergeAsync(subtaskId)` which stages the recorded files and runs `git commit --no-edit`. Flips worktree to `Merged`. Loop resumes with remaining subtasks.
5. On `AbortAsync`: calls `TaskMergeService.AbortMergeAsync(subtaskId)` which runs `git merge --abort`. Planning stays in `Planned`. Already-merged earlier subtasks remain `Merged`. Orchestration state cleared.
**Idempotent restart:** if the worker restarts mid Merge-all, in-memory state is lost. A fresh `StartAsync` re-runs pre-flight; already-`Merged` worktrees are skipped by the loop (their status gates them out). User experience: "I clicked Merge all again and it continued from where it left off."
### 5. Conflict Resolution dialog
Avalonia modal (`ConflictResolutionView` + `ConflictResolutionViewModel`).
- **Header:** `Conflicts in subtask: <title> merging into <target-branch>`.
- **File list:** full absolute paths of conflicted files.
- **`[Open all in VS Code]`** — for each file, spawn `code <absolute-path>` via `Process.Start`. If `code` is not on PATH, show an inline error row with the file list so the user can copy paths manually. No popup-on-popup.
- **`[I've resolved — continue]`** — calls `ContinuePlanningMerge(planningTaskId)` hub method, closes dialog. The orchestration loop continues with the remaining subtasks.
- **`[Abort this merge]`** — calls `AbortPlanningMerge(planningTaskId)` hub method, closes dialog. Planning stays `Planned`.
### 6. Data model
**No schema changes.**
- Conflicted files are queried from git on demand (`git diff --name-only --diff-filter=U`) while the merge is in progress.
- Integration branch name is derived from the Planning task slug: `planning/<slug>-integration`.
- Planning completion uses existing `TaskStatus.Done`.
### 7. Services
**New:**
- **`PlanningAggregator`** (`src/ClaudeDo.Worker/Planning/PlanningAggregator.cs`)
- `GetAggregatedDiffAsync(planningTaskId, ct)` — returns per-subtask diff entries.
- `BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)` — creates/resets the integration branch, merges subtasks sequentially, returns `(success, combinedDiff)` or `(failure, firstConflictSubtaskId, conflictedFiles)`. Always leaves the integration branch in a consistent state (aborts + resets on failure).
- `CleanupIntegrationBranchAsync(planningTaskId, ct)` — deletes the integration branch.
- **`PlanningMergeOrchestrator`** (singleton, `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`)
- Owns in-memory state per planning task: `{ remainingSubtasks, targetBranch, currentSubtaskId }`.
- `StartAsync(planningTaskId, targetBranch)`, `ContinueAsync(planningTaskId)`, `AbortAsync(planningTaskId)`.
- Emits SignalR events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`.
**Modified:**
- **`TaskMergeService`**
- `MergeAsync` gets a `leaveConflictsInTree: bool` parameter (default `false`). When `true`, on conflict records conflicted files on the returned result, does **not** call `git merge --abort`.
- New `ContinueMergeAsync(taskId, ct)` — stages the recorded conflicted files and runs `git commit --no-edit`, flips worktree to `Merged`.
- New `AbortMergeAsync(taskId, ct)` — runs `git merge --abort`, restores pre-merge state.
- Existing callers unaffected by the default.
- **`WorkerHub`** — new methods:
- `GetPlanningAggregate(planningTaskId)`
- `BuildPlanningIntegrationBranch(planningTaskId, targetBranch)`
- `MergeAllPlanning(planningTaskId, targetBranch)`
- `ContinuePlanningMerge(planningTaskId)`
- `AbortPlanningMerge(planningTaskId)`
- **`TasksIslandViewModel.Regroup`**
- Exclude tasks with `ParentTaskId != null` from virtual lists.
- Include Planning parents in `virtual:queued` / `virtual:running` based on children's statuses.
- Keep children attached to parent in the tree at all times until Planning is `Done`.
### 8. UI components (new)
- `PlanningDiffView` + `PlanningDiffViewModel` — aggregated diff viewer (§3).
- `ConflictResolutionView` + `ConflictResolutionViewModel` — conflict dialog (§5).
- Planning Detail section inside the existing task detail pane — subtask list + merge target dropdown + two buttons (§2).
## Error handling
- **Pre-flight failures** — surface as inline errors in the Planning detail panel. No merge work attempted.
- **Preview-build conflict** — keep grouped diff available; show a warning banner identifying the conflicting pair of subtasks.
- **Merge-all conflict** — Conflict Resolution dialog (§5). The failed subtask's worktree stays `Active`; prior successes stay `Merged`.
- **VS Code not on PATH** — inline error row in the Conflict dialog with copyable file paths.
- **Worker restart mid-merge** — in-memory state lost; restarting Merge-all is idempotent because merged worktrees are skipped by status gating.
## Testing
Convention: xUnit integration tests with real SQLite and real git (`tests/ClaudeDo.Worker.Tests`).
**`PlanningAggregatorTests`** — real git fixture
- `GetAggregatedDiffAsync` returns one entry per subtask with correct stats.
- `BuildIntegrationBranchAsync` with non-conflicting subtasks — success, branch contains all changes.
- `BuildIntegrationBranchAsync` with conflicting subtasks — failure, branch reset (not mid-merge), correct subtask id and file list reported.
- Rebuild overwrites a stale integration branch.
- `CleanupIntegrationBranchAsync` removes the branch.
**`PlanningMergeOrchestratorTests`** — real git + real DB
- Happy path: all subtasks merge → worktrees `Merged`, Planning `Done`, `PlanningCompleted` emitted.
- Conflict path: first subtask conflicts → repo left in conflict state, `PlanningMergeConflict` emitted with correct file list, worktree stays `Active`, Planning stays `Planned`.
- `ContinueAsync` after conflict: resolution commits, loop proceeds, final state `Done`.
- `AbortAsync` after conflict: `merge --abort` restores clean state, earlier merged subtasks remain `Merged`, Planning stays `Planned`.
- Pre-flight rejection: running subtask, failed subtask, dirty repo — each returns the expected error with no side effects.
- Idempotent restart: partial merge + fresh `StartAsync` — already-`Merged` worktrees skipped.
**`TaskMergeServiceConflictTests`** (extending existing tests)
- `MergeAsync(leaveConflictsInTree: true)` on conflict: no `merge --abort`, returns conflicted files, worktree state unchanged.
- `ContinueMergeAsync`: completes in-progress merge, flips worktree to `Merged`.
- `AbortMergeAsync`: runs `merge --abort`, restores clean state.
**`TasksIslandRegroupTests`** — ViewModel unit tests, no DB
- Queued subtask with a Planning parent is NOT in `virtual:queued` as its own row.
- Planning parent with any Queued child IS in `virtual:queued`.
- Done subtask stays nested under Planning parent until Planning is `Done`.
- After Planning is marked `Done`, parent + children move to Completed together.
**Manual smoke test** (documented in PR description):
- End-to-end planning session in the app: create plan, finalize, let subtasks run.
- Open aggregated diff, toggle Preview combined.
- Merge-all happy path.
- Merge-all conflict path with VS Code dialog open/continue.
- Merge-all conflict path abort.
## Open questions
None at this stage. All decisions from the brainstorming session are captured above.

View File

@@ -0,0 +1,95 @@
# Planning UX Polish + Sequential Subtask Queue
**Status:** design
**Date:** 2026-04-24
**Scope:** three small UX changes + one feature — sequential execution of planning subtasks triggered from the context menu.
## Goals
1. Collapse the children of a finished planning-parent row in the task list by default.
2. Allow the user to collapse the Description section in the Details pane.
3. Halve the width of the GridSplitters between islands.
4. Let the user queue all subtasks of a planning parent so they run one after another, with a new `Waiting` status for pending siblings.
## 1. Auto-collapse done planning parents
**Rule for "done":** a planning parent is "done" when every one of its children has `Status == Done`.
**Changes:**
- `TaskRowViewModel`: add UI-only `[ObservableProperty] bool _areChildrenExpanded`. Default computed from status — `false` when the row is a done planning parent, else `true`. Not persisted.
- Add `[RelayCommand] void ToggleChildrenExpanded()`.
- `TasksIslandView.axaml` (or `TaskRowView.axaml`): chevron button on the planning-parent row, visible only when `IsPlanningParent && HasPlanningChildren`. Bound to the toggle command.
- `TasksIslandViewModel.Regroup()`: before adding child rows to `OpenItems`/`CompletedItems`, check each child's parent row in `Items`. If the parent's `AreChildrenExpanded == false`, skip the child.
- When a planning parent flips from "not done" → "done" in `OnWorkerTaskUpdated`, call `Regroup()` so the collapse takes effect.
No DB changes.
## 2. Collapsible description in Details pane
**Changes:**
- `DetailsIslandViewModel`: `[ObservableProperty] bool _isDescriptionExpanded = true` + `[RelayCommand] void ToggleDescriptionExpanded()`.
- `DetailsIslandView.axaml`: wrap the existing description `TextBox` in a `StackPanel`; add a thin header row with the label "Description" and a chevron button. Body's `IsVisible` binds to the flag.
- State is per ViewModel instance — reset to `true` whenever a different task is loaded.
No persistence.
## 3. Narrower GridSplitters
`MainWindow.axaml` lines 158 and 170: `Width="5"``Width="3"` on both `GridSplitter` elements.
That's the whole change.
## 4. Sequential subtask queue
### Data
- `ClaudeDo.Data/Models/TaskStatus.cs`: add a new enum value `Waiting` (lowercase serialized form `waiting`, matching existing convention).
- Verify status is stored as string (it should be based on existing patterns). If stored as int, ensure new value gets a stable numeric slot at the end of the enum to avoid breaking existing rows. **No EF migration** beyond what the enum emits automatically.
### Worker
- New SignalR hub method: `QueuePlanningSubtasksAsync(string parentTaskId) : Task`.
- Loads all children of the parent, ordered by `SortOrder`.
- Validates: parent must be a planning parent, children must currently all be in `Manual` or `Planned` (reject if any child is already Queued/Running/Done/Failed, surface a friendly error).
- First child → `Queued`. All other children → `Waiting`. Save.
- Emit `TaskUpdated` for each affected task.
- Chain progression — hook into the existing finish/complete path that already fires `TaskFinished`:
- On a child task finishing with status `Done` **and** its parent has waiting siblings: find the next sibling by `(ParentTaskId == parent.Id && Status == Waiting)` ordered by `SortOrder`, flip to `Queued`, emit `TaskUpdated`, and let the existing queue pickup loop pick it up.
- On `Failed`: do nothing. Remaining `Waiting` siblings stay waiting. (A toast for failed tasks will be added in a later spec.)
This logic lives in a new `PlanningChainCoordinator` service (or similar) in `ClaudeDo.Worker/Planning/`, registered as a singleton and wired into whatever already emits task-finished events.
### UI
- `TaskRowView` — add context menu entry **"Queue subtasks sequentially"**:
- `IsVisible` bound to `IsPlanningParent && HasPlanningChildren`.
- `IsEnabled` when all children are in `Manual` / `Planned` state (new property on `TaskRowViewModel`: `CanQueueSubtasksSequentially`).
- Calls `WorkerClient.QueuePlanningSubtasksAsync(Id)`.
- `TaskRowViewModel`:
- Add `IsWaiting => Status == TaskStatus.Waiting` and extend `StatusChipClass` switch to return a new class `"waiting"`.
- Add `CanQueueSubtasksSequentially` (computed; requires access to children).
- `StatusColorConverter` — add a muted color for `Waiting` (proposed: the existing `TextMuteBrush` or a faint cyan).
- Task list — planning parent continues to appear in virtual:queued because it has a `Queued` child (existing logic). **Extend** the virtual:queued match predicate in `TasksIslandViewModel.TaskMatchesList` so a task matches when `Status == Queued || Status == Waiting`. This ensures all sibling subtasks (the queued one + the waiting ones) render under the parent in that list.
### Client
- `IWorkerClient` / `WorkerClient`: add `QueuePlanningSubtasksAsync(string parentTaskId)` that calls the hub method.
## Out of scope
- Toast notifications on subtask failure (separate follow-up spec).
- Retrying a stopped chain from a failed task (user does it manually via existing actions).
- Persisting the collapse state of planning parents or the Description across sessions.
- Drag-to-reorder of waiting subtasks (execution order = `SortOrder` at the moment the chain starts).
## Validation plan
Manual:
- Plan a task with 3 subtasks. Context-menu → Queue subtasks sequentially. Confirm first = Queued, others = Waiting. Watch the first run to Done, confirm the second flips Queued → Running automatically.
- Force-fail subtask 2 (cancel or make it fail). Confirm subtask 3 stays Waiting; no further dispatch.
- Once all three are Done, confirm the planning parent auto-collapses in the list.
- Toggle the Description chevron in the Details pane on an arbitrary task.
- Eyeball the narrower GridSplitter — still resizable, still hittable.
Automated (minimal — only where cheap):
- Worker-level unit test for `PlanningChainCoordinator`: happy-path chain advance on Done; no advance on Failed; correct ordering by `SortOrder`.

View File

@@ -0,0 +1,172 @@
# Planning Session MCP via Ephemeral Worktree
**Date:** 2026-04-24
**Status:** Design approved, pending implementation plan
**Scope:** `ClaudeDo.Worker` — planning session launch, MCP config delivery
## Problem
When a user starts a planning session, `claude` is spawned in the list's working directory via Windows Terminal and passed `--mcp-config <absolute-path>` pointing at a session-local `mcp.json`. In practice, the spawned `claude` session does **not** pick up the ClaudeDo MCP server: `mcp__claudedo__*` tools are not available, and no trust prompt is shown. The user has to fall back to the built-in `TaskCreate` tool, which writes nothing to ClaudeDo.
The `--mcp-config` flag is documented for headless (`-p`) invocations; in interactive TUI mode it appears to be either ignored or silently dropped on at least some CLI versions. The JSON payload itself is already correct (verified against Claude Code docs — `type: "http"` + `Authorization` header is the documented form).
The reliable path per Claude Code docs is project-root `.mcp.json` auto-discovery plus a one-time trust approval (or `enableAllProjectMcpServers: true`).
## Goal
Spawn planning sessions so that `mcp__claudedo__*` tools are available immediately, without modifying any file in the user's working directory and without requiring a trust prompt.
## Non-goals
- Installer-time MCP registration (rejected — loses per-session token isolation; pollutes every `claude` invocation on the machine).
- Changing how task execution (non-planning) spawns `claude`.
- Supporting planning on a working directory that is not a git repository.
## Approach: ephemeral planning worktree
Each planning session runs inside its own short-lived git worktree, created from `HEAD` of the list's working directory. The worktree is the isolated surface where we write `.mcp.json` and the settings override. The worktree is force-removed on `FinalizeAsync` / `DiscardAsync`.
### Files changed
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` (extend to carry worktree path + branch name)
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` (may drop `McpConfigPath` if no longer used)
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
- `src/ClaudeDo.Worker/Runner/WorktreeMaintenanceService.cs` (optional — defensive startup prune)
- DI registration in `src/ClaudeDo.Worker/Program.cs` (inject `GitService`, `WorkerConfig`, `IDbContextFactory<ClaudeDoDbContext>` into `PlanningSessionManager`)
### Data flow on `StartAsync`
1. Resolve `list.WorkingDir`; hard-error if `null`, not a directory, or not a git repo (`GitService.IsGitRepoAsync`).
2. Resolve `HEAD` via `GitService.RevParseHeadAsync`.
3. Resolve worktree strategy from `AppSettingsRepository.GetAsync` (same resolution as `WorktreeManager.CreateAsync`):
- `sibling``<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>`
- `central``<CentralWorktreeRoot>\planning\<taskId>`
Normalize with `Path.GetFullPath`.
4. Branch name: `claudedo/planning/<taskId-stripped-of-dashes>`.
5. `GitService.WorktreeAddAsync(list.WorkingDir, branchName, worktreePath, baseCommit, ct)`. On `"already exists"` failure, run the same self-heal pattern as `WorktreeManager.CreateAsync` (list worktrees for branch → force-remove stale → prune → delete branch → retry once).
6. Write into the worktree:
- `<worktreePath>\.mcp.json` — JSON with env-var expansion for the token (see below).
- `<worktreePath>\.claude\settings.local.json``{ "enableAllProjectMcpServers": true }` (create `.claude` dir if missing).
7. Write session artifacts in the session directory (unchanged from today): `system-prompt.md`, `initial-prompt.txt`. The session-local `mcp.json` is no longer written — drop that write.
8. Return `PlanningSessionStartContext` with `WorkingDir = worktreePath` and a new `WorktreePath` field (redundant with `WorkingDir` for now, but explicit for cleanup). Also carry `BranchName` so finalize/discard can delete it.
### MCP JSON payload
```json
{
"mcpServers": {
"claudedo": {
"type": "http",
"url": "http://127.0.0.1:47821/mcp",
"headers": {
"Authorization": "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
}
}
}
}
```
The token never lives on disk in literal form — `${CLAUDEDO_PLANNING_TOKEN}` is expanded by Claude Code at load time from the spawned process's environment.
### `.claude/settings.local.json` payload
```json
{
"enableAllProjectMcpServers": true
}
```
Since the worktree is always empty of user customizations (fresh checkout), we write this file unconditionally. No merge / backup logic needed.
### Launcher changes (`WindowsTerminalPlanningLauncher`)
- `LaunchStartAsync`:
- Set `psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token` (new field on `PlanningSessionStartContext`).
- `-d` now points at the worktree path (already handled by `ctx.WorkingDir` change).
- **Remove** `--mcp-config` and its path argument.
- Keep `--allowedTools mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill``enableAllProjectMcpServers` only handles trust, not per-tool pre-approval.
- Keep `--append-system-prompt-file` as the "single-value flag buffer" before the positional prompt (the existing arg-order concern is unchanged).
- `LaunchResumeAsync`:
- Same env-var setup.
- Same `-d <worktreePath>`.
- **Remove** `--mcp-config` (the worktree's `.mcp.json` is discovered automatically).
- Keep `--resume <ClaudeSessionId>`.
### Finalize / Discard
`PlanningSessionManager.FinalizeAsync` and `DiscardAsync` gain:
1. Look up the worktree path + branch name (deterministic from `taskId` → reuse the same resolution code as `StartAsync`).
2. `GitService.WorktreeRemoveAsync(list.WorkingDir, worktreePath, force: true, ct)``--force` because claude may have created scratch files.
3. `GitService.BranchDeleteAsync(list.WorkingDir, branchName, force: true, ct)`.
4. Delete the session dir as today.
All three steps are best-effort in `DiscardAsync` (log warnings, don't throw — the user explicitly asked to discard). `FinalizeAsync` should propagate failures, since a failed cleanup leaves resources we care about.
### Resume
Resume already looks up `list.WorkingDir` from the list; the worktree path is deterministic from `taskId`. `ResumeAsync` must:
1. Verify the worktree directory exists; if not, hard-error ("planning session was discarded or lost — cannot resume").
2. Return `PlanningSessionResumeContext` with `WorkingDir = worktreePath` and the token (re-read from session state — see Token persistence below).
### Token persistence
The token today is generated in `StartAsync` and embedded in `mcp.json` at creation time — never read again. With env-var expansion, the token must be available on **resume**. Options:
- **A) Persist token to session dir** (`<sessionDir>\token`) with `FileOptions.WriteAllBytes`, restrict file ACL to current user. Read on resume.
- **B) Store token hash in DB, raw token in memory only** — breaks across Worker restarts → no resume possible.
**Chosen: A.** Token file sits inside the existing session directory (`<PlanningSessionManager._rootDirectory>\<taskId>\token`), restricted to the current user via Windows ACLs (`File.SetAccessControl` with an explicit DACL granting `FullControl` to `WindowsIdentity.GetCurrent()` only). Cleaned up in `DiscardAsync`/`FinalizeAsync` with the rest of the session dir.
### Defensive startup cleanup
`WorktreeMaintenanceService` already prunes worktrees tracked in the DB. Planning worktrees are **not** in the DB (they're purely filesystem-backed, keyed by `taskId` via path convention). Add a lightweight pass:
- Enumerate directories matching `<root>\.claudedo-worktrees\planning\*` (for each strategy / central root we know about).
- For each, check whether a corresponding session dir exists under `~/.todo-app/sessions/<taskId>`.
- If no session dir: `git worktree remove --force` + `git branch -D claudedo/planning/<taskId-stripped>`.
This is a small addition; if scoped too large, defer to a follow-up and accept that a crashed Worker leaves orphaned worktrees until manual cleanup.
## Edge cases
| Case | Behavior |
|------|----------|
| `list.WorkingDir` not a git repo | Hard-error on `StartAsync`. Surface message in UI. |
| Worktree branch already exists from a prior crashed session | Self-heal: force-remove matching worktrees, prune, delete branch, retry once. (Same pattern as `WorktreeManager.CreateAsync`.) |
| User closes Windows Terminal without clicking Finalize/Discard | Session dir + worktree remain. `ResumeAsync` works. Startup cleanup handles abandoned sessions whose session dir the user manually deletes. |
| Claude creates/edits files in the planning worktree | Discarded with the worktree. No impact on user's real working dir. |
| User deletes the session dir out from under the Worker | `ResumeAsync` hard-errors. Startup cleanup GCs the orphaned worktree. |
| Two simultaneous planning sessions on the same task | Already prevented by task status transition (`Planning` is exclusive). No new consideration. |
| `HEAD` is on a detached commit | `git worktree add` handles this fine — base commit is explicit. |
## Testing
Extend `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (or a new file) with integration tests using the real-SQLite + real-git pattern the project already uses:
- **Start happy path:** worktree dir exists after `StartAsync`, contains `.mcp.json` with `${CLAUDEDO_PLANNING_TOKEN}` literal, contains `.claude/settings.local.json` with `enableAllProjectMcpServers: true`.
- **Finalize cleanup:** worktree dir is gone, branch is gone, session dir is gone.
- **Discard cleanup:** same as finalize.
- **Self-heal:** pre-create a stale branch `claudedo/planning/<id>`, then `StartAsync` must succeed.
- **Non-git working dir:** `StartAsync` throws a specific error type.
- **Resume after Worker restart:** seed session dir + token file, recreate `PlanningSessionManager`, `ResumeAsync` returns context pointing at the still-existing worktree.
Mock `IPlanningTerminalLauncher` (already an interface) so tests don't actually spawn `wt.exe`.
## Trade-offs and alternatives considered
1. **Write `.mcp.json` into the user's working dir with backup/restore.** Rejected — clobber risk, file-noise on crash, user's `.gitignore` may not cover it, exposes token alongside source even with env-var expansion (because expansion is on claude's side, the raw `${VAR}` string still lives in the user's repo).
2. **User-scope registration via installer** (`claude mcp add --scope user`). Rejected — requires a static secret baked into the Worker, loses per-session isolation, every `claude` session on the machine sees claudedo tools.
3. **Keep `--mcp-config` and debug why it's not honored.** Rejected — even if it works on the maintainer's machine, the behavior is undocumented for interactive TUI mode, and we'd need a fallback anyway. Fixing to the documented path eliminates the uncertainty.
## Open questions resolved
- **WorkingDir must be a git repo?** Yes — hard-error.
- **Worktree path strategy?** Follow the same `sibling`/`central` setting as task execution.
- **HEAD snapshot vs WIP?** HEAD snapshot is fine — planning proposes subtasks, doesn't edit files.
## Implementation sequencing
A separate implementation plan (via `superpowers:writing-plans`) will break this into test-first steps.

View File

@@ -17,7 +17,9 @@
<converters:EqStatusConverter x:Key="EqStatus"/> <converters:EqStatusConverter x:Key="EqStatus"/>
<converters:UpperCaseConverter x:Key="UpperCase"/> <converters:UpperCaseConverter x:Key="UpperCase"/>
<converters:IconKeyConverter x:Key="IconKey"/> <converters:IconKeyConverter x:Key="IconKey"/>
<converters:DotBrushConverter x:Key="DotBrush"/> <converters:DotBrushConverter x:Key="DotBrush"/>
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> </Application.Resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,6 +1,7 @@
using Avalonia; using Avalonia;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Releases;
using ClaudeDo.Ui; using ClaudeDo.Ui;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
@@ -9,6 +10,8 @@ using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
namespace ClaudeDo.App; namespace ClaudeDo.App;
@@ -75,6 +78,20 @@ sealed class Program
sc.AddSingleton<GitService>(); sc.AddSingleton<GitService>();
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl)); sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
// Release check + installer update
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallerLocator>();
sc.AddSingleton(sp =>
{
var releases = sp.GetRequiredService<IReleaseClient>();
var informational = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
// Strip MinVer build metadata ("+sha") and any prerelease suffix for the update-compare.
var version = (informational ?? "0.0.0").Split('+')[0];
return new UpdateCheckService(releases, version);
});
// ViewModels // ViewModels
sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();
@@ -88,7 +105,9 @@ sealed class Program
sp, sp,
sp.GetRequiredService<WorkerClient>())); sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<TasksIslandViewModel>(sp => sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>())); new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>()));
sc.AddSingleton<DetailsIslandViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel( new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),

View File

@@ -45,6 +45,13 @@ public class ClaudeDoDbContext : DbContext
walCmd.ExecuteNonQuery(); walCmd.ExecuteNonQuery();
} }
// Enable FK enforcement — SQLite defaults to OFF per connection.
using (var fkCmd = conn.CreateCommand())
{
fkCmd.CommandText = "PRAGMA foreign_keys=ON;";
fkCmd.ExecuteNonQuery();
}
// If the 'lists' table exists but __EFMigrationsHistory does not, // If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration. // this is a pre-EF database. Baseline the InitialCreate migration.
using (var cmd = conn.CreateCommand()) using (var cmd = conn.CreateCommand())

View File

@@ -14,6 +14,10 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
: v == TaskStatus.Running ? "running" : v == TaskStatus.Running ? "running"
: v == TaskStatus.Done ? "done" : v == TaskStatus.Done ? "done"
: v == TaskStatus.Failed ? "failed" : v == TaskStatus.Failed ? "failed"
: v == TaskStatus.Planning ? "planning"
: v == TaskStatus.Planned ? "planned"
: v == TaskStatus.Draft ? "draft"
: v == TaskStatus.Waiting ? "waiting"
: throw new ArgumentOutOfRangeException(nameof(v)); : throw new ArgumentOutOfRangeException(nameof(v));
private static TaskStatus StatusFromString(string v) private static TaskStatus StatusFromString(string v)
@@ -22,6 +26,10 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
: v == "running" ? TaskStatus.Running : v == "running" ? TaskStatus.Running
: v == "done" ? TaskStatus.Done : v == "done" ? TaskStatus.Done
: v == "failed" ? TaskStatus.Failed : v == "failed" ? TaskStatus.Failed
: v == "planning" ? TaskStatus.Planning
: v == "planned" ? TaskStatus.Planned
: v == "draft" ? TaskStatus.Draft
: v == "waiting" ? TaskStatus.Waiting
: throw new ArgumentOutOfRangeException(nameof(v)); : throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<TaskStatus, string> StatusConverter = private static readonly ValueConverter<TaskStatus, string> StatusConverter =
@@ -53,6 +61,18 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.Notes).HasColumnName("notes");
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token");
builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at");
builder.Property(t => t.CreatedBy).HasColumnName("created_by");
builder.HasOne(t => t.Parent)
.WithMany(t => t.Children)
.HasForeignKey(t => t.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.List) builder.HasOne(t => t.List)
.WithMany(l => l.Tasks) .WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId) .HasForeignKey(t => t.ListId)
@@ -76,5 +96,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort"); builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
} }
} }

View File

@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable

View File

@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPlanningSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "parent_task_id",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "planning_finalized_at",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "planning_session_id",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "planning_session_token",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "idx_tasks_parent_task_id",
table: "tasks",
column: "parent_task_id");
migrationBuilder.AddForeignKey(
name: "FK_tasks_tasks_parent_task_id",
table: "tasks",
column: "parent_task_id",
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_tasks_tasks_parent_task_id",
table: "tasks");
migrationBuilder.DropIndex(
name: "idx_tasks_parent_task_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "parent_task_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "planning_finalized_at",
table: "tasks");
migrationBuilder.DropColumn(
name: "planning_session_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "planning_session_token",
table: "tasks");
}
}
}

View File

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

View File

@@ -82,7 +82,7 @@ namespace ClaudeDo.Data.Migrations
{ {
Id = 1, Id = 1,
DefaultClaudeInstructions = "", DefaultClaudeInstructions = "",
DefaultMaxTurns = 30, DefaultMaxTurns = 100,
DefaultModel = "sonnet", DefaultModel = "sonnet",
DefaultPermissionMode = "bypassPermissions", DefaultPermissionMode = "bypassPermissions",
WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupDays = 7,
@@ -236,6 +236,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("created_at"); .HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description") b.Property<string>("Description")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("description"); .HasColumnName("description");
@@ -273,6 +277,22 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("notes"); .HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result") b.Property<string>("Result")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("result"); .HasColumnName("result");
@@ -310,6 +330,9 @@ namespace ClaudeDo.Data.Migrations
b.HasIndex("ListId") b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id"); .HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status") b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status"); .HasDatabaseName("idx_tasks_status");
@@ -502,7 +525,14 @@ namespace ClaudeDo.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List"); b.Navigation("List");
b.Navigation("Parent");
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
@@ -566,6 +596,8 @@ namespace ClaudeDo.Data.Migrations
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.Navigation("Children");
b.Navigation("Runs"); b.Navigation("Runs");
b.Navigation("Subtasks"); b.Navigation("Subtasks");

View File

@@ -8,8 +8,8 @@ public sealed class AppSettingsEntity
public string DefaultClaudeInstructions { get; set; } = string.Empty; public string DefaultClaudeInstructions { get; set; } = string.Empty;
public string DefaultModel { get; set; } = "sonnet"; public string DefaultModel { get; set; } = "sonnet";
public int DefaultMaxTurns { get; set; } = 30; public int DefaultMaxTurns { get; set; } = 100;
public string DefaultPermissionMode { get; set; } = "bypassPermissions"; public string DefaultPermissionMode { get; set; } = "auto";
public string WorktreeStrategy { get; set; } = "sibling"; public string WorktreeStrategy { get; set; } = "sibling";
public string? CentralWorktreeRoot { get; set; } public string? CentralWorktreeRoot { get; set; }

View File

@@ -7,6 +7,10 @@ public enum TaskStatus
Running, Running,
Done, Done,
Failed, Failed,
Planning,
Planned,
Draft,
Waiting,
} }
public sealed class TaskEntity public sealed class TaskEntity
@@ -31,10 +35,20 @@ public sealed class TaskEntity
public string? Notes { get; set; } public string? Notes { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public string? ParentTaskId { get; set; }
public string? PlanningSessionId { get; set; }
public string? PlanningSessionToken { get; set; }
public DateTime? PlanningFinalizedAt { get; set; }
public string? CreatedBy { get; set; }
// Navigation properties // Navigation properties
public ListEntity List { get; set; } = null!; public ListEntity List { get; set; } = null!;
public WorktreeEntity? Worktree { get; set; } public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>(); public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>(); public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>(); public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
public TaskEntity? Parent { get; set; }
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
} }

View File

@@ -0,0 +1,9 @@
namespace ClaudeDo.Data.Models;
public enum WorkerLogLevel
{
Info,
Success,
Warn,
Error,
}

View File

@@ -0,0 +1,58 @@
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, Agent }
public static class PromptFiles
{
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
public static string PathFor(PromptKind kind) => kind switch
{
PromptKind.System => Path.Combine(Root, "system.md"),
PromptKind.Planning => Path.Combine(Root, "planning.md"),
PromptKind.Agent => Path.Combine(Root, "agent.md"),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
public static void EnsureExists(PromptKind kind)
{
Directory.CreateDirectory(Root);
var path = PathFor(kind);
if (File.Exists(path)) return;
File.WriteAllText(path, DefaultFor(kind));
}
public static string? ReadOrNull(PromptKind kind)
{
var path = PathFor(kind);
if (!File.Exists(path)) return null;
var content = File.ReadAllText(path).Trim();
return string.IsNullOrEmpty(content) ? null : content;
}
private static string DefaultFor(PromptKind kind) => kind switch
{
PromptKind.System =>
"# System Prompt\n\n" +
"Baseline instructions appended to every task run.\n" +
"Edit this file to inject project-wide rules (style, conventions, hard constraints).\n",
PromptKind.Planning =>
"You are a planning assistant for ClaudeDo.\n" +
"Your role is to help break down a task into smaller, actionable subtasks.\n" +
"Your final goal WILL ALWAYS be the creation of Subtasks.\n\n" +
"ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the\n" +
"start of every planning session, and follow its process end-to-end. It guides\n" +
"you through clarifying questions, approach exploration, and design approval\n" +
"BEFORE any subtasks are created. Do not create child tasks until the user has\n" +
"approved a design.\n\n" +
"NEVER change files yourself.\n\n" +
"ALWAYS use the available MCP tools (mcp__claudedo__*) to create child tasks once\n" +
"the design is approved. When you are done planning, finalize the session.\n\n" +
"Be concise and focused. Each subtask should be independently executable.\n",
PromptKind.Agent =>
"# Agent Prompt\n\n" +
"Appended to the system prompt for tasks tagged \"agent\" (auto-queued runs).\n" +
"Use this for autonomous-execution rules that don't apply to manual runs.\n",
_ => ""
};
}

View File

@@ -36,7 +36,7 @@ public sealed class AppSettingsRepository
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel; row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
row.DefaultMaxTurns = updated.DefaultMaxTurns; row.DefaultMaxTurns = updated.DefaultMaxTurns;
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode) row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
? "bypassPermissions" : updated.DefaultPermissionMode; ? "auto" : updated.DefaultPermissionMode;
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy; row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot) row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
? null : updated.CentralWorktreeRoot; ? null : updated.CentralWorktreeRoot;

View File

@@ -75,6 +75,15 @@ public sealed class TaskRepository
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default) public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
=> GetByListIdAsync(listId, ct); => GetByListIdAsync(listId, ct);
public async Task<List<TaskEntity>> GetByCreatorAsync(string createdBy, CancellationToken ct = default)
{
return await _context.Tasks
.AsNoTracking()
.Where(t => t.CreatedBy == createdBy)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync(ct);
}
#endregion #endregion
#region Status transitions #region Status transitions
@@ -206,6 +215,223 @@ public sealed class TaskRepository
#endregion #endregion
#region Planning
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
{
return await _context.Tasks
.AsNoTracking()
.Where(t => t.ParentTaskId == parentId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
}
public async Task<TaskEntity> CreateChildAsync(
string parentId,
string title,
string? description,
IReadOnlyList<string>? tagNames,
string? commitType,
CancellationToken ct = default)
{
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null)
throw new InvalidOperationException($"Parent task {parentId} not found.");
var maxSort = await _context.Tasks
.Where(t => t.ListId == parent.ListId)
.Select(t => (int?)t.SortOrder)
.MaxAsync(ct);
var child = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = parent.ListId,
Title = title,
Description = description,
Status = TaskStatus.Draft,
CreatedAt = DateTime.UtcNow,
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
ParentTaskId = parentId,
SortOrder = (maxSort ?? -1) + 1,
};
_context.Tasks.Add(child);
if (tagNames is not null && tagNames.Count > 0)
{
foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase))
{
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct);
if (tag is null)
{
tag = new TagEntity { Name = tagName };
_context.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
child.Tags.Add(tag);
}
}
await _context.SaveChangesAsync(ct);
return child;
}
public async Task UpdatePlanningTaskAsync(
string taskId,
string? title,
string? description,
CancellationToken ct = default)
{
var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct)
?? throw new InvalidOperationException("Planning task not found.");
if (title is not null) entity.Title = title;
if (description is not null) entity.Description = description;
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Title, entity.Title)
.SetProperty(t => t.Description, entity.Description), ct);
}
public async Task<TaskEntity?> SetPlanningStartedAsync(
string taskId,
string sessionToken,
CancellationToken ct = default)
{
var affected = await _context.Tasks
.Where(t => t.Id == taskId && t.Status == TaskStatus.Manual)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Planning)
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
if (affected == 0) return null;
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
}
public async Task UpdatePlanningSessionIdAsync(
string parentId,
string sessionId,
CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
}
public async Task<TaskEntity?> FindByPlanningTokenAsync(
string token,
CancellationToken ct = default)
{
if (string.IsNullOrEmpty(token)) return null;
return await _context.Tasks
.AsNoTracking()
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
}
public async Task<int> FinalizePlanningAsync(
string parentId,
bool queueAgentTasks,
CancellationToken ct = default)
{
using var tx = await _context.Database.BeginTransactionAsync(ct);
var parent = await _context.Tasks
.AsNoTracking()
.Include(t => t.List).ThenInclude(l => l.Tags)
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null || parent.Status != TaskStatus.Planning)
throw new InvalidOperationException($"Task {parentId} is not in Planning state.");
var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent");
var drafts = await _context.Tasks
.Include(t => t.Tags)
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
.ToListAsync(ct);
int count = 0;
foreach (var draft in drafts)
{
var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent");
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual;
count++;
}
var finalizedAt = DateTime.UtcNow;
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Planned)
.SetProperty(t => t.PlanningFinalizedAt, finalizedAt)
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
await _context.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return count;
}
public async Task<bool> DiscardPlanningAsync(
string parentId,
CancellationToken ct = default)
{
using var tx = await _context.Database.BeginTransactionAsync(ct);
var parent = await _context.Tasks
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null || parent.Status != TaskStatus.Planning)
{
await tx.RollbackAsync(ct);
return false;
}
await _context.Tasks
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
.ExecuteDeleteAsync(ct);
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Manual)
.SetProperty(t => t.PlanningSessionId, (string?)null)
.SetProperty(t => t.PlanningSessionToken, (string?)null)
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
await tx.CommitAsync(ct);
return true;
}
public async Task TryCompleteParentAsync(
string parentId,
CancellationToken ct = default)
{
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null || parent.Status != TaskStatus.Planned) return;
var children = await _context.Tasks
.Where(t => t.ParentTaskId == parentId)
.Select(t => t.Status)
.ToListAsync(ct);
if (children.Count == 0) return;
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
if (!allTerminal) return;
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
var finishedAt = DateTime.UtcNow;
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, finalStatus)
.SetProperty(t => t.FinishedAt, finishedAt), ct);
}
#endregion
#region Queue selection #region Queue selection
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)

View File

@@ -2,6 +2,7 @@ using System.Net.Http;
using System.Reflection; using System.Reflection;
using System.Windows; using System.Windows;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Releases;
using ClaudeDo.Installer.Pages.InstallPage; using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.PathsPage; using ClaudeDo.Installer.Pages.PathsPage;
using ClaudeDo.Installer.Pages.ServicePage; using ClaudeDo.Installer.Pages.ServicePage;
@@ -21,6 +22,104 @@ public partial class App : Application
{ {
base.OnStartup(e); base.OnStartup(e);
// --- Self-update pre-flight ---
// Resolve current exe path. Assembly.Location may point to a .dll for apphost-based
// .NET apps; swap to the .exe companion when that happens.
var currentExePath = Assembly.GetEntryAssembly()!.Location;
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
}
// Arg form: --replace-self "<old-path>"
var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
{
var oldPath = e.Args[replaceSelfIndex + 1];
var relaunched = await SelfUpdater.HandleReplaceSelfAsync(
oldPath: oldPath,
currentExePath: currentExePath,
launchProcess: path =>
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
return true;
}
catch { return false; }
});
if (relaunched)
{
Shutdown(0);
return;
}
// Replacement failed — fall through to normal wizard from the temp location.
}
else
{
// Normal launch: check for a newer installer.
using var selfUpdateHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var selfUpdateReleases = new ReleaseClient(selfUpdateHttp);
var currentVersion = GetInstallerVersion();
var decision = await SelfUpdater.DecideUpdateAsync(selfUpdateReleases, currentVersion, CancellationToken.None);
if (decision.Kind == SelfUpdateDecisionKind.UpdateAvailable)
{
var prompt = new SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
DarkTitleBar.Apply(prompt);
var ok = prompt.ShowDialog() == true;
if (!ok)
{
Shutdown(0);
return;
}
if (prompt.Choice == SelfUpdateChoice.Update)
{
prompt.ShowProgress("Downloading...");
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
var verifiedPath = await SelfUpdater.DownloadAndVerifyAsync(
selfUpdateReleases,
decision.InstallerAsset!,
decision.ChecksumsAsset!,
tempDir,
new Progress<long>(_ => { }),
CancellationToken.None);
if (verifiedPath is null)
{
MessageBox.Show(prompt,
"Update download or verification failed. Continuing with current installer.",
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
}
else
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo(verifiedPath)
{
UseShellExecute = true,
};
psi.ArgumentList.Add("--replace-self");
psi.ArgumentList.Add(currentExePath);
System.Diagnostics.Process.Start(psi);
Shutdown(0);
return;
}
catch (Exception ex)
{
MessageBox.Show(prompt,
"Failed to launch updated installer: " + ex.Message + "\nContinuing with current installer.",
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
}
// SelfUpdateChoice.Continue — fall through to normal wizard.
}
// No-update or check failed — fall through to normal wizard.
}
// --- Existing wizard start-up unchanged below this line ---
_services = BuildServices(); _services = BuildServices();
var context = _services.GetRequiredService<InstallContext>(); var context = _services.GetRequiredService<InstallContext>();
@@ -50,6 +149,7 @@ public partial class App : Application
context.Mode = state.Mode; context.Mode = state.Mode;
context.InstalledVersion = state.Existing?.Version; context.InstalledVersion = state.Existing?.Version;
context.LatestVersion = state.LatestVersion; context.LatestVersion = state.LatestVersion;
context.LatestTagUnparseable = state.LatestTagUnparseable;
if (state.Existing is not null) if (state.Existing is not null)
context.InstallDirectory = state.Existing.InstallDir; context.InstallDirectory = state.Existing.InstallDir;

View File

@@ -45,6 +45,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" /> <ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -10,6 +10,7 @@ public sealed class InstallContext
public string? InstallerVersion { get; set; } // from this installer's assembly public string? InstallerVersion { get; set; } // from this installer's assembly
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep) public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline) public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
public bool LatestTagUnparseable { get; set; } // true if latest tag isn't a System.Version
// PathsPage // PathsPage
public string DbPath { get; set; } = "~/.todo-app/todo.db"; public string DbPath { get; set; } = "~/.todo-app/todo.db";

View File

@@ -8,7 +8,8 @@ public sealed record InstallManifest(
string Version, string Version,
string InstallDir, string InstallDir,
string WorkerDir, string WorkerDir,
DateTimeOffset InstalledAt); DateTimeOffset InstalledAt,
string? DataDir = null);
public static class InstallManifestStore public static class InstallManifestStore
{ {

View File

@@ -1,10 +1,17 @@
using ClaudeDo.Releases;
namespace ClaudeDo.Installer.Core; namespace ClaudeDo.Installer.Core;
public sealed record DetectedState( public sealed record DetectedState(
InstallerMode Mode, InstallerMode Mode,
InstallManifest? Existing, InstallManifest? Existing,
GiteaRelease? LatestRelease, GiteaRelease? LatestRelease,
string? LatestVersion); string? LatestVersion)
{
/// <summary>True when a release was returned but its tag isn't a parseable
/// System.Version (e.g. "0.2.0-beta") — so we couldn't decide if it's newer.</summary>
public bool LatestTagUnparseable { get; init; }
}
public sealed class InstallModeDetector public sealed class InstallModeDetector
{ {
@@ -26,23 +33,16 @@ public sealed class InstallModeDetector
return new DetectedState(InstallerMode.Config, manifest, null, null); return new DetectedState(InstallerMode.Config, manifest, null, null);
var latestVersion = release.TagName.TrimStart('v', 'V'); var latestVersion = release.TagName.TrimStart('v', 'V');
if (IsNewer(latestVersion, manifest.Version)) var cmp = VersionComparer.Compare(latestVersion, manifest.Version);
var newer = cmp.IsNewer;
var unparseable = cmp.Unparseable;
if (newer)
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion); return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion); return new DetectedState(InstallerMode.Config, manifest, release, latestVersion)
{
LatestTagUnparseable = unparseable,
};
} }
/// <summary>
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
/// AND latest &gt; current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
/// treated as "not newer" — the user drops into Config mode with no update offered.
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
/// If the project starts shipping pre-release tags, revisit this.
/// </summary>
private static bool IsNewer(string latest, string current)
{
if (!Version.TryParse(latest, out var lv)) return false;
if (!Version.TryParse(current, out var cv)) return false;
return lv > cv;
}
} }

View File

@@ -67,10 +67,13 @@ public sealed class UninstallRunner
failures.Add($"install dir ({_context.InstallDirectory}): {err}"); failures.Add($"install dir ({_context.InstallDirectory}): {err}");
} }
// 6) Delete ~/.todo-app (config + DB + logs) — only if user opted in. // 6) Delete data dir (config + DB + logs) — only if user opted in.
// Prefer the manifest-recorded DataDir so a customised DbPath is honoured;
// fall back to the default ~/.todo-app for older manifests.
if (removeAppData) if (removeAppData)
{ {
var appData = Paths.AppDataRoot(); var manifest = InstallManifestStore.TryRead(_context.InstallDirectory);
var appData = manifest?.DataDir ?? Paths.AppDataRoot();
if (Directory.Exists(appData)) if (Directory.Exists(appData))
{ {
progress.Report($"Deleting {appData}..."); progress.Report($"Deleting {appData}...");

View File

@@ -1,6 +1,7 @@
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Releases;
namespace ClaudeDo.Installer.Steps; namespace ClaudeDo.Installer.Steps;

View File

@@ -1,3 +1,4 @@
using System.IO;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -15,6 +16,10 @@ public sealed class InitDatabaseStep : IInstallStep
var expandedPath = Paths.Expand(ctx.DbPath); var expandedPath = Paths.Expand(ctx.DbPath);
progress.Report($"Initializing database at {expandedPath}"); progress.Report($"Initializing database at {expandedPath}");
var parent = Path.GetDirectoryName(expandedPath);
if (!string.IsNullOrEmpty(parent))
Directory.CreateDirectory(parent);
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>() var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={expandedPath}") .UseSqlite($"Data Source={expandedPath}")
.Options; .Options;

View File

@@ -43,13 +43,21 @@ public sealed class RegisterServiceStep : IInstallStep
// Create service // Create service
var startType = ctx.AutoStart ? "auto" : "demand"; var startType = ctx.AutoStart ? "auto" : "demand";
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
if (ctx.ServiceAccount == "CurrentUser") if (ctx.ServiceAccount == "CurrentUser")
return StepResult.Fail( return StepResult.Fail(
"Service cannot run as Current User without a password. " + "Service cannot run as Current User without a password. " +
"Select 'Local System' or extend ServicePage to capture a password."); "Select 'Local System' or extend ServicePage to capture a password.");
var objArg = ctx.ServiceAccount switch
{
"LocalSystem" => " obj= LocalSystem",
"NetworkService" => " obj= \"NT AUTHORITY\\NetworkService\"",
"LocalService" => " obj= \"NT AUTHORITY\\LocalService\"",
_ => "",
};
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}{objArg}";
progress.Report("Creating service..."); progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct); var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode == 1072) if (exitCode == 1072)

View File

@@ -13,15 +13,26 @@ public sealed class StartServiceStep : IInstallStep
progress.Report($"Starting {ServiceName}..."); progress.Report($"Starting {ServiceName}...");
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct); var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
if (exit == 0) return StepResult.Ok(); // 1056 = ERROR_SERVICE_ALREADY_RUNNING — fine, fall through to the readiness poll.
if (exit != 0 && exit != 1056)
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
if (exit == 1056) if (exit == 1056)
{
progress.Report("Service was already running."); progress.Report("Service was already running.");
return StepResult.Ok();
// sc.exe start returns as soon as SCM accepts the command. Poll until the
// service actually reports RUNNING so downstream steps and SignalR clients
// don't race the worker's startup.
progress.Report("Waiting for service to reach RUNNING state...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, output) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (q == 0 && output.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
return StepResult.Ok();
await Task.Delay(1000, ct);
} }
return StepResult.Fail($"sc.exe start failed with exit code {exit}"); return StepResult.Fail("Service did not reach RUNNING state within 30 seconds.");
} }
} }

View File

@@ -1,4 +1,5 @@
using System.IO; using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps; namespace ClaudeDo.Installer.Steps;
@@ -14,11 +15,14 @@ public sealed class WriteInstallManifestStep : IInstallStep
try try
{ {
var dataDir = Path.GetDirectoryName(Paths.Expand(ctx.DbPath));
var manifest = new InstallManifest( var manifest = new InstallManifest(
Version: ctx.InstalledVersion, Version: ctx.InstalledVersion,
InstallDir: ctx.InstallDirectory, InstallDir: ctx.InstallDirectory,
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"), WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
InstalledAt: DateTimeOffset.UtcNow); InstalledAt: DateTimeOffset.UtcNow,
DataDir: dataDir);
InstallManifestStore.Write(ctx.InstallDirectory, manifest); InstallManifestStore.Write(ctx.InstallDirectory, manifest);
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}"); progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");

View File

@@ -0,0 +1,25 @@
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="ClaudeDo Installer Update"
Width="460" Height="200"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
Background="#1a1a1a" Foreground="#f0f0f0">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="A newer installer is available"/>
<TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
<TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="UpdateBtn" Content="Update" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
<Button x:Name="ContinueBtn" Content="Continue anyway" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
<Button x:Name="CancelBtn" Content="Cancel" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,42 @@
using System.Windows;
namespace ClaudeDo.Installer.Views;
public enum SelfUpdateChoice { Update, Continue, Cancel }
public partial class SelfUpdatePromptWindow : Window
{
public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel;
public SelfUpdatePromptWindow(string currentVersion, string latestVersion)
{
InitializeComponent();
DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?";
}
public void ShowProgress(string text)
{
ProgressText.Text = text;
ProgressText.Visibility = Visibility.Visible;
UpdateBtn.IsEnabled = false;
ContinueBtn.IsEnabled = false;
}
private void UpdateBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Update;
DialogResult = true;
}
private void ContinueBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Continue;
DialogResult = true;
}
private void CancelBtn_Click(object sender, RoutedEventArgs e)
{
Choice = SelfUpdateChoice.Cancel;
DialogResult = false;
}
}

View File

@@ -1,5 +1,6 @@
using System.Windows; using System.Windows;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Releases;
using ClaudeDo.Installer.Steps; using ClaudeDo.Installer.Steps;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -50,7 +51,12 @@ public partial class SettingsViewModel : ObservableObject
_uninstallRunner = uninstallRunner; _uninstallRunner = uninstallRunner;
_selectedPage = Pages.FirstOrDefault(); _selectedPage = Pages.FirstOrDefault();
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}"; var label = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
if (!string.IsNullOrEmpty(context.LatestVersion))
label += $" Latest: {context.LatestVersion}";
if (context.LatestTagUnparseable)
label += " (pre-release tag — auto-update disabled)";
VersionLabel = label;
_ = LoadAllAsync(); _ = LoadAllAsync();
} }
@@ -98,8 +104,39 @@ public partial class SettingsViewModel : ObservableObject
}; };
uiCfg.Save(); uiCfg.Save();
StatusMessage = "Settings saved.";
IsStatusError = false; IsStatusError = false;
StatusMessage = "Settings saved.";
// Worker reads its config at process start, so changes only take effect after a restart.
var restart = MessageBox.Show(
"Restart the worker service now so the new settings take effect?",
"Restart Worker",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (restart != MessageBoxResult.Yes)
{
StatusMessage = "Settings saved. Restart the worker service manually to apply.";
return;
}
var progress = new Progress<string>(msg => StatusMessage = msg);
var stop = await _stopService.ExecuteAsync(_context, progress, CancellationToken.None);
if (!stop.Success)
{
StatusMessage = $"Saved, but worker stop failed: {stop.ErrorMessage}";
IsStatusError = true;
return;
}
var start = await _startService.ExecuteAsync(_context, progress, CancellationToken.None);
if (!start.Success)
{
StatusMessage = $"Saved, but worker start failed: {start.ErrorMessage}";
IsStatusError = true;
return;
}
StatusMessage = "Settings saved. Worker restarted.";
} }
[RelayCommand] [RelayCommand]

View File

@@ -1,7 +1,7 @@
using System.IO; using System.IO;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace ClaudeDo.Installer.Core; namespace ClaudeDo.Releases;
public static class ChecksumVerifier public static class ChecksumVerifier
{ {

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -1,4 +1,4 @@
namespace ClaudeDo.Installer.Core; namespace ClaudeDo.Releases;
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size); public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);

View File

@@ -2,7 +2,7 @@ using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
namespace ClaudeDo.Installer.Core; namespace ClaudeDo.Releases;
public sealed class ReleaseClient : IReleaseClient public sealed class ReleaseClient : IReleaseClient
{ {

View File

@@ -0,0 +1,15 @@
namespace ClaudeDo.Releases;
public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
public enum SelfUpdateDecisionKind
{
NoUpdate,
UpdateAvailable,
}
public sealed record SelfUpdateDecision(
SelfUpdateDecisionKind Kind,
string? LatestVersion = null,
ReleaseAsset? InstallerAsset = null,
ReleaseAsset? ChecksumsAsset = null);

View File

@@ -0,0 +1,126 @@
using System.IO;
using System.Net.Http;
using System.Text.RegularExpressions;
namespace ClaudeDo.Releases;
public static partial class SelfUpdater
{
[GeneratedRegex(@"^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
private static partial Regex InstallerAssetRegex();
public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> assets)
{
foreach (var asset in assets)
{
var m = InstallerAssetRegex().Match(asset.Name);
if (m.Success)
{
return new InstallerAssetMatch(asset, m.Groups["version"].Value);
}
}
return null;
}
public static async Task<SelfUpdateDecision> DecideUpdateAsync(
IReleaseClient releases,
string currentVersion,
CancellationToken ct)
{
GiteaRelease? release;
try
{
release = await releases.GetLatestReleaseAsync(ct);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
}
if (release is null)
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
var match = FindInstallerAsset(release.Assets);
if (match is null)
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
var cmp = VersionComparer.Compare(match.Version, currentVersion);
if (!cmp.IsNewer)
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
var checksums = release.Assets.FirstOrDefault(
a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase));
return new SelfUpdateDecision(
SelfUpdateDecisionKind.UpdateAvailable,
LatestVersion: match.Version,
InstallerAsset: match.Asset,
ChecksumsAsset: checksums);
}
public static async Task<bool> HandleReplaceSelfAsync(
string oldPath,
string currentExePath,
Func<string, bool> launchProcess,
int maxWaitMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs);
while (DateTime.UtcNow < deadline)
{
try
{
if (File.Exists(oldPath))
{
File.Delete(oldPath);
}
break;
}
catch (IOException)
{
await Task.Delay(100);
}
catch (UnauthorizedAccessException)
{
await Task.Delay(100);
}
}
if (File.Exists(oldPath))
{
return false;
}
File.Copy(currentExePath, oldPath, overwrite: false);
return launchProcess(oldPath);
}
public static async Task<string?> DownloadAndVerifyAsync(
IReleaseClient releases,
ReleaseAsset installerAsset,
ReleaseAsset checksumsAsset,
string tempDir,
IProgress<long> progress,
CancellationToken ct)
{
Directory.CreateDirectory(tempDir);
var installerPath = Path.Combine(tempDir, installerAsset.Name);
var checksumsPath = Path.Combine(tempDir, "checksums.txt");
try
{
await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct);
await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress<long>(_ => { }), ct);
}
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException)
{
return null;
}
var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct);
var map = ChecksumVerifier.ParseChecksumsFile(checksumsText);
if (!map.TryGetValue(installerAsset.Name, out var expected))
return null;
return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null;
}
}

View File

@@ -0,0 +1,18 @@
namespace ClaudeDo.Releases;
public readonly record struct VersionCompareResult(bool IsNewer, bool Unparseable);
public static class VersionComparer
{
public static VersionCompareResult Compare(string latest, string current)
{
var latestTrimmed = (latest ?? "").TrimStart('v', 'V');
var currentTrimmed = (current ?? "").TrimStart('v', 'V');
var unparseable = !Version.TryParse(latestTrimmed, out var lv)
| !Version.TryParse(currentTrimmed, out var cv);
if (unparseable) return new VersionCompareResult(false, true);
return new VersionCompareResult(lv > cv, false);
}
}

View File

@@ -0,0 +1,2 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ClaudeDo.Ui.Tests")]

View File

@@ -2,6 +2,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" /> <ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
<ProjectReference Include="..\ClaudeDo.Releases\ClaudeDo.Releases.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -9,6 +10,12 @@
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Ui.Tests" />
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -22,6 +29,7 @@
<AvaloniaResource Include="Assets/Fonts/*.ttf" /> <AvaloniaResource Include="Assets/Fonts/*.ttf" />
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" /> <AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" /> <AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
<AvaloniaResource Include="..\ClaudeDo.App\Assets\ClaudeTask.ico" Link="Assets\ClaudeTask.ico" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Ui.Converters;
public sealed class WorkerLogLevelToBrushConverter : IValueConverter
{
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#4CAF50"));
private static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#FFA726"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#EF5350"));
private static readonly IBrush InfoFallback = new SolidColorBrush(Color.Parse("#888888"));
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not WorkerLogLevel level)
return AvaloniaProperty.UnsetValue;
return level switch
{
WorkerLogLevel.Success => SuccessBrush,
WorkerLogLevel.Warn => WarnBrush,
WorkerLogLevel.Error => ErrorBrush,
WorkerLogLevel.Info => ResolveInfoBrush(),
_ => AvaloniaProperty.UnsetValue,
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
private static IBrush ResolveInfoBrush()
{
if (Application.Current is { } app &&
app.Resources.TryGetResource("TextDimBrush", app.ActualThemeVariant, out var res) &&
res is IBrush brush)
{
return brush;
}
return InfoFallback;
}
}

View File

@@ -81,6 +81,14 @@
<!-- Icon.ArrowOut — filled arrow for "open external" button --> <!-- 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> <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>
<!-- Icon.Settings (gear) -->
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
<!-- Badge brushes -->
<SolidColorBrush x:Key="DraftBadgeBrush" Color="#4A5568"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="#D69E2E"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="#3182CE"/>
</Styles.Resources> </Styles.Resources>
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -95,6 +103,8 @@
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" /> <Setter Property="CornerRadius" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Button.title-ctrl:pointerover /template/ ContentPresenter"> <Style Selector="Button.title-ctrl:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" /> <Setter Property="Background" Value="{StaticResource Surface2Brush}" />
@@ -221,6 +231,8 @@
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="6" /> <Setter Property="CornerRadius" Value="6" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter"> <Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" /> <Setter Property="Background" Value="{StaticResource Surface2Brush}" />
@@ -539,6 +551,20 @@
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" /> <Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
</Style> </Style>
<!-- Count badge — larger, high contrast, brighter when the row is active -->
<Style Selector="TextBlock.list-count">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="8,0,4,0" />
</Style>
<Style Selector="Border.list-item.active TextBlock.list-count">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- LIST SECTION HEADER --> <!-- LIST SECTION HEADER -->
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -582,16 +608,21 @@
<!-- KBD CHIP --> <!-- KBD CHIP -->
<!-- ============================================================ --> <!-- ============================================================ -->
<Style Selector="Border.kbd"> <Style Selector="Border.kbd">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" /> <Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" /> <Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="6,2" /> <Setter Property="Padding" Value="8,3" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style> </Style>
<Style Selector="Border.kbd > TextBlock"> <Style Selector="Border.kbd > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" /> <Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Center" />
</Style> </Style>
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -840,4 +871,31 @@
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" /> <Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
</Style> </Style>
<!-- ============================================================ -->
<!-- PLANNING / DRAFT BADGES -->
<!-- ============================================================ -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Padding" Value="4,1"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style Selector="Border.badge > TextBlock">
<Setter Property="FontSize" Value="9"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Border.badge.draft">
<Setter Property="Background" Value="{DynamicResource DraftBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planning">
<Setter Property="Background" Value="{DynamicResource PlanningBadgeBrush}"/>
</Style>
<Style Selector="Border.badge.planned">
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
</Style>
</Styles> </Styles>

View File

@@ -0,0 +1,19 @@
using System.Runtime.InteropServices;
namespace ClaudeDo.Ui.Services;
internal static class ForegroundHelper
{
private const int ASFW_ANY = -1;
[DllImport("user32.dll", SetLastError = true)]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
// Grants any process the right to take foreground on next SetForegroundWindow call.
// Used before RPCs that cause a helper process (e.g. wt.exe) to spawn a new window.
public static void AllowAny()
{
if (!OperatingSystem.IsWindows()) return;
try { AllowSetForegroundWindow(ASFW_ANY); } catch { }
}
}

View File

@@ -0,0 +1,43 @@
using System.ComponentModel;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Ui.Services;
public interface IWorkerClient : INotifyPropertyChanged
{
bool IsConnected { get; }
event Action<string, string, DateTime>? TaskStartedEvent;
event Action<string, string, string, DateTime>? TaskFinishedEvent;
event Action<string>? TaskUpdatedEvent;
event Action<string>? WorktreeUpdatedEvent;
event Action<string, string>? TaskMessageEvent;
event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent;
event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
event Action<string>? PlanningMergeAbortedEvent;
event Action<string>? PlanningCompletedEvent;
Task WakeQueueAsync();
Task RunNowAsync(string taskId);
Task ContinueTaskAsync(string taskId, string followUpPrompt);
Task ResetTaskAsync(string taskId);
Task CancelTaskAsync(string taskId);
Task<List<AgentInfo>> GetAgentsAsync();
Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId);
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
Task ContinuePlanningMergeAsync(string planningTaskId);
Task AbortPlanningMergeAsync(string planningTaskId);
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,47 @@
namespace ClaudeDo.Ui.Services;
public sealed class InstallerLocator
{
private const string InstallJson = "install.json";
private const string InstallerExe = "ClaudeDo.Installer.exe";
private const string UninstallerSubdir = "uninstaller";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory) ?? FindByRegistry();
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
var manifest = Path.Combine(dir.FullName, InstallJson);
if (File.Exists(manifest))
{
var candidate = Path.Combine(dir.FullName, UninstallerSubdir, InstallerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, UninstallerSubdir, InstallerExe);
return File.Exists(candidate) ? candidate : null;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,34 @@
namespace ClaudeDo.Ui.Services;
public sealed record PlanningSessionFilesDto(
string SessionDirectory,
string McpConfigPath,
string SystemPromptPath,
string InitialPromptPath);
public sealed record PlanningSessionStartInfo(
string ParentTaskId,
string WorkingDir,
PlanningSessionFilesDto Files);
public sealed record PlanningSessionResumeInfo(
string ParentTaskId,
string WorkingDir,
string ClaudeSessionId,
string McpConfigPath);
public sealed record SubtaskDiffDto(
string SubtaskId,
string Title,
string BranchName,
string BaseCommit,
string HeadCommit,
string? DiffStat,
string UnifiedDiff);
public sealed record CombinedDiffResultDto(
bool Success,
string? IntegrationBranch,
string? UnifiedDiff,
string? FirstConflictSubtaskId,
IReadOnlyList<string>? ConflictedFiles);

View File

@@ -0,0 +1,73 @@
using ClaudeDo.Releases;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.Services;
public enum UpdateCheckStatus
{
NeverChecked,
CheckFailed,
UpToDate,
UpdateAvailable,
}
public sealed partial class UpdateCheckService : ObservableObject
{
private readonly IReleaseClient _releases;
[ObservableProperty] private bool _isUpdateAvailable;
[ObservableProperty] private string? _latestVersion;
[ObservableProperty] private string _currentVersion;
[ObservableProperty] private bool _isChecking;
[ObservableProperty] private UpdateCheckStatus _lastCheckStatus = UpdateCheckStatus.NeverChecked;
public UpdateCheckService(IReleaseClient releases, string currentVersion)
{
_releases = releases;
_currentVersion = currentVersion;
}
public async Task CheckNowAsync(CancellationToken ct)
{
IsChecking = true;
try
{
GiteaRelease? rel;
try
{
rel = await _releases.GetLatestReleaseAsync(ct);
}
catch
{
LastCheckStatus = UpdateCheckStatus.CheckFailed;
IsUpdateAvailable = false;
return;
}
if (rel is null)
{
LastCheckStatus = UpdateCheckStatus.CheckFailed;
IsUpdateAvailable = false;
return;
}
var latest = (rel.TagName ?? "").TrimStart('v', 'V');
var cmp = VersionComparer.Compare(latest, CurrentVersion);
if (cmp.IsNewer)
{
LatestVersion = latest;
IsUpdateAvailable = true;
LastCheckStatus = UpdateCheckStatus.UpdateAvailable;
}
else
{
IsUpdateAvailable = false;
LastCheckStatus = UpdateCheckStatus.UpToDate;
}
}
finally
{
IsChecking = false;
}
}
}

View File

@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.SignalR.Client;
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
public record ActiveTask(string Slot, string TaskId, DateTime StartedAt); public record ActiveTask(string Slot, string TaskId, DateTime StartedAt);
public sealed record WorkerLogEntry(string Message, WorkerLogLevel Level, DateTime TimestampUtc);
sealed class IndefiniteRetryPolicy : IRetryPolicy sealed class IndefiniteRetryPolicy : IRetryPolicy
{ {
@@ -24,7 +25,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy
_delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)]; _delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)];
} }
public partial class WorkerClient : ObservableObject, IAsyncDisposable public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient
{ {
private readonly HubConnection _hub; private readonly HubConnection _hub;
private CancellationTokenSource? _startCts; private CancellationTokenSource? _startCts;
@@ -46,6 +47,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public event Action<string>? WorktreeUpdatedEvent; public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? RunNowRequestedEvent; public event Action<string>? RunNowRequestedEvent;
public event Action<string>? ListUpdatedEvent; public event Action<string>? ListUpdatedEvent;
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
public event Action<string>? PlanningMergeAbortedEvent;
public event Action<string>? PlanningCompletedEvent;
public string? LastMergeAllTarget { get; private set; }
public WorkerClient(string signalRUrl) public WorkerClient(string signalRUrl)
{ {
@@ -116,6 +126,36 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
{ {
Dispatcher.UIThread.Post(() => ListUpdatedEvent?.Invoke(listId)); Dispatcher.UIThread.Post(() => ListUpdatedEvent?.Invoke(listId));
}); });
_hub.On<string, WorkerLogLevel, DateTime>("WorkerLog", (message, level, timestampUtc) =>
{
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
});
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
});
_hub.On<string, string>("PlanningSubtaskMerged", (planningTaskId, subtaskId) =>
{
Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId));
});
_hub.On<string, string, IReadOnlyList<string>>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles));
});
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
{
Dispatcher.UIThread.Post(() => PlanningMergeAbortedEvent?.Invoke(planningTaskId));
});
_hub.On<string>("PlanningCompleted", planningTaskId =>
{
Dispatcher.UIThread.Post(() => PlanningCompletedEvent?.Invoke(planningTaskId));
});
} }
public Task StartAsync() public Task StartAsync()
@@ -231,6 +271,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("RefreshAgents"); await _hub.InvokeAsync("RefreshAgents");
} }
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
{
try
{
return await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
}
catch
{
return null;
}
}
private async Task SeedActiveTasksAsync() private async Task SeedActiveTasksAsync()
{ {
try try
@@ -289,7 +341,14 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public async Task<ListConfigDto?> GetListConfigAsync(string listId) public async Task<ListConfigDto?> GetListConfigAsync(string listId)
{ {
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId); try
{
return await _hub.InvokeAsync<ListConfigDto?>("GetListConfig", listId);
}
catch
{
return null;
}
} }
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
@@ -321,6 +380,82 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
} }
} }
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
{
try
{
var result = await _hub.InvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId);
return result ?? [];
}
catch
{
return [];
}
}
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
{
try
{
return await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
}
catch
{
return null;
}
}
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
{
LastMergeAllTarget = targetBranch;
await _hub.InvokeAsync("MergeAllPlanning", planningTaskId, targetBranch);
}
public async Task ContinuePlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
}
public async Task AbortPlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
}
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
{
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
}
// IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, ct);
async Task IWorkerClient.ResumePlanningSessionAsync(string taskId, CancellationToken ct)
=> await ResumePlanningSessionAsync(taskId, ct);
async Task IWorkerClient.DiscardPlanningSessionAsync(string taskId, CancellationToken ct)
=> await DiscardPlanningSessionAsync(taskId, ct);
async Task IWorkerClient.FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
=> await FinalizePlanningSessionAsync(taskId, queueAgentTasks, ct);
async Task<int> IWorkerClient.GetPendingDraftCountAsync(string taskId, CancellationToken ct)
=> await GetPendingDraftCountAsync(taskId, ct);
// DTOs for deserializing hub responses // DTOs for deserializing hub responses
private sealed class ActiveTaskDto private sealed class ActiveTaskDto
{ {
@@ -348,3 +483,4 @@ public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, s
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath); public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath); public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath); public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
public sealed record SeedResultDto(int Copied, int Skipped);

View File

@@ -16,7 +16,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class DetailsIslandViewModel : ViewModelBase public sealed partial class DetailsIslandViewModel : ViewModelBase
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker; private readonly IWorkerClient _worker;
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
// Current task row (set by IslandsShellViewModel via Bind) // Current task row (set by IslandsShellViewModel via Bind)
@@ -26,9 +26,33 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Editable fields // Editable fields
[ObservableProperty] private string _editableTitle = ""; [ObservableProperty] private string _editableTitle = "";
[ObservableProperty] private string _editableDescription = "";
[ObservableProperty] private bool _isEditingDescription;
[ObservableProperty] private bool _isDescriptionExpanded = true;
[ObservableProperty] private string _notes = ""; [ObservableProperty] private string _notes = "";
[ObservableProperty] private string _promptInput = ""; [ObservableProperty] private string _promptInput = "";
public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription;
public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription;
partial void OnIsDescriptionExpandedChanged(bool value)
{
OnPropertyChanged(nameof(IsDescriptionEditorVisible));
OnPropertyChanged(nameof(IsDescriptionPreviewVisible));
}
partial void OnIsEditingDescriptionChanged(bool value)
{
OnPropertyChanged(nameof(IsDescriptionEditorVisible));
OnPropertyChanged(nameof(IsDescriptionPreviewVisible));
}
[RelayCommand]
private void ToggleEditDescription() => IsEditingDescription = !IsEditingDescription;
[RelayCommand]
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
// Short task-id badge, e.g. "#T1A" // Short task-id badge, e.g. "#T1A"
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
@@ -78,6 +102,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
private bool _suppressAgentSave; private bool _suppressAgentSave;
private CancellationTokenSource? _agentSaveCts; private CancellationTokenSource? _agentSaveCts;
private bool _suppressDescSave;
private CancellationTokenSource? _descSaveCts;
public bool IsAgentSectionEnabled => !IsRunning; public bool IsAgentSectionEnabled => !IsRunning;
[ObservableProperty] private string? _worktreePath; [ObservableProperty] private string? _worktreePath;
@@ -112,6 +139,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[ObservableProperty] private string _newSubtaskTitle = ""; [ObservableProperty] private string _newSubtaskTitle = "";
// Planning merge controls
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
[ObservableProperty] private string? _selectedMergeTarget;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(MergeAllCommand))]
private bool _canMergeAll;
[ObservableProperty] private string? _mergeAllDisabledReason;
[ObservableProperty] private string? _mergeAllError;
// Claude CLI stream-json parser + buffer for partial text deltas // Claude CLI stream-json parser + buffer for partial text deltas
private readonly StreamLineFormatter _formatter = new(); private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _claudeBuf = new(); private readonly StringBuilder _claudeBuf = new();
@@ -139,7 +175,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting // Set by the view so DeleteTaskCommand can prompt yes/no before deleting
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; } public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services) // Set by the view so ReviewCombinedDiffCommand can show the planning diff modal
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
// Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, IServiceProvider services)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
@@ -182,6 +224,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_worker.WorktreeUpdatedEvent += taskId => _worker.WorktreeUpdatedEvent += taskId =>
{ {
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId); if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
};
_worker.TaskUpdatedEvent += taskId =>
{
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
};
Subtasks.CollectionChanged += (_, _) =>
{
RecomputeCanMergeAll();
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
}; };
} }
@@ -242,6 +296,31 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave(); partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave(); partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave();
partial void OnEditableDescriptionChanged(string value)
{
if (_suppressDescSave || Task is null) return;
_descSaveCts?.Cancel();
_descSaveCts = new CancellationTokenSource();
_ = SaveDescriptionAsync(_descSaveCts.Token);
}
private async System.Threading.Tasks.Task SaveDescriptionAsync(CancellationToken ct)
{
try
{
await System.Threading.Tasks.Task.Delay(400, ct);
if (Task is null) return;
await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
var entity = await repo.GetByIdAsync(Task.Id);
if (entity is null) return;
entity.Description = string.IsNullOrWhiteSpace(EditableDescription) ? null : EditableDescription;
await repo.UpdateAsync(entity);
}
catch (OperationCanceledException) { }
catch { }
}
private void QueueAgentSave() private void QueueAgentSave()
{ {
if (_suppressAgentSave || Task is null) return; if (_suppressAgentSave || Task is null) return;
@@ -310,12 +389,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
OnPropertyChanged(nameof(TaskIdBadge)); OnPropertyChanged(nameof(TaskIdBadge));
Log.Clear(); Log.Clear();
Subtasks.Clear(); Subtasks.Clear();
MergeTargetBranches.Clear();
SelectedMergeTarget = null;
CanMergeAll = false;
MergeAllDisabledReason = null;
MergeAllError = null;
_claudeBuf.Clear(); _claudeBuf.Clear();
if (row == null) if (row == null)
{ {
_subscribedTaskId = null; _subscribedTaskId = null;
EditableTitle = ""; EditableTitle = "";
EditableDescription = "";
Notes = ""; Notes = "";
Model = null; Model = null;
WorktreePath = null; WorktreePath = null;
@@ -360,6 +445,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
if (entity == null) return; if (entity == null) return;
EditableTitle = entity.Title; EditableTitle = entity.Title;
_suppressDescSave = true;
try { EditableDescription = entity.Description ?? ""; }
finally { _suppressDescSave = false; }
Notes = entity.Notes ?? ""; Notes = entity.Notes ?? "";
Model = entity.Model; Model = entity.Model;
WorktreePath = entity.Worktree?.Path; WorktreePath = entity.Worktree?.Path;
@@ -378,14 +466,191 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Subscribe only after DB load confirms the task exists // Subscribe only after DB load confirms the task exists
_subscribedTaskId = row.Id; _subscribedTaskId = row.Id;
// Replay the latest run's persisted log so output is visible across app restarts.
await ReplayLogFileAsync(entity.LogPath, ct);
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id); var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
foreach (var s in subs) foreach (var s in subs)
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed }); Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
if (entity.Status == ClaudeDo.Data.Models.TaskStatus.Planning ||
entity.Status == ClaudeDo.Data.Models.TaskStatus.Planned)
{
await LoadPlanningChildrenAsync(row.Id, ct);
}
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
} }
private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(logPath)) return;
var expanded = ExpandUserPath(logPath);
if (!System.IO.File.Exists(expanded)) return;
try
{
const int maxLines = 2000;
string[] all;
await using (var fs = new System.IO.FileStream(
expanded,
System.IO.FileMode.Open,
System.IO.FileAccess.Read,
System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
using (var reader = new System.IO.StreamReader(fs))
{
var list = new List<string>();
while (await reader.ReadLineAsync(ct) is { } line)
list.Add(line);
all = list.ToArray();
}
ct.ThrowIfCancellationRequested();
var start = Math.Max(0, all.Length - maxLines);
for (int i = start; i < all.Length; i++)
{
ct.ThrowIfCancellationRequested();
if (_subscribedTaskId is null) return;
// Worker writes raw Claude CLI stdout to disk (no prefix) but broadcasts
// it with a "[stdout] " prefix. Match the live-stream format so the same
// stream-json parser handles both.
var line = all[i];
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
OnTaskMessage(_subscribedTaskId, normalized);
}
FlushClaudeBuffer();
}
catch (OperationCanceledException) { throw; }
catch { /* best-effort replay */ }
}
private static string ExpandUserPath(string path)
{
if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
return System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
path[2..]);
if (path == "~")
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return path;
}
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var children = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Where(t => t.ParentTaskId == parentTaskId)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
foreach (var child in children)
{
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
if (existing != null)
{
existing.Status = child.Status;
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
}
}
if (MergeTargetBranches.Count == 0)
{
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
if (childWithWorktree != null)
{
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
if (targets != null)
{
MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches)
MergeTargetBranches.Add(b);
SelectedMergeTarget = targets.DefaultBranch;
}
}
}
RecomputeCanMergeAll();
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
}
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
{
if (Task is null) return;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var child = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == childTaskId && t.ParentTaskId == Task.Id);
if (child == null) return;
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
if (existing != null)
{
existing.Status = child.Status;
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
}
RecomputeCanMergeAll();
}
catch { /* best-effort */ }
}
internal void RecomputeCanMergeAll()
{
var notDone = Subtasks.Count(c => c.Status != ClaudeDo.Data.Models.TaskStatus.Done);
if (notDone > 0)
{
CanMergeAll = false;
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
return;
}
var badWt = Subtasks.FirstOrDefault(c =>
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Discarded ||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Kept);
if (badWt is not null)
{
CanMergeAll = false;
MergeAllDisabledReason = "at least one worktree was discarded/kept";
return;
}
CanMergeAll = true;
MergeAllDisabledReason = null;
}
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
{
if (Task is null || ShowPlanningDiffModal is null) return;
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, Task.Id, SelectedMergeTarget ?? "main");
await vm.InitializeAsync();
await ShowPlanningDiffModal(vm);
}
private bool CanReviewDiff() => Task?.IsPlanningParent == true && Subtasks.Any();
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private async System.Threading.Tasks.Task MergeAllAsync()
{
MergeAllError = null;
try
{
await _worker.MergeAllPlanningAsync(Task!.Id, SelectedMergeTarget ?? "main");
}
catch (Exception ex)
{
MergeAllError = ex.Message;
}
}
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId) private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
{ {
try try
@@ -481,9 +746,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone."); var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
if (!ok) return; if (!ok) return;
} }
await using var ctx = _dbFactory.CreateDbContext(); try
var repo = new TaskRepository(ctx); {
await repo.DeleteAsync(row.Id); await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
await repo.DeleteAsync(row.Id);
}
catch (DbUpdateException ex) when (
ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)
{
if (ShowErrorAsync != null)
await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first.");
return;
}
if (DeleteFromList != null) if (DeleteFromList != null)
await DeleteFromList(row); await DeleteFromList(row);
CloseDetail?.Invoke(); CloseDetail?.Invoke();
@@ -595,4 +871,6 @@ public sealed partial class SubtaskRowViewModel : ViewModelBase
public required string Id { get; init; } public required string Id { get; init; }
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
[ObservableProperty] private bool _done; [ObservableProperty] private bool _done;
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
} }

View File

@@ -5,8 +5,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class ListNavItemViewModel : ViewModelBase public sealed partial class ListNavItemViewModel : ViewModelBase
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Name { get; init; }
public required ListKind Kind { get; init; } public required ListKind Kind { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private int _count; [ObservableProperty] private int _count;
[ObservableProperty] private bool _isActive; [ObservableProperty] private bool _isActive;
[ObservableProperty] private string? _workingDir; [ObservableProperty] private string? _workingDir;

View File

@@ -2,6 +2,8 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
@@ -38,8 +40,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row) private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
{ {
if (row is null || ShowListSettingsModal is null || _services is null) return; if (row is null || ShowListSettingsModal is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(row.Id, row.Name, row.WorkingDir, row.DefaultCommitType); await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
await ShowListSettingsModal(vm); await ShowListSettingsModal(vm);
await RefreshRowAsync(row.Id); await RefreshRowAsync(row.Id);
} }
@@ -68,7 +71,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
: Environment.UserName.ToUpperInvariant(); : Environment.UserName.ToUpperInvariant();
if (_worker is not null) if (_worker is not null)
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id); {
_worker.ListUpdatedEvent += id => _ = RefreshRowAsync(id);
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
}
} }
public async Task LoadAsync(CancellationToken ct = default) public async Task LoadAsync(CancellationToken ct = default)
@@ -82,6 +90,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" }, new ListNavItemViewModel { Id = "smart:my-day", Name = "My Day", Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" }, new ListNavItemViewModel { Id = "smart:important", Name = "Important", Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" }, new ListNavItemViewModel { Id = "smart:planned", Name = "Planned", Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = "virtual:queued", Name = "Queue", Kind = ListKind.Virtual, IconKey = "Inbox" },
new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" }, new ListNavItemViewModel { Id = "virtual:running", Name = "Running", Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" }, new ListNavItemViewModel { Id = "virtual:review", Name = "Review", Kind = ListKind.Virtual, IconKey = "Eye" },
}; };
@@ -116,13 +125,91 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
public async Task RefreshCountsAsync(CancellationToken ct = default) public async Task RefreshCountsAsync(CancellationToken ct = default)
{ {
foreach (var i in Items) i.Count = 0; try
await Task.CompletedTask; {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// Snapshot the open (non-Done) tasks once; small enough collection for client-side grouping.
var open = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status != TaskStatus.Done)
.Select(t => new { t.ListId, t.Status, t.IsMyDay, t.IsStarred, Scheduled = t.ScheduledFor })
.ToListAsync(ct);
var running = open.Count(t => t.Status == TaskStatus.Running);
var queued = open.Count(t => t.Status == TaskStatus.Queued);
var review = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == TaskStatus.Done && t.Worktree != null && t.Worktree.State == WorktreeState.Active)
.CountAsync(ct);
foreach (var item in SmartLists)
{
item.Count = item.Id switch
{
"smart:my-day" => open.Count(t => t.IsMyDay),
"smart:important" => open.Count(t => t.IsStarred),
"smart:planned" => open.Count(t => t.Scheduled != null),
"virtual:queued" => queued,
"virtual:running" => running,
"virtual:review" => review,
_ => 0,
};
}
foreach (var item in UserLists)
{
var listId = item.Id.StartsWith("user:", StringComparison.Ordinal)
? item.Id["user:".Length..]
: item.Id;
item.Count = open.Count(t => t.ListId == listId);
}
}
catch (OperationCanceledException) { throw; }
catch { /* best-effort refresh */ }
} }
[RelayCommand] [RelayCommand]
private void Select(ListNavItemViewModel item) => SelectedList = item; private void Select(ListNavItemViewModel item) => SelectedList = item;
[RelayCommand]
private async Task CreateListAsync()
{
var entity = new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = "New list",
DefaultCommitType = "chore",
CreatedAt = DateTime.UtcNow,
};
await using (var ctx = await _dbFactory.CreateDbContextAsync())
{
var lists = new ListRepository(ctx);
await lists.AddAsync(entity);
}
var item = new ListNavItemViewModel
{
Id = $"user:{entity.Id}",
Name = entity.Name,
Kind = ListKind.User,
IconKey = "Folder",
DotColorKey = "Moss",
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
};
Items.Add(item);
UserLists.Add(item);
SelectedList = item;
if (ShowListSettingsModal is not null && _services is not null)
{
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType);
await ShowListSettingsModal(vm);
await RefreshRowAsync(item.Id);
}
}
partial void OnSelectedListChanged(ListNavItemViewModel? value) partial void OnSelectedListChanged(ListNavItemViewModel? value)
{ {
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value); foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
@@ -142,6 +229,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
var entity = await lists.GetByIdAsync(rawId); var entity = await lists.GetByIdAsync(rawId);
if (entity is null) return; if (entity is null) return;
row.Name = entity.Name;
row.WorkingDir = entity.WorkingDir; row.WorkingDir = entity.WorkingDir;
row.DefaultCommitType = entity.DefaultCommitType; row.DefaultCommitType = entity.DefaultCommitType;
} }

View File

@@ -23,6 +23,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private int _diffDeletions; [ObservableProperty] private int _diffDeletions;
[ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow; [ObservableProperty] private bool _dropHintBelow;
[ObservableProperty] private string? _parentTaskId;
[ObservableProperty] private bool _isExpanded = true;
[ObservableProperty] private bool _hasPlanningChildren;
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}"; public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
@@ -31,12 +34,31 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public int StepsCount { get; init; } public int StepsCount { get; init; }
public int StepsCompleted { get; init; } public int StepsCompleted { get; init; }
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
public bool IsPlanningParent => Status == TaskStatus.Planning
|| Status == TaskStatus.Planned
|| HasPlanningChildren;
public bool IsDraft => Status == TaskStatus.Draft;
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
public bool CanResumeOrDiscardPlanning => Status == TaskStatus.Planning;
public string? PlanningBadge => Status switch
{
TaskStatus.Planning => "PLANNING",
TaskStatus.Planned => "PLANNED",
_ => null,
};
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch); public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0; public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
public bool HasTags => Tags.Count > 0; public bool HasTags => Tags.Count > 0;
public bool HasSteps => StepsCount > 0; public bool HasSteps => StepsCount > 0;
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done; public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
public bool IsRunning => Status == TaskStatus.Running; public bool IsRunning => Status == TaskStatus.Running;
public bool IsQueued => Status == TaskStatus.Queued;
public bool IsWaiting => Status == TaskStatus.Waiting;
public bool HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail); public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
public string DiffAdditionsText => $"+{DiffAdditions}"; public string DiffAdditionsText => $"+{DiffAdditions}";
@@ -49,6 +71,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
TaskStatus.Failed => "error", TaskStatus.Failed => "error",
TaskStatus.Done => "review", TaskStatus.Done => "review",
TaskStatus.Queued => "queued", TaskStatus.Queued => "queued",
TaskStatus.Waiting => "waiting",
_ => "idle", _ => "idle",
}; };
@@ -56,35 +79,58 @@ public sealed partial class TaskRowViewModel : ViewModelBase
{ {
OnPropertyChanged(nameof(StatusChipClass)); OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(HasLiveTail)); OnPropertyChanged(nameof(HasLiveTail));
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
} }
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnHasPlanningChildrenChanged(bool value)
=> OnPropertyChanged(nameof(IsPlanningParent));
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch)); partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail)); partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue)); partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
partial void OnScheduledForChanged(DateTime? value) => OnPropertyChanged(nameof(IsOverdue)); partial void OnScheduledForChanged(DateTime? value)
{
OnPropertyChanged(nameof(IsOverdue));
OnPropertyChanged(nameof(HasSchedule));
}
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); } partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); } partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
public static TaskRowViewModel FromEntity(TaskEntity t) public static TaskRowViewModel FromEntity(TaskEntity t)
{
var row = new TaskRowViewModel { Id = t.Id, CreatedAt = t.CreatedAt };
row.UpdateFromEntity(t);
return row;
}
public void UpdateFromEntity(TaskEntity t)
{ {
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat); var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
return new TaskRowViewModel Title = t.Title;
{ ListName = t.List?.Name ?? "";
Id = t.Id, Done = t.Status == TaskStatus.Done;
Title = t.Title, IsStarred = t.IsStarred;
ListName = t.List?.Name ?? "", IsMyDay = t.IsMyDay;
Done = t.Status == TaskStatus.Done, Status = t.Status;
IsStarred = t.IsStarred, Branch = t.Worktree?.BranchName;
IsMyDay = t.IsMyDay, DiffStat = t.Worktree?.DiffStat;
Status = t.Status, ScheduledFor = t.ScheduledFor;
Branch = t.Worktree?.BranchName, DiffAdditions = add;
DiffStat = t.Worktree?.DiffStat, DiffDeletions = del;
ScheduledFor = t.ScheduledFor, ParentTaskId = t.ParentTaskId;
DiffAdditions = add,
DiffDeletions = del,
CreatedAt = t.CreatedAt,
};
} }
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions". // Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".

View File

@@ -4,6 +4,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -12,11 +14,14 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TasksIslandViewModel : ViewModelBase public sealed partial class TasksIslandViewModel : ViewModelBase
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient? _worker;
private readonly Dictionary<string, bool> _expandedState = new();
private ListNavItemViewModel? _currentList; private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts; private CancellationTokenSource? _loadCts;
public event EventHandler? SelectionChanged; public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested; public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged;
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty); public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
public ObservableCollection<TaskRowViewModel> Items { get; } = new(); public ObservableCollection<TaskRowViewModel> Items { get; } = new();
@@ -38,9 +43,86 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
[ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = "COMPLETED"; [ObservableProperty] private string _completedHeader = "COMPLETED";
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory) public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker;
if (_worker is not null)
{
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage;
}
}
private void OnWorkerTaskMessage(string taskId, string line)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.LiveTail = line;
}
private async void OnWorkerTaskUpdated(string taskId)
{
var list = _currentList;
if (list is null) return;
// virtual:queued / virtual:running include Planning parents whose children match,
// which can't be decided from a single entity. Always full-reload in those cases.
if (list.Kind == ListKind.Virtual &&
(list.Id == "virtual:queued" || list.Id == "virtual:running"))
{
LoadForList(list);
return;
}
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
{
if (existing is not null) Items.Remove(existing);
}
else
{
var matches = TaskMatchesList(entity, list);
if (existing is not null && matches) existing.UpdateFromEntity(entity);
else if (existing is not null) Items.Remove(existing);
else if (matches) { LoadForList(list); return; }
else return;
}
Regroup();
UpdateSubtitle();
}
catch { }
}
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
// parent matches iff any child has the matching status. OnWorkerTaskUpdated handles those
// lists via a full reload rather than the delta path.
private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch
{
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null,
ListKind.User => $"user:{t.ListId}" == list.Id,
_ => false,
};
private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm)
HeaderTitle = vm.Name;
} }
public void LoadForList(ListNavItemViewModel? list) public void LoadForList(ListNavItemViewModel? list)
@@ -50,7 +132,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_loadCts = new CancellationTokenSource(); _loadCts = new CancellationTokenSource();
var ct = _loadCts.Token; var ct = _loadCts.Token;
if (_currentList is not null)
_currentList.PropertyChanged -= OnCurrentListPropertyChanged;
_currentList = list; _currentList = list;
if (_currentList is not null)
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
Items.Clear(); Items.Clear();
OverdueItems.Clear(); OverdueItems.Clear();
OpenItems.Clear(); OpenItems.Clear();
@@ -80,36 +167,105 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
static bool IsPlanningStatus(TaskStatus s) => s == TaskStatus.Planning || s == TaskStatus.Planned;
IEnumerable<TaskEntity> filtered = list.Kind switch IEnumerable<TaskEntity> filtered = list.Kind switch
{ {
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay), ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred), ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null), ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running), ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t =>
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active), (t.Status == TaskStatus.Queued && t.ParentTaskId == null) ||
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && (c.Status == TaskStatus.Queued || c.Status == TaskStatus.Waiting)))),
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t =>
(t.Status == TaskStatus.Running && t.ParentTaskId == null) ||
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null),
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id), ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
_ => Enumerable.Empty<TaskEntity>(), _ => Enumerable.Empty<TaskEntity>(),
}; };
foreach (var t in filtered) var filteredList = filtered.ToList();
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
{
if (existingIds.Add(c.Id))
filteredList.Add(c);
}
foreach (var t in filteredList)
Items.Add(TaskRowViewModel.FromEntity(t)); Items.Add(TaskRowViewModel.FromEntity(t));
// Mark any top-level row that has at least one child as a planning parent,
// so its subtasks remain expandable even after the parent is queued/running.
var parentsWithChildren = Items
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
.Select(r => r.ParentTaskId!)
.ToHashSet();
foreach (var r in Items)
if (parentsWithChildren.Contains(r.Id))
r.HasPlanningChildren = true;
Regroup(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
} }
private void Regroup() internal void Regroup()
{ {
OverdueItems.Clear(); OverdueItems.Clear();
OpenItems.Clear(); OpenItems.Clear();
CompletedItems.Clear(); CompletedItems.Clear();
var today = DateTime.Today; // Auto-collapse planning parents whose every child is Done (unless the user
// has explicitly toggled the row — saved state wins).
var childrenByParent = Items
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
.GroupBy(r => r.ParentTaskId!)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild))
{
if (_expandedState.ContainsKey(parent.Id)) continue;
if (childrenByParent.TryGetValue(parent.Id, out var kids)
&& kids.Count > 0
&& kids.All(c => c.Status == TaskStatus.Done))
{
parent.IsExpanded = false;
}
}
// Restore IsExpanded from saved state
foreach (var r in Items) foreach (var r in Items)
{ {
if (r.Done) if (_expandedState.TryGetValue(r.Id, out var saved))
r.IsExpanded = saved;
}
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
// Items is already ordered by SortOrder from the DB query.
var topLevel = Items.Where(r => !r.IsChild);
var flat = new List<TaskRowViewModel>();
foreach (var parent in topLevel)
{
flat.Add(parent);
// Also expand for Done parents so their (Done) children reach the classification
// loop and land in CompletedItems alongside the parent.
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
{
var children = Items.Where(r => r.ParentTaskId == parent.Id);
flat.AddRange(children);
}
}
var today = DateTime.Today;
foreach (var r in flat)
{
var underOpenPlanningParent = r.IsChild &&
flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);
if (r.Done && !underOpenPlanningParent)
CompletedItems.Add(r); CompletedItems.Add(r);
else if (r.ScheduledFor is { } d && d.Date < today) else if (r.ScheduledFor is { } d && d.Date < today)
OverdueItems.Add(r); OverdueItems.Add(r);
@@ -170,6 +326,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
Regroup(); Regroup();
NewTaskTitle = ""; NewTaskTitle = "";
UpdateSubtitle(); UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
} }
public bool CanReorder => _currentList?.Kind == ListKind.User; public bool CanReorder => _currentList?.Kind == ListKind.User;
@@ -199,15 +356,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
if (source.IsRunning || target.IsRunning) return; if (source.IsRunning || target.IsRunning) return;
if (ReferenceEquals(source, target)) return; if (ReferenceEquals(source, target)) return;
var srcIdx = Items.IndexOf(source); // Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
var tgtIdx = Items.IndexOf(target); MoveWithinCollection(Items, source, target, placeBelow);
if (srcIdx < 0 || tgtIdx < 0) return;
Items.RemoveAt(srcIdx); // Apply the same move in whichever section the row lives in.
var newTgtIdx = Items.IndexOf(target); // Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
var insertIdx = placeBelow ? newTgtIdx + 1 : newTgtIdx; // that's determined by Done flag and ScheduledFor date, not drag-drop.
if (insertIdx < 0 || insertIdx > Items.Count) insertIdx = Items.Count; var sourceSection = SectionFor(source);
Items.Insert(insertIdx, source); var targetSection = SectionFor(target);
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
MoveWithinCollection(sourceSection, source, target, placeBelow);
var listId = _currentList.Id["user:".Length..]; var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList(); var orderedIds = Items.Select(i => i.Id).ToList();
@@ -223,8 +381,33 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
if (e is not null) e.SortOrder = i; if (e is not null) e.SortOrder = i;
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}
Regroup(); private static void MoveWithinCollection(
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
TaskRowViewModel source,
TaskRowViewModel target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
var tgtIdx = coll.IndexOf(target);
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
if (srcIdx < finalIdx) finalIdx--;
if (finalIdx < 0) finalIdx = 0;
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
if (finalIdx == srcIdx) return;
coll.Move(srcIdx, finalIdx);
}
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
{
if (OverdueItems.Contains(row)) return OverdueItems;
if (OpenItems.Contains(row)) return OpenItems;
if (CompletedItems.Contains(row)) return CompletedItems;
return null;
} }
[RelayCommand] [RelayCommand]
@@ -241,6 +424,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
} }
Regroup(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
} }
[RelayCommand] [RelayCommand]
@@ -254,8 +438,61 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
entity.IsStarred = row.IsStarred; entity.IsStarred = row.IsStarred;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
TasksChanged?.Invoke(this, EventArgs.Empty);
} }
[RelayCommand]
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
if (row is null || row.IsRunning) return;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
entity.Status = TaskStatus.Queued;
await db.SaveChangesAsync();
row.Status = TaskStatus.Queued;
if (_worker is not null)
{
try { await _worker.WakeQueueAsync(); } catch { }
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
{
if (row is null) return;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
entity.Status = TaskStatus.Manual;
await db.SaveChangesAsync();
row.Status = TaskStatus.Manual;
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
{
if (row is null) return;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
entity.ScheduledFor = when;
await db.SaveChangesAsync();
row.ScheduledFor = when;
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private Task ClearScheduleAsync(TaskRowViewModel? row) =>
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
[RelayCommand] [RelayCommand]
private void Select(TaskRowViewModel row) => SelectedTask = row; private void Select(TaskRowViewModel row) => SelectedTask = row;
@@ -265,8 +502,102 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void Sort() { /* placeholder — UI-only */ } private void Sort() { /* placeholder — UI-only */ }
public event EventHandler? OpenListSettingsRequested;
[RelayCommand] [RelayCommand]
private void More() { /* placeholder — UI-only */ } private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || row.Status != TaskStatus.Manual) return;
ForegroundHelper.AllowAny();
try { await _worker!.StartPlanningSessionAsync(row.Id); }
catch { }
}
[RelayCommand]
private async Task RunInteractivelyAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
ForegroundHelper.AllowAny();
try { await _worker.OpenInteractiveTerminalAsync(row.Id); }
catch { }
}
[RelayCommand]
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsPlanningParent) return;
if (_worker is null) return;
try
{
var draftCount = await _worker.GetPendingDraftCountAsync(row.Id);
var modalVm = new UnfinishedPlanningModalViewModel
{
TaskTitle = row.Title,
DraftCount = draftCount,
};
if (ShowUnfinishedPlanningModal is null)
return;
await ShowUnfinishedPlanningModal(modalVm);
var choice = await modalVm.Result.Task;
switch (choice)
{
case UnfinishedPlanningModalResult.Resume:
ForegroundHelper.AllowAny();
await _worker.ResumePlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningModalResult.FinalizeNow:
await _worker.FinalizePlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningModalResult.Discard:
await _worker.DiscardPlanningSessionAsync(row.Id);
break;
case UnfinishedPlanningModalResult.Cancel:
default:
break;
}
}
catch { }
}
[RelayCommand]
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
try { await _worker!.DiscardPlanningSessionAsync(row.Id); }
catch { }
}
[RelayCommand]
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
catch { }
}
[RelayCommand]
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: true); }
catch { }
}
[RelayCommand]
private void ToggleExpand(TaskRowViewModel? row)
{
if (row is null) return;
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded);
_expandedState[row.Id] = next;
row.IsExpanded = next;
Regroup();
}
partial void OnSelectedTaskChanged(TaskRowViewModel? value) partial void OnSelectedTaskChanged(TaskRowViewModel? value)
{ {

View File

@@ -1,39 +1,71 @@
using Avalonia.Threading;
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Planning;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
public sealed partial class IslandsShellViewModel : ViewModelBase public sealed partial class IslandsShellViewModel : ViewModelBase
{ {
public ListsIslandViewModel Lists { get; } public ListsIslandViewModel? Lists { get; }
public TasksIslandViewModel Tasks { get; } public TasksIslandViewModel? Tasks { get; }
public DetailsIslandViewModel Details { get; } public DetailsIslandViewModel? Details { get; }
public WorkerClient Worker { get; } public WorkerClient? Worker { get; }
public UpdateCheckService UpdateCheck => _updateCheck;
public string ConnectionText => public string ConnectionText =>
Worker.IsConnected ? "Online" Worker?.IsConnected == true ? "Online"
: Worker.IsReconnecting ? "Connecting…" : Worker?.IsReconnecting == true ? "Connecting…"
: "Offline"; : "Offline";
public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting; public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
private readonly UpdateCheckService _updateCheck;
private readonly InstallerLocator _installerLocator;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
// Set by MainWindow to open the conflict resolution dialog.
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
[ObservableProperty] private bool _isUpdateBannerVisible;
[ObservableProperty] private string? _updateBannerLatestVersion;
[ObservableProperty] private string? _inlineUpdateStatus;
private bool _bannerDismissedThisSession;
[ObservableProperty] [ObservableProperty]
private double _windowWidth = 1280; private double _windowWidth = 1280;
[ObservableProperty]
private string? _workerLogText;
[ObservableProperty]
private WorkerLogLevel _workerLogLevel;
[ObservableProperty]
private bool _isWorkerLogVisible;
public bool ShowDetails => WindowWidth >= 1100; public bool ShowDetails => WindowWidth >= 1100;
public bool ShowLists => WindowWidth >= 780; public bool ShowLists => WindowWidth >= 780;
[RelayCommand] private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
private void FocusSearch() => Lists.RequestFocusSearch();
[RelayCommand] [RelayCommand]
private void FocusAddTask() => Tasks.RequestFocusAddTask(); private void FocusSearch() => Lists?.RequestFocusSearch();
[RelayCommand]
private void FocusAddTask() => Tasks?.RequestFocusAddTask();
public async Task ToggleSelectedDoneAsync() public async Task ToggleSelectedDoneAsync()
{ {
if (Tasks.SelectedTask is { } row) if (Tasks?.SelectedTask is { } row)
await Tasks.ToggleDoneCommand.ExecuteAsync(row); await Tasks.ToggleDoneCommand.ExecuteAsync(row);
} }
@@ -43,19 +75,92 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowLists)); OnPropertyChanged(nameof(ShowLists));
} }
public void OnWorkerLogReceived(WorkerLogEntry entry)
{
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
WorkerLogText = $"{hhmm} · {entry.Message}";
WorkerLogLevel = entry.Level;
IsWorkerLogVisible = true;
_clearTimer.Stop();
_clearTimer.Start();
}
public void ClearWorkerLog()
{
IsWorkerLogVisible = false;
WorkerLogText = null;
}
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
{
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
_ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
}
private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
{
if (ShowConflictDialog == null || _dbFactory == null) return;
string subtaskTitle = subtaskId;
string worktreePath = System.Environment.CurrentDirectory;
string targetBranch = Worker?.LastMergeAllTarget ?? "main";
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks
.Include(t => t.Worktree)
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == subtaskId);
if (entity != null)
{
subtaskTitle = entity.Title;
if (entity.Worktree?.Path is { } p)
worktreePath = p;
}
}
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
var vm = new ConflictResolutionViewModel(
Worker!,
planningTaskId,
subtaskTitle,
targetBranch,
conflictedFiles,
worktreePath);
await ShowConflictDialog(vm);
}
// For tests only — does NOT wire up events.
internal IslandsShellViewModel() { }
public IslandsShellViewModel( public IslandsShellViewModel(
ListsIslandViewModel lists, ListsIslandViewModel lists,
TasksIslandViewModel tasks, TasksIslandViewModel tasks,
DetailsIslandViewModel details, DetailsIslandViewModel details,
WorkerClient worker) WorkerClient worker,
UpdateCheckService updateCheck,
InstallerLocator installerLocator,
IDbContextFactory<ClaudeDoDbContext> dbFactory)
{ {
Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists = lists; Tasks = tasks; Details = details; Worker = worker;
_updateCheck = updateCheck;
_installerLocator = installerLocator;
_dbFactory = dbFactory;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
Tasks.OpenListSettingsRequested += (_, _) =>
{
if (Lists.SelectedList is { } row)
Lists.OpenListSettingsCommand.Execute(row);
};
Details.CloseDetail = () => Tasks.SelectedTask = null; Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.DeleteFromList = _ => Details.DeleteFromList = row =>
{ {
Tasks.LoadForList(Lists.SelectedList); Tasks.LoadForList(Lists.SelectedList);
_ = Lists.RefreshCountsAsync();
return System.Threading.Tasks.Task.CompletedTask; return System.Threading.Tasks.Task.CompletedTask;
}; };
Worker.PropertyChanged += (_, e) => Worker.PropertyChanged += (_, e) =>
@@ -66,6 +171,84 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(IsOffline));
} }
}; };
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
_clearTimer.Elapsed += (_, _) =>
{
if (Dispatcher.UIThread.CheckAccess())
ClearWorkerLog();
else
Dispatcher.UIThread.Post(ClearWorkerLog);
};
_ = Lists.LoadAsync(); _ = Lists.LoadAsync();
_updateCheck.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
{
RefreshBannerFromStatus();
}
};
// Fire-and-forget startup check — never block UI.
_ = Task.Run(async () =>
{
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
});
}
private void RefreshBannerFromStatus()
{
switch (_updateCheck.LastCheckStatus)
{
case UpdateCheckStatus.UpdateAvailable:
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
IsUpdateBannerVisible = true;
InlineUpdateStatus = null;
break;
case UpdateCheckStatus.UpToDate:
IsUpdateBannerVisible = false;
ShowInlineStatus($"You're up to date (v{_updateCheck.CurrentVersion})");
break;
case UpdateCheckStatus.CheckFailed:
ShowInlineStatus("Could not check for updates");
break;
}
}
private async void ShowInlineStatus(string text)
{
InlineUpdateStatus = text;
await Task.Delay(3000);
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
}
[RelayCommand]
private async Task CheckForUpdatesAsync()
{
await _updateCheck.CheckNowAsync(CancellationToken.None);
}
[RelayCommand]
private void DismissBanner()
{
_bannerDismissedThisSession = true;
IsUpdateBannerVisible = false;
}
[RelayCommand]
private void UpdateNow()
{
var path = _installerLocator.Find();
if (path is null) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
Environment.Exit(0);
}
catch
{
// Intentionally silent — if this fails there's nothing useful to show.
}
} }
} }

View File

@@ -14,8 +14,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
[ObservableProperty] private string _defaultClaudeInstructions = ""; [ObservableProperty] private string _defaultClaudeInstructions = "";
[ObservableProperty] private string _defaultModel = "sonnet"; [ObservableProperty] private string _defaultModel = "sonnet";
[ObservableProperty] private int _defaultMaxTurns = 30; [ObservableProperty] private int _defaultMaxTurns = 100;
[ObservableProperty] private string _defaultPermissionMode = "bypassPermissions"; [ObservableProperty] private string _defaultPermissionMode = "auto";
[ObservableProperty] private string _worktreeStrategy = "sibling"; [ObservableProperty] private string _worktreeStrategy = "sibling";
[ObservableProperty] private string? _centralWorktreeRoot; [ObservableProperty] private string? _centralWorktreeRoot;
[ObservableProperty] private bool _worktreeAutoCleanupEnabled; [ObservableProperty] private bool _worktreeAutoCleanupEnabled;
@@ -28,7 +28,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" }; public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
public IReadOnlyList<string> PermissionModes { get; } = new[] public IReadOnlyList<string> PermissionModes { get; } = new[]
{ "bypassPermissions", "acceptEdits", "plan", "default" }; { "auto", "bypassPermissions", "acceptEdits", "plan", "default" };
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" }; public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
public string AppVersion { get; } = public string AppVersion { get; } =
@@ -38,6 +38,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs"); public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json"); public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent);
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
public SettingsModalViewModel(WorkerClient worker) public SettingsModalViewModel(WorkerClient worker)
@@ -56,7 +60,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? ""; DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
DefaultModel = dto.DefaultModel ?? "sonnet"; DefaultModel = dto.DefaultModel ?? "sonnet";
DefaultMaxTurns = dto.DefaultMaxTurns; DefaultMaxTurns = dto.DefaultMaxTurns;
DefaultPermissionMode = dto.DefaultPermissionMode ?? "bypassPermissions"; DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
CentralWorktreeRoot = dto.CentralWorktreeRoot; CentralWorktreeRoot = dto.CentralWorktreeRoot;
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
@@ -103,7 +107,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
DefaultClaudeInstructions ?? "", DefaultClaudeInstructions ?? "",
DefaultModel ?? "sonnet", DefaultModel ?? "sonnet",
DefaultMaxTurns, DefaultMaxTurns,
DefaultPermissionMode ?? "bypassPermissions", DefaultPermissionMode ?? "auto",
WorktreeStrategy ?? "sibling", WorktreeStrategy ?? "sibling",
string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot, string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot,
WorktreeAutoCleanupEnabled, WorktreeAutoCleanupEnabled,
@@ -161,6 +165,32 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
finally { IsBusy = false; } finally { IsBusy = false; }
} }
[RelayCommand]
private async Task RestoreDefaultAgents()
{
IsBusy = true;
StatusMessage = "";
try
{
var result = await _worker.RestoreDefaultAgentsAsync();
if (result is null)
StatusMessage = "Worker offline.";
else if (result.Copied == 0 && result.Skipped == 0)
StatusMessage = "No default agents bundled.";
else if (result.Copied == 0)
StatusMessage = "All default agents already present.";
else
StatusMessage = $"Restored {result.Copied} default agent(s).";
await _worker.RefreshAgentsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Restore failed: {ex.Message}";
}
finally { IsBusy = false; }
}
[RelayCommand] [RelayCommand]
private void OpenPath(string? path) private void OpenPath(string? path)
{ {
@@ -173,4 +203,20 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
} }
catch { /* ignore */ } catch { /* ignore */ }
} }
[RelayCommand]
private void OpenPrompt(string? kindName)
{
if (!Enum.TryParse<PromptKind>(kindName, ignoreCase: true, out var kind)) return;
try
{
PromptFiles.EnsureExists(kind);
var path = PromptFiles.PathFor(kind);
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
StatusMessage = $"Open failed: {ex.Message}";
}
}
} }

View File

@@ -0,0 +1,27 @@
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public enum UnfinishedPlanningModalResult
{
Cancel,
Resume,
FinalizeNow,
Discard,
}
public sealed partial class UnfinishedPlanningModalViewModel : ViewModelBase
{
[ObservableProperty] private string _taskTitle = "";
[ObservableProperty] private int _draftCount;
public TaskCompletionSource<UnfinishedPlanningModalResult> Result { get; } = new();
public Action? CloseAction { get; set; }
[RelayCommand] private void Resume() { Result.TrySetResult(UnfinishedPlanningModalResult.Resume); CloseAction?.Invoke(); }
[RelayCommand] private void FinalizeNow() { Result.TrySetResult(UnfinishedPlanningModalResult.FinalizeNow); CloseAction?.Invoke(); }
[RelayCommand] private void Discard() { Result.TrySetResult(UnfinishedPlanningModalResult.Discard); CloseAction?.Invoke(); }
[RelayCommand] private void Cancel() { Result.TrySetResult(UnfinishedPlanningModalResult.Cancel); CloseAction?.Invoke(); }
}

View File

@@ -0,0 +1,83 @@
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Planning;
public sealed partial class ConflictResolutionViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _planningTaskId;
private readonly string _worktreePath;
public string SubtaskTitle { get; }
public string TargetBranch { get; }
public IReadOnlyList<string> ConflictedFiles { get; }
[ObservableProperty] private string? _vsCodeError;
[ObservableProperty] private string? _actionError;
public Action? CloseRequested { get; set; }
public ConflictResolutionViewModel(
IWorkerClient worker,
string planningTaskId,
string subtaskTitle,
string targetBranch,
IReadOnlyList<string> conflictedFiles,
string worktreePath)
{
_worker = worker;
_planningTaskId = planningTaskId;
_worktreePath = worktreePath;
SubtaskTitle = subtaskTitle;
TargetBranch = targetBranch;
ConflictedFiles = conflictedFiles;
}
[RelayCommand]
private void OpenInVsCode()
{
try
{
var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\""));
Process.Start(new ProcessStartInfo
{
FileName = "code",
Arguments = args,
WorkingDirectory = _worktreePath,
UseShellExecute = true,
});
VsCodeError = null;
}
catch (Exception ex)
{
VsCodeError = $"Could not launch VS Code: {ex.Message}. Paths are listed above — copy them manually.";
}
}
[RelayCommand]
private async Task ContinueAsync()
{
ActionError = null;
try
{
await _worker.ContinuePlanningMergeAsync(_planningTaskId);
CloseRequested?.Invoke();
}
catch (Exception ex) { ActionError = ex.Message; }
}
[RelayCommand]
private async Task AbortAsync()
{
ActionError = null;
try
{
await _worker.AbortPlanningMergeAsync(_planningTaskId);
CloseRequested?.Invoke();
}
catch (Exception ex) { ActionError = ex.Message; }
}
}

View File

@@ -0,0 +1,91 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Planning;
public sealed partial class PlanningDiffViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _planningTaskId;
private readonly string _targetBranch;
public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();
[ObservableProperty] private SubtaskDiffRow? _selectedSubtask;
[ObservableProperty] private string _displayedDiff = "";
[ObservableProperty] private bool _isCombinedMode;
[ObservableProperty] private string? _combinedWarning;
[ObservableProperty] private bool _isLoadingCombined;
public Action? CloseAction { get; set; }
public PlanningDiffViewModel(IWorkerClient worker, string planningTaskId, string targetBranch)
{
_worker = worker;
_planningTaskId = planningTaskId;
_targetBranch = targetBranch;
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
public async Task InitializeAsync()
{
var items = await _worker.GetPlanningAggregateAsync(_planningTaskId);
Subtasks.Clear();
foreach (var i in items)
Subtasks.Add(new SubtaskDiffRow(i.SubtaskId, i.Title, i.DiffStat, i.UnifiedDiff));
SelectedSubtask = Subtasks.FirstOrDefault();
}
partial void OnSelectedSubtaskChanged(SubtaskDiffRow? value)
{
if (!IsCombinedMode)
DisplayedDiff = value?.UnifiedDiff ?? "";
}
[RelayCommand]
private async Task ToggleCombinedAsync()
{
if (IsCombinedMode)
{
IsLoadingCombined = true;
try
{
var result = await _worker.BuildPlanningIntegrationBranchAsync(_planningTaskId, _targetBranch);
if (result is null)
{
DisplayedDiff = "";
CombinedWarning = "Could not build combined preview (hub error).";
}
else if (result.Success)
{
DisplayedDiff = result.UnifiedDiff ?? "";
CombinedWarning = null;
}
else
{
var files = result.ConflictedFiles?.Count ?? 0;
CombinedWarning = $"Cannot build combined preview: subtask {result.FirstConflictSubtaskId} conflicts with an earlier subtask ({files} files).";
DisplayedDiff = "";
}
}
finally
{
IsLoadingCombined = false;
}
}
else
{
DisplayedDiff = SelectedSubtask?.UnifiedDiff ?? "";
CombinedWarning = null;
}
}
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
}
public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);

View File

@@ -0,0 +1,5 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ClaudeDo.Ui.Views.Controls.MarkdownView">
<StackPanel x:Name="Host" Spacing="6"/>
</UserControl>

View File

@@ -0,0 +1,231 @@
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
namespace ClaudeDo.Ui.Views.Controls;
public partial class MarkdownView : UserControl
{
public static readonly StyledProperty<string?> MarkdownProperty =
AvaloniaProperty.Register<MarkdownView, string?>(nameof(Markdown));
public string? Markdown
{
get => GetValue(MarkdownProperty);
set => SetValue(MarkdownProperty, value);
}
private StackPanel? _host;
public MarkdownView()
{
InitializeComponent();
_host = this.FindControl<StackPanel>("Host");
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == MarkdownProperty)
Render(change.GetNewValue<string?>());
}
private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled);
private void Render(string? md)
{
_host ??= this.FindControl<StackPanel>("Host");
if (_host is null) return;
_host.Children.Clear();
if (string.IsNullOrWhiteSpace(md))
return;
var lines = md.Replace("\r\n", "\n").Split('\n');
int i = 0;
while (i < lines.Length)
{
var line = lines[i];
if (string.IsNullOrWhiteSpace(line)) { i++; continue; }
if (line.StartsWith("### "))
{
_host.Children.Add(Heading(line[4..], 13));
i++;
continue;
}
if (line.StartsWith("## "))
{
_host.Children.Add(Heading(line[3..], 15));
i++;
continue;
}
if (line.StartsWith("# "))
{
_host.Children.Add(Heading(line[2..], 17));
i++;
continue;
}
if (line.StartsWith("- ") || line.StartsWith("* "))
{
while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* ")))
{
_host.Children.Add(Bullet(lines[i][2..]));
i++;
}
continue;
}
var num = NumberedItem.Match(line);
if (num.Success)
{
while (i < lines.Length && NumberedItem.IsMatch(lines[i]))
{
var m = NumberedItem.Match(lines[i]);
_host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
i++;
}
continue;
}
var paragraph = new System.Text.StringBuilder();
while (i < lines.Length
&& !string.IsNullOrWhiteSpace(lines[i])
&& !IsBlockStart(lines[i]))
{
if (paragraph.Length > 0) paragraph.Append(' ');
paragraph.Append(lines[i].Trim());
i++;
}
_host.Children.Add(Paragraph(paragraph.ToString()));
}
}
private static bool IsBlockStart(string line) =>
line.StartsWith("# ") || line.StartsWith("## ") || line.StartsWith("### ")
|| line.StartsWith("- ") || line.StartsWith("* ")
|| NumberedItem.IsMatch(line);
private static Control Heading(string text, double size)
{
var tb = new SelectableTextBlock
{
FontSize = size,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 4, 0, 2),
};
AppendInlines(tb.Inlines!, text);
return tb;
}
private static Control Paragraph(string text)
{
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
AppendInlines(tb.Inlines!, text);
return tb;
}
private static Control Bullet(string text)
{
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("14,*") };
var dot = new TextBlock
{
Text = "•",
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 0, 4, 0),
};
Grid.SetColumn(dot, 0);
grid.Children.Add(dot);
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
AppendInlines(tb.Inlines!, text);
Grid.SetColumn(tb, 1);
grid.Children.Add(tb);
return grid;
}
private static Control Numbered(string num, string text)
{
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("20,*") };
var lbl = new TextBlock
{
Text = $"{num}.",
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 0, 4, 0),
};
Grid.SetColumn(lbl, 0);
grid.Children.Add(lbl);
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
AppendInlines(tb.Inlines!, text);
Grid.SetColumn(tb, 1);
grid.Children.Add(tb);
return grid;
}
private static void AppendInlines(InlineCollection inlines, string text)
{
int i = 0;
var plain = new System.Text.StringBuilder();
void FlushPlain()
{
if (plain.Length == 0) return;
inlines.Add(new Run(plain.ToString()));
plain.Clear();
}
while (i < text.Length)
{
if (i + 1 < text.Length && text[i] == '*' && text[i + 1] == '*')
{
int close = text.IndexOf("**", i + 2, System.StringComparison.Ordinal);
if (close > i + 2)
{
FlushPlain();
inlines.Add(new Run(text[(i + 2)..close]) { FontWeight = FontWeight.Bold });
i = close + 2;
continue;
}
}
if (text[i] == '*')
{
int close = text.IndexOf('*', i + 1);
if (close > i + 1)
{
FlushPlain();
inlines.Add(new Run(text[(i + 1)..close]) { FontStyle = FontStyle.Italic });
i = close + 1;
continue;
}
}
if (text[i] == '`')
{
int close = text.IndexOf('`', i + 1);
if (close > i + 1)
{
FlushPlain();
inlines.Add(new Run(text[(i + 1)..close])
{
FontFamily = new FontFamily("Consolas,Menlo,monospace"),
Background = new SolidColorBrush(Color.FromArgb(60, 127, 127, 127)),
});
i = close + 1;
continue;
}
}
plain.Append(text[i]);
i++;
}
FlushPlain();
}
}

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands" xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView" x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
x:DataType="vm:DetailsIslandViewModel"> x:DataType="vm:DetailsIslandViewModel">
<DockPanel> <DockPanel>
@@ -138,6 +139,36 @@
<ScrollViewer VerticalScrollBarVisibility="Auto"> <ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="0"> <StackPanel Spacing="0">
<!-- Planning merge section — visible only for planning parent tasks -->
<Border Padding="18,12,18,12"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
IsVisible="{Binding Task.IsPlanningParent}">
<StackPanel Spacing="8">
<TextBlock Classes="section-label" Text="MERGE" Margin="0,0,0,2"/>
<StackPanel Spacing="4">
<TextBlock Text="Merge target"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"/>
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Review combined diff"
Command="{Binding ReviewCombinedDiffCommand}"/>
<Button Content="Merge all subtasks"
IsEnabled="{Binding CanMergeAll}"
Command="{Binding MergeAllCommand}"
ToolTip.Tip="{Binding MergeAllDisabledReason}"/>
</StackPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="OrangeRed"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
</Border>
<!-- Steps section --> <!-- Steps section -->
<Border Padding="18,12,18,12" <Border Padding="18,12,18,12"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
@@ -184,6 +215,77 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Details (description) section -->
<Border Padding="18,12,18,12"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Button Grid.Column="0"
Classes="flat"
Command="{Binding ToggleDescriptionExpandedCommand}"
Padding="0"
Margin="0,0,6,2"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="▾" FontSize="10"
IsVisible="{Binding IsDescriptionExpanded}"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="▸" FontSize="10"
IsVisible="{Binding !IsDescriptionExpanded}"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Classes="section-label" Text="DETAILS"/>
</StackPanel>
</Button>
<Button Grid.Column="2"
Classes="icon-btn"
Padding="6,2"
Margin="0,0,4,0"
ToolTip.Tip="Copy description to clipboard"
IsVisible="{Binding IsDescriptionExpanded}"
Click="OnCopyDescriptionClick">
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
</Button>
<Button Grid.Column="3"
Classes="icon-btn"
Command="{Binding ToggleEditDescriptionCommand}"
Padding="6,2"
FontSize="10"
ToolTip.Tip="Toggle edit/preview"
IsVisible="{Binding IsDescriptionEditorVisible}">
<TextBlock Text="Preview"/>
</Button>
<Button Grid.Column="3"
Classes="icon-btn"
Command="{Binding ToggleEditDescriptionCommand}"
Padding="6,2"
FontSize="10"
ToolTip.Tip="Toggle edit/preview"
IsVisible="{Binding IsDescriptionPreviewVisible}">
<TextBlock Text="Edit"/>
</Button>
</Grid>
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="80"
MaxHeight="320"
PlaceholderText="Add task details (markdown supported)..."
Padding="8"
FontFamily="{DynamicResource MonoFont}"
FontSize="12"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="6"
IsVisible="{Binding IsDescriptionEditorVisible}"/>
<ctl:MarkdownView Markdown="{Binding EditableDescription}"
IsVisible="{Binding IsDescriptionPreviewVisible}"/>
</StackPanel>
</Border>
<!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight --> <!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
<islands:SessionTerminalView MaxHeight="420"/> <islands:SessionTerminalView MaxHeight="420"/>

View File

@@ -1,10 +1,12 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.Views.Modals; using ClaudeDo.Ui.Views.Modals;
using ClaudeDo.Ui.Views.Planning;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
@@ -44,10 +46,58 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner); await modal.ShowDialog(owner);
}; };
vm.ShowPlanningDiffModal = async (planningDiffVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new PlanningDiffView { DataContext = planningDiffVm };
await modal.ShowDialog(owner);
};
vm.ConfirmAsync = ShowConfirmAsync; vm.ConfirmAsync = ShowConfirmAsync;
vm.ShowErrorAsync = ShowErrorDialogAsync;
} }
} }
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var ok = new Button { Content = "OK", MinWidth = 90 };
var dialog = new Window
{
Title = "Error",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { ok }
}
}
}
};
ok.Click += (_, _) => dialog.Close();
await dialog.ShowDialog(owner);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message) private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
@@ -98,4 +148,12 @@ public partial class DetailsIslandView : UserControl
if (DataContext is DetailsIslandViewModel vm) if (DataContext is DetailsIslandViewModel vm)
vm.SaveNotesCommand.Execute(null); vm.SaveNotesCommand.Execute(null);
} }
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(vm.EditableDescription ?? string.Empty);
}
} }

View File

@@ -66,7 +66,7 @@
</StackPanel> </StackPanel>
<!-- More button --> <!-- More button -->
<Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center" <Button Grid.Column="2" Classes="icon-btn" VerticalAlignment="Center"
Command="{Binding OpenListSettingsCommand}" Command="{Binding OpenSettingsCommand}"
ToolTip.Tip="Settings"> ToolTip.Tip="Settings">
<PathIcon Data="{StaticResource Icon.MoreHorizontal}" <PathIcon Data="{StaticResource Icon.MoreHorizontal}"
Width="14" Height="14" Width="14" Height="14"
@@ -112,11 +112,8 @@
VerticalAlignment="Center" Margin="8,0" VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/> Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
<!-- Count --> <!-- Count -->
<TextBlock Grid.Column="2" <TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}" Text="{Binding Count}"/>
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>
@@ -137,9 +134,9 @@
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
</ContextMenu> </ContextMenu>
</Border.ContextMenu> </Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto,Auto"> <Grid ColumnDefinitions="20,*,Auto">
<!-- Left accent bar for active state --> <!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="4" <Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent" Background="Transparent"
CornerRadius="8" IsHitTestVisible="False" CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}"> IsVisible="{Binding IsActive}">
@@ -160,23 +157,8 @@
VerticalAlignment="Center" Margin="8,0" VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="13"/> Foreground="{DynamicResource TextDimBrush}" FontSize="13"/>
<!-- Count --> <!-- Count -->
<TextBlock Grid.Column="2" <TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}" Text="{Binding Count}"/>
FontFamily="{DynamicResource MonoFamily}" FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
<!-- Gear button -->
<Button Grid.Column="3"
Content="⚙"
ToolTip.Tip="Settings..."
Background="Transparent"
BorderThickness="0"
Padding="4,0"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>
@@ -184,7 +166,8 @@
</ItemsControl> </ItemsControl>
<!-- + New list button --> <!-- + New list button -->
<Button Classes="new-list-btn" Margin="0,4,0,0"> <Button Classes="new-list-btn" Margin="0,4,0,0"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}" <PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13" Width="13" Height="13"

View File

@@ -50,23 +50,20 @@
</Grid> </Grid>
<!-- ── Log output ── --> <!-- ── Log output ── -->
<ScrollViewer Name="LogScroll" VerticalScrollBarVisibility="Auto" <ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible"
AllowAutoHide="False"
Padding="10,8,10,12"> Padding="10,8,10,12">
<ItemsControl ItemsSource="{Binding Log}"> <ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel"> <DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,46,*" Margin="0,1"> <Grid ColumnDefinitions="60,*" Margin="0,1">
<!-- Timestamp --> <!-- Timestamp -->
<TextBlock Grid.Column="0" <TextBlock Grid.Column="0"
Classes="log-ts" Classes="log-ts"
Text="{Binding TimestampFormatted}"/> Text="{Binding TimestampFormatted}"/>
<!-- Kind marker -->
<TextBlock Grid.Column="1"
Classes="log-kind"
Tag="{Binding ClassName}"
Text="{Binding KindMarker}"/>
<!-- Message text — selectable so the user can copy raw output --> <!-- Message text — selectable so the user can copy raw output -->
<SelectableTextBlock Grid.Column="2" <SelectableTextBlock Grid.Column="1"
Text="{Binding Text}" Tag="{Binding ClassName}" Text="{Binding Text}" Tag="{Binding ClassName}"
FontFamily="{DynamicResource MonoFont}" FontSize="11" FontFamily="{DynamicResource MonoFont}" FontSize="11"
Foreground="{DynamicResource TextDimBrush}" Foreground="{DynamicResource TextDimBrush}"

View File

@@ -15,119 +15,236 @@
Background="{DynamicResource MossBrush}" CornerRadius="1" Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/> IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="task-row" <!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
Margin="0" <Grid Grid.Row="1" ColumnDefinitions="Auto,*">
Classes.selected="{Binding IsSelected}"
Classes.done="{Binding Done}">
<Grid ColumnDefinitions="4,32,*,32" Margin="6,8,10,8">
<!-- Left accent bar (visible when selected) --> <!-- Indent track (only visible for child tasks) -->
<Border Grid.Column="0" Classes="task-row-accent" <Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
IsVisible="{Binding IsSelected}"/> <Rectangle Width="1" Fill="{DynamicResource LineBrush}"
HorizontalAlignment="Right" Margin="0,4"/>
</Border>
<!-- Done toggle --> <!-- Main task card -->
<Button Grid.Column="1" Classes="flat" VerticalAlignment="Top" <Border Grid.Column="1" Classes="task-row"
Margin="0,2,0,0" Margin="0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}" Classes.selected="{Binding IsSelected}"
CommandParameter="{Binding}"> Classes.done="{Binding Done}">
<Ellipse Width="18" Height="18" Classes="task-check" <Border.ContextMenu>
Classes.done="{Binding Done}"/> <ContextMenu>
</Button> <MenuItem Header="Send to queue"
IsVisible="{Binding !IsQueued}"
Click="OnSendToQueueClick"/>
<MenuItem Header="Remove from queue"
IsVisible="{Binding IsQueued}"
Click="OnRemoveFromQueueClick"/>
<Separator/>
<MenuItem Header="Run interactively"
Click="OnRunInteractivelyClick"/>
<MenuItem Header="Open planning Session"
Click="OnOpenPlanningSessionClick"
IsVisible="{Binding CanOpenPlanningSession}"/>
<MenuItem Header="Resume planning Session"
Click="OnResumePlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="Discard planning session"
Click="OnDiscardPlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="Queue subtasks sequentially"
Click="OnQueuePlanningSubtasksClick"
IsVisible="{Binding HasPlanningChildren}"/>
<Separator/>
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
<MenuItem Header="Clear schedule"
IsVisible="{Binding HasSchedule}"
Click="OnClearScheduleClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,32" Margin="6,8,10,8">
<!-- Title + chip row + live tail --> <!-- Chevron toggle (only for planning parent tasks) -->
<StackPanel Grid.Column="2" Spacing="6" VerticalAlignment="Center"> <Button Grid.Column="1"
<TextBlock Classes="task-title" IsVisible="{Binding IsPlanningParent}"
Text="{Binding Title}" FontSize="14" Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleExpandCommand}"
Foreground="{DynamicResource TextBrush}" CommandParameter="{Binding}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/> Classes="icon-btn"
Width="18" Height="18"
VerticalAlignment="Center">
<Panel>
<TextBlock Text="▾" FontSize="10" IsVisible="{Binding IsExpanded}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Text="▸" FontSize="10" IsVisible="{Binding !IsExpanded}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
</Panel>
</Button>
<!-- Chip row --> <!-- Done toggle -->
<StackPanel Orientation="Horizontal" Spacing="6"> <Button Grid.Column="2" Classes="flat" VerticalAlignment="Top"
Margin="0,2,0,0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleDoneCommand}"
CommandParameter="{Binding}">
<Ellipse Width="18" Height="18" Classes="task-check"
Classes.done="{Binding Done}"/>
</Button>
<!-- Status chip --> <!-- Title + chip row + live tail -->
<Border Classes="chip" <StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}" <StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}" <TextBlock Classes="task-title"
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}" Text="{Binding Title}" FontSize="14"
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}"> Foreground="{DynamicResource TextBrush}"
<TextBlock Text="{Binding Status}"/> FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
</Border> Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
<!-- List chip with dot --> <!-- Badges: DRAFT and planning session -->
<Border Classes="chip chip-list">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse Width="6" Height="6"
Fill="{DynamicResource MossBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding ListName}"/>
</StackPanel>
</Border>
<!-- Branch chip -->
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<PathIcon Width="10" Height="10" <Border Classes="badge draft" IsVisible="{Binding IsDraft}">
Data="{StaticResource Icon.GitBranch}" <TextBlock Text="DRAFT"/>
Foreground="{DynamicResource TextDimBrush}"/> </Border>
<TextBlock Text="{Binding Branch}"/> <Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/>
</Border>
</StackPanel>
</StackPanel>
<!-- Chip row -->
<StackPanel Orientation="Horizontal" Spacing="6">
<!-- Status chip -->
<Border Classes="chip"
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
<TextBlock Text="{Binding Status}"/>
</Border>
<!-- Dequeue button (only when Queued) -->
<Button Classes="icon-btn dequeue-btn"
IsVisible="{Binding IsQueued}"
ToolTip.Tip="Remove from queue"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RemoveFromQueueCommand}"
CommandParameter="{Binding}">
<PathIcon Width="10" Height="10" Data="{StaticResource Icon.X}"/>
</Button>
<!-- List chip with dot -->
<Border Classes="chip chip-list">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse Width="6" Height="6"
Fill="{DynamicResource MossBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding ListName}"/>
</StackPanel>
</Border>
<!-- Branch chip -->
<Border Classes="chip chip-branch" IsVisible="{Binding HasBranch}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.GitBranch}"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding Branch}"/>
</StackPanel>
</Border>
<!-- Diff chip -->
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
</StackPanel>
</Border>
<!-- Tag chips -->
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="6"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Classes="chip chip-tag">
<TextBlock Text="{Binding}"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Live-tail row (visible when running + has tail) -->
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
<StackPanel Spacing="3">
<TextBlock Text="{Binding LiveTail}"
TextTrimming="CharacterEllipsis" MaxLines="1"/>
<Grid Height="3" HorizontalAlignment="Stretch">
<Rectangle Fill="{DynamicResource Surface3Brush}"
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
<Rectangle Fill="{DynamicResource MossBrush}"
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
</Grid>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Diff chip -->
<Border Classes="chip chip-diff" IsVisible="{Binding HasDiff}">
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="diff-add" Text="{Binding DiffAdditionsText}"/>
<TextBlock Classes="diff-del" Text="{Binding DiffDeletionsText}"/>
</StackPanel>
</Border>
<!-- Tag chips -->
<ItemsControl ItemsSource="{Binding Tags}" IsVisible="{Binding HasTags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="6"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Classes="chip chip-tag">
<TextBlock Text="{Binding}"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel> </StackPanel>
<!-- Live-tail row (visible when running + has tail) --> <!-- Star toggle -->
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}"> <Button Grid.Column="5" Classes="icon-btn star-btn"
<StackPanel Spacing="3"> Classes.on="{Binding IsStarred}"
<TextBlock Text="{Binding LiveTail}" VerticalAlignment="Top" Margin="0,2,0,0"
TextTrimming="CharacterEllipsis" MaxLines="1"/> Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
<Grid Height="3" HorizontalAlignment="Stretch"> CommandParameter="{Binding}">
<Rectangle Fill="{DynamicResource Surface3Brush}" <PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/> </Button>
<Rectangle Fill="{DynamicResource MossBrush}" </Grid>
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/> </Border>
</Grid>
</StackPanel>
</Border>
</StackPanel>
<!-- Star toggle --> </Grid>
<Button Grid.Column="3" Classes="icon-btn star-btn"
Classes.on="{Binding IsStarred}"
VerticalAlignment="Top" Margin="0,2,0,0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
CommandParameter="{Binding}">
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
</Button>
</Grid>
</Border>
<!-- Below-row indicator: only expands when visible (used for the last row of a section) --> <!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}"> <Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
<Border Height="2" VerticalAlignment="Center" Margin="4,0" <Border Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"/> Background="{DynamicResource MossBrush}" CornerRadius="1"/>
</Grid> </Grid>
<!-- Hidden schedule anchor (its Flyout is shown from the context menu) -->
<Button Grid.Row="1" x:Name="ScheduleAnchor"
Width="1" Height="1" Opacity="0"
HorizontalAlignment="Left" VerticalAlignment="Top"
IsHitTestVisible="False" Focusable="False">
<Button.Flyout>
<Flyout Placement="Bottom" ShowMode="Standard">
<Border Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1" CornerRadius="10"
Padding="16" Width="300">
<StackPanel Spacing="12">
<TextBlock Text="Schedule task"
FontWeight="SemiBold" FontSize="13"
Foreground="{DynamicResource TextBrush}"/>
<StackPanel Spacing="6">
<TextBlock Text="DATE" FontSize="10" Opacity="0.6"
Foreground="{DynamicResource TextDimBrush}"/>
<DatePicker x:Name="ScheduleDate" HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="TIME" FontSize="10" Opacity="0.6"
Foreground="{DynamicResource TextDimBrush}"/>
<TimePicker x:Name="ScheduleTime" ClockIdentifier="24HourClock"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,4,0,0">
<Button Content="Cancel" Click="OnScheduleCancelClick" MinWidth="76"/>
<Button Content="Schedule" Classes="accent" Click="OnScheduleSetClick" MinWidth="76"/>
</StackPanel>
</StackPanel>
</Border>
</Flyout>
</Button.Flyout>
</Button>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -1,16 +1,102 @@
using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Animation; using Avalonia.Animation;
using Avalonia.Animation.Easings; using Avalonia.Animation.Easings;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
public partial class TaskRowView : UserControl public partial class TaskRowView : UserControl
{ {
private TaskRowViewModel? _pendingScheduleRow;
public TaskRowView() { InitializeComponent(); } public TaskRowView() { InitializeComponent(); }
private TasksIslandViewModel? FindTasksVm() =>
this.GetVisualAncestors().OfType<ItemsControl>()
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
private async void OnSendToQueueClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.SendToQueueCommand.ExecuteAsync(row);
}
private async void OnRemoveFromQueueClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RemoveFromQueueCommand.ExecuteAsync(row);
}
private async void OnClearScheduleClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ClearScheduleCommand.ExecuteAsync(row);
}
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.OpenPlanningSessionCommand.ExecuteAsync(row);
}
private async void OnRunInteractivelyClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RunInteractivelyCommand.ExecuteAsync(row);
}
private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ResumePlanningSessionCommand.ExecuteAsync(row);
}
private async void OnDiscardPlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
}
private async void OnQueuePlanningSubtasksClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
}
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel row) return;
_pendingScheduleRow = row;
var seed = row.ScheduledFor ?? DateTime.Now.AddHours(1);
ScheduleDate.SelectedDate = new DateTimeOffset(seed.Date, TimeSpan.Zero);
ScheduleTime.SelectedTime = seed.TimeOfDay;
ScheduleAnchor.Flyout?.ShowAt(ScheduleAnchor);
}
private async void OnScheduleSetClick(object? sender, RoutedEventArgs e)
{
ScheduleAnchor.Flyout?.Hide();
if (_pendingScheduleRow is null || ScheduleDate.SelectedDate is null) return;
var date = ScheduleDate.SelectedDate.Value.Date;
var time = ScheduleTime.SelectedTime ?? TimeSpan.FromHours(9);
var when = date + time;
if (FindTasksVm() is { } tvm)
await tvm.SetScheduledForAsync(_pendingScheduleRow, when);
_pendingScheduleRow = null;
}
private void OnScheduleCancelClick(object? sender, RoutedEventArgs e)
{
ScheduleAnchor.Flyout?.Hide();
_pendingScheduleRow = null;
}
protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override async void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{ {
base.OnAttachedToVisualTree(e); base.OnAttachedToVisualTree(e);

View File

@@ -36,8 +36,8 @@
ToolTip.Tip="Show completed"> ToolTip.Tip="Show completed">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
</Button> </Button>
<Button Classes="icon-btn" Command="{Binding MoreCommand}" ToolTip.Tip="More"> <Button Classes="icon-btn" Command="{Binding OpenListSettingsCommand}" ToolTip.Tip="List settings">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.MoreHorizontal}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Settings}"/>
</Button> </Button>
</StackPanel> </StackPanel>
</Grid> </Grid>

View File

@@ -4,6 +4,8 @@ using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
@@ -15,26 +17,47 @@ public partial class TasksIslandView : UserControl
public TasksIslandView() public TasksIslandView()
{ {
InitializeComponent(); InitializeComponent();
// Tunnel handler runs BEFORE Button's class handler so we can start a drag
// without the Button first marking the event as handled.
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel); AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
DataContextChanged += (_, _) => DataContextChanged += (_, _) =>
{ {
if (DataContext is TasksIslandViewModel vm) if (DataContext is TasksIslandViewModel vm)
{
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus(); vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
vm.ShowUnfinishedPlanningModal = async (modalVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) { modalVm.CancelCommand.Execute(null); return; }
var modal = new UnfinishedPlanningModalView { DataContext = modalVm };
// Closing via the OS title-bar (if ever enabled) also resolves the TCS.
modal.Closed += (_, _) => modalVm.CancelCommand.Execute(null);
await modal.ShowDialog(owner);
// ShowDialog completes once the window is closed (CloseAction or OS close).
};
}
}; };
} }
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e) private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{ {
if (DataContext is not TasksIslandViewModel vm || !vm.CanReorder) return; if (DataContext is not TasksIslandViewModel vm) return;
if (e.Source is not Visual src) return; if (e.Source is not Visual src) return;
var button = src as Button ?? src.FindAncestorOfType<Button>(); var button = src as Button ?? src.FindAncestorOfType<Button>();
if (button?.DataContext is not TaskRowViewModel row) return; if (button?.DataContext is not TaskRowViewModel row) return;
if (row.IsRunning) return;
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return; if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
// Select now so the details pane updates whether the gesture becomes a click or a drag.
// (Button.Click doesn't fire once DoDragDropAsync captures the pointer.)
vm.SelectedTask = row;
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
// don't start a drag — that would capture the pointer and swallow the inner Click.
var nestedInsideButton = button.Parent is Visual parentVisual
&& parentVisual.FindAncestorOfType<Button>() is not null;
if (nestedInsideButton) return;
if (!vm.CanReorder || row.IsRunning) return;
var data = new DataTransfer(); var data = new DataTransfer();
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id)); data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
try try
@@ -133,11 +156,18 @@ public partial class TasksIslandView : UserControl
if (source is null || source.IsRunning) return; if (source is null || source.IsRunning) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2; var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
// Clear the 6px drop-hint spacer BEFORE the move so the reorder animates
// into its truly-final layout in one step (otherwise the row lands in the
// gap, then the gap collapses and everything shifts up a second time).
vm.ClearDropHints();
await vm.ReorderAsync(source, target, placeBelow); await vm.ReorderAsync(source, target, placeBelow);
} }
finally catch
{ {
vm.ClearDropHints(); vm.ClearDropHints();
throw;
} }
} }

View File

@@ -2,20 +2,26 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels" xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands" xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:converters="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.MainWindow" x:Class="ClaudeDo.Ui.Views.MainWindow"
x:DataType="vm:IslandsShellViewModel" x:DataType="vm:IslandsShellViewModel"
Title="ClaudeDo" Title="ClaudeDo"
Width="1280" Height="820" MinWidth="780" MinHeight="600" Width="1280" Height="820" MinWidth="780" MinHeight="600"
Background="{DynamicResource VoidBrush}" Background="{DynamicResource VoidBrush}"
SystemDecorations="None" Icon="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico"
CanResize="True"
SystemDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"> ExtendClientAreaTitleBarHeightHint="-1">
<Window.Resources>
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
</Window.Resources>
<Window.KeyBindings> <Window.KeyBindings>
<KeyBinding Gesture="OemQuestion" Command="{Binding FocusSearchCommand}"/> <KeyBinding Gesture="OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/> <KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/> <KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Grid RowDefinitions="36,*,22"> <Grid RowDefinitions="36,Auto,*,22">
<!-- Custom title bar --> <!-- Custom title bar -->
<Border Grid.Row="0" <Border Grid.Row="0"
Background="{DynamicResource DeepBrush}" Background="{DynamicResource DeepBrush}"
@@ -26,11 +32,11 @@
<!-- Left: brand block --> <!-- Left: brand block -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center" Margin="14,0,0,0"> VerticalAlignment="Center" Margin="14,0,0,0">
<!-- Green checkbox glyph --> <!-- App icon (matches taskbar) -->
<PathIcon Classes="title-brand-icon" <Image Source="avares://ClaudeDo.Ui/Assets/ClaudeTask.ico"
Data="{StaticResource Icon.BrandCheck}" Width="16" Height="16"
Width="14" Height="14" VerticalAlignment="Center"
Foreground="{DynamicResource MossBrush}" /> RenderOptions.BitmapInterpolationMode="HighQuality"/>
<!-- CLAUDEDO label --> <!-- CLAUDEDO label -->
<TextBlock Classes="title-brand-name" <TextBlock Classes="title-brand-name"
Text="CLAUDEDO" Text="CLAUDEDO"
@@ -52,6 +58,17 @@
Foreground="{DynamicResource TextDimBrush}" Foreground="{DynamicResource TextDimBrush}"
LetterSpacing="1.4" LetterSpacing="1.4"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<!-- Help menu -->
<Menu Margin="12,0,0,0"
Background="Transparent"
VerticalAlignment="Center">
<MenuItem Header="Help"
FontSize="11"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="Check for updates"
Command="{Binding CheckForUpdatesCommand}"/>
</MenuItem>
</Menu>
</StackPanel> </StackPanel>
<!-- Middle: draggable strip --> <!-- Middle: draggable strip -->
@@ -75,8 +92,47 @@
</Grid> </Grid>
</Border> </Border>
<!-- Update banner -->
<Border Grid.Row="1"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
Padding="14,6"
IsVisible="{Binding IsUpdateBannerVisible}">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Foreground="{DynamicResource TextDimBrush}"
FontSize="12">
<Run Text="Update available: v"/>
<Run Text="{Binding UpdateCheck.CurrentVersion}"/>
<Run Text=" → v"/>
<Run Text="{Binding UpdateBannerLatestVersion}"/>
</TextBlock>
<Button Grid.Column="1"
Margin="0,0,8,0"
Padding="10,3"
Content="Update now"
Command="{Binding UpdateNowCommand}"/>
<Button Grid.Column="2"
Padding="10,3"
Content="Dismiss"
Command="{Binding DismissBannerCommand}"/>
</Grid>
</Border>
<!-- Inline update status (appears at right of banner row when no banner) -->
<TextBlock Grid.Row="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,14,0"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
Text="{Binding InlineUpdateStatus}"
IsVisible="{Binding InlineUpdateStatus, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<!-- Background gradient layer --> <!-- Background gradient layer -->
<Border Grid.Row="1"> <Border Grid.Row="2">
<Border.Background> <Border.Background>
<RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%"> <RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%">
<GradientStop Offset="0" Color="{StaticResource DeepColor}" /> <GradientStop Offset="0" Color="{StaticResource DeepColor}" />
@@ -85,51 +141,84 @@
</Border.Background> </Border.Background>
</Border> </Border>
<!-- Three islands --> <!-- Three islands (user-resizable) -->
<Grid Grid.Row="1" Margin="7" ColumnDefinitions="260,*,320"> <Grid Grid.Row="2" Margin="7">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260" MinWidth="200"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*" MinWidth="320"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="320" MinWidth="280"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Classes="island" Margin="7"> <Border Grid.Column="0" Classes="island" Margin="7">
<islands:ListsIslandView DataContext="{Binding Lists}"/> <islands:ListsIslandView DataContext="{Binding Lists}"/>
</Border> </Border>
<Border Grid.Column="1" Classes="island" Margin="7">
<GridSplitter Grid.Column="1"
Width="3"
Background="Transparent"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ResizeDirection="Columns"
ResizeBehavior="PreviousAndNext"/>
<Border Grid.Column="2" Classes="island" Margin="7">
<islands:TasksIslandView DataContext="{Binding Tasks}"/> <islands:TasksIslandView DataContext="{Binding Tasks}"/>
</Border> </Border>
<Border Grid.Column="2" Classes="island" Margin="7"
<GridSplitter Grid.Column="3"
Width="3"
Background="Transparent"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ResizeDirection="Columns"
ResizeBehavior="PreviousAndNext"
IsVisible="{Binding ShowDetails}"/>
<Border Grid.Column="4" Classes="island" Margin="7"
IsVisible="{Binding ShowDetails}"> IsVisible="{Binding ShowDetails}">
<islands:DetailsIslandView DataContext="{Binding Details}"/> <islands:DetailsIslandView DataContext="{Binding Details}"/>
</Border> </Border>
</Grid> </Grid>
<!-- Footer: connection status --> <!-- Footer: connection status -->
<Border Grid.Row="2" <Border Grid.Row="3"
Background="{DynamicResource DeepBrush}" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"> BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="7" <DockPanel LastChildFill="True" Margin="14,0">
VerticalAlignment="Center" Margin="14,0"> <!-- Left: connection pill -->
<Ellipse Width="7" Height="7" Fill="#4CAF50" <StackPanel DockPanel.Dock="Left" Orientation="Horizontal" Spacing="7"
IsVisible="{Binding Worker.IsConnected}"/> VerticalAlignment="Center">
<Ellipse Width="7" Height="7" Fill="#FFA726" <Ellipse Width="7" Height="7" Fill="#4CAF50"
IsVisible="{Binding Worker.IsReconnecting}"/> IsVisible="{Binding Worker.IsConnected}"/>
<Ellipse Width="7" Height="7" Fill="#EF5350" <Ellipse Width="7" Height="7" Fill="#FFA726"
IsVisible="{Binding IsOffline}"/> IsVisible="{Binding Worker.IsReconnecting}"/>
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}" <Ellipse Width="7" Height="7" Fill="#EF5350"
IsVisible="{Binding IsOffline}"/>
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Right: worker log line -->
<TextBlock DockPanel.Dock="Right"
Text="{Binding WorkerLogText}"
IsVisible="{Binding IsWorkerLogVisible}"
Foreground="{Binding WorkerLogLevel, Converter={StaticResource WorkerLogLevelToBrush}}"
FontFamily="{DynamicResource MonoFont}" FontFamily="{DynamicResource MonoFont}"
FontSize="10" FontSize="10"
LetterSpacing="1.4" LetterSpacing="1.4"
Foreground="{DynamicResource TextDimBrush}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<TextBlock Text="·"
FontFamily="{DynamicResource MonoFont}" <!-- Spacer between pill and log -->
FontSize="10" <Panel/>
Foreground="{DynamicResource TextFaintBrush}" </DockPanel>
VerticalAlignment="Center"/>
<TextBlock Text="WORKER"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Border> </Border>
</Grid> </Grid>
</Window> </Window>

View File

@@ -1,6 +1,7 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.Views.Planning;
namespace ClaudeDo.Ui.Views; namespace ClaudeDo.Ui.Views;
@@ -10,6 +11,19 @@ public partial class MainWindow : Window
{ {
InitializeComponent(); InitializeComponent();
KeyDown += OnWindowKeyDown; KeyDown += OnWindowKeyDown;
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (DataContext is IslandsShellViewModel vm)
{
vm.ShowConflictDialog = async (conflictVm) =>
{
var modal = new ConflictResolutionView { DataContext = conflictVm };
await modal.ShowDialog(this);
};
}
} }
private void OnWindowKeyDown(object? sender, KeyEventArgs e) private void OnWindowKeyDown(object? sender, KeyEventArgs e)

View File

@@ -4,77 +4,178 @@
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView" x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
x:DataType="vm:ListSettingsModalViewModel" x:DataType="vm:ListSettingsModalViewModel"
Title="List settings" Title="List settings"
Width="520" Height="600" Width="520" Height="720"
CanResize="True"
MinWidth="460" MinHeight="520"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
CanResize="False"> Background="{DynamicResource SurfaceBrush}">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" />
<Button Content="Save" Command="{Binding SaveCommand}" Classes="accent" />
</StackPanel>
<ScrollViewer> <Window.KeyBindings>
<StackPanel Spacing="16"> <KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
<TextBlock Text="General" FontSize="16" FontWeight="SemiBold" /> <KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="TextBlock.section-label">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="LetterSpacing" Value="1.4"/>
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}"/>
<Setter Property="Margin" Value="4,0,0,6"/>
</Style>
<Style Selector="TextBlock.field-label">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<Style Selector="Border.section">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="14"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</Window.Styles>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1">
<Grid RowDefinitions="36,*,52">
<!-- Title bar -->
<Border Grid.Row="0"
x:Name="TitleBar"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="TitleBar_PointerPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="LIST SETTINGS"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CancelCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Body -->
<ScrollViewer Grid.Row="1" Padding="20,16">
<StackPanel Spacing="18">
<!-- GENERAL -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="GENERAL"/>
<Border Classes="section">
<StackPanel Spacing="12">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Name" /> <TextBlock Classes="field-label" Text="Name"/>
<TextBox Text="{Binding Name}" /> <TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Working directory" /> <TextBlock Classes="field-label" Text="Working directory"/>
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="(none)" /> <TextBox Grid.Column="0" Text="{Binding WorkingDir}" Watermark="(none)" />
<Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" /> <Button Grid.Column="1" Content="Browse..." Margin="8,0,0,0" Click="BrowseClicked" />
</Grid> </Grid>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Default commit type" /> <TextBlock Classes="field-label" Text="Default commit type"/>
<ComboBox ItemsSource="{Binding CommitTypeOptions}" <ComboBox ItemsSource="{Binding CommitTypeOptions}"
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}" SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" /> HorizontalAlignment="Left" MinWidth="160" />
</StackPanel> </StackPanel>
</StackPanel>
</Border>
</StackPanel>
<Separator Margin="0,8,0,8" /> <!-- AGENT -->
<StackPanel Spacing="0">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto" Margin="4,0,0,6">
<TextBlock Grid.Column="0" Text="Agent" FontSize="16" FontWeight="SemiBold" /> <TextBlock Classes="section-label" Text="AGENT" Margin="0"/>
<Button Grid.Column="1" Content="Reset agent settings" <Button Grid.Column="1" Content="Reset agent settings"
Command="{Binding ResetAgentSettingsCommand}" /> Command="{Binding ResetAgentSettingsCommand}" />
</Grid> </Grid>
<Border Classes="section">
<StackPanel Spacing="12">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Model" /> <TextBlock Classes="field-label" Text="Model"/>
<ComboBox ItemsSource="{Binding ModelOptions}" <ComboBox ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}" SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" /> HorizontalAlignment="Left" MinWidth="160" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="System prompt (appended)" /> <TextBlock Classes="field-label" Text="System prompt (appended)"/>
<TextBox Text="{Binding SystemPrompt, Mode=TwoWay}" <TextBox Text="{Binding SystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap" AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="80" /> MinHeight="80" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Agent file" /> <TextBlock Classes="field-label" Text="Agent file"/>
<ComboBox ItemsSource="{Binding Agents}" <Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0"
ItemsSource="{Binding Agents}"
SelectedItem="{Binding SelectedAgent, Mode=TwoWay}" SelectedItem="{Binding SelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="240"> HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<StackPanel> <StackPanel>
<TextBlock Text="{Binding Name}" /> <TextBlock Text="{Binding Name}"
<TextBlock Text="{Binding Description}" Opacity="0.6" FontSize="11" /> Foreground="{DynamicResource TextBrush}"/>
</StackPanel> <TextBlock Text="{Binding Description}"
</DataTemplate> Foreground="{DynamicResource TextMuteBrush}"
</ComboBox.ItemTemplate> FontSize="11" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<Button Grid.Column="1" Content="Browse..."
Margin="8,0,0,0" Click="BrowseAgentClicked" />
</Grid>
<TextBlock Text="{Binding SelectedAgent.Path}"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
Foreground="{DynamicResource TextFaintBrush}"
TextTrimming="PrefixCharacterEllipsis"
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </Border>
</DockPanel> </StackPanel>
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" MinWidth="90"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window> </Window>

View File

@@ -1,6 +1,8 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Modals;
@@ -12,6 +14,63 @@ public partial class ListSettingsModalView : Window
InitializeComponent(); InitializeComponent();
} }
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
private async void BrowseAgentClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not ListSettingsModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var files = await top.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choose agent file",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Agent files (*.md)") { Patterns = new[] { "*.md" } },
new FilePickerFileType("All files") { Patterns = new[] { "*" } },
},
});
if (files.Count == 0) return;
var path = files[0].Path.LocalPath;
var existing = vm.Agents.FirstOrDefault(a => string.Equals(a.Path, path, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
vm.SelectedAgent = existing;
return;
}
var (name, description) = ReadFrontmatter(path);
var agent = new AgentInfo(name, description, path);
vm.Agents.Add(agent);
vm.SelectedAgent = agent;
}
private static (string name, string description) ReadFrontmatter(string filePath)
{
var fallback = System.IO.Path.GetFileNameWithoutExtension(filePath);
try
{
using var reader = new System.IO.StreamReader(filePath);
if (reader.ReadLine()?.Trim() != "---") return (fallback, "");
string name = fallback, description = "";
while (reader.ReadLine() is { } line)
{
if (line.Trim() == "---") break;
if (line.StartsWith("name:")) name = line["name:".Length..].Trim();
else if (line.StartsWith("description:")) description = line["description:".Length..].Trim();
}
return (name, description);
}
catch { return (fallback, ""); }
}
private async void BrowseClicked(object? sender, RoutedEventArgs e) private async void BrowseClicked(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not ListSettingsModalViewModel vm) return; if (DataContext is not ListSettingsModalViewModel vm) return;

View File

@@ -4,77 +4,135 @@
x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView" x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
x:DataType="vm:MergeModalViewModel" x:DataType="vm:MergeModalViewModel"
Title="Merge worktree" Title="Merge worktree"
Width="560" Height="420" Width="560" Height="460"
CanResize="False" CanResize="False"
WindowStartupLocation="CenterOwner"> SystemDecorations="None"
<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto"> ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<TextBlock Grid.Row="0" <Window.KeyBindings>
Text="{Binding TaskTitle, StringFormat='Merging: {0}'}" <KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
FontWeight="SemiBold" Margin="0,0,0,12" /> </Window.KeyBindings>
<StackPanel Grid.Row="1" Orientation="Vertical" Margin="0,0,0,8"> <Window.Styles>
<TextBlock Text="Target branch" Margin="0,0,0,4" /> <Style Selector="TextBlock.field-label">
<ComboBox ItemsSource="{Binding Branches}" <Setter Property="FontSize" Value="11"/>
SelectedItem="{Binding SelectedBranch}" <Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
HorizontalAlignment="Stretch" <Setter Property="Margin" Value="0,0,0,4"/>
IsEnabled="{Binding !IsBusy}" /> </Style>
</StackPanel> <Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</Window.Styles>
<CheckBox Grid.Row="2" <Border Background="{DynamicResource SurfaceBrush}"
Content="Remove worktree after merge" BorderBrush="{DynamicResource LineBrush}"
IsChecked="{Binding RemoveWorktree}" BorderThickness="1">
IsEnabled="{Binding !IsBusy}" <Grid RowDefinitions="36,*,52">
Margin="0,0,0,8" />
<StackPanel Grid.Row="3" Orientation="Vertical" Margin="0,0,0,8"> <!-- Title bar -->
<TextBlock Text="Commit message" Margin="0,0,0,4" /> <Border Grid.Row="0"
<TextBox Text="{Binding CommitMessage}" x:Name="TitleBar"
AcceptsReturn="True" Background="{DynamicResource DeepBrush}"
TextWrapping="Wrap" BorderBrush="{DynamicResource LineBrush}"
Height="70" BorderThickness="0,0,0,1"
IsEnabled="{Binding !IsBusy}" /> PointerPressed="TitleBar_PointerPressed">
</StackPanel> <Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="MERGE WORKTREE"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CancelCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<TextBlock Grid.Row="4" <!-- Body -->
Text="{Binding ErrorMessage}" <ScrollViewer Grid.Row="1" Padding="20,16">
Foreground="IndianRed" <StackPanel Spacing="12">
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,0,8" />
<Border Grid.Row="5" <TextBlock Text="{Binding TaskTitle, StringFormat='Merging: {0}'}"
BorderBrush="IndianRed" FontWeight="SemiBold"
BorderThickness="1" Foreground="{DynamicResource TextBrush}" />
Padding="8"
IsVisible="{Binding HasConflict}">
<StackPanel>
<TextBlock Text="Conflicted files:" FontWeight="SemiBold" Margin="0,0,0,4" />
<ItemsControl ItemsSource="{Binding ConflictFiles}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<StackPanel Grid.Row="6" Orientation="Horizontal" <StackPanel Spacing="4">
HorizontalAlignment="Right" Margin="0,12,0,0"> <TextBlock Classes="field-label" Text="Target branch"/>
<TextBlock Text="{Binding SuccessMessage}" <ComboBox ItemsSource="{Binding Branches}"
Foreground="SeaGreen" SelectedItem="{Binding SelectedBranch}"
VerticalAlignment="Center" HorizontalAlignment="Stretch"
Margin="0,0,12,0" IsEnabled="{Binding !IsBusy}" />
IsVisible="{Binding SuccessMessage, Converter={x:Static ObjectConverters.IsNotNull}}" /> </StackPanel>
<Button Content="Cancel"
Command="{Binding CancelCommand}"
Margin="0,0,8,0" />
<Button Content="Merge"
Command="{Binding SubmitCommand}"
IsDefault="True"
Classes="accent" />
</StackPanel>
</Grid> <CheckBox Content="Remove worktree after merge"
IsChecked="{Binding RemoveWorktree}"
IsEnabled="{Binding !IsBusy}" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Commit message"/>
<TextBox Text="{Binding CommitMessage}"
AcceptsReturn="True"
TextWrapping="Wrap"
Height="70"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Border BorderBrush="{DynamicResource BloodBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="12,10"
IsVisible="{Binding HasConflict}">
<StackPanel Spacing="4">
<TextBlock Text="Conflicted files:"
FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}" />
<ItemsControl ItemsSource="{Binding ConflictFiles}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
Foreground="{DynamicResource TextDimBrush}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Text="{Binding SuccessMessage}"
Foreground="{DynamicResource MossBrightBrush}"
IsVisible="{Binding SuccessMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Merge" Classes="primary"
Command="{Binding SubmitCommand}"
IsDefault="True" MinWidth="90"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window> </Window>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Modals;
@@ -16,4 +17,10 @@ public partial class MergeModalView : Window
} }
}; };
} }
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
} }

View File

@@ -184,6 +184,55 @@
</Border> </Border>
</StackPanel> </StackPanel>
<!-- AGENTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="AGENTS"/>
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Text="Restore bundled default agents (code-reviewer, test-writer, debugger, security-reviewer, explorer, researcher). Existing files are not overwritten."
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"/>
<Button Content="Restore default agents"
Command="{Binding RestoreDefaultAgentsCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Left"/>
</StackPanel>
</Border>
</StackPanel>
<!-- PROMPTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="PROMPTS"/>
<Border Classes="section">
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="System"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono"
Text="{Binding SystemPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="0" Grid.Column="2" Content="Open in editor"
Command="{Binding OpenPromptCommand}"
CommandParameter="System"/>
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Planning"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono"
Text="{Binding PlanningPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="1" Grid.Column="2" Content="Open in editor"
Command="{Binding OpenPromptCommand}"
CommandParameter="Planning"/>
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Agent"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono"
Text="{Binding AgentPromptPath}" VerticalAlignment="Center"/>
<Button Grid.Row="2" Grid.Column="2" Content="Open in editor"
Command="{Binding OpenPromptCommand}"
CommandParameter="Agent"/>
</Grid>
</Border>
</StackPanel>
<!-- ABOUT --> <!-- ABOUT -->
<StackPanel Spacing="0"> <StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="ABOUT"/> <TextBlock Classes="section-label" Text="ABOUT"/>

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