From 3097d5f63bee2297eca8e346dd0b876f80fa20fe Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 10 Jun 2026 14:52:27 +0200 Subject: [PATCH] hand a ticket to Claude from the sidebar (T-327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering a ticket card reveals a person-simple-run icon; clicking it fetches the full ticket (pql.tickets.show withContext), builds a "pick this up and start" prompt, and publishes ('builtin.tickets','pick-up', {id,prompt}) on the message bus. The Claude builtin subscribes and injects it into the active session (primary, else first visible) as a user turn — a quiet no-op when no session is live. Sidebar stays decoupled from the orchestrator (bus-only). Prompt-builder test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 26 +++++ CHANGELOG.md | 4 + lib/builtin/claude/src/extension.dart | 14 +++ lib/builtin/tickets/src/pick_up_prompt.dart | 36 ++++++ lib/builtin/tickets/src/tickets_view.dart | 104 ++++++++++++------ test/builtin/tickets/pick_up_prompt_test.dart | 37 +++++++ 7 files changed, 188 insertions(+), 34 deletions(-) create mode 100644 lib/builtin/tickets/src/pick_up_prompt.dart create mode 100644 test/builtin/tickets/pick_up_prompt_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index ac11bca0..3ab2e047 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3174,3 +3174,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 ('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; 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', 'done', 'done', NULL, '2026-06-10 12:32:07', '2026-06-10 12:32:07', '2026-06-10 12:32:07', NULL, 'ea979d683072b321ce6eae3dbbbf6c31', 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', 'ready', 'done', NULL, '2026-06-10 12:51:50', '2026-06-10 12:51:50', '2026-06-10 12:51:50', NULL, 'a8a700b9b92afcb998b5c2e69660e1b2', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index a2249a35..da8d20ac 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2397,3 +2397,29 @@ Acceptance: 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:32:07', NULL, '9ed7b932a0448a813e89c462e383b88a', 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 ('06FB2W4G9K8ZF782W7H2TM5XA8', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Hover ''pick up ticket'' run-icon that injects the full ticket into the focused Claude pane via the message bus', 'On ticket-card mouseover in the tickets sidebar, surface a small `person-simple-run` icon that, when clicked, hands the full ticket to the currently-focused Claude conversation panel as a "pick this up and start processing it" prompt. Routed over the message bus, not by reaching into the session orchestrator directly. + +**Interaction** +- Hover a ticket card -> a `person-simple-run` action icon fades in (top-right of the card, where the green-arrow mock points). +- Click -> the focused Claude pane receives an injected user prompt that contains the full ticket (all fields, as the context/detail pane renders it) plus a short instruction to begin working it. + +**Where (file:line)** +- Card + hover: `_TicketCard` in lib/builtin/tickets/src/tickets_view.dart:226-304. `ClideTappable` (line 242) already exposes a `hovered` bool in its builder (line 244) — gate the icon''s visibility on that. No new MouseRegion needed. +- Icon: `PhosphorIcons.byName(''person-simple-run'')` (lib/widgets/src/icons/phosphor.dart:49). Confirmed present in phosphor_glyphs.g.dart (0xe730). +- Full-ticket payload: fetch via the same path the context pane uses — `pql.tickets.show` with `withContext:true` (ticket_detail_controller.dart:45-49) -> TicketDetail (id/title/type/status/priority/description/parentId/decisionRef/assignedTo, ticket_detail_controller.dart:12-20). No serializer exists yet; build the prompt text from these fields (same content the detail view renders, ticket_detail_view.dart:59-127). + +**Use the message bus (required)** +- Do NOT call ClaudeSessionOrchestrator.injectMessage directly from the sidebar. Publish on the bus instead, matching the existing tickets pattern: `messages.publish(''builtin.tickets'', '''', {...})` (see ticket_detail_controller.dart:62 publishing ''focus''; bus lives on the kernel facade as `messages`, facade.dart:87/144). +- Proposed: publish `(''builtin.tickets'', ''pick-up'', { ''id'': T-NNN, ''prompt'': })`. +- The Claude builtin subscribes to that channel and injects into the focused session — it resolves the focused pane via focus.activeContributionId (focus.dart:27-28) and routes through the orchestrator (session_orchestrator.dart:331-336 injectMessage / stream_json_session.dart:635 send). This keeps the sidebar decoupled from Claude internals and honors the bus-for-interaction rule. + +**Edge cases (settle in review)** +- No focused Claude pane (focus.activeContributionId null / non-Claude pane focused): no-op with a toast, or fall back to ''primary''? Decide in review. +- Busy session (mid-turn): queue the inject vs. block with a hint. +- Prompt shape: confirm the exact wording/format of the injected message (full ticket markdown + a one-line "start processing this ticket" lead-in). + +**Acceptance** +- Hovering a sidebar ticket card reveals a `person-simple-run` icon; it is hidden when not hovered. +- Clicking it publishes a single message-bus event carrying the full ticket; the focused Claude pane receives an injected prompt containing all ticket fields and an instruction to begin. +- The sidebar code does not import or call the session orchestrator directly — interaction is bus-only. +- Clicking with no focused Claude pane degrades gracefully (no crash).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 12:10:04', '2026-06-10 12:51:50', NULL, 'a9982bdc3ed2e36cc57787e4582c4e46', 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 6eab9a8c..0bfc61bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Hand a ticket to Claude from the sidebar.** Hovering a ticket card reveals a + run icon; clicking it hands the full ticket to the active Claude pane as a + "pick this up and start" prompt. Routed over the message bus, so the sidebar + stays decoupled from the session internals. (T-327) - **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 diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 26c841f6..c8471350 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -348,6 +348,20 @@ class ClaudeExtension extends ClideExtension { // 'image' message; we inject the matching card into the conversation the // user is looking at (the primary lead, else the first visible session). _subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow)); + + // A sidebar "pick up" click (T-327) publishes the full ticket; inject it + // into the active conversation as a user turn so Claude starts working it. + _subs.add(ctx.messages.subscribe(publisher: 'builtin.tickets', channel: 'pick-up').listen(_onTicketPickUp)); + } + + /// Hand a picked-up ticket to the active Claude session (T-327): the primary + /// lead, else the first visible session. A quiet no-op when none is live. + void _onTicketPickUp(Message m) { + final prompt = m.data['prompt'] as String?; + if (prompt == null || prompt.isEmpty) return; + final target = _orchestrator?.byId('primary') ?? _orchestrator?.visibleSessions.firstOrNull; + if (target == null) return; + _orchestrator?.injectMessage(target.id, prompt); } /// Close every session that doesn't belong to the newly-active workspace diff --git a/lib/builtin/tickets/src/pick_up_prompt.dart b/lib/builtin/tickets/src/pick_up_prompt.dart new file mode 100644 index 00000000..1dd4f952 --- /dev/null +++ b/lib/builtin/tickets/src/pick_up_prompt.dart @@ -0,0 +1,36 @@ +/// Builds the "pick up this ticket" prompt injected into Claude (T-327). +/// +/// Takes a `pql.tickets.show` ticket map (the same fields the detail pane +/// renders) and renders it as markdown with a one-line lead-in telling Claude +/// to start working it. +library; + +String pickUpPrompt(Map ticket) { + String? field(String key) { + final v = ticket[key]; + final s = v is String ? v.trim() : null; + return (s == null || s.isEmpty) ? null : s; + } + + final meta = [ + if (field('type') != null) field('type')!, + if (field('status') != null) field('status')!, + if (field('priority') != null) field('priority')!, + if (field('parent_id') != null) 'parent ${field('parent_id')}', + if (field('decision_ref') != null) field('decision_ref')!, + if (field('assigned_to') != null) '@${field('assigned_to')}', + ].join(' · '); + + final buf = StringBuffer() + ..writeln('Pick up and start working this ticket. Read it fully, then begin.') + ..writeln() + ..writeln('**${field('id') ?? '?'} — ${field('title') ?? ''}**'); + if (meta.isNotEmpty) buf.writeln(meta); + final desc = field('description'); + if (desc != null) { + buf + ..writeln() + ..writeln(desc); + } + return buf.toString().trimRight(); +} diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index dd05af50..8b09e8b5 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:clide/builtin/tickets/src/pick_up_prompt.dart'; import 'package:clide/builtin/tickets/src/ticket_colors.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; @@ -248,46 +249,53 @@ class _TicketCard extends StatelessWidget { borderRadius: BorderRadius.circular(4), border: Border.all(color: focused ? tokens.globalFocus : (hovered ? tokens.panelActiveBorder : tokens.panelBorder), width: 1), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( children: [ - // Parent shown as a muted breadcrumb above; the card's own ticket - // sits below it under a tree connector and in bold, so it's clear - // which id is the subject and which is its parent (T-281). - if (entry.parentId != null) - ClideTappable( - onTap: () => ClideKernel.of(context).messages.publish('builtin.tickets', 'selection', {'id': entry.parentId}), - builder: (ctx, hovered, _) => Padding( - padding: const EdgeInsets.only(bottom: 1), - child: ClideText( - entry.parentId!, - fontSize: clideFontSmall, - color: hovered ? tokens.globalForeground : tokens.globalTextMuted, - fontFamily: clideMonoFamily, - ), - ), - ), - Row( + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (entry.parentId != null) ClideText('└ ', fontSize: clideFontSmall, color: tokens.globalTextMuted), - ClideTooltip( - message: entry.type ?? 'task', - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle), + // Parent shown as a muted breadcrumb above; the card's own ticket + // sits below it under a tree connector and in bold, so it's clear + // which id is the subject and which is its parent (T-281). + if (entry.parentId != null) + ClideTappable( + onTap: () => ClideKernel.of(context).messages.publish('builtin.tickets', 'selection', {'id': entry.parentId}), + builder: (ctx, hovered, _) => Padding( + padding: const EdgeInsets.only(bottom: 1), + child: ClideText( + entry.parentId!, + fontSize: clideFontSmall, + color: hovered ? tokens.globalForeground : tokens.globalTextMuted, + fontFamily: clideMonoFamily, + ), + ), ), + Row( + children: [ + if (entry.parentId != null) ClideText('└ ', fontSize: clideFontSmall, color: tokens.globalTextMuted), + ClideTooltip( + message: entry.type ?? 'task', + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: typeColor, shape: BoxShape.circle), + ), + ), + const SizedBox(width: 6), + ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600), + ], ), - const SizedBox(width: 6), - ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalForeground, fontFamily: clideMonoFamily, fontWeight: FontWeight.w600), + const SizedBox(height: 4), + ClideText(entry.title, fontSize: clideFontCaption), + if (statusLabel != null) ...[ + const SizedBox(height: 6), + _StatusBadge(label: statusLabel, tokens: tokens, status: entry.status), + ], ], ), - const SizedBox(height: 4), - ClideText(entry.title, fontSize: clideFontCaption), - if (statusLabel != null) ...[ - const SizedBox(height: 6), - _StatusBadge(label: statusLabel, tokens: tokens, status: entry.status), - ], + // Hover affordance (T-327): hand the full ticket to the focused + // Claude pane via the message bus. + if (hovered) Positioned(top: 0, right: 0, child: _PickUpAction(id: entry.id, tokens: tokens)), ], ), ), @@ -303,6 +311,34 @@ class _TicketCard extends StatelessWidget { }; } +/// The hover "pick up" run-icon (T-327): fetches the full ticket and publishes +/// it on the message bus for the focused Claude pane to inject — the sidebar +/// stays decoupled from the session orchestrator (bus-only). +class _PickUpAction extends StatelessWidget { + const _PickUpAction({required this.id, required this.tokens}); + final String id; + final SurfaceTokens tokens; + + @override + Widget build(BuildContext context) { + return ClideTappable( + tooltip: 'Pick up — hand this ticket to the Claude pane', + onTap: () { + final kernel = ClideKernel.of(context); + unawaited(() async { + final resp = await kernel.ipc.request('pql.tickets.show', args: {'id': id, 'withContext': true}); + if (!resp.ok) return; // a missing ticket / failed fetch is a quiet no-op + kernel.messages.publish('builtin.tickets', 'pick-up', {'id': id, 'prompt': pickUpPrompt(resp.data)}); + }()); + }, + builder: (ctx, hovered, _) => Padding( + padding: const EdgeInsets.all(2), + child: ClideIcon(PhosphorIcons.byName('person-simple-run'), size: 14, color: hovered ? tokens.globalFocus : tokens.globalTextMuted), + ), + ); + } +} + class _StatusBadge extends StatelessWidget { const _StatusBadge({required this.label, required this.tokens, this.status}); final String label; diff --git a/test/builtin/tickets/pick_up_prompt_test.dart b/test/builtin/tickets/pick_up_prompt_test.dart new file mode 100644 index 00000000..c3dd3a24 --- /dev/null +++ b/test/builtin/tickets/pick_up_prompt_test.dart @@ -0,0 +1,37 @@ +/// The "pick up this ticket" prompt builder (T-327): lead-in, header, meta +/// line, and description, with missing fields omitted gracefully. +library; + +import 'package:clide/builtin/tickets/src/pick_up_prompt.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('renders the lead-in, header, meta line, and description', () { + final p = pickUpPrompt({ + 'id': 'T-327', + 'title': 'Pick up icon', + 'type': 'story', + 'status': 'ready', + 'priority': 'medium', + 'parent_id': 'T-276', + 'description': 'Do the thing.', + }); + expect(p, startsWith('Pick up and start working this ticket')); + expect(p, contains('**T-327 — Pick up icon**')); + expect(p, contains('story · ready · medium · parent T-276')); + expect(p, contains('Do the thing.')); + }); + + test('omits the meta line and parent when those fields are absent', () { + final p = pickUpPrompt({'id': 'T-1', 'title': 'Bare'}); + expect(p, contains('**T-1 — Bare**')); + expect(p, isNot(contains('·'))); + expect(p, isNot(contains('parent'))); + }); + + test('includes the decision ref and assignee when present', () { + final p = pickUpPrompt({'id': 'T-2', 'title': 'x', 'decision_ref': 'D-90', 'assigned_to': 'jeroen'}); + expect(p, contains('D-90')); + expect(p, contains('@jeroen')); + }); +}