feat(ui): answer a running task's question inline in Mission Control

TaskMonitorViewModel surfaces a pending AskUser question (TaskQuestionAsked /
TaskQuestionResolved events) with an AnswerDraft + SubmitAnswerCommand that calls
the new IWorkerClient.AnswerTaskQuestionAsync; MonitorPaneView shows an accent
question banner with an input box above the terminal. Pending question is cleared
on answer/resolve/finish and re-hydrated on attach via GetPendingQuestionAsync.
en/de localization for missionControl.question.*; test fakes updated.
This commit is contained in:
Mika Kuns
2026-06-25 22:53:46 +02:00
parent c7f8280106
commit 917301d61c
10 changed files with 224 additions and 2 deletions

View File

@@ -20,6 +20,11 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string, string>? TaskMessageEvent;
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
/// <summary>A running task raised a question via AskUser: (taskId, questionId, question).</summary>
event Action<string, string, string>? TaskQuestionAskedEvent;
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
event Action<string, string>? TaskQuestionResolvedEvent;
event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent;
@@ -39,6 +44,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task WakeQueueAsync();
Task RunNowAsync(string taskId);
Task ContinueTaskAsync(string taskId, string followUpPrompt);
/// <summary>Answer a question a running task raised via AskUser.</summary>
Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer);
/// <summary>The question a running task is currently blocked on, if any (for re-attach).</summary>
Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId);
Task ResetTaskAsync(string taskId);
Task CancelTaskAsync(string taskId);
Task<List<AgentInfo>> GetAgentsAsync();

View File

@@ -47,6 +47,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
public event Action<string, string>? TaskMessageEvent;
public event Action<string>? TaskUpdatedEvent;
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent;
@@ -136,6 +138,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId));
});
_hub.On<string, string, string>("TaskQuestionAsked", (taskId, questionId, question) =>
{
Dispatcher.UIThread.Post(() => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question));
});
_hub.On<string, string>("TaskQuestionResolved", (taskId, questionId) =>
{
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
});
_hub.On<string>("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<bool>("AnswerTaskQuestion", taskId, questionId, answer); }
catch { /* offline or already resolved — the UI clears optimistically */ }
}
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId)
=> TryInvokeAsync<PendingQuestionDto>("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,

View File

@@ -75,6 +75,23 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
private readonly Action<string, string, DateTime> _onTaskStarted;
private readonly Action<string, string, string, DateTime> _onTaskFinished;
private readonly Action<string> _onTaskUpdated;
private readonly Action<string, string, string> _onTaskQuestionAsked;
private readonly Action<string, string> _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<ClaudeDoDbContext> 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;
}
}

View File

@@ -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 */ }
}

View File

@@ -73,6 +73,34 @@
</StackPanel>
</Border>
<!-- Question prompt (AskUser): answer a running task's question inline -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding HasPendingQuestion}"
Background="{DynamicResource AccentSoftBrush}"
BorderBrush="{DynamicResource AccentBrush}"
BorderThickness="0,0,0,1" Padding="12,8">
<StackPanel Spacing="6">
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.question.title}"
Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold" />
<TextBlock Text="{Binding PendingQuestion}" TextWrapping="Wrap"
Foreground="{DynamicResource TextBrush}" />
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
<TextBox Grid.Column="0"
Text="{Binding AnswerDraft, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="{loc:Tr missionControl.question.placeholder}"
AcceptsReturn="False">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SubmitAnswerCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="1"
Content="{loc:Tr missionControl.question.send}"
Command="{Binding SubmitAnswerCommand}" />
</Grid>
</StackPanel>
</Border>
<!-- Console body: reuse SessionTerminalView -->
<islands:SessionTerminalView
Entries="{Binding Log}"