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>
217 lines
9.5 KiB
Markdown
217 lines
9.5 KiB
Markdown
# 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:**
|
|
|
|
```csharp
|
|
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 to `LiveText`
|
|
- Bounding: if `LiveText.Length > 50_000`, trim from the front at the next newline boundary
|
|
- View: replace `ItemsControl` with a read-only `TextBox` (`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 `RunNowRequestedEvent` for this task: set `IsStarting = true`, `StatusText = "starting..."`
|
|
- On `TaskStartedEvent` for this task: set `IsStarting = false`
|
|
- `RunNowCommand.CanExecute` also returns false when `IsStarting` (prevents double-click)
|
|
- View: RunNow button disables and shows "Starting..." state
|
|
|
|
**Layer 3 — TaskDetailViewModel (detail view):**
|
|
|
|
- Subscribe to `RunNowRequestedEvent` → if current task, set `StatusText = "starting..."`, clear `LiveText`, reset formatter
|
|
- Subscribe to `TaskStartedEvent` → if current task, set `StatusText = "running"`
|
|
- Both are overwritten naturally when `OnTaskUpdated` fires 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:
|
|
|
|
```csharp
|
|
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. `OnTaskUpdated` triggers `LoadAsync`, which reloads from disk. Same content, re-parsed — no visible disruption.
|
|
- **Log file missing/deleted:** `File.Exists` check 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. `FormatFile` applies 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 hub `GetAgents()`
|
|
- `Task RefreshAgentsAsync()` — calls hub `RefreshAgents()`
|
|
- `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 from `GetAgentsAsync()`, 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 `.md` files 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 reload
|
|
- `src/ClaudeDo.Ui/Views/TaskDetailView.axaml` — TextBox replaces ItemsControl, auto-scroll
|
|
- `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` — auto-scroll handler (if needed)
|
|
- `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — IsStarting property
|
|
- `src/ClaudeDo.Ui/Views/TaskListView.axaml` — starting state visual
|
|
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — RunNowRequestedEvent, GetAgentsAsync, RefreshAgentsAsync, AgentInfo DTO
|
|
- `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` — config fields, agent loading
|
|
- `src/ClaudeDo.Ui/Views/ListEditorView.axaml` — config section, theming
|
|
- `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — config override fields, agent loading
|
|
- `src/ClaudeDo.Ui/Views/TaskEditorView.axaml` — config section, theming
|
|
- `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — wire RunNowRequestedEvent to TaskItemViewModels
|
|
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — default model fallback to `claude-sonnet-4-6`
|