diff --git a/conductor/tracks/ticket_queue_mgmt_20260306/plan.md b/conductor/tracks/ticket_queue_mgmt_20260306/plan.md index 5fe50ec..a9f4b60 100644 --- a/conductor/tracks/ticket_queue_mgmt_20260306/plan.md +++ b/conductor/tracks/ticket_queue_mgmt_20260306/plan.md @@ -28,7 +28,7 @@ Focus: Add priority to Ticket model ## Phase 2: Priority UI Focus: Add priority dropdown to ticket display -- [ ] Task 2.1: Add priority dropdown +- [~] Task 2.1: Add priority dropdown - WHERE: `src/gui_2.py` ticket rendering - WHAT: Dropdown for priority selection - HOW: diff --git a/src/gui_2.py b/src/gui_2.py index 424beb4..bd6f2a5 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -105,6 +105,8 @@ class App: self.node_editor_config = ed.Config() self.node_editor_ctx = ed.create_editor(self.node_editor_config) self.ui_selected_ticket_id: Optional[str] = None + self.ui_selected_tickets: set[str] = set() + self.ui_new_ticket_priority: str = "medium" self._autofocus_response_tab = False gui_cfg = self.config.get("gui", {}) self.ui_separate_message_panel = gui_cfg.get("separate_message_panel", False) @@ -1930,6 +1932,137 @@ class App: self._scroll_tool_calls_to_bottom = False if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_tool_calls_panel") + def bulk_execute(self) -> None: + for tid in self.ui_selected_tickets: + t = next((t for t in self.active_tickets if str(t.get('id', '')) == tid), None) + if t: t['status'] = 'ready' + self._push_mma_state_update() + + def bulk_skip(self) -> None: + for tid in self.ui_selected_tickets: + t = next((t for t in self.active_tickets if str(t.get('id', '')) == tid), None) + if t: t['status'] = 'completed' + self._push_mma_state_update() + + def bulk_block(self) -> None: + for tid in self.ui_selected_tickets: + t = next((t for t in self.active_tickets if str(t.get('id', '')) == tid), None) + if t: t['status'] = 'blocked' + self._push_mma_state_update() + + def _reorder_ticket(self, src_idx: int, dst_idx: int) -> None: + if src_idx == dst_idx: return + new_tickets = list(self.active_tickets) + ticket = new_tickets.pop(src_idx) + new_tickets.insert(dst_idx, ticket) + # Validate dependencies: a ticket cannot be placed before any of its dependencies + id_to_idx = {str(t.get('id', '')): i for i, t in enumerate(new_tickets)} + valid = True + for i, t in enumerate(new_tickets): + deps = t.get('depends_on', []) + for d_id in deps: + if d_id in id_to_idx and id_to_idx[d_id] >= i: + valid = False + break + if not valid: break + if valid: + self.active_tickets = new_tickets + self._push_mma_state_update() + + def _render_ticket_queue(self) -> None: + imgui.text("Ticket Queue Management") + if not self.active_track: + imgui.text_disabled("No active track.") + return + + # Select All / None + if imgui.button("Select All"): + self.ui_selected_tickets = {str(t.get('id', '')) for t in self.active_tickets} + imgui.same_line() + if imgui.button("Select None"): + self.ui_selected_tickets.clear() + + imgui.same_line() + imgui.spacing() + imgui.same_line() + + # Bulk Actions + if imgui.button("Bulk Execute"): + self.bulk_execute() + imgui.same_line() + if imgui.button("Bulk Skip"): + self.bulk_skip() + imgui.same_line() + if imgui.button("Bulk Block"): + self.bulk_block() + # Table + flags = imgui.TableFlags_.borders | imgui.TableFlags_.row_bg | imgui.TableFlags_.resizable | imgui.TableFlags_.scroll_y + if imgui.begin_table("ticket_queue_table", 5, flags, imgui.ImVec2(0, 300)): + imgui.table_setup_column("Select", imgui.TableColumnFlags_.width_fixed, 40) + imgui.table_setup_column("ID", imgui.TableColumnFlags_.width_fixed, 80) + imgui.table_setup_column("Priority", imgui.TableColumnFlags_.width_fixed, 100) + imgui.table_setup_column("Status", imgui.TableColumnFlags_.width_fixed, 100) + imgui.table_setup_column("Description", imgui.TableColumnFlags_.width_stretch) + imgui.table_headers_row() + + for i, t in enumerate(self.active_tickets): + tid = str(t.get('id', '')) + imgui.table_next_row() + + # Select + imgui.table_next_column() + is_sel = tid in self.ui_selected_tickets + changed, is_sel = imgui.checkbox(f"##sel_{tid}", is_sel) + if changed: + if is_sel: self.ui_selected_tickets.add(tid) + else: self.ui_selected_tickets.discard(tid) + + # ID + imgui.table_next_column() + is_selected = (tid == self.ui_selected_ticket_id) + opened, _ = imgui.selectable(f"{tid}##drag_{tid}", is_selected) + if opened: + self.ui_selected_ticket_id = tid + + if imgui.begin_drag_drop_source(): + imgui.set_drag_drop_payload("TICKET_REORDER", i) + imgui.text(f"Moving {tid}") + imgui.end_drag_drop_source() + + if imgui.begin_drag_drop_target(): + payload = imgui.accept_drag_drop_payload("TICKET_REORDER") + if payload: + src_idx = int(payload.data) + self._reorder_ticket(src_idx, i) + imgui.end_drag_drop_target() + + # Priority + + imgui.table_next_column() + prio = t.get('priority', 'medium') + p_col = vec4(180, 180, 180) # gray + if prio == 'high': p_col = vec4(255, 100, 100) # red + elif prio == 'medium': p_col = vec4(255, 255, 100) # yellow + + imgui.push_style_color(imgui.Col_.text, p_col) + if imgui.begin_combo(f"##prio_{tid}", prio, imgui.ComboFlags_.height_small): + for p_opt in ['high', 'medium', 'low']: + if imgui.selectable(p_opt, p_opt == prio)[0]: + t['priority'] = p_opt + self._push_mma_state_update() + imgui.end_combo() + imgui.pop_style_color() + + # Status + imgui.table_next_column() + imgui.text(t.get('status', 'todo')) + + # Description + imgui.table_next_column() + imgui.text(t.get('description', '')) + + imgui.end_table() + def _render_mma_dashboard(self) -> None: if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_mma_dashboard") # Task 5.3: Dense Summary Line @@ -2178,6 +2311,8 @@ class App: imgui.pop_id() imgui.separator() + self._render_ticket_queue() + imgui.separator() # 4. Task DAG Visualizer imgui.text("Task DAG") if self.active_track and self.node_editor_ctx: @@ -2292,11 +2427,19 @@ class App: _, self.ui_new_ticket_desc = imgui.input_text_multiline("Description##new_ticket", self.ui_new_ticket_desc, imgui.ImVec2(-1, 60)) _, self.ui_new_ticket_target = imgui.input_text("Target File##new_ticket", self.ui_new_ticket_target) _, self.ui_new_ticket_deps = imgui.input_text("Depends On (IDs, comma-separated)##new_ticket", self.ui_new_ticket_deps) + imgui.text("Priority:") + imgui.same_line() + if imgui.begin_combo("##new_prio", self.ui_new_ticket_priority): + for p_opt in ['high', 'medium', 'low']: + if imgui.selectable(p_opt, p_opt == self.ui_new_ticket_priority)[0]: + self.ui_new_ticket_priority = p_opt + imgui.end_combo() if imgui.button("Create"): new_ticket = { "id": self.ui_new_ticket_id, "description": self.ui_new_ticket_desc, "status": "todo", + "priority": self.ui_new_ticket_priority, "assigned_to": "tier3-worker", "target_file": self.ui_new_ticket_target, "depends_on": [d.strip() for d in self.ui_new_ticket_deps.split(",") if d.strip()] @@ -2318,6 +2461,15 @@ class App: ticket = next((t for t in self.active_tickets if str(t.get('id', '')) == self.ui_selected_ticket_id), None) if ticket: imgui.text(f"Status: {ticket.get('status', 'todo')}") + prio = ticket.get('priority', 'medium') + imgui.text("Priority:") + imgui.same_line() + if imgui.begin_combo(f"##edit_prio_{ticket.get('id')}", prio): + for p_opt in ['high', 'medium', 'low']: + if imgui.selectable(p_opt, p_opt == prio)[0]: + ticket['priority'] = p_opt + self._push_mma_state_update() + imgui.end_combo() imgui.text(f"Target: {ticket.get('target_file', '')}") deps = ticket.get('depends_on', []) imgui.text(f"Depends on: {', '.join(deps)}") diff --git a/tests/test_ticket_queue.py b/tests/test_ticket_queue.py index 6752c44..dc2ff60 100644 --- a/tests/test_ticket_queue.py +++ b/tests/test_ticket_queue.py @@ -1,4 +1,5 @@ import pytest +from unittest.mock import patch from src.models import Ticket def test_ticket_priority_default(): @@ -35,3 +36,74 @@ def test_ticket_from_dict_default_priority(): } ticket = Ticket.from_dict(data) assert ticket.priority == "medium" + +class TestBulkOperations: + def test_bulk_execute(self, mock_app): + mock_app.active_tickets = [ + {"id": "T1", "status": "todo"}, + {"id": "T2", "status": "todo"}, + {"id": "T3", "status": "todo"} + ] + mock_app.ui_selected_tickets = {"T1", "T3"} + + with patch.object(mock_app.controller, "_push_mma_state_update") as mock_push: + mock_app.bulk_execute() + assert mock_app.active_tickets[0]["status"] == "ready" + assert mock_app.active_tickets[1]["status"] == "todo" + assert mock_app.active_tickets[2]["status"] == "ready" + mock_push.assert_called_once() + + def test_bulk_skip(self, mock_app): + mock_app.active_tickets = [ + {"id": "T1", "status": "todo"}, + {"id": "T2", "status": "todo"} + ] + mock_app.ui_selected_tickets = {"T1"} + + with patch.object(mock_app.controller, "_push_mma_state_update") as mock_push: + mock_app.bulk_skip() + assert mock_app.active_tickets[0]["status"] == "completed" + assert mock_app.active_tickets[1]["status"] == "todo" + mock_push.assert_called_once() + + def test_bulk_block(self, mock_app): + mock_app.active_tickets = [ + {"id": "T1", "status": "todo"}, + {"id": "T2", "status": "todo"} + ] + mock_app.ui_selected_tickets = {"T1", "T2"} + + with patch.object(mock_app.controller, "_push_mma_state_update") as mock_push: + mock_app.bulk_block() + assert mock_app.active_tickets[0]["status"] == "blocked" + assert mock_app.active_tickets[1]["status"] == "blocked" + mock_push.assert_called_once() + +class TestReorder: + def test_reorder_ticket_valid(self, mock_app): + mock_app.active_tickets = [ + {"id": "T1", "depends_on": []}, + {"id": "T2", "depends_on": []}, + {"id": "T3", "depends_on": ["T1"]} + ] + with patch.object(mock_app.controller, "_push_mma_state_update") as mock_push: + # Move T1 to index 1: [T2, T1, T3]. T3 depends on T1. T1 index 1 < T3 index 2. VALID. + mock_app._reorder_ticket(0, 1) + assert mock_app.active_tickets[0]["id"] == "T2" + assert mock_app.active_tickets[1]["id"] == "T1" + assert mock_app.active_tickets[2]["id"] == "T3" + mock_push.assert_called_once() + + def test_reorder_ticket_invalid(self, mock_app): + mock_app.active_tickets = [ + {"id": "T1", "depends_on": []}, + {"id": "T2", "depends_on": ["T1"]} + ] + with patch.object(mock_app.controller, "_push_mma_state_update") as mock_push: + # Move T1 after T2: [T2, T1]. T2 depends on T1, but T1 is now at index 1 while T2 is at index 0. + # Violation: dependency T1 (index 1) is not before T2 (index 0). + mock_app._reorder_ticket(0, 1) + # Should NOT change + assert mock_app.active_tickets[0]["id"] == "T1" + assert mock_app.active_tickets[1]["id"] == "T2" + mock_push.assert_not_called()