hand a ticket to Claude from the sidebar (T-327)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:52:27 +02:00
co-authored by Claude Opus 4.8
parent aa6f112164
commit 3097d5f63b
7 changed files with 188 additions and 34 deletions
@@ -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;
+26
View File
@@ -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'', ''<channel>'', {...})` (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'': <full ticket + instruction text> })`.
- 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);
+4
View File
@@ -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
+14
View File
@@ -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
@@ -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<String, Object?> 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 = <String>[
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();
}
+70 -34
View File
@@ -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;
@@ -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'));
});
}