Merge branch 'claudedo/df0e925e1ff4419d8b7a45f8a184f6d0'

This commit is contained in:
mika kuns
2026-08-05 09:26:46 +02:00
21 changed files with 27 additions and 278 deletions
+1 -3
View File
@@ -138,7 +138,6 @@
"ctxMarkDone": "Erledigt",
"ctxMarkCancelled": "Abgebrochen",
"ctxOpenConPtySession": "ConPTY-Sitzung öffnen",
"ctxPickUpInTerminal": "Im Terminal fortsetzen",
"ctxOpenPlanningSession": "Planungssitzung öffnen",
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
"ctxFinalizePlanningSession": "Plan finalisieren",
@@ -192,7 +191,6 @@
"addReposTip": "Repos als Listen hinzufügen"
},
"details": {
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
"deleteTaskTip": "Aufgabe löschen",
"killSessionTip": "Laufende Sitzung beenden",
"closeTip": "Schließen",
@@ -539,7 +537,7 @@
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar." },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar." },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
+1 -3
View File
@@ -138,7 +138,6 @@
"ctxMarkDone": "Done",
"ctxMarkCancelled": "Cancelled",
"ctxOpenConPtySession": "Open ConPTY session",
"ctxPickUpInTerminal": "Pick up in terminal",
"ctxOpenPlanningSession": "Open planning Session",
"ctxResumePlanningSession": "Resume planning Session",
"ctxFinalizePlanningSession": "Finalize plan",
@@ -192,7 +191,6 @@
"addReposTip": "Add repos as lists"
},
"details": {
"pickUpInTerminalTip": "Pick up this session in a terminal",
"deleteTaskTip": "Delete task",
"killSessionTip": "Kill the running session",
"closeTip": "Close",
@@ -539,7 +537,7 @@
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available." },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available." },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
+2 -2
View File
@@ -35,7 +35,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `PickUpInTerminalAsync`, `ToggleManual` (per-task manual flag) and `SyncInteractiveSessions` (mirrors Mission Control's open ConPTY panes onto the rows); MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `ToggleManual` (per-task manual flag) and `SyncInteractiveSessions` (mirrors Mission Control's open ConPTY panes onto the rows); MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
@@ -45,7 +45,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
## Services
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, refine), pick-up-in-terminal + embedded ConPTY launch specs (`GetInteractiveLaunchSpecAsync`/`GetAdHocLaunchSpecAsync`), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, refine), embedded ConPTY launch specs (`GetInteractiveLaunchSpecAsync`/`GetAdHocLaunchSpecAsync`), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
- **INotesApi** / **WorkerNotesApi** — daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`).
- **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner).
@@ -74,8 +74,6 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
Task AbortConflictMergeAsync(string taskId);
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
// Picks up a task's Claude session in a real terminal window (--resume).
Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default);
/// <summary>Commits an interactively-worked task's worktree and moves it to WaitingForReview
/// (the only path that flips a hand-driven ConPTY session into the review/merge pipeline).</summary>
Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default);
-3
View File
@@ -510,9 +510,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
public async Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("ResumeTaskInTerminal", taskId, ct);
public async Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("SubmitTaskForReview", taskId, ct);
@@ -819,7 +819,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
NotifySessionSections();
OnPropertyChanged(nameof(CanAcceptDrop));
OnPropertyChanged(nameof(CanPickUpInTerminal));
}
[RelayCommand]
@@ -961,24 +960,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { /* offline */ }
}
// Pick up in a terminal only where a session + worktree reliably still exist (parked
// for review or failed mid-run); the worker reports a clear error otherwise.
public bool CanPickUpInTerminal => Task is not null
&& Task.Status is ClaudeDo.Data.Models.TaskStatus.WaitingForReview
or ClaudeDo.Data.Models.TaskStatus.Failed;
[RelayCommand]
private async System.Threading.Tasks.Task PickUpInTerminalAsync()
{
if (Task is null) return;
ClaudeDo.Ui.Services.ForegroundHelper.AllowAny();
try { await _worker.ResumeTaskInTerminalAsync(Task.Id); }
catch (System.Exception ex)
{
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
}
}
[RelayCommand(CanExecute = nameof(CanEnqueue))]
private async System.Threading.Tasks.Task EnqueueAsync()
{
@@ -74,11 +74,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
&& !IsManual;
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
// Pick up in a terminal only where a session + worktree reliably still exist: a task
// parked for review, or one that failed mid-run. The worker validates and reports a
// clear error if there's no resumable session/worktree.
public bool CanPickUpInTerminal => Status is TaskStatus.WaitingForReview or TaskStatus.Failed;
public string? PlanningBadge => PlanningPhase switch
{
PlanningPhase.Active => Loc.T("vm.planningBadge.active"),
@@ -177,7 +172,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsWaitingForReview));
OnPropertyChanged(nameof(CanPickUpInTerminal));
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
@@ -966,15 +966,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
OpenConPtySessionRequested?.Invoke(row.Id);
}
[RelayCommand]
private async Task PickUpInTerminalAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
ForegroundHelper.AllowAny();
try { await _worker.ResumeTaskInTerminalAsync(row.Id); }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.pickUpInTerminalFailed", ex.Message)); }
}
[RelayCommand]
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
{
@@ -6,7 +6,7 @@
x:Class="ClaudeDo.Ui.Views.Islands.Detail.TaskHeaderBar"
x:DataType="vm:DetailsIslandViewModel">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<Grid ColumnDefinitions="*,Auto,Auto">
<!-- Column 0: id badge + editable title -->
<StackPanel Grid.Column="0" Spacing="0">
@@ -27,18 +27,8 @@
Padding="0"/>
</StackPanel>
<!-- Column 1: pick up in terminal (review / failed) -->
<!-- Column 1: trash button (not running) -->
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding PickUpInTerminalCommand}"
ToolTip.Tip="{loc:Tr details.pickUpInTerminalTip}"
IsVisible="{Binding CanPickUpInTerminal}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="14" Height="14"/>
</Button>
<!-- Column 2: trash button (not running) -->
<Button Grid.Column="2" Classes="icon-btn"
Command="{Binding DeleteTaskCommand}"
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
IsVisible="{Binding !IsRunning}"
@@ -48,8 +38,8 @@
Foreground="{DynamicResource BloodBrush}"/>
</Button>
<!-- Column 2: skull button (running) -->
<Button Grid.Column="2" Classes="icon-btn"
<!-- Column 1: skull button (running) -->
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding StopCommand}"
ToolTip.Tip="{loc:Tr details.killSessionTip}"
IsVisible="{Binding IsRunning}"
@@ -59,8 +49,8 @@
Foreground="{DynamicResource BloodBrush}"/>
</Button>
<!-- Column 3: gear button with agent settings flyout -->
<Button Grid.Column="3" Classes="icon-btn"
<!-- Column 2: gear button with agent settings flyout -->
<Button Grid.Column="2" Classes="icon-btn"
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
IsEnabled="{Binding AgentSettings.IsEnabled}"
VerticalAlignment="Top"
@@ -58,9 +58,6 @@
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
Click="OnOpenConPtySessionClick"/>
<MenuItem Header="{loc:Tr tasks.ctxPickUpInTerminal}"
Click="OnPickUpInTerminalClick"
IsVisible="{Binding CanPickUpInTerminal}"/>
<MenuItem Header="{loc:Tr tasks.ctxOpenPlanningSession}"
Click="OnOpenPlanningSessionClick"
IsVisible="{Binding CanOpenPlanningSession}"/>
@@ -82,12 +82,6 @@ public partial class TaskRowView : UserControl
vm.OpenConPtySessionCommand.Execute(row);
}
private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.PickUpInTerminalCommand.ExecuteAsync(row);
}
private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
+2 -2
View File
@@ -12,7 +12,7 @@ Worker/
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning sessions + pick-up-in-terminal
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning sessions
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
External/ — ExternalMcpService + sibling tool classes
Config/ — WorkerConfig
@@ -156,7 +156,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`, `CreateMergeHelperTask` (creates the ClaudeDo task that owns a list-handler run — `Idle`/`IsManual=true`, `HandlerBaseCommit` stamped to the list repo's current HEAD via `InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` — called by the UI before it opens the task-based ConPTY tile), `SubmitTaskForReview` (branches on whether the task has a `WorktreeEntity`: with one, commits it and moves on; without one, it's a worktree-less list-handler host task and it just stamps `HandlerHeadCommit` to the list repo's current HEAD — both paths then flip the task Idle/Failed → WaitingForReview). **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
- Interactive sessions (embedded ConPTY, UI process): `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`, `CreateMergeHelperTask` (creates the ClaudeDo task that owns a list-handler run — `Idle`/`IsManual=true`, `HandlerBaseCommit` stamped to the list repo's current HEAD via `InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` — called by the UI before it opens the task-based ConPTY tile), `SubmitTaskForReview` (branches on whether the task has a `WorktreeEntity`: with one, commits it and moves on; without one, it's a worktree-less list-handler host task and it just stamps `HandlerHeadCommit` to the list repo's current HEAD — both paths then flip the task Idle/Failed → WaitingForReview). **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
+2 -33
View File
@@ -639,42 +639,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return ctx;
}
// Picks up a task's Claude session in a real terminal window (--resume) so the user can
// drive it by hand. Only for tasks the worker isn't actively running, with a persisted
// session id and a live worktree.
public Task ResumeTaskInTerminal(string taskId) => HubGuard(async () =>
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
?? throw new KeyNotFoundException();
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
throw new InvalidOperationException("Can't pick up a running or queued task — interrupt it first.");
var run = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, Context.ConnectionAborted);
if (run?.SessionId is not { Length: > 0 } sessionId)
throw new InvalidOperationException("This task has no resumable Claude session yet.");
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
throw new InvalidOperationException("This task has no active worktree to resume in.");
if (!Directory.Exists(worktree.Path))
throw new InvalidOperationException("The task's worktree directory no longer exists.");
try
{
await _launcher.LaunchResumeAsync(worktree.Path, sessionId, Context.ConnectionAborted);
}
catch (TerminalLaunchException ex)
{
throw new InvalidOperationException(ex.Message);
}
});
// Builds the launch spec an embedded ConPTY terminal (UI process) needs to open an
// interactive Claude session in a task's worktree -- same worktree prep as an
// autonomous run (session-skills seeding, run env vars), --resume if the task has a
// persisted session or a fresh-start spec otherwise. Guards mirror ResumeTaskInTerminal.
// persisted session or a fresh-start spec otherwise. Guards: no running/queued task,
// and (once a worktree exists) it must be live on disk.
public Task<LaunchSpec> GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
@@ -7,10 +7,6 @@ public interface ITerminalLauncher
{
Task LaunchPlanningStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
Task LaunchPlanningResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
// Resumes an arbitrary task's Claude session (--resume <id>) in a visible terminal so
// the user can pick up the conversation by hand — the "pick up in terminal" action.
Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken);
}
public sealed class TerminalLaunchException : Exception
@@ -24,10 +24,12 @@ using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Planning;
// Spawns the Claude CLI inside a visible Windows Terminal window. Used for every
// human-driven session: an interactive planning session (start/resume) and the ad-hoc
// "Run interactively" action. Headless task execution does NOT come through here — that
// path is ClaudeProcess (prompt over stdin, no terminal).
// Spawns the Claude CLI inside a visible Windows Terminal window for an interactive
// planning session (start/resume). Headless task execution does NOT come through here —
// that path is ClaudeProcess (prompt over stdin, no terminal) — nor does an embedded
// ConPTY interactive session (IInteractiveLaunchSpecService), which reuses this class's
// arg-building helpers (BuildResumeArgs, BuildPlanningStart/ResumeArgs) for a bare
// Exe/Args pair instead of a wrapped pwsh command line.
public sealed class WindowsTerminalLauncher : ITerminalLauncher
{
private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
@@ -84,26 +86,6 @@ public sealed class WindowsTerminalLauncher : ITerminalLauncher
return Task.CompletedTask;
}
public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken)
{
if (!Directory.Exists(workingDir))
throw new TerminalLaunchException($"Working directory does not exist: {workingDir}");
var resolvedWt = ResolveWtOrThrow();
var resolvedClaude = ResolveClaudeOrThrow();
var command = BuildResumeCommand(resolvedClaude, claudeSessionId);
StartInWindowsTerminal(resolvedWt, workingDir, command, static _ => { });
return Task.CompletedTask;
}
// Resumes a session by id with no extra flags: the user drives every tool approval in the
// terminal, unlike planning which additionally allowlists its MCP planning tools.
internal static string BuildResumeCommand(string claudePath, string claudeSessionId) =>
BuildPwshCommand(claudePath, BuildResumeArgs(claudeSessionId));
// The raw claude CLI args for a --resume launch, shared with InteractiveLaunchSpecService
// (which needs the bare Exe/Args pair for a ConPTY host, not a wrapped pwsh command line).
internal static IReadOnlyList<string> BuildResumeArgs(string claudeSessionId) =>
@@ -15,8 +15,9 @@ namespace ClaudeDo.Worker.Runner;
// session-skills seeded onto disk (reuses ISessionSkillSeeder + TaskRunner.UnionSkillNames,
// exactly like TaskRunner.RunAsync/ContinueAsync) and the same run environment variables
// (reuses ClaudeProcess's MCP_TOOL_TIMEOUT). Exe/Args reuse WindowsTerminalLauncher's
// --resume argument construction. Guards mirror WorkerHub.ResumeTaskInTerminal, except a
// never-run task (no persisted SessionId) is not an error here -- it's a fresh-start spec.
// --resume argument construction. Guards: no running/queued task, and (once a worktree
// exists) it must be live on disk -- but a never-run task (no persisted SessionId) is not
// an error here, it's a fresh-start spec.
//
// The run-scoped "claudedo_run" MCP server (AskUser/SuggestImprovement) that TaskRunner
// wires per headless run is intentionally NOT reused: it exists so an unattended run can
@@ -94,7 +94,6 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
public virtual Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
@@ -85,88 +85,6 @@ public sealed class PlanningHubTests : IDisposable
return (listId, task.Id);
}
private async Task SeedRunAsync(string taskId, string? sessionId)
{
await new TaskRunRepository(_ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = taskId,
RunNumber = 1,
IsRetry = false,
Prompt = "p",
SessionId = sessionId,
});
}
private async Task<string> SeedWorktreeAsync(string taskId, WorktreeState state)
{
var path = Path.Combine(_rootDir, $"wt_{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
await new WorktreeRepository(_ctx).AddAsync(new WorktreeEntity
{
TaskId = taskId,
Path = path,
BranchName = "claudedo/x",
BaseCommit = "base",
State = state,
CreatedAt = DateTime.UtcNow,
});
return path;
}
[Fact]
public async Task ResumeTaskInTerminal_LaunchesWithWorktreePathAndSessionId()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-99");
var wtPath = await SeedWorktreeAsync(taskId, WorktreeState.Active);
var hub = CreateHub();
await hub.ResumeTaskInTerminal(taskId);
Assert.Equal(1, _launcher.LaunchTerminalResumeCalls);
Assert.Equal(wtPath, _launcher.LastResumeWorkingDir);
Assert.Equal("sess-99", _launcher.LastResumeSessionId);
}
[Fact]
public async Task ResumeTaskInTerminal_NoSession_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, sessionId: null);
await SeedWorktreeAsync(taskId, WorktreeState.Active);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[Fact]
public async Task ResumeTaskInTerminal_RunningTask_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-1");
await SeedWorktreeAsync(taskId, WorktreeState.Active);
var task = await _tasks.GetByIdAsync(taskId);
task!.Status = TaskStatus.Running;
await _tasks.UpdateAsync(task);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[Fact]
public async Task ResumeTaskInTerminal_NoWorktree_Throws()
{
var (_, taskId) = await SeedAsync();
await SeedRunAsync(taskId, "sess-1");
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.ResumeTaskInTerminal(taskId));
Assert.Equal(0, _launcher.LaunchTerminalResumeCalls);
}
[Fact]
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
{
@@ -274,19 +192,6 @@ internal sealed class FakeTerminalLauncher : ITerminalLauncher
LaunchResumeCalls++;
return Task.CompletedTask;
}
public int LaunchTerminalResumeCalls { get; private set; }
public string? LastResumeWorkingDir { get; private set; }
public string? LastResumeSessionId { get; private set; }
public Task LaunchResumeAsync(string workingDir, string claudeSessionId, CancellationToken cancellationToken)
{
if (ShouldThrow) throw new TerminalLaunchException("fake launch failure");
LaunchTerminalResumeCalls++;
LastResumeWorkingDir = workingDir;
LastResumeSessionId = claudeSessionId;
return Task.CompletedTask;
}
}
internal sealed class RecordingClientProxy : IClientProxy
@@ -104,25 +104,9 @@ public sealed class WindowsTerminalLauncherTests
}
[Fact]
public void BuildResumeCommand_ResumesSessionSingleQuoted()
public void BuildResumeArgs_ResumesSession()
{
var command = WindowsTerminalLauncher.BuildResumeCommand("claude.exe", "sess-42");
Assert.Contains("--resume", command);
// The session id is single-quoted so ';' and friends pass through untouched.
Assert.Contains("'--resume' 'sess-42'", command);
// A plain resume drives the real interactive TUI — no planning-only flags.
Assert.DoesNotContain("--permission-mode", command);
Assert.DoesNotContain("--append-system-prompt-file", command);
}
[Fact]
public async Task LaunchResumeAsync_WorkingDirMissing_Throws()
{
var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude");
var missing = Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid());
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
sut.LaunchResumeAsync(missing, "sess-1", CancellationToken.None));
Assert.Contains("Working directory", ex.Message);
var args = WindowsTerminalLauncher.BuildResumeArgs("sess-42");
Assert.Equal(new[] { "--resume", "sess-42" }, args);
}
}
@@ -11,7 +11,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Runner;
/// Verifies InteractiveLaunchSpecService's guards mirror WorkerHub.ResumeTaskInTerminal
/// Verifies InteractiveLaunchSpecService's guards
/// (Running/Queued rejected, missing/invalid worktree rejected) and that it reuses the
/// autonomous-run worktree prep: session-skills seeding via ISessionSkillSeeder, and
/// produces --resume for a resumable task vs. a fresh-start spec for a never-run task.
@@ -69,9 +69,6 @@ sealed class FakeWorkerClient : IWorkerClient
public Task CancelReviewAsync(string taskId) => Task.CompletedTask;
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
public int PickUpInTerminalCalls { get; private set; }
public string? LastPickUpTaskId { get; private set; }
public Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default) { PickUpInTerminalCalls++; LastPickUpTaskId = taskId; return Task.CompletedTask; }
public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
@@ -191,28 +188,6 @@ public class TasksIslandViewModelPlanningTests
PlanningPhase phase = PlanningPhase.None)
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
[Fact]
public async Task PickUpInTerminal_InvokesWorkerWithTaskId()
{
var row = MakeRow("t1", TaskStatus.WaitingForReview);
var (vm, worker) = VmFactory.Create([row]);
await vm.PickUpInTerminalCommand.ExecuteAsync(row);
Assert.Equal(1, worker.PickUpInTerminalCalls);
Assert.Equal("t1", worker.LastPickUpTaskId);
}
[Fact]
public void CanPickUpInTerminal_OnlyForReviewOrFailed()
{
Assert.True(MakeRow("a", TaskStatus.WaitingForReview).CanPickUpInTerminal);
Assert.True(MakeRow("b", TaskStatus.Failed).CanPickUpInTerminal);
Assert.False(MakeRow("c", TaskStatus.Idle).CanPickUpInTerminal);
Assert.False(MakeRow("d", TaskStatus.Running).CanPickUpInTerminal);
Assert.False(MakeRow("e", TaskStatus.Done).CanPickUpInTerminal);
}
[Fact]
public void PlanningParentWithChildren_CollapsedByDefault_ToggleExpands()
{