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