Covers four issues from first real test: raw NDJSON display, missing start feedback, lost live output, and config editor gaps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9.5 KiB
UI Fixes Design Spec
Post-integration fixes for the Worker CLI modernization. Addresses four issues found during first real test.
Issue 1: Raw NDJSON in Live Log
Problem
TaskDetailViewModel.OnTaskMessage receives raw NDJSON lines from SignalR TaskMessage broadcasts and displays them as-is in an ItemsControl. Users see JSON like {"type":"stream_event","event":{...}} instead of readable output.
Solution: StreamLineFormatter
New stateful helper class at ClaudeDo.Ui/Helpers/StreamLineFormatter.cs.
Responsibility: Convert a single raw NDJSON line into human-readable text for display.
API:
public sealed class StreamLineFormatter
{
// Returns formatted text to append, or null to skip the line.
public string? FormatLine(string ndjsonLine);
// Reads all lines from an NDJSON log file, formats each, returns complete text.
public string FormatFile(string filePath);
}
Event mapping (moderate detail level):
| NDJSON structure | Display output |
|---|---|
stream_event → content_block_delta → text_delta |
The delta text content (appended inline) |
stream_event → content_block_start → tool_use |
\n[Tool: {name}]\n |
stream_event → content_block_stop |
\n (line separator) |
stream_event → content_block_delta → input_json_delta |
null (skip — tool input noise) |
stream_event → message_start / message_delta |
null (skip) |
result |
\n--- Result ---\n{result_text}\n |
system with subtype: api_retry |
\n[Retrying API call...]\n |
assistant |
null (skip — content arrives via stream_events) |
| Malformed JSON / unknown type | Raw line as-is (fallback) |
State tracking: The formatter tracks whether the previous line was a text delta to avoid inserting unnecessary newlines between consecutive text chunks.
Display model change
Replace ObservableCollection<string> LiveLines with [ObservableProperty] string _liveText = "".
OnTaskMessage: pass line through_formatter.FormatLine(line), append result toLiveText- Bounding: if
LiveText.Length > 50_000, trim from the front at the next newline boundary - View: replace
ItemsControlwith a read-onlyTextBox(AcceptsReturn="True",TextWrapping="NoWrap", monospace font) - Auto-scroll to bottom on text change (code-behind handler on PropertyChanged)
Rationale: Text deltas stream per-token. An ItemsControl with hundreds of tiny entries causes UI overhead. A single TextBox with appended text gives a natural terminal feel and better performance.
Issue 2: No Immediate Feedback on Task Start
Problem
After clicking RunNow, nothing happens visually until the Worker processes the request, updates the DB, and broadcasts TaskStarted. The delay (typically <1s, but noticeable) makes the app feel unresponsive.
Solution: Three-layer optimistic feedback
Layer 1 — WorkerClient local event:
Add event Action<string>? RunNowRequestedEvent to WorkerClient.
In RunNowAsync(taskId): fire RunNowRequestedEvent(taskId) before calling _hub.InvokeAsync("RunNow", taskId). This gives the UI an instant signal.
Layer 2 — TaskItemViewModel (list view):
- Add
[ObservableProperty] bool _isStarting - On
RunNowRequestedEventfor this task: setIsStarting = true,StatusText = "starting..." - On
TaskStartedEventfor this task: setIsStarting = false RunNowCommand.CanExecutealso returns false whenIsStarting(prevents double-click)- View: RunNow button disables and shows "Starting..." state
Layer 3 — TaskDetailViewModel (detail view):
- Subscribe to
RunNowRequestedEvent→ if current task, setStatusText = "starting...", clearLiveText, reset formatter - Subscribe to
TaskStartedEvent→ if current task, setStatusText = "running" - Both are overwritten naturally when
OnTaskUpdatedfires and reloads from DB
Wiring: TaskListViewModel subscribes to WorkerClient.RunNowRequestedEvent and updates the matching TaskItemViewModel. TaskDetailViewModel subscribes directly to WorkerClient events (same pattern as existing TaskMessage/TaskUpdated subscriptions).
Issue 3: Live Output Lost After Completion
Problem
LiveText is in-memory only. Once the task finishes and the user navigates away, the log content is gone. The NDJSON log file exists on disk (at task.LogPath) but is never loaded back into the UI.
Solution: Load from disk on revisit
In TaskDetailViewModel.LoadAsync, after loading the task entity:
if (task.LogPath is not null
&& task.Status is TaskStatus.Done or TaskStatus.Failed
&& File.Exists(task.LogPath))
{
_formatter = new StreamLineFormatter();
LiveText = _formatter.FormatFile(task.LogPath);
}
Reuses StreamLineFormatter.FormatFile from Issue 1 — no new infrastructure needed.
Edge cases:
- Task completes while watching: LiveText already has streamed content.
OnTaskUpdatedtriggersLoadAsync, which reloads from disk. Same content, re-parsed — no visible disruption. - Log file missing/deleted:
File.Existscheck handles it. LiveText stays empty. The "Result" field above still shows result markdown from the DB. - Large log files: Bounded by the same 50,000 char limit as live streaming.
FormatFileapplies the same trim-from-front logic.
Issue 4: Config Editors + Modal Theming
Problem A: Modal dialogs unstyled
ListEditorView and TaskEditorView are <Window> elements with no explicit background. They render as black with white text, not matching the app's green-accented dark theme defined in App.axaml.
Fix: Apply app resource brushes
Both editor <Window> elements get:
Background="{StaticResource WindowBgBrush}"(#1c1e21)- Label TextBlocks:
Foreground="{StaticResource TextSecondaryBrush}" - Save button:
Background="{StaticResource AccentBrush}"(green accent#3d9474) - Cancel button: default Fluent dark theme (correct once window bg is set)
Problem B: No UI for model/prompt/agent config
The backend supports per-list config (list_config table) and per-task overrides (tasks.model, tasks.system_prompt, tasks.agent_path), but there are no editor fields for these.
Solution: Extend existing editors
Model selection
ComboBox shows short display labels, mapped to actual model IDs:
| Display | Model ID |
|---|---|
| Sonnet | claude-sonnet-4-6 |
| Opus | claude-opus-4-6 |
| Haiku | claude-haiku-4-5 |
Default model: Sonnet (claude-sonnet-4-6). Applied in TaskRunner config resolution as the final fallback when both task and list config have no model set.
WorkerClient — add GetAgents
New methods on WorkerClient:
Task<List<AgentInfo>> GetAgentsAsync()— calls hubGetAgents()Task RefreshAgentsAsync()— calls hubRefreshAgents()record AgentInfo(string Name, string Description, string Path)— DTO
ListEditorViewModel extensions
Three new properties:
[ObservableProperty] string _model— ComboBox: Sonnet (default for new lists), Opus, Haiku[ObservableProperty] string? _systemPrompt— TextBox, multiline, optional[ObservableProperty] string? _agentPath— ComboBox populated fromGetAgentsAsync(), empty = none
InitForEdit loads existing config via ListRepository.GetConfigAsync().
Save persists via ListRepository.SetConfigAsync().
TaskEditorViewModel extensions
Same three fields, but with inheritance indicators:
- Model ComboBox: first option
"(list default)"→ maps to null (inherit from list config, which falls back to Sonnet) - SystemPrompt: placeholder text
"(inherits from list)" - AgentPath ComboBox: first option
"(list default)"→ maps to null
InitForEdit reads from TaskEntity.Model/SystemPrompt/AgentPath.
Save writes them back to the entity.
View layout
Both editors add an "Agent Config" section below existing fields, separated by a horizontal divider line. Contains: Model dropdown, System Prompt text area, Agent File picker. Always visible (no collapse — only three fields).
Window heights increase to accommodate new fields:
- ListEditorView: 280 → ~450
- TaskEditorView: 420 → ~580
Out of Scope
- Agent file creation/editing UI (agents are managed as
.mdfiles on disk; editors only pick from existing agents) - Token usage display in live output
- Run history viewer (multiple runs per task)
- Rich text rendering (markdown in result/output)
Files Changed
New
src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
Modified
src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs— LiveText, formatter, start feedback, log reloadsrc/ClaudeDo.Ui/Views/TaskDetailView.axaml— TextBox replaces ItemsControl, auto-scrollsrc/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs— auto-scroll handler (if needed)src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs— IsStarting propertysrc/ClaudeDo.Ui/Views/TaskListView.axaml— starting state visualsrc/ClaudeDo.Ui/Services/WorkerClient.cs— RunNowRequestedEvent, GetAgentsAsync, RefreshAgentsAsync, AgentInfo DTOsrc/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs— config fields, agent loadingsrc/ClaudeDo.Ui/Views/ListEditorView.axaml— config section, themingsrc/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs— config override fields, agent loadingsrc/ClaudeDo.Ui/Views/TaskEditorView.axaml— config section, themingsrc/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs— wire RunNowRequestedEvent to TaskItemViewModelssrc/ClaudeDo.Worker/Runner/TaskRunner.cs— default model fallback toclaude-sonnet-4-6