pick-up sets the ticket to in_progress on accept (T-339)

When a ticket is handed to a live Claude pane (T-327), advance it to
in_progress on the receiving side of the bus — gated on acceptance, so a
pick-up with no live session stays a quiet no-op and never mutates state.
Only a not-yet-started ticket (backlog/ready) transitions, so re-picking
up a review/done ticket doesn't drag it backwards. On success it publishes
(builtin.tickets, changed) so the sidebar refreshes.

The handler logic moves into a testable applyTicketPickUp() seam; the
sidebar button now carries the current status in the pick-up payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:18:16 +02:00
co-authored by Claude Opus 4.8
parent dea3e70e49
commit a948bc8a1f
6 changed files with 173 additions and 8 deletions
@@ -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;
+15
View File
@@ -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);
+6
View File
@@ -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,
+44 -7
View File
@@ -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<bool> applyTicketPickUp(
Map<String, Object?> 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;
}
+3 -1
View File
@@ -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(
@@ -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<String>.broadcast();
final List<String> writes = [];
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) => writes.add(line);
@override
Future<void> kill() async {}
}
void main() {
late ClaudeSessionOrchestrator orch;
late FakeDaemonClient ipc;
late MessageBus messages;
late List<Map<String, Object?>> statusCalls;
late List<Message> changed;
late StreamSubscription<Message> 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<String, Object?> 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<void>.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<void>.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<void>.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);
});
}