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