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:
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user