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
+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;