diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index adeba25..4278521 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -243,7 +243,12 @@ "empty": "Keine laufenden Aufgaben", "settings": "Einstellungen", "queue": "Warteschlange", - "blocked": "Blockiert" + "blocked": "Blockiert", + "question": { + "title": "Claude fragt nach", + "placeholder": "Antwort eingeben…", + "send": "Senden" + } }, "modals": { "logVisualizer": { diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index ee7e35c..33c22cf 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -243,7 +243,12 @@ "empty": "No running tasks", "settings": "Settings", "queue": "Queue", - "blocked": "Blocked" + "blocked": "Blocked", + "question": { + "title": "Claude is asking", + "placeholder": "Type your answer…", + "send": "Send" + } }, "modals": { "logVisualizer": { diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index cba70e5..d438c03 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -20,6 +20,11 @@ public interface IWorkerClient : INotifyPropertyChanged event Action? TaskMessageEvent; event Action? WorkerLogReceivedEvent; + /// A running task raised a question via AskUser: (taskId, questionId, question). + event Action? TaskQuestionAskedEvent; + /// A pending question was answered, timed out, or the run ended: (taskId, questionId). + event Action? TaskQuestionResolvedEvent; + event Action? PrepStartedEvent; event Action? PrepLineEvent; event Action? PrepFinishedEvent; @@ -39,6 +44,10 @@ public interface IWorkerClient : INotifyPropertyChanged Task WakeQueueAsync(); Task RunNowAsync(string taskId); Task ContinueTaskAsync(string taskId, string followUpPrompt); + /// Answer a question a running task raised via AskUser. + Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer); + /// The question a running task is currently blocked on, if any (for re-attach). + Task GetPendingQuestionAsync(string taskId); Task ResetTaskAsync(string taskId); Task CancelTaskAsync(string taskId); Task> GetAgentsAsync(); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 34fe2d8..dfe0355 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -47,6 +47,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? TaskFinishedEvent; public event Action? TaskMessageEvent; public event Action? TaskUpdatedEvent; + public event Action? TaskQuestionAskedEvent; + public event Action? TaskQuestionResolvedEvent; public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? ListUpdatedEvent; @@ -136,6 +138,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId)); }); + _hub.On("TaskQuestionAsked", (taskId, questionId, question) => + { + Dispatcher.UIThread.Post(() => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question)); + }); + + _hub.On("TaskQuestionResolved", (taskId, questionId) => + { + Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId)); + }); + _hub.On("WorktreeUpdated", taskId => { Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId)); @@ -261,6 +273,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt); } + public async Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer) + { + try { await _hub.InvokeAsync("AnswerTaskQuestion", taskId, questionId, answer); } + catch { /* offline or already resolved — the UI clears optimistically */ } + } + + public Task GetPendingQuestionAsync(string taskId) + => TryInvokeAsync("GetPendingQuestion", taskId); + public async Task ResetTaskAsync(string taskId) { await _hub.InvokeAsync("ResetTask", taskId); @@ -586,6 +607,7 @@ public sealed record WorktreeOverviewDto( bool PathExistsOnDisk); public sealed record ForceRemoveResultDto(bool Removed, string? Reason); +public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question); public sealed record OnlineInboxStateDto( bool Enabled, diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs index 504bdd0..ed5938d 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs @@ -75,6 +75,23 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable private readonly Action _onTaskStarted; private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; + private readonly Action _onTaskQuestionAsked; + private readonly Action _onTaskQuestionResolved; + + // A question the running task raised via AskUser and is blocking on, plus the answer + // the user is typing. Ephemeral (in-memory + live events) — the task is still Running. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasPendingQuestion))] + [NotifyCanExecuteChangedFor(nameof(SubmitAnswerCommand))] + private string? _pendingQuestionId; + + [ObservableProperty] private string? _pendingQuestion; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SubmitAnswerCommand))] + private string _answerDraft = string.Empty; + + public bool HasPendingQuestion => PendingQuestionId is not null; public TaskMonitorViewModel(IDbContextFactory dbFactory, IWorkerClient worker) { @@ -101,6 +118,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──", }); AgentState = FinishedStatusToStateKey(status); + ClearPendingQuestion(); _ = RefreshOutcomeAsync(taskId); }; _worker.TaskFinishedEvent += _onTaskFinished; @@ -111,8 +129,50 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable _ = RefreshStatusAsync(taskId); }; _worker.TaskUpdatedEvent += _onTaskUpdated; + + _onTaskQuestionAsked = (taskId, questionId, question) => + { + if (taskId != _subscribedTaskId) return; + PendingQuestionId = questionId; + PendingQuestion = question; + }; + _worker.TaskQuestionAskedEvent += _onTaskQuestionAsked; + + _onTaskQuestionResolved = (taskId, questionId) => + { + if (taskId == _subscribedTaskId && PendingQuestionId == questionId) + ClearPendingQuestion(); + }; + _worker.TaskQuestionResolvedEvent += _onTaskQuestionResolved; } + // Surface a pending question (used by live event + re-attach hydration). + public void SetPendingQuestion(string questionId, string question) + { + PendingQuestionId = questionId; + PendingQuestion = question; + } + + private void ClearPendingQuestion() + { + PendingQuestionId = null; + PendingQuestion = null; + AnswerDraft = string.Empty; + } + + [RelayCommand(CanExecute = nameof(CanSubmitAnswer))] + private async System.Threading.Tasks.Task SubmitAnswer() + { + var questionId = PendingQuestionId; + if (questionId is null || string.IsNullOrEmpty(_subscribedTaskId)) return; + var answer = AnswerDraft; + if (string.IsNullOrWhiteSpace(answer)) return; + ClearPendingQuestion(); // optimistic; the resolved event also clears + await _worker.AnswerTaskQuestionAsync(_subscribedTaskId, questionId, answer); + } + + private bool CanSubmitAnswer() => HasPendingQuestion && !string.IsNullOrWhiteSpace(AnswerDraft); + partial void OnAgentStateChanged(string value) { OnPropertyChanged(nameof(AgentStatusLabel)); @@ -140,6 +200,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable AgentState = "idle"; SessionOutcome = null; Roadblocks = null; + ClearPendingQuestion(); } [ObservableProperty] @@ -361,5 +422,7 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable _worker.TaskStartedEvent -= _onTaskStarted; _worker.TaskFinishedEvent -= _onTaskFinished; _worker.TaskUpdatedEvent -= _onTaskUpdated; + _worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked; + _worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved; } } diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index bf9a828..12fe34f 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -161,6 +161,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId); monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown); await monitor.ReplayLogFileAsync(entity.LogPath, CancellationToken.None); + + // Re-attach: if the task is blocked on an AskUser question right now, surface it. + var pending = await _worker.GetPendingQuestionAsync(taskId); + if (pending is not null && monitor.SubscribedTaskId == taskId) + monitor.SetPendingQuestion(pending.QuestionId, pending.Question); } catch { /* best-effort hydrate */ } } diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MonitorPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MonitorPaneView.axaml index df2ab48..5036550 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MonitorPaneView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MonitorPaneView.axaml @@ -73,6 +73,34 @@ + + + + + + + + + + + +