Merge claudedo/8c1c213004574c4fad6beb75b84b70d7
This commit is contained in:
@@ -74,6 +74,12 @@ Offene Entscheidungen dazu:
|
|||||||
`WaitingForReview`, and the detail pane's diff/merge card shows the full range of everything the
|
`WaitingForReview`, and the detail pane's diff/merge card shows the full range of everything the
|
||||||
run merged to main (via the new `HandlerBaseCommit`/`HandlerHeadCommit` fallback — no
|
run merged to main (via the new `HandlerBaseCommit`/`HandlerHeadCommit` fallback — no
|
||||||
`WorktreeEntity` is created for this task, so the diff comes from `list.WorkingDir` directly).
|
`WorktreeEntity` is created for this task, so the diff comes from `list.WorkingDir` directly).
|
||||||
|
- **Roadblock reply field (2026-08-05)** — build + unit tests all green, but **not visually
|
||||||
|
verified**: on a `Done` (or `WaitingForReview`/`Failed`/`Cancelled`) task with a reported
|
||||||
|
roadblock, the ROADBLOCK card shows a reply textbox + Send button under the roadblock text.
|
||||||
|
Check layout/spacing against the AskUser question card it's modeled on, Enter-to-send, the
|
||||||
|
disabled state + hint text when there's no session ID to resume, and that an override-slot-busy
|
||||||
|
error shows up in the footer log strip (not a modal) with the typed text still in the field.
|
||||||
Approve should go straight to `Done` with no merge attempt. Design choice: the review range
|
Approve should go straight to `Done` with no merge attempt. Design choice: the review range
|
||||||
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
|
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
|
||||||
specifically so `list_worktrees`/the Worktrees overview never see it.
|
specifically so `list_worktrees`/the Worktrees overview never see it.
|
||||||
|
|||||||
@@ -215,6 +215,12 @@
|
|||||||
"prepTitle": "Tagesvorbereitung",
|
"prepTitle": "Tagesvorbereitung",
|
||||||
"planDay": "Tag planen",
|
"planDay": "Tag planen",
|
||||||
"prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen",
|
"prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen",
|
||||||
|
"roadblockReply": {
|
||||||
|
"placeholder": "Auf den Roadblock antworten…",
|
||||||
|
"send": "Senden",
|
||||||
|
"noSession": "Keine Sitzung zum Fortsetzen — dieser Lauf hat keine Session-ID zum Antworten.",
|
||||||
|
"failed": "Antwort konnte nicht gesendet werden: {0}"
|
||||||
|
},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
"sectionLabel": "ANHÄNGE",
|
"sectionLabel": "ANHÄNGE",
|
||||||
"dropToAttach": "Zum Anhängen ablegen",
|
"dropToAttach": "Zum Anhängen ablegen",
|
||||||
|
|||||||
@@ -215,6 +215,12 @@
|
|||||||
"prepTitle": "Daily prep",
|
"prepTitle": "Daily prep",
|
||||||
"planDay": "Plan day",
|
"planDay": "Plan day",
|
||||||
"prepEmpty": "No prep run today yet — click Plan day",
|
"prepEmpty": "No prep run today yet — click Plan day",
|
||||||
|
"roadblockReply": {
|
||||||
|
"placeholder": "Reply to the roadblock…",
|
||||||
|
"send": "Send",
|
||||||
|
"noSession": "No session to resume — this run has no session ID to reply into.",
|
||||||
|
"failed": "Couldn't send the reply: {0}"
|
||||||
|
},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
"sectionLabel": "ATTACHMENTS",
|
"sectionLabel": "ATTACHMENTS",
|
||||||
"dropToAttach": "Drop to attach",
|
"dropToAttach": "Drop to attach",
|
||||||
|
|||||||
@@ -36,7 +36,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`.
|
- **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`.
|
- **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), `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.
|
- **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 ROADBLOCK card also has a reply field (`RoadblockReplyDraft`/`SendRoadblockReplyCommand`, gated by `CanReplyToRoadblock` on `LatestRunSessionId`) that resumes the session via the same `ContinueTaskAsync` transport as `ContinueCommand` but with the user's own text instead of the fixed re-run prompt; failures raise `ErrorReported`, wired by the shell into `FlashFooterError`, 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`).
|
- **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`.
|
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, `VerifyCommand` (optional post-merge verify gate, own field/section — not part of `AgentConfigEditorViewModel`), delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync(verifyCommand)`, since both fields land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, `VerifyCommand` (optional post-merge verify gate, own field/section — not part of `AgentConfigEditorViewModel`), delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync(verifyCommand)`, since both fields land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
||||||
|
|||||||
@@ -191,8 +191,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CanReplyToRoadblock))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(SendRoadblockReplyCommand))]
|
||||||
private string? _latestRunSessionId;
|
private string? _latestRunSessionId;
|
||||||
|
|
||||||
|
// A resumable session to reply to a reported roadblock with (via ContinueAsync's
|
||||||
|
// transport). Distinct from ContinueCommand: that one is Failed/Cancelled-only and
|
||||||
|
// reruns with a fixed prompt, this one answers a roadblock reported on any terminal
|
||||||
|
// state (see ShowRoadblockCard) with the user's own text.
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(SendRoadblockReplyCommand))]
|
||||||
|
private string _roadblockReplyDraft = string.Empty;
|
||||||
|
|
||||||
|
public bool CanReplyToRoadblock => !string.IsNullOrEmpty(LatestRunSessionId);
|
||||||
|
|
||||||
|
// Surfaces a UI-originated failure (e.g. override slot busy) to the shell's footer
|
||||||
|
// error strip — mirrors TasksIslandViewModel.ErrorReported.
|
||||||
|
public event Action<string>? ErrorReported;
|
||||||
|
|
||||||
[ObservableProperty] private string? _model;
|
[ObservableProperty] private string? _model;
|
||||||
|
|
||||||
[ObservableProperty] private string? _worktreePath;
|
[ObservableProperty] private string? _worktreePath;
|
||||||
@@ -333,6 +349,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
DequeueCommand.NotifyCanExecuteChanged();
|
DequeueCommand.NotifyCanExecuteChanged();
|
||||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||||
ContinueCommand.NotifyCanExecuteChanged();
|
ContinueCommand.NotifyCanExecuteChanged();
|
||||||
|
SendRoadblockReplyCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
||||||
@@ -524,6 +541,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Task = row;
|
Task = row;
|
||||||
OnPropertyChanged(nameof(TaskIdBadge));
|
OnPropertyChanged(nameof(TaskIdBadge));
|
||||||
Monitor.Reset();
|
Monitor.Reset();
|
||||||
|
RoadblockReplyDraft = string.Empty;
|
||||||
Subtasks.Clear();
|
Subtasks.Clear();
|
||||||
ChildOutcomes.Clear();
|
ChildOutcomes.Clear();
|
||||||
Attachments.Clear();
|
Attachments.Clear();
|
||||||
@@ -1025,6 +1043,26 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private bool CanContinue() =>
|
private bool CanContinue() =>
|
||||||
Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId);
|
Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId);
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSendRoadblockReply))]
|
||||||
|
private async System.Threading.Tasks.Task SendRoadblockReplyAsync()
|
||||||
|
{
|
||||||
|
if (Task == null) return;
|
||||||
|
var text = RoadblockReplyDraft;
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _worker.ContinueTaskAsync(Task.Id, text);
|
||||||
|
RoadblockReplyDraft = string.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ErrorReported?.Invoke(Loc.T("details.roadblockReply.failed", ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanSendRoadblockReply() =>
|
||||||
|
Task != null && _worker.IsConnected && CanReplyToRoadblock && !string.IsNullOrWhiteSpace(RoadblockReplyDraft);
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanResetAndRetry))]
|
[RelayCommand(CanExecute = nameof(CanResetAndRetry))]
|
||||||
private async System.Threading.Tasks.Task ResetAndRetryAsync()
|
private async System.Threading.Tasks.Task ResetAndRetryAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
if (Lists.SelectedList is { } row)
|
if (Lists.SelectedList is { } row)
|
||||||
Lists.LetClaudeHandleListCommand.Execute(row);
|
Lists.LetClaudeHandleListCommand.Execute(row);
|
||||||
};
|
};
|
||||||
|
Details.ErrorReported += FlashFooterError;
|
||||||
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
||||||
Details.DeleteFromList = row =>
|
Details.DeleteFromList = row =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -78,6 +78,25 @@
|
|||||||
<SelectableTextBlock Text="{Binding Roadblocks}"
|
<SelectableTextBlock Text="{Binding Roadblocks}"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
Foreground="{DynamicResource TextDimBrush}"/>
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
Text="{Binding RoadblockReplyDraft, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
PlaceholderText="{loc:Tr details.roadblockReply.placeholder}"
|
||||||
|
IsEnabled="{Binding CanReplyToRoadblock}"
|
||||||
|
AcceptsReturn="False">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SendRoadblockReplyCommand}"/>
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="{loc:Tr details.roadblockReply.send}"
|
||||||
|
Command="{Binding SendRoadblockReplyCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="meta"
|
||||||
|
Text="{loc:Tr details.roadblockReply.noSession}"
|
||||||
|
IsVisible="{Binding !CanReplyToRoadblock}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Localization;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class DetailsIslandRoadblockReplyTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public DetailsIslandRoadblockReplyTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_roadblock_reply_test_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
|
||||||
|
// Loc is a process-wide ambient singleton other tests also mutate — pin it to
|
||||||
|
// the real locale data so this test's assertions don't depend on run order.
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||||
|
dir = Path.GetDirectoryName(dir);
|
||||||
|
Loc.Current = new Localizer(
|
||||||
|
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||||
|
{
|
||||||
|
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
|
||||||
|
Task.FromResult(new List<DailyNoteDto>());
|
||||||
|
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
|
||||||
|
Task.FromResult<DailyNoteDto?>(null);
|
||||||
|
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||||
|
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingWorkerClient : StubWorkerClient
|
||||||
|
{
|
||||||
|
public override bool IsConnected => true;
|
||||||
|
public (string TaskId, string Prompt)? LastContinue;
|
||||||
|
public Exception? ThrowOnContinue;
|
||||||
|
|
||||||
|
public override Task ContinueTaskAsync(string taskId, string followUpPrompt)
|
||||||
|
{
|
||||||
|
if (ThrowOnContinue is not null) throw ThrowOnContinue;
|
||||||
|
LastContinue = (taskId, followUpPrompt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
|
||||||
|
{
|
||||||
|
var factory = new TestDbFactory(NewContext);
|
||||||
|
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DetailsIslandViewModel PrepareDoneTaskWithRoadblock(DetailsIslandViewModel vm, string taskId = "task-rb-1")
|
||||||
|
{
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Done });
|
||||||
|
vm.Monitor.ApplyState(TaskStatus.Done);
|
||||||
|
vm.Roadblocks = "needs go-ahead to merge branches";
|
||||||
|
return vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanReplyToRoadblock_IsFalse_WithoutSessionId()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new RecordingWorkerClient());
|
||||||
|
PrepareDoneTaskWithRoadblock(vm);
|
||||||
|
|
||||||
|
Assert.True(vm.ShowRoadblockCard);
|
||||||
|
Assert.Null(vm.LatestRunSessionId);
|
||||||
|
Assert.False(vm.CanReplyToRoadblock);
|
||||||
|
Assert.False(vm.SendRoadblockReplyCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanReplyToRoadblock_BecomesTrue_OnceSessionIdIsPresent()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new RecordingWorkerClient());
|
||||||
|
PrepareDoneTaskWithRoadblock(vm);
|
||||||
|
|
||||||
|
vm.LatestRunSessionId = "session-abc";
|
||||||
|
|
||||||
|
Assert.True(vm.CanReplyToRoadblock);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendRoadblockReplyCommand_IsDisabled_WhenDraftIsEmptyOrWhitespace()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new RecordingWorkerClient());
|
||||||
|
PrepareDoneTaskWithRoadblock(vm);
|
||||||
|
vm.LatestRunSessionId = "session-abc";
|
||||||
|
|
||||||
|
Assert.False(vm.SendRoadblockReplyCommand.CanExecute(null));
|
||||||
|
|
||||||
|
vm.RoadblockReplyDraft = " ";
|
||||||
|
Assert.False(vm.SendRoadblockReplyCommand.CanExecute(null));
|
||||||
|
|
||||||
|
vm.RoadblockReplyDraft = "go ahead, merge it";
|
||||||
|
Assert.True(vm.SendRoadblockReplyCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendRoadblockReply_PassesTypedText_NotTheHardcodedContinueConstant()
|
||||||
|
{
|
||||||
|
var worker = new RecordingWorkerClient();
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
PrepareDoneTaskWithRoadblock(vm);
|
||||||
|
vm.LatestRunSessionId = "session-abc";
|
||||||
|
vm.RoadblockReplyDraft = "yes, go ahead and merge";
|
||||||
|
|
||||||
|
await vm.SendRoadblockReplyCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(("task-rb-1", "yes, go ahead and merge"), worker.LastContinue);
|
||||||
|
Assert.NotEqual("Continue working on this task.", worker.LastContinue?.Prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendRoadblockReply_ClearsDraft_OnSuccess()
|
||||||
|
{
|
||||||
|
var worker = new RecordingWorkerClient();
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
PrepareDoneTaskWithRoadblock(vm);
|
||||||
|
vm.LatestRunSessionId = "session-abc";
|
||||||
|
vm.RoadblockReplyDraft = "go ahead";
|
||||||
|
|
||||||
|
await vm.SendRoadblockReplyCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, vm.RoadblockReplyDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendRoadblockReply_WhenWorkerThrows_ReportsErrorAndKeepsDraft()
|
||||||
|
{
|
||||||
|
var worker = new RecordingWorkerClient { ThrowOnContinue = new Exception("override slot busy") };
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
PrepareDoneTaskWithRoadblock(vm);
|
||||||
|
vm.LatestRunSessionId = "session-abc";
|
||||||
|
vm.RoadblockReplyDraft = "go ahead";
|
||||||
|
|
||||||
|
string? reportedError = null;
|
||||||
|
vm.ErrorReported += msg => reportedError = msg;
|
||||||
|
|
||||||
|
await vm.SendRoadblockReplyCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.NotNull(reportedError);
|
||||||
|
Assert.Contains("override slot busy", reportedError);
|
||||||
|
Assert.Equal("go ahead", vm.RoadblockReplyDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_ClearsRoadblockReplyDraft_ForNewTask()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new RecordingWorkerClient());
|
||||||
|
PrepareDoneTaskWithRoadblock(vm);
|
||||||
|
vm.LatestRunSessionId = "session-abc";
|
||||||
|
vm.RoadblockReplyDraft = "leftover text";
|
||||||
|
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = "task-rb-2", Status = TaskStatus.Idle });
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, vm.RoadblockReplyDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowContinueAndCanContinue_AreUnaffected_ByRoadblockReplyAddition()
|
||||||
|
{
|
||||||
|
// Regression: the existing Continue button (Failed/Cancelled only) must keep
|
||||||
|
// working exactly as before — the roadblock reply field is an additional path,
|
||||||
|
// not a replacement.
|
||||||
|
var vm = BuildVm(new RecordingWorkerClient());
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = "task-rb-3", Status = TaskStatus.Failed });
|
||||||
|
vm.Monitor.ApplyState(TaskStatus.Failed);
|
||||||
|
|
||||||
|
Assert.True(vm.ShowContinue);
|
||||||
|
Assert.False(vm.ContinueCommand.CanExecute(null)); // no session id yet
|
||||||
|
|
||||||
|
vm.LatestRunSessionId = "session-xyz";
|
||||||
|
Assert.True(vm.ContinueCommand.CanExecute(null));
|
||||||
|
|
||||||
|
vm.Monitor.ApplyState(TaskStatus.Done);
|
||||||
|
Assert.False(vm.ShowContinue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(TaskStatus.WaitingForReview, true)]
|
||||||
|
[InlineData(TaskStatus.Done, true)]
|
||||||
|
[InlineData(TaskStatus.Failed, true)]
|
||||||
|
[InlineData(TaskStatus.Cancelled, true)]
|
||||||
|
[InlineData(TaskStatus.Running, false)]
|
||||||
|
[InlineData(TaskStatus.Queued, false)]
|
||||||
|
[InlineData(TaskStatus.Idle, false)]
|
||||||
|
[InlineData(TaskStatus.WaitingForChildren, false)]
|
||||||
|
public void ShowRoadblockCard_MatchesStatusMatrix_WhenARoadblockWasReported(TaskStatus status, bool expectedVisible)
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new RecordingWorkerClient());
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = "task-rb-matrix", Status = status });
|
||||||
|
vm.Monitor.ApplyState(status);
|
||||||
|
vm.Roadblocks = "needs a go-ahead";
|
||||||
|
|
||||||
|
Assert.Equal(expectedVisible, vm.ShowRoadblockCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowRoadblockCard_IsFalse_WithoutARoadblock_RegardlessOfStatus()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new RecordingWorkerClient());
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = "task-rb-none", Status = TaskStatus.Done });
|
||||||
|
vm.Monitor.ApplyState(TaskStatus.Done);
|
||||||
|
|
||||||
|
Assert.False(vm.ShowRoadblockCard);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user