diff --git a/.agent/work-plans/issue-127/plan.md b/.agent/work-plans/issue-127/plan.md new file mode 100644 index 0000000..197b26e --- /dev/null +++ b/.agent/work-plans/issue-127/plan.md @@ -0,0 +1,87 @@ +# Plan: CAMP running-task view P1 — read-only structured task tree + +## Issue + +https://github.com/rolker/camp/issues/127 (Part of #123) + +## Context + +CAMP's per-robot panel (`Platform`, `platform.ui`) shows `helmManager` + +`missionManager` in a vertical splitter. The `MissionManager` placeholder just +dumps a flattened `Heartbeat` into a text box. The boat now publishes structured +`marine_nav_interfaces/TaskFeedback` (current task + `TaskInformation[]`) on +`marine/status/mission_tasks` (unh_marine_autonomy#236). This adds a read-only +structured tree view sourced from that topic. + +## Approach + +1. **`RunningTasksModel`** (`QAbstractItemModel`) — builds a tree from the flat + `TaskInformation[]` using slash-delimited `id`s (`survey_a/line_1` → child of + `survey_a`; synthesize intermediate group rows when a path segment has no + explicit task). Columns: name / type / priority / status / done. Holds the + `current_navigation_task` id; `data()` returns a bold font + highlight + background for that row. `setTasks(current, rows)` rebuilds via + begin/endResetModel (small list, low rate — reset is fine for P1). +2. **`RunningTasksView`** (`QWidget`, node-injection — the `helm_manager` idiom, + NOT `camp_ros::ROSWidget`): `setNode()` + `updateRobotNamespace()`; owns a + `QTreeView` + `RunningTasksModel` built in the constructor (no `.ui` needed). + Exposes `taskSelected(QString id)` signal + `setSelectedTask(QString id)` slot + (wired to selection now; map glue consumes them in P2). +3. **Subscription** — `TaskFeedback` on `/marine/status/mission_tasks`, + created with the guarded Realtime callback group: + `if (auto ctx = camp_ros::RosContext::instance()) opts.callback_group = + ctx->group(camp_ros::RosContext::Group::Realtime);`. The executor-thread + callback converts the message to a GUI-friendly `QVector` under a + mutex, then `QMetaObject::invokeMethod(this, "applyPendingTasks", + Qt::QueuedConnection)` marshals to the GUI thread (same pattern as + `MissionManager`). Never touch Qt from the ROS thread. +4. **Integration** — promote `RunningTasksView` in `platform.ui` (third widget in + the splitter); in `platform.cpp` call `runningTasksView->setNode(node_)` + (alongside the helm `setNode`) and `updateRobotNamespace(...)` (alongside the + others). +5. **Build wiring** — add the two `.cpp` to `SOURCES` (CMakeLists); + `find_package(marine_nav_interfaces REQUIRED)` + add to the executable's + `ament_target_dependencies`; `marine_nav_interfaces` in + `package.xml`. + +## Files to Change + +| File | Change | +|------|--------| +| `src/camp/running_tasks/running_tasks_model.{h,cpp}` | New tree model | +| `src/camp/running_tasks/running_tasks_view.{h,cpp}` | New node-injection widget | +| `src/camp/platform_manager/platform.ui` | Promote `RunningTasksView` in splitter | +| `src/camp/platform_manager/platform.cpp` | `setNode` + `updateRobotNamespace` | +| `CMakeLists.txt` | SOURCES + `marine_nav_interfaces` dep | +| `package.xml` | `marine_nav_interfaces` | + +## Principles Self-Check + +| Principle | Consideration | +|---|---| +| Thread safety | ROS callback never touches Qt; marshals via queued invoke + mutex, mirroring `MissionManager`. | +| Dual-use (no compromise) | Node-injection core + guarded Realtime group → CAMP gets the curated group, rqt (later) falls back to default. No `ROSWidget` coupling. | +| Additive | Placeholder `MissionManager` stays; this is a sibling view. | + +## ADR Compliance + +| ADR | Triggered | How addressed | +|---|---|---| +| (camp repo ADRs, if any) | TBD | Scan during implementation; this is additive UI, no message/contract changes. | + +## Consequences + +| If we change... | Also update... | In plan? | +|---|---|---| +| Add `marine_nav_interfaces` use | `package.xml` + CMake find_package + deps | Yes | +| New per-robot widget | `platform.ui` + `platform.cpp` node/namespace wiring | Yes | + +## Open Questions + +- Status column rendering: P1 shows the raw `status` YAML string (truncated). Full + parse/formatting deferred to P4 (mission progress). + +## Estimated Scope + +Single PR (new widget + model + wiring). Builds on the camp executable; rqt +wrapper and map linkage are separate later phases under #123. diff --git a/.agent/work-plans/issue-127/progress.md b/.agent/work-plans/issue-127/progress.md new file mode 100644 index 0000000..139add0 --- /dev/null +++ b/.agent/work-plans/issue-127/progress.md @@ -0,0 +1,38 @@ +--- +issue: 127 +--- + +# Issue #127 — CAMP running-task view P1: read-only structured task tree + +## Plan Authored +**Status**: complete +**When**: 2026-06-28 09:26 -04:00 +**By**: Claude Code Agent (Claude Opus 4.8 (1M context)) + +**Plan**: `.agent/work-plans/issue-127/plan.md` at `2a1f7ce` +**Branch**: feature/issue-127 at `2a1f7ce` +**Phases**: single (P1 of camp#123) + +### Open questions +- [ ] Status column shows raw status YAML first line (truncated) in P1; full parse deferred to P4. + +## Local Review (Pre-Push) +**Status**: complete +**When**: 2026-06-28 09:33 -04:00 +**By**: Claude Code Agent (Claude Opus 4.8 (1M context)) +**Verdict**: approved (must-fix addressed before push) + +**Branch**: feature/issue-127 at `e96eb34` (impl) — review fixes committed on top +**Mode**: pre-push +**Depth**: Deep (reason: custom QAbstractItemModel + ROS→GUI thread marshaling) +**Must-fix**: 1 (fixed) | **Suggestions**: 3 (2 fixed, 1 deferred) +**Round**: 1 | **Ship**: recommended — must-fix fixed + verified by rebuild + +### Findings +- [x] (must-fix) ~RunningTasksView=default → UAF: pending_mutex_/rows_ destroyed before subscription_; callback in flight at teardown derefs freed members. FIXED: explicit dtor resets subscription_ first — `running_tasks_view.cpp` +- [x] (suggestion) Selection-restore re-emitted taskSelected() every republish → P2 feedback-loop risk. FIXED: QSignalBlocker around the restore — `running_tasks_view.cpp` +- [x] (suggestion) current_task_ stored raw vs normalized node fullId → highlight could miss on stray-slash id. FIXED: normalize current_task_ in setTasks — `running_tasks_model.cpp` +- [ ] (suggestion, deferred to P2) Synthetic group rows emit taskSelected(groupId) for a non-task; guard when map glue lands — `running_tasks_view.cpp` + +### Model correctness (Lens A, verified) +- index/parent/rowCount/columnCount mutually consistent; rowInParent stable (append-only per rebuild); reset ordering correct (view restores by string id, not stale QModelIndex); null-safe. Walked rows [survey_a/line_2, survey_a, survey_a/line_1] — round-trips. diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c140ce..9e49072 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,7 @@ find_package(nav2_msgs REQUIRED) find_package(pluginlib REQUIRED) find_package(marine_autonomy REQUIRED) find_package(marine_interfaces REQUIRED) +find_package(marine_nav_interfaces REQUIRED) find_package(qt_gui_cpp REQUIRED) find_package(rosgraph_msgs REQUIRED) @@ -106,6 +107,8 @@ set(SOURCES src/camp/ais/ais_contact.cpp src/camp/ais/ais_manager.cpp src/camp/helm_manager/helm_manager.cpp + src/camp/running_tasks/running_tasks_model.cpp + src/camp/running_tasks/running_tasks_view.cpp src/camp/roslink.cpp src/camp/nav_source.cpp ) @@ -146,6 +149,7 @@ ament_target_dependencies(CCOMAutonomousMissionPlanner nav2_msgs marine_autonomy marine_interfaces + marine_nav_interfaces rclcpp tf2 diff --git a/package.xml b/package.xml index 5706619..bd4ce83 100644 --- a/package.xml +++ b/package.xml @@ -24,6 +24,7 @@ pluginlib marine_autonomy marine_interfaces + marine_nav_interfaces tf2 diff --git a/src/camp/platform_manager/platform.cpp b/src/camp/platform_manager/platform.cpp index 49995bc..ac594a1 100644 --- a/src/camp/platform_manager/platform.cpp +++ b/src/camp/platform_manager/platform.cpp @@ -99,6 +99,7 @@ void Platform::update(const marine_interfaces::msg::Platform& platform) platformNamespace = platform.platform_namespace; m_ui->helmManager->updateRobotNamespace(platformNamespace.c_str()); m_ui->missionManager->updateRobotNamespace(platformNamespace.c_str()); + m_ui->runningTasksView->updateRobotNamespace(platformNamespace.c_str()); path_topic_ = "/" + platformNamespace + "/received_global_plan"; subscribeToPathTopic(); @@ -227,6 +228,7 @@ void Platform::onNodeUpdated() ns.second->nodeStarted(node_, transform_buffer_); m_ui->helmManager->setNode(node_); m_ui->missionManager->nodeStarted(node_, transform_buffer_); + m_ui->runningTasksView->setNode(node_); subscribeToPathTopic(); } diff --git a/src/camp/platform_manager/platform.ui b/src/camp/platform_manager/platform.ui index 2547bdc..e40adff 100644 --- a/src/camp/platform_manager/platform.ui +++ b/src/camp/platform_manager/platform.ui @@ -21,6 +21,7 @@ + @@ -49,6 +50,12 @@
mission_manager/mission_manager.h
1 + + RunningTasksView + QWidget +
running_tasks/running_tasks_view.h
+ 1 +
diff --git a/src/camp/running_tasks/running_tasks_model.cpp b/src/camp/running_tasks/running_tasks_model.cpp new file mode 100644 index 0000000..bc2e61c --- /dev/null +++ b/src/camp/running_tasks/running_tasks_model.cpp @@ -0,0 +1,214 @@ +#include "running_tasks/running_tasks_model.h" + +#include +#include +#include + +RunningTasksModel::RunningTasksModel(QObject* parent) + : QAbstractItemModel(parent), root_(std::make_unique()) +{ +} + +RunningTasksModel::~RunningTasksModel() = default; + +RunningTasksModel::Node* RunningTasksModel::nodeForIndex( + const QModelIndex& index) const +{ + if (index.isValid()) + return static_cast(index.internalPointer()); + return root_.get(); +} + +QModelIndex RunningTasksModel::index(int row, int column, + const QModelIndex& parent) const +{ + if (!hasIndex(row, column, parent)) + return QModelIndex(); + Node* p = nodeForIndex(parent); + if (!p || row < 0 || row >= static_cast(p->children.size())) + return QModelIndex(); + return createIndex(row, column, p->children[row].get()); +} + +QModelIndex RunningTasksModel::parent(const QModelIndex& child) const +{ + if (!child.isValid()) + return QModelIndex(); + Node* n = nodeForIndex(child); + if (!n || !n->parent || n->parent == root_.get()) + return QModelIndex(); + return createIndex(n->parent->rowInParent, 0, n->parent); +} + +int RunningTasksModel::rowCount(const QModelIndex& parent) const +{ + if (parent.column() > 0) + return 0; + Node* p = nodeForIndex(parent); + return p ? static_cast(p->children.size()) : 0; +} + +int RunningTasksModel::columnCount(const QModelIndex& /*parent*/) const +{ + return ColumnCount; +} + +QVariant RunningTasksModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + Node* n = nodeForIndex(index); + if (!n) + return QVariant(); + + const bool is_current = n->hasTask && !current_task_.isEmpty() && + n->fullId == current_task_; + + switch (role) + { + case Qt::DisplayRole: + switch (index.column()) + { + case Name: + return n->segment; + case Type: + return n->hasTask ? n->row.type : QVariant(); + case Priority: + return n->hasTask ? QVariant(n->row.priority) : QVariant(); + case Status: + return n->hasTask ? n->row.status : QVariant(); + case Done: + return n->hasTask + ? QVariant(n->row.done ? QStringLiteral("done") + : QString()) + : QVariant(); + default: + return QVariant(); + } + case Qt::FontRole: + if (is_current) + { + QFont font; + font.setBold(true); + return font; + } + return QVariant(); + case Qt::BackgroundRole: + if (is_current) + return QBrush(QColor(0xDD, 0xEE, 0xFF)); + return QVariant(); + case Qt::ToolTipRole: + return n->hasTask ? n->fullId : QVariant(); + default: + return QVariant(); + } +} + +QVariant RunningTasksModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) + return QVariant(); + switch (section) + { + case Name: + return QStringLiteral("Task"); + case Type: + return QStringLiteral("Type"); + case Priority: + return QStringLiteral("Priority"); + case Status: + return QStringLiteral("Status"); + case Done: + return QStringLiteral("Done"); + default: + return QVariant(); + } +} + +void RunningTasksModel::rebuild(const QVector& rows) +{ + root_ = std::make_unique(); + for (const TaskRow& row : rows) + { + const QStringList segments = row.id.split('/', Qt::SkipEmptyParts); + if (segments.isEmpty()) + continue; + Node* node = root_.get(); + QString accumulated; + for (const QString& segment : segments) + { + accumulated = accumulated.isEmpty() ? segment + : accumulated + '/' + segment; + // Find an existing child for this segment, else create it. + Node* child = nullptr; + for (const std::unique_ptr& candidate : node->children) + { + if (candidate->segment == segment) + { + child = candidate.get(); + break; + } + } + if (!child) + { + auto created = std::make_unique(); + created->segment = segment; + created->fullId = accumulated; + created->parent = node; + created->rowInParent = static_cast(node->children.size()); + child = created.get(); + node->children.push_back(std::move(created)); + } + node = child; + } + // The leaf node for this id carries the task data. An intermediate node that + // is also an explicit task (both "survey_a" and "survey_a/line_1" present) + // gets its data filled here when its own row is processed. + node->row = row; + node->hasTask = true; + } +} + +void RunningTasksModel::setTasks(const QString& current_task, + const QVector& rows) +{ + beginResetModel(); + // Normalize the current-task id the same way node fullIds are built (split on + // '/', drop empty segments, re-join) so the highlight matches even if the id + // arrives with stray leading/trailing/double slashes. + current_task_ = current_task.split('/', Qt::SkipEmptyParts).join('/'); + rebuild(rows); + endResetModel(); +} + +QString RunningTasksModel::idForIndex(const QModelIndex& index) const +{ + Node* n = nodeForIndex(index); + return n ? n->fullId : QString(); +} + +RunningTasksModel::Node* RunningTasksModel::findById(Node* node, + const QString& id) const +{ + if (!node) + return nullptr; + if (node != root_.get() && node->fullId == id) + return node; + for (const std::unique_ptr& child : node->children) + { + if (Node* found = findById(child.get(), id)) + return found; + } + return nullptr; +} + +QModelIndex RunningTasksModel::indexForId(const QString& id) const +{ + if (id.isEmpty()) + return QModelIndex(); + Node* node = findById(root_.get(), id); + if (!node || node == root_.get()) + return QModelIndex(); + return createIndex(node->rowInParent, 0, node); +} diff --git a/src/camp/running_tasks/running_tasks_model.h b/src/camp/running_tasks/running_tasks_model.h new file mode 100644 index 0000000..0d83eaa --- /dev/null +++ b/src/camp/running_tasks/running_tasks_model.h @@ -0,0 +1,74 @@ +#ifndef CAMP_RUNNING_TASKS_MODEL_H +#define CAMP_RUNNING_TASKS_MODEL_H + +#include +#include +#include +#include +#include + +/// A single task as displayed in the running-tasks tree. Populated from +/// marine_nav_interfaces/TaskInformation on the GUI thread — the ROS message is +/// converted to this Qt-friendly struct off the executor thread so the model +/// never touches live ROS types. +struct TaskRow +{ + QString id; ///< full slash-delimited id, e.g. "survey_a/line_1" + QString type; ///< task type, e.g. "survey_line" + int priority = 0; + QString status; ///< status summary (first line of the status YAML) + bool done = false; +}; + +/// Tree model over a flat TaskInformation list, keyed on the hierarchical id. +/// "survey_a/line_1" becomes a child of "survey_a"; intermediate path segments +/// with no explicit task get a synthetic group row. The task whose id equals the +/// current navigation task is rendered bold + highlighted. +class RunningTasksModel : public QAbstractItemModel +{ + Q_OBJECT +public: + enum Column { Name = 0, Type, Priority, Status, Done, ColumnCount }; + + explicit RunningTasksModel(QObject* parent = nullptr); + ~RunningTasksModel() override; + + // QAbstractItemModel + QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& child) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; + + /// Replace the whole tree from a fresh TaskFeedback snapshot. + void setTasks(const QString& current_task, const QVector& rows); + + /// Full task id for an index (empty if invalid). + QString idForIndex(const QModelIndex& index) const; + /// Index of the row carrying the given full id (invalid if not present). + QModelIndex indexForId(const QString& id) const; + +private: + struct Node + { + QString segment; ///< this level's path segment (display name) + QString fullId; ///< full path id ("" for the hidden root) + TaskRow row; ///< task data; meaningful only when hasTask + bool hasTask = false; ///< false for a synthesized intermediate group + Node* parent = nullptr; + int rowInParent = 0; + std::vector> children; + }; + + Node* nodeForIndex(const QModelIndex& index) const; + Node* findById(Node* node, const QString& id) const; + void rebuild(const QVector& rows); + + std::unique_ptr root_; + QString current_task_; +}; + +#endif // CAMP_RUNNING_TASKS_MODEL_H diff --git a/src/camp/running_tasks/running_tasks_view.cpp b/src/camp/running_tasks/running_tasks_view.cpp new file mode 100644 index 0000000..bb48242 --- /dev/null +++ b/src/camp/running_tasks/running_tasks_view.cpp @@ -0,0 +1,152 @@ +#include "running_tasks/running_tasks_view.h" + +#include +#include +#include +#include +#include +#include + +#include "ros/ros_context.h" + +RunningTasksView::RunningTasksView(QWidget* parent) + : QWidget(parent), + tree_(new QTreeView(this)), + model_(new RunningTasksModel(this)) +{ + tree_->setModel(model_); + tree_->setRootIsDecorated(true); + tree_->setSelectionBehavior(QAbstractItemView::SelectRows); + tree_->setSelectionMode(QAbstractItemView::SingleSelection); + tree_->setEditTriggers(QAbstractItemView::NoEditTriggers); + tree_->header()->setStretchLastSection(true); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(tree_); + + connect(tree_->selectionModel(), &QItemSelectionModel::currentRowChanged, + this, &RunningTasksView::onCurrentRowChanged); +} + +RunningTasksView::~RunningTasksView() +{ + // Stop new callback dispatch before the members the callback touches + // (pending_mutex_, pending_rows_) are torn down — they are declared after + // subscription_ and so are destroyed first under reverse-order destruction. + // (Full safety on shutdown also relies on the CAMP executor being stopped + // before these widgets are destroyed; this guards the local ordering.) + subscription_.reset(); +} + +void RunningTasksView::setNode(rclcpp::Node::SharedPtr node) +{ + node_ = node; + subscribe(); +} + +void RunningTasksView::updateRobotNamespace(QString robot_namespace) +{ + robot_namespace_ = robot_namespace; + subscribe(); +} + +void RunningTasksView::subscribe() +{ + // Need both a node and a namespace before a subscription can be created. + subscription_.reset(); + if (!node_ || robot_namespace_.isEmpty()) + return; + + // Match the boat publisher: marine/status/mission_tasks, depth-10 volatile. + // Robustness over the udp_bridge comes from the boat's periodic re-publish, + // not transient_local. Join the curated Realtime callback group when running + // inside CAMP; in an rqt host RosContext::instance() is empty and the + // subscription falls back to the node's default group. + rclcpp::SubscriptionOptions options; + if (auto ctx = camp_ros::RosContext::instance()) + options.callback_group = + ctx->group(camp_ros::RosContext::Group::Realtime); + + const std::string topic = + "/" + robot_namespace_.toStdString() + "/marine/status/mission_tasks"; + subscription_ = + node_->create_subscription( + topic, rclcpp::QoS(10), + std::bind(&RunningTasksView::taskFeedbackCallback, this, + std::placeholders::_1), + options); +} + +void RunningTasksView::taskFeedbackCallback( + const marine_nav_interfaces::msg::TaskFeedback& msg) +{ + // Runs on the ROS executor thread — convert to Qt-friendly rows here and + // hand off to the GUI thread; never touch widgets from this thread. + QVector rows; + rows.reserve(static_cast(msg.tasks.size())); + for (const auto& task : msg.tasks) + { + TaskRow row; + row.id = QString::fromStdString(task.id); + row.type = QString::fromStdString(task.type); + row.priority = task.priority; + // Status is a YAML blob; show its first line as a summary for P1. + row.status = + QString::fromStdString(task.status).section('\n', 0, 0).trimmed(); + row.done = task.done; + rows.push_back(row); + } + + { + std::lock_guard lock(pending_mutex_); + pending_current_ = QString::fromStdString(msg.current_navigation_task); + pending_rows_ = rows; + } + QMetaObject::invokeMethod(this, "applyPendingTasks", Qt::QueuedConnection); +} + +void RunningTasksView::applyPendingTasks() +{ + QString current; + QVector rows; + { + std::lock_guard lock(pending_mutex_); + current = pending_current_; + rows = pending_rows_; + } + + // Preserve the selected task across the model reset. + const QString selected = model_->idForIndex(tree_->currentIndex()); + + model_->setTasks(current, rows); + tree_->expandAll(); + + if (!selected.isEmpty()) + { + const QModelIndex restored = model_->indexForId(selected); + if (restored.isValid()) + { + // Restoring the prior selection must not look like a fresh user + // selection: block signals so taskSelected() isn't re-emitted on every + // periodic republish (which would re-trigger map glue in P2). + const QSignalBlocker blocker(tree_->selectionModel()); + tree_->setCurrentIndex(restored); + } + } +} + +void RunningTasksView::onCurrentRowChanged(const QModelIndex& current, + const QModelIndex& /*previous*/) +{ + const QString id = model_->idForIndex(current); + if (!id.isEmpty()) + emit taskSelected(id); +} + +void RunningTasksView::setSelectedTask(QString id) +{ + const QModelIndex index = model_->indexForId(id); + if (index.isValid()) + tree_->setCurrentIndex(index); +} diff --git a/src/camp/running_tasks/running_tasks_view.h b/src/camp/running_tasks/running_tasks_view.h new file mode 100644 index 0000000..04bd252 --- /dev/null +++ b/src/camp/running_tasks/running_tasks_view.h @@ -0,0 +1,62 @@ +#ifndef CAMP_RUNNING_TASKS_VIEW_H +#define CAMP_RUNNING_TASKS_VIEW_H + +#include +#include +#include +#include + +#include "rclcpp/rclcpp.hpp" +#include "marine_nav_interfaces/msg/task_feedback.hpp" +#include "running_tasks/running_tasks_model.h" + +class QTreeView; + +/// Read-only structured running-task view. Node-injection widget (the +/// helm_manager idiom, not camp_ros::ROSWidget): hand it a node with setNode() +/// and a robot namespace with updateRobotNamespace(); it subscribes to +/// marine/status/mission_tasks and renders the structured TaskFeedback as a +/// tree, highlighting the currently-executing task. Designed to work both +/// embedded in CAMP and (later) hosted in an rqt plugin. +class RunningTasksView : public QWidget +{ + Q_OBJECT + +public: + explicit RunningTasksView(QWidget* parent = nullptr); + ~RunningTasksView() override; + + void setNode(rclcpp::Node::SharedPtr node); + +signals: + /// User selected a task row (full id). Map glue (P2) consumes this. + void taskSelected(QString id); + +public slots: + void updateRobotNamespace(QString robot_namespace); + /// Select the row with the given full id (e.g. from a map click in P2). + void setSelectedTask(QString id); + +private slots: + void applyPendingTasks(); + void onCurrentRowChanged(const QModelIndex& current, + const QModelIndex& previous); + +private: + void subscribe(); + void taskFeedbackCallback(const marine_nav_interfaces::msg::TaskFeedback& msg); + + QTreeView* tree_; + RunningTasksModel* model_; + + rclcpp::Node::SharedPtr node_; + rclcpp::Subscription::SharedPtr + subscription_; + QString robot_namespace_; + + std::mutex pending_mutex_; + QString pending_current_; + QVector pending_rows_; +}; + +#endif // CAMP_RUNNING_TASKS_VIEW_H