diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 8f13b6e3..e261dd9b 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -3187,3 +3187,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 ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'backlog', 'ready', NULL, '2026-06-10 13:54:02', '2026-06-10 13:54:02', '2026-06-10 13:54:02', NULL, '5cb488441033ac5a781d4b7d91dbda6f', 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 ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'in_progress', 'done', NULL, '2026-06-10 13:55:30', '2026-06-10 13:55:30', '2026-06-10 13:55:30', NULL, 'f618ee8a3a33dabfa822bd0cebae4e03', 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 ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'ready', 'done', NULL, '2026-06-10 14:03:10', '2026-06-10 14:03:10', '2026-06-10 14:03:10', NULL, '96365bc3ef7dbf505448be2dad16e4b6', 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 ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'ready', 'done', NULL, '2026-06-10 14:14:33', '2026-06-10 14:14:33', '2026-06-10 14:14:33', NULL, 'd028dbbdbad663a682c5ed4d92dc7cc2', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 11061048..ed0f2219 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2591,3 +2591,18 @@ Root cause: the sidechain fold keys entirely off the transcript envelope. In tra Need to confirm whether the prompt envelope is missing isSidechain (likely — the spawning prompt is the FIRST user turn of the sidechain and may be emitted by the harness without the flag, or with a parentUuid that points at the spawning turn rather than the Agent tool_use). Check stream_json_session.dart emission too. Fix direction (TBD after confirming the envelope shape): either (a) ensure the prompt envelope is correctly flagged/parented, or (b) in _sidechainFold associate the first sidechain-adjacent user prompt with its Agent tool_use even when the flag/chain is incomplete. Related: T-263 (prompt folds into the call), T-264 (run nests under the card).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:47:04', '2026-06-10 14:03:10', NULL, 'ee474dfe790eb6e72bd6a7381cd900e2', 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 ('06FB3JM0AXK1CTWD720RV1AVZ0', 'story', '06FB0TNQM5TWC00GW0P3X02HZW', 'Pick-up sets ticket to in_progress on accept', 'When a ticket is handed to Claude via the sidebar Pick-up button (T-327), also transition the ticket to in_progress — but on the receiving side of the message bus, not in the button. + +Today the Pick-up button (`_PickUpAction` in lib/builtin/tickets/src/tickets_view.dart) only fetches the ticket and publishes a (builtin.tickets, pick-up) message carrying {id, prompt}. The Claude pane receives it in `_onTicketPickUp` (lib/builtin/claude/src/extension.dart:359) and injects the prompt into the active session. The state transition should live here, gated on acceptance — i.e. only set in_progress once a live session actually accepts the prompt (injectMessage succeeds against a target session), so a pick-up with no live Claude pane stays a quiet no-op and does not mutate state. + +Implementation notes: +- In `_onTicketPickUp`, after a successful injectMessage to a target session, call the existing status transition path: IPC `pql.tickets.status` with {ids: [id], status: ''in_progress''} (same call the detail view''s _StatusControls uses). +- The ''id'' is already in the message payload — currently only ''prompt'' is read; also read ''id''. +- After a successful transition, publish (builtin.tickets, changed, {id}) so the sidebar/detail refresh, matching ticket_detail_view.dart behaviour. +- Keep it idempotent/safe: if the ticket is already in_progress (or terminal), don''t fight the user — consider skipping when current status != open/todo. Verify the configured status vocabulary via `pql ticket statuslist`. +- No live session => no injection => no state change (preserve existing no-op semantics). + +Acceptance: +- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state. +- Picking up with no live session leaves the ticket untouched. +- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 14:14:33', NULL, 'd615d07653f4916b50514993935b90da', 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 e1224a1b..35d73bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **Picking up a ticket now starts it.** When a ticket is handed to a live + Claude pane via the sidebar pick-up button, it also transitions to + `in_progress` and the sidebar refreshes — but only on acceptance (a live + session took the prompt) and only from a not-yet-started status, so a + pick-up with no live pane stays a quiet no-op and a re-pick-up never drags a + `review`/`done` ticket backwards. (T-339) - **Clickable file references in the Claude conversation.** Workspace file paths mentioned by Claude — bare (`lib/app.dart`), with a line (`lib/app.dart:42`), backticked, or as markdown links — are now clickable and open in the editor, diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index c8471350..4a5b7dd3 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -354,14 +354,13 @@ class ClaudeExtension extends ClideExtension { _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. + /// Hand a picked-up ticket to the active Claude session (T-327/T-339). The + /// decision + transition live in [applyTicketPickUp] so they're testable + /// without the activation machinery. 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); + final ctx = _ctx; + if (ctx == null) return; + unawaited(applyTicketPickUp(m.data, orchestrator: _orchestrator, ipc: ctx.ipc, messages: ctx.messages)); } /// Close every session that doesn't belong to the newly-active workspace @@ -472,3 +471,41 @@ class ClaudeExtension extends ClideExtension { return IpcResponse.ok(id: '', data: const {'status': 'shown'}); } } + +/// Statuses a pick-up may advance from: a not-yet-started ticket. Picking up a +/// ticket that's already `in_progress`/`review`/`done`/`cancelled` injects the +/// prompt but leaves the status alone, so a re-pick-up never drags it backwards +/// or reopens it (T-339). +const _kPickUpStartableStatuses = {'backlog', 'ready'}; + +/// Inject a picked-up ticket's prompt into the active session (the `primary` +/// lead, else the first visible one) and, on acceptance from a not-yet-started +/// ticket, advance it to `in_progress` and publish a `changed` so the sidebar +/// refreshes (T-327/T-339). Returns whether a live session accepted the prompt. +/// +/// With no live session there's no injection and no state change — a quiet +/// no-op. Kept free of the extension lifecycle so it's directly testable. +@visibleForTesting +Future applyTicketPickUp( + Map data, { + required ClaudeSessionOrchestrator? orchestrator, + required DaemonClient ipc, + required MessageBus messages, +}) async { + final prompt = data['prompt'] as String?; + if (prompt == null || prompt.isEmpty) return false; + final target = orchestrator?.byId('primary') ?? orchestrator?.visibleSessions.firstOrNull; + if (target == null) return false; // no live session → quiet no-op, no state change + orchestrator!.injectMessage(target.id, prompt); + + final id = data['id'] as String?; + final status = data['status'] as String?; + if (id != null && id.isNotEmpty && _kPickUpStartableStatuses.contains(status)) { + final resp = await ipc.request('pql.tickets.status', args: { + 'ids': [id], + 'status': 'in_progress', + }); + if (resp.ok) messages.publish('builtin.tickets', 'changed', {'id': id}); + } + return true; +} diff --git a/lib/builtin/tickets/src/tickets_view.dart b/lib/builtin/tickets/src/tickets_view.dart index 8b09e8b5..5c735a1a 100644 --- a/lib/builtin/tickets/src/tickets_view.dart +++ b/lib/builtin/tickets/src/tickets_view.dart @@ -328,7 +328,9 @@ class _PickUpAction extends StatelessWidget { 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)}); + // Carry the current status so the receiver can gate the in_progress + // transition without a second fetch (T-339). + kernel.messages.publish('builtin.tickets', 'pick-up', {'id': id, 'prompt': pickUpPrompt(resp.data), 'status': resp.data['status']}); }()); }, builder: (ctx, hovered, _) => Padding( diff --git a/test/builtin/claude/ticket_pick_up_test.dart b/test/builtin/claude/ticket_pick_up_test.dart new file mode 100644 index 00000000..d00cb680 --- /dev/null +++ b/test/builtin/claude/ticket_pick_up_test.dart @@ -0,0 +1,104 @@ +/// Tests for the sidebar pick-up handler (T-327/T-339): a live session accepts +/// the prompt and a not-yet-started ticket advances to in_progress; with no live +/// session nothing is injected and the ticket is untouched. +library; + +import 'dart:async'; + +import 'package:clide/builtin/claude/src/extension.dart'; +import 'package:clide/builtin/claude/src/session_orchestrator.dart'; +import 'package:clide/builtin/claude/src/stream_json_session.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/fake_ipc.dart'; + +class _FakeProc implements StreamJsonProcess { + final _ctl = StreamController.broadcast(); + final List writes = []; + @override + Stream get lines => _ctl.stream; + @override + void writeLine(String line) => writes.add(line); + @override + Future kill() async {} +} + +void main() { + late ClaudeSessionOrchestrator orch; + late FakeDaemonClient ipc; + late MessageBus messages; + late List> statusCalls; + late List changed; + late StreamSubscription changedSub; + + setUp(() { + orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async => _FakeProc()); + ipc = FakeDaemonClient(log: Logger(), events: DaemonBus()); + messages = MessageBus(); + statusCalls = []; + ipc.stub('pql.tickets.status', (args) async { + statusCalls.add(args); + return IpcResponse.ok(id: '', data: const {'ok': true}); + }); + changed = []; + changedSub = messages.subscribe(publisher: 'builtin.tickets', channel: 'changed').listen(changed.add); + }); + + tearDown(() async { + await changedSub.cancel(); + orch.dispose(); + messages.dispose(); + ipc.dispose(); + }); + + Map payload({String status = 'ready'}) => {'id': 'T-9', 'prompt': 'pick this up', 'status': status}; + + test('accepted: a live session injects the prompt and starts the ticket (T-339)', () async { + await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo')); + + final accepted = await applyTicketPickUp(payload(), orchestrator: orch, ipc: ipc, messages: messages); + await Future.delayed(Duration.zero); // let the bus deliver 'changed' + + expect(accepted, isTrue); + expect(statusCalls, hasLength(1)); + expect(statusCalls.single['ids'], ['T-9']); + expect(statusCalls.single['status'], 'in_progress'); + expect(changed.single.data['id'], 'T-9'); + }); + + test('no live session: nothing injected, ticket untouched (T-339)', () async { + // Orchestrator has no sessions → quiet no-op. + final accepted = await applyTicketPickUp(payload(), orchestrator: orch, ipc: ipc, messages: messages); + await Future.delayed(Duration.zero); + + expect(accepted, isFalse); + expect(statusCalls, isEmpty); + expect(changed, isEmpty); + }); + + test('already started: injects but does not move the status backwards (T-339)', () async { + await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo')); + + final accepted = await applyTicketPickUp(payload(status: 'in_progress'), orchestrator: orch, ipc: ipc, messages: messages); + await Future.delayed(Duration.zero); + + expect(accepted, isTrue); // prompt still delivered + expect(statusCalls, isEmpty); // but no transition + expect(changed, isEmpty); + }); + + test('a backlog ticket is also startable', () async { + await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo')); + await applyTicketPickUp(payload(status: 'backlog'), orchestrator: orch, ipc: ipc, messages: messages); + expect(statusCalls, hasLength(1)); + }); + + test('an empty prompt is ignored entirely', () async { + await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo')); + final accepted = await applyTicketPickUp({'id': 'T-9', 'prompt': '', 'status': 'ready'}, orchestrator: orch, ipc: ipc, messages: messages); + expect(accepted, isFalse); + expect(statusCalls, isEmpty); + }); +}