From fb3c96c40518f2b77a6fd8de178f1714a3a6824a Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 14 Apr 2026 16:08:12 +0200 Subject: [PATCH] docs(ui): add design spec for post-integration UI fixes 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) --- .../specs/2026-04-14-ui-fixes-design.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-14-ui-fixes-design.md diff --git a/docs/superpowers/specs/2026-04-14-ui-fixes-design.md b/docs/superpowers/specs/2026-04-14-ui-fixes-design.md new file mode 100644 index 0000000..bdf7c83 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-ui-fixes-design.md @@ -0,0 +1,216 @@ +# 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 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? 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 `` 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 `` 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> 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`