feat(gui): Implement manual ticket queue management with priority, multi-select, and drag-drop reordering
This commit is contained in:
@@ -28,7 +28,7 @@ Focus: Add priority to Ticket model
|
|||||||
## Phase 2: Priority UI
|
## Phase 2: Priority UI
|
||||||
Focus: Add priority dropdown to ticket display
|
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
|
- WHERE: `src/gui_2.py` ticket rendering
|
||||||
- WHAT: Dropdown for priority selection
|
- WHAT: Dropdown for priority selection
|
||||||
- HOW:
|
- HOW:
|
||||||
|
|||||||
152
src/gui_2.py
152
src/gui_2.py
@@ -105,6 +105,8 @@ class App:
|
|||||||
self.node_editor_config = ed.Config()
|
self.node_editor_config = ed.Config()
|
||||||
self.node_editor_ctx = ed.create_editor(self.node_editor_config)
|
self.node_editor_ctx = ed.create_editor(self.node_editor_config)
|
||||||
self.ui_selected_ticket_id: Optional[str] = None
|
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
|
self._autofocus_response_tab = False
|
||||||
gui_cfg = self.config.get("gui", {})
|
gui_cfg = self.config.get("gui", {})
|
||||||
self.ui_separate_message_panel = gui_cfg.get("separate_message_panel", False)
|
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
|
self._scroll_tool_calls_to_bottom = False
|
||||||
if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_tool_calls_panel")
|
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:
|
def _render_mma_dashboard(self) -> None:
|
||||||
if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_mma_dashboard")
|
if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_mma_dashboard")
|
||||||
# Task 5.3: Dense Summary Line
|
# Task 5.3: Dense Summary Line
|
||||||
@@ -2178,6 +2311,8 @@ class App:
|
|||||||
|
|
||||||
imgui.pop_id()
|
imgui.pop_id()
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
|
self._render_ticket_queue()
|
||||||
|
imgui.separator()
|
||||||
# 4. Task DAG Visualizer
|
# 4. Task DAG Visualizer
|
||||||
imgui.text("Task DAG")
|
imgui.text("Task DAG")
|
||||||
if self.active_track and self.node_editor_ctx:
|
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_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_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)
|
_, 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"):
|
if imgui.button("Create"):
|
||||||
new_ticket = {
|
new_ticket = {
|
||||||
"id": self.ui_new_ticket_id,
|
"id": self.ui_new_ticket_id,
|
||||||
"description": self.ui_new_ticket_desc,
|
"description": self.ui_new_ticket_desc,
|
||||||
"status": "todo",
|
"status": "todo",
|
||||||
|
"priority": self.ui_new_ticket_priority,
|
||||||
"assigned_to": "tier3-worker",
|
"assigned_to": "tier3-worker",
|
||||||
"target_file": self.ui_new_ticket_target,
|
"target_file": self.ui_new_ticket_target,
|
||||||
"depends_on": [d.strip() for d in self.ui_new_ticket_deps.split(",") if d.strip()]
|
"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)
|
ticket = next((t for t in self.active_tickets if str(t.get('id', '')) == self.ui_selected_ticket_id), None)
|
||||||
if ticket:
|
if ticket:
|
||||||
imgui.text(f"Status: {ticket.get('status', 'todo')}")
|
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', '')}")
|
imgui.text(f"Target: {ticket.get('target_file', '')}")
|
||||||
deps = ticket.get('depends_on', [])
|
deps = ticket.get('depends_on', [])
|
||||||
imgui.text(f"Depends on: {', '.join(deps)}")
|
imgui.text(f"Depends on: {', '.join(deps)}")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
from src.models import Ticket
|
from src.models import Ticket
|
||||||
|
|
||||||
def test_ticket_priority_default():
|
def test_ticket_priority_default():
|
||||||
@@ -35,3 +36,74 @@ def test_ticket_from_dict_default_priority():
|
|||||||
}
|
}
|
||||||
ticket = Ticket.from_dict(data)
|
ticket = Ticket.from_dict(data)
|
||||||
assert ticket.priority == "medium"
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user