From 4f632d3cddefdbe4f994c7d7fe0b2efc6502ef5a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 10 Jun 2026 14:31:35 +0200 Subject: [PATCH] dock Claude's task list above the composer (T-308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude's TodoWrite checklist was invisible. Add a TaskItem/TaskStatus model + a latest-wins parser (taskListFrom) that reads the most recent TodoWrite tool call (it replaces the whole list each time), and a compact display-only ClaudeTaskDock pinned between the conversation and the composer: collapsed to "N tasks · M done" + the current in-progress item, expandable to the full checklist with per-item status glyphs + a11y labels. Hidden when there are no tasks. Parser + widget tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 18 +++ CHANGELOG.md | 5 + lib/builtin/claude/src/claude_pane.dart | 8 ++ lib/builtin/claude/src/claude_task_dock.dart | 123 ++++++++++++++++++ lib/builtin/claude/src/task_list.dart | 53 ++++++++ .../builtin/claude/claude_task_dock_test.dart | 72 ++++++++++ test/builtin/claude/task_list_test.dart | 68 ++++++++++ 8 files changed, 348 insertions(+) create mode 100644 lib/builtin/claude/src/claude_task_dock.dart create mode 100644 lib/builtin/claude/src/task_list.dart create mode 100644 test/builtin/claude/claude_task_dock_test.dart create mode 100644 test/builtin/claude/task_list_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index d84bad2b..8e4ba1f7 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3172,3 +3172,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM5TWC00GW0P3X02HZW', 'status', 'ready', 'backlog', NULL, '2026-06-10 12:08:18', '2026-06-10 12:08:18', '2026-06-10 12:08:18', NULL, '12eee2948e8f79732b40384a40429e14', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2W4G9K8ZF782W7H2TM5XA8', 'status', 'backlog', 'ready', NULL, '2026-06-10 12:12:30', '2026-06-10 12:12:30', '2026-06-10 12:12:30', NULL, '6b9aaf9bd7ec3ad4ad3b95d1468ff9cf', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1XDWKQ594ET4GDYEFK5ZJ4', 'status', 'ready', 'done', NULL, '2026-06-10 12:14:00', '2026-06-10 12:14:00', '2026-06-10 12:14:00', NULL, 'd90deec4e27b4298c6f7280338599c01', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'status', 'ready', 'done', NULL, '2026-06-10 12:31:23', '2026-06-10 12:31:23', '2026-06-10 12:31:23', NULL, 'dbac53eef0b8d58dbb5f00a2f77ee52f', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 4dcb246c..725c072d 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2361,3 +2361,21 @@ ACCEPTANCE - Widget test covers the new button resolving to a DenyTool with the expected note. CLARIFICATION (the TOOLTIP line above lost a word to shell escaping): ClideButton already exposes a tooltip parameter (clide_button.dart:17,26,42) — pass tooltip on the new button for the mouseover; no widget change needed.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:55:55', '2026-06-10 12:14:00', NULL, '995bc6dbbc7231306303b838aba8a2eb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB1Q1Y3CYJHD7W5F68VDB6T4', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Surface Claude''s task list docked above the bottom input pane', 'Claude maintains a task/todo list during a session (the TodoWrite / TaskCreate checklist), but clide never shows it — it is completely invisible to the user. The transcript reader (lib/builtin/claude/src/transcript_reader.dart:205-212) skips the relevant event types and there is no model class for task items; the conversation view (conversation_view.dart) renders none. So the user has no view of what Claude is tracking or how far along a multi-step task is. + +Surface it as a compact, always-current task list DOCKED to the bottom input pane of the Claude conversation view. Per the Explore: the pane is a Column in claude_pane.dart (_ClaudePaneState.build, ~line 474-542) with ConversationView (Expanded) on top and the ToolPromptCard/ClaudeComposer at the bottom (~line 519-525); the task list should sit between them — directly above the composer (claude_composer.dart), non-expanded — so it stays pinned and visible as Claude works. + +Scope: +- PARSE: model Claude''s task list from the transcript. Find the event type carrying the todo/task items (currently among the skipped types in transcript_reader.dart) and add a TaskList model + items (text + status: pending/in_progress/completed). Latest-write-wins — the list reflects the most recent todo state, not an append log. +- RENDER: a docked task-list widget above the composer. Compact: collapsed/summary by default (e.g. ''N tasks · M done'' + current in_progress item), expandable to the full checklist. Per-item status glyph (pending/in-progress/done) using existing tokens + Phosphor icons (ui-design skill). Hide entirely when there are no tasks. +- Keep it display-only and de-emphasised so it never competes with the composer for attention; honour D-78 (interaction zone) — this is a display surface, not an interactive control. +- a11y: list semantics, status announced per item. + +Acceptance: +1. When Claude has an active task list, it shows docked directly above the input pane and updates live as items change state. +2. Empty/no-task sessions show nothing (no empty chrome). +3. Collapsed by default with a one-line summary; expandable to the full list. +4. Display-only; the composer keeps full function and focus behaviour. +5. Tests: transcript_reader parses the task-list event into the model (incl. status transitions + latest-wins); widget renders states + collapse/expand + hidden-when-empty. + +Refs: conversation card patterns (conversation_card.dart), D-78 (interaction zone), ui-design skill (tokens/icons).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 09:28:04', '2026-06-10 12:31:23', NULL, 'ddfb6e694a6c5b6e3bc39132cd57318c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index f62ad932..467262df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Claude's task list is now visible, docked above the composer.** When Claude + is tracking a TodoWrite checklist, a compact display-only strip shows it pinned + above the input — collapsed to `N tasks · M done` + the current in-progress + item, expandable to the full list with per-item status glyphs. Hidden when + there are no tasks. (T-308) - **A "Deny & simplify" option on the permission card.** Alongside Allow / Allow-and-remember / Deny, a fourth button denies the action with a preformatted note telling Claude it was too complex for the permission system diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 7b57911a..b3360d48 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -9,6 +9,7 @@ import 'claude_banner.dart'; import 'claude_composer.dart'; import 'claude_config.dart'; import 'claude_status.dart'; +import 'claude_task_dock.dart'; import 'clipboard_paste.dart'; import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey; import 'conversation_controller.dart'; @@ -21,6 +22,7 @@ import 'session_orchestrator.dart'; import 'session_picker.dart'; import 'slash_commands.dart'; import 'stream_json_session.dart'; +import 'task_list.dart'; import 'transcript_reader.dart'; /// The Claude conversation pane. Drives `claude` over the stream-json control @@ -513,6 +515,12 @@ class _ClaudePaneState extends State { ), ), ), + // Claude's task list, docked above the composer (T-308). Rebuilds + // with the conversation; renders nothing when there are no tasks. + ListenableBuilder( + listenable: _conversation!, + builder: (_, __) => ClaudeTaskDock(tasks: taskListFrom(_conversation!.items)), + ), // An open prompt takes the composer's space and hides the text // input until it's answered, so interaction stays out of the // conversation stream (D-78). diff --git a/lib/builtin/claude/src/claude_task_dock.dart b/lib/builtin/claude/src/claude_task_dock.dart new file mode 100644 index 00000000..a814cdde --- /dev/null +++ b/lib/builtin/claude/src/claude_task_dock.dart @@ -0,0 +1,123 @@ +/// Claude's task list, docked above the composer (T-308). +/// +/// A compact, display-only surface (D-78 — not an interactive control) pinned +/// between the conversation and the composer so the user can always see what +/// Claude is tracking and how far along it is. Collapsed by default to a +/// one-line summary (`N tasks · M done` + the current in-progress item); +/// tapping expands the full checklist. Renders nothing when there are no tasks. +library; + +import 'package:clide/builtin/claude/src/task_list.dart'; +import 'package:clide/kernel/src/theme/controller.dart'; +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; + +class ClaudeTaskDock extends StatefulWidget { + const ClaudeTaskDock({super.key, required this.tasks}); + + final List tasks; + + @override + State createState() => _ClaudeTaskDockState(); +} + +class _ClaudeTaskDockState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final tasks = widget.tasks; + if (tasks.isEmpty) return const SizedBox.shrink(); // no chrome when empty + + final tokens = ClideTheme.of(context).surface; + final done = tasks.where((t) => t.status == TaskStatus.completed).length; + final inProgress = tasks.where((t) => t.status == TaskStatus.inProgress); + final current = inProgress.isEmpty ? null : inProgress.first.text; + final summary = '${tasks.length} task${tasks.length == 1 ? '' : 's'} · $done done'; + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 6), + child: ClideTappable( + onTap: () => setState(() => _expanded = !_expanded), + tooltip: _expanded ? 'Collapse tasks' : 'Expand tasks', + builder: (context, hovered, focused) => Container( + decoration: BoxDecoration( + color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground, + border: Border.all(color: tokens.panelBorder), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // The summary row IS the toggle — a single labelled button node + // (its inner text is announced via the label, so exclude it). + Semantics( + button: true, + label: 'Claude task list, $summary, ${_expanded ? 'expanded' : 'collapsed'}', + excludeSemantics: true, + child: _summaryRow(tokens, summary, current), + ), + if (_expanded) ...[ + ClideDivider(), + Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [for (final t in tasks) _taskRow(tokens, t)], + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _summaryRow(SurfaceTokens tokens, String summary, String? current) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + ClideIcon(_expanded ? const ChevronDownIcon() : const ChevronRightIcon(), size: 12, color: tokens.globalTextMuted), + const SizedBox(width: 8), + ClideText(summary, fontSize: clideFontCaption, color: tokens.globalTextMuted), + if (!_expanded && current != null) ...[ + const SizedBox(width: 10), + Expanded(child: ClideText(current, fontSize: clideFontCaption, color: tokens.globalTextMuted, maxLines: 1, overflow: TextOverflow.ellipsis)), + ] else + const Spacer(), + ], + ), + ); + + Widget _taskRow(SurfaceTokens tokens, TaskItem t) { + final (String glyph, Color color, String word) = switch (t.status) { + TaskStatus.completed => ('check-circle', tokens.statusSuccess, 'done'), + TaskStatus.inProgress => ('circle-half', tokens.globalFocus, 'in progress'), + TaskStatus.pending => ('circle', tokens.globalTextMuted, 'pending'), + }; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Semantics( + label: '${t.text}, $word', + container: true, + excludeSemantics: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding(padding: const EdgeInsets.only(top: 1), child: ClideIcon(PhosphorIcons.byName(glyph), size: 13, color: color)), + const SizedBox(width: 8), + Expanded( + child: ClideText( + t.text, + fontSize: clideFontCaption, + color: t.status == TaskStatus.completed ? tokens.globalTextMuted : tokens.globalForeground, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/builtin/claude/src/task_list.dart b/lib/builtin/claude/src/task_list.dart new file mode 100644 index 00000000..25b0c06a --- /dev/null +++ b/lib/builtin/claude/src/task_list.dart @@ -0,0 +1,53 @@ +/// Claude's working task list, modelled from the conversation for the docked +/// task view (T-308). +/// +/// Claude tracks tasks with the `TodoWrite` tool, which **replaces the whole +/// list** on each call — so the current state is simply the todos of the most +/// recent `TodoWrite`. This is a latest-wins snapshot, not an append log; older +/// `TodoWrite` calls are superseded. +library; + +import 'package:clide/builtin/claude/src/transcript_reader.dart'; + +enum TaskStatus { pending, inProgress, completed } + +class TaskItem { + const TaskItem({required this.text, required this.status}); + + final String text; + final TaskStatus status; + + @override + bool operator ==(Object other) => other is TaskItem && other.text == text && other.status == status; + + @override + int get hashCode => Object.hash(text, status); +} + +/// The current task list — the todos of the most recent `TodoWrite` tool call, +/// or empty if Claude hasn't written one this session. +List taskListFrom(List items) { + for (var i = items.length - 1; i >= 0; i--) { + final it = items[i]; + if (it is! AssistantToolUse || it.name != 'TodoWrite') continue; + final raw = it.input['todos']; + if (raw is! List) return const []; + return [ + for (final t in raw) + if (t is Map) + TaskItem( + // `content` is the canonical label; `activeForm` is the present-tense + // variant TodoWrite also carries — fall back to it, then to empty. + text: (t['content'] ?? t['activeForm'] ?? '').toString(), + status: _statusFrom(t['status']), + ), + ]; + } + return const []; +} + +TaskStatus _statusFrom(Object? raw) => switch (raw) { + 'in_progress' => TaskStatus.inProgress, + 'completed' => TaskStatus.completed, + _ => TaskStatus.pending, + }; diff --git a/test/builtin/claude/claude_task_dock_test.dart b/test/builtin/claude/claude_task_dock_test.dart new file mode 100644 index 00000000..833d7560 --- /dev/null +++ b/test/builtin/claude/claude_task_dock_test.dart @@ -0,0 +1,72 @@ +/// Widget coverage for the docked task list (T-308): hidden when empty, +/// collapsed summary with the current in-progress item, expand/collapse, and +/// per-item status semantics. +library; + +import 'package:clide/builtin/claude/src/claude_task_dock.dart'; +import 'package:clide/builtin/claude/src/task_list.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + const tasks = [ + TaskItem(text: 'wire the dock', status: TaskStatus.completed), + TaskItem(text: 'render the rows', status: TaskStatus.inProgress), + TaskItem(text: 'write the tests', status: TaskStatus.pending), + ]; + + Future pump(WidgetTester tester, List items) async { + await tester.pumpWidget(harness( + f, + Align(alignment: Alignment.topLeft, child: SizedBox(width: 400, child: ClaudeTaskDock(tasks: items))), + )); + await tester.pump(); + } + + testWidgets('renders nothing when there are no tasks', (tester) async { + await pump(tester, const []); + expect(find.byType(ClideText), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('collapsed: a one-line summary + the current in-progress item; rows hidden', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester, tasks); + expect(find.text('3 tasks · 1 done'), findsOneWidget); + expect(find.text('render the rows'), findsOneWidget); // current in-progress in the summary + // The other rows aren't shown while collapsed. + expect(find.text('wire the dock'), findsNothing); + expect(find.text('write the tests'), findsNothing); + expect(find.bySemanticsLabel('Claude task list, 3 tasks · 1 done, collapsed'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('tapping expands to the full checklist with per-item status', (tester) async { + final handle = tester.ensureSemantics(); + await pump(tester, tasks); + await tester.tap(find.bySemanticsLabel('Claude task list, 3 tasks · 1 done, collapsed')); + await tester.pump(); + + expect(find.text('wire the dock'), findsOneWidget); + expect(find.text('render the rows'), findsOneWidget); + expect(find.text('write the tests'), findsOneWidget); + // Per-item status announced for AT. + expect(find.bySemanticsLabel('wire the dock, done'), findsOneWidget); + expect(find.bySemanticsLabel('render the rows, in progress'), findsOneWidget); + expect(find.bySemanticsLabel('write the tests, pending'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('singular task count reads "1 task"', (tester) async { + await pump(tester, const [TaskItem(text: 'lonely', status: TaskStatus.pending)]); + expect(find.text('1 task · 0 done'), findsOneWidget); + }); +} diff --git a/test/builtin/claude/task_list_test.dart b/test/builtin/claude/task_list_test.dart new file mode 100644 index 00000000..747d6b06 --- /dev/null +++ b/test/builtin/claude/task_list_test.dart @@ -0,0 +1,68 @@ +/// Parser for Claude's TodoWrite task list (T-308): latest-wins, status +/// mapping, content/activeForm fallback, and graceful handling of non-TodoWrite +/// or malformed input. +library; + +import 'package:clide/builtin/claude/src/task_list.dart'; +import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final _t = DateTime.utc(2026); + +AssistantToolUse _todo(Object todos, {String id = 'x'}) => + AssistantToolUse(uuid: id, timestamp: _t, isSidechain: false, toolUseId: id, name: 'TodoWrite', input: {'todos': todos}); + +void main() { + test('parses todos with their status', () { + final tasks = taskListFrom([ + _todo([ + {'content': 'a', 'status': 'pending'}, + {'content': 'b', 'status': 'in_progress'}, + {'content': 'c', 'status': 'completed'}, + ]) + ]); + expect(tasks, const [ + TaskItem(text: 'a', status: TaskStatus.pending), + TaskItem(text: 'b', status: TaskStatus.inProgress), + TaskItem(text: 'c', status: TaskStatus.completed), + ]); + }); + + test('the latest TodoWrite wins — a snapshot, not an append log', () { + final tasks = taskListFrom([ + _todo([ + {'content': 'old', 'status': 'pending'} + ], id: '1'), + _todo([ + {'content': 'new', 'status': 'in_progress'} + ], id: '2'), + ]); + expect(tasks, const [TaskItem(text: 'new', status: TaskStatus.inProgress)]); + }); + + test('content falls back to activeForm then empty; unknown status → pending', () { + final tasks = taskListFrom([ + _todo([ + {'activeForm': 'doing it', 'status': 'in_progress'}, + {'status': 'weird'}, + ]) + ]); + expect(tasks[0].text, 'doing it'); + expect(tasks[1].text, ''); + expect(tasks[1].status, TaskStatus.pending); + }); + + test('no TodoWrite → empty', () { + expect(taskListFrom(const []), isEmpty); + expect( + taskListFrom([ + AssistantToolUse(uuid: 'b', timestamp: _t, isSidechain: false, toolUseId: 'b', name: 'Bash', input: const {'command': 'ls'}) + ]), + isEmpty, + ); + }); + + test('malformed todos (not a list) → empty', () { + expect(taskListFrom([_todo('nope')]), isEmpty); + }); +}