From 44d949eb8f3f15113cafec546dfafae403abe169 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Tue, 25 Aug 2026 20:00:07 +0200 Subject: [PATCH 01/36] Polish thread panel interactions --- CMakeLists.txt | 2 + docs/ui-behavior.md | 20 +- src/codex/PresentationModel.cpp | 12 + src/codex/PresentationModel.h | 3 + src/codex/TurnSettingsWidget.cpp | 38 +-- src/codex/ui/UiStyle.cpp | 28 +++ src/codex/ui/UiStyle.h | 5 + src/greenfield/codex/middle/ThreadPane.cpp | 255 +++++++++++++++++++-- src/greenfield/codex/middle/ThreadPane.h | 15 ++ tests/codex/GreenfieldLayoutTest.cpp | 155 ++++++++++++- 10 files changed, 467 insertions(+), 66 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a9a08fb..475d7ec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -276,6 +276,8 @@ if(BUILD_TESTING) src/codex/TurnSettingsWidget.h src/codex/ui/ExpandingPromptEditor.cpp src/codex/ui/ExpandingPromptEditor.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h src/greenfield/codex/middle/ComposerPane.cpp src/greenfield/codex/middle/ComposerPane.h src/greenfield/codex/middle/ConversationCards.cpp diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 1595603..5d24f0d 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -31,8 +31,24 @@ bottom or is owned by the user. - The selected thread is identified by its stable app-server thread ID. - Once selected, a hydrated thread remains visible in the sidebar for the session even when it is outside the ordinary top-level thread ordering; an - authoritative removal still removes it, and other rows retain app-server - ordering. + authoritative removal still removes it. +- The sidebar sorts all visible rows by a user-selected criterion. `Recent` is + the default and uses the app-server's provider-defined `recencyAt` value, + newest first. `Created` uses `createdAt` newest first, and `Last changed` + uses `updatedAt` newest first. `Alphanumeric` sorts displayed titles + case-insensitively with natural number ordering, so 2 precedes 10 and titles + beginning with numbers precede other titles. Timestamp values that are not + available sort after timestamped threads. The directions are fixed; the UI + does not provide a separate ascending/descending control. +- Each visible thread is presented as a compact card. Its status indicator is + part of that card, and hover and selection strengthen the same card surface + instead of introducing a separate row treatment. The Sort control uses the + same centered chevron treatment as the prompt settings. +- A left click selects a thread and changes the displayed conversation. A + right click opens actions for the pointed-to card without changing the + selected thread or displayed conversation. That card retains its hover + treatment until the non-blocking menu closes; dismissing the menu does not + replay the closing click into another control. - Sending always targets the visibly selected thread. CodexUI validates the visible selection before dispatch and never creates a thread as an implicit fallback for missing or inconsistent selection state. diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 8d103e4..05a838e 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -39,6 +39,15 @@ bool boolValue(const nlohmann::json &object, const char *key, : fallback; } +void updateTimestamp(const nlohmann::json &object, const char *key, + std::optional &target) { + if (!object.is_object()) + return; + const auto iterator = object.find(key); + if (iterator != object.end() && iterator->is_number_integer()) + target = iterator->get(); +} + std::string statusValue(const nlohmann::json &value) { if (value.is_string()) return value.get(); @@ -585,6 +594,9 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, const auto status = raw.find("status"); if (status != raw.end()) result.status = statusValue(*status); + updateTimestamp(raw, "createdAt", result.createdAt); + updateTimestamp(raw, "updatedAt", result.updatedAt); + updateTimestamp(raw, "recencyAt", result.recencyAt); result.archived = boolValue(raw, "archived", result.archived); const auto turns = raw.find("turns"); diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 0477add..3a6f5d3 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -44,6 +44,9 @@ struct ThreadPresentation { std::string preview; std::string cwd; std::string status; + std::optional createdAt; + std::optional updatedAt; + std::optional recencyAt; std::vector turnOrder; std::unordered_map turns; nlohmann::json raw = nlohmann::json::object(); diff --git a/src/codex/TurnSettingsWidget.cpp b/src/codex/TurnSettingsWidget.cpp index 446c20c..f57bc05 100644 --- a/src/codex/TurnSettingsWidget.cpp +++ b/src/codex/TurnSettingsWidget.cpp @@ -3,6 +3,7 @@ #include "codex/TurnSettingsWidget.h" #include "codex/FileSelectionDialog.h" +#include "codex/ui/UiStyle.h" #include #include @@ -13,8 +14,6 @@ #include #include #include -#include -#include #include #include #include @@ -33,29 +32,6 @@ constexpr auto DefaultValue = "default"; constexpr int SettingControlHeight = 32; constexpr int SettingLabelSpacing = 5; -void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, - bool highlighted) { - if (!indicator.isValid() || indicator.isEmpty()) - return; - const QPointF center = indicator.center(); - QPainterPath chevron; - chevron.moveTo(center.x() - 3.5, center.y() - 1.5); - chevron.lineTo(center.x(), center.y() + 2.0); - chevron.lineTo(center.x() + 3.5, center.y() - 1.5); - - QColor color(QStringLiteral("#667085")); - if (!enabled) - color = QColor(QStringLiteral("#98a2b3")); - else if (highlighted) - color = QColor(QStringLiteral("#1d2633")); - - QPainter painter(widget); - painter.setRenderHint(QPainter::Antialiasing, true); - painter.setPen(QPen(color, 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); - painter.setBrush(Qt::NoBrush); - painter.drawPath(chevron); -} - class CompactComboBox final : public QComboBox { protected: void paintEvent(QPaintEvent *event) override { @@ -65,9 +41,9 @@ class CompactComboBox final : public QComboBox { initStyleOption(&option); const QRect indicator = style()->subControlRect( QStyle::CC_ComboBox, &option, QStyle::SC_ComboBoxArrow, this); - drawChevron(this, indicator, option.state & QStyle::State_Enabled, - option.state & - (QStyle::State_MouseOver | QStyle::State_HasFocus)); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)); } }; @@ -87,9 +63,9 @@ class ChevronMenuButton final : public QPushButton { QRect indicator(contents.right() - std::max(12, indicatorWidth), contents.top(), std::max(12, indicatorWidth), contents.height()); - drawChevron(this, indicator, option.state & QStyle::State_Enabled, - option.state & - (QStyle::State_MouseOver | QStyle::State_HasFocus)); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)); } }; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 0e27578..01af198 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -4,11 +4,37 @@ #include #include +#include +#include +#include #include namespace codexui::UiStyle { +void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, + bool highlighted) { + if (!indicator.isValid() || indicator.isEmpty()) + return; + const QPointF center = indicator.center(); + QPainterPath chevron; + chevron.moveTo(center.x() - 3.5, center.y() - 1.5); + chevron.lineTo(center.x(), center.y() + 2.0); + chevron.lineTo(center.x() + 3.5, center.y() - 1.5); + + QColor color(QStringLiteral("#667085")); + if (!enabled) + color = QColor(QStringLiteral("#98a2b3")); + else if (highlighted) + color = QColor(QStringLiteral("#1d2633")); + + QPainter painter(widget); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(QPen(color, 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(chevron); +} + QString applicationStyleSheet() { const qreal configuredSize = QFontInfo(QApplication::font()).pointSizeF(); const qreal baseSize = configuredSize > 0.0 ? configuredSize : 10.0; @@ -82,6 +108,8 @@ QString applicationStyleSheet() { QPushButton[kind="stop"]:hover { background: #fff1f1; } QPushButton[codexChevron="true"] { padding-right: 26px; } QPushButton[codexChevron="true"]::menu-indicator { image: none; width: 0; } + QToolButton[codexChevron="true"] { padding-right: 26px; } + QToolButton[codexChevron="true"]::menu-indicator { image: none; width: 0; } QPushButton[changed="true"] { background: #e5eeff; color: #2f6feb; diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index c18df86..e912ed7 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -5,6 +5,9 @@ #include +class QRect; +class QWidget; + namespace codexui::UiStyle { inline constexpr auto appBackground = "#f6f8fb"; @@ -29,6 +32,8 @@ inline constexpr auto destructive = "#b83a3a"; inline constexpr auto purple = "#6941c6"; QString applicationStyleSheet(); +void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, + bool highlighted); } // namespace codexui::UiStyle diff --git a/src/greenfield/codex/middle/ThreadPane.cpp b/src/greenfield/codex/middle/ThreadPane.cpp index 7d89773..ef8a316 100644 --- a/src/greenfield/codex/middle/ThreadPane.cpp +++ b/src/greenfield/codex/middle/ThreadPane.cpp @@ -3,13 +3,22 @@ #include "codex/middle/ThreadPane.h" #include "codex/PresentationModel.h" +#include "codex/ui/UiStyle.h" #include +#include +#include #include +#include #include #include #include +#include +#include #include +#include +#include +#include #include #include @@ -19,6 +28,55 @@ namespace codexui::codex::middle { namespace { +constexpr int ContextMenuRole = Qt::UserRole + 1; + +class ThreadListWidget final : public QListWidget { +protected: + QItemSelectionModel::SelectionFlags + selectionCommand(const QModelIndex &index, + const QEvent *event = nullptr) const override { + if (event && (event->type() == QEvent::MouseButtonPress || + event->type() == QEvent::MouseButtonRelease)) { + const auto *mouse = static_cast(event); + if (mouse->button() == Qt::RightButton) + return QItemSelectionModel::NoUpdate; + } + return QListWidget::selectionCommand(index, event); + } +}; + +class ThreadItemDelegate final : public QStyledItemDelegate { +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override { + QStyleOptionViewItem effective = option; + if (index.data(ContextMenuRole).toBool()) + effective.state |= QStyle::State_MouseOver; + QStyledItemDelegate::paint(painter, effective, index); + } +}; + +class ChevronToolButton final : public QToolButton { +protected: + void paintEvent(QPaintEvent *event) override { + QToolButton::paintEvent(event); + QStyleOptionToolButton option; + initStyleOption(&option); + const QRect contents = + style()->subElementRect(QStyle::SE_ToolButtonLayoutItem, &option, this); + const int indicatorWidth = + style()->pixelMetric(QStyle::PM_MenuButtonIndicator, &option, this); + const QRect indicator(contents.right() - std::max(12, indicatorWidth), + contents.top(), std::max(12, indicatorWidth), + contents.height()); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)); + } +}; + QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -96,6 +154,15 @@ QWidget *createRow() { return row; } +std::optional timestampFor(const ThreadPresentation &thread, + ThreadPane::SortCriterion criterion) { + if (criterion == ThreadPane::SortCriterion::Created) + return thread.createdAt; + if (criterion == ThreadPane::SortCriterion::LastChanged) + return thread.updatedAt; + return thread.recencyAt; +} + } // namespace ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { @@ -146,21 +213,52 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { }); toolbar->addWidget(refresh); toolbar->addStretch(); + sortButton = new ChevronToolButton; + sortButton->setObjectName(QStringLiteral("threadSortButton")); + sortButton->setProperty("kind", "subtle"); + sortButton->setProperty("codexChevron", true); + sortButton->setPopupMode(QToolButton::InstantPopup); + sortButton->setFixedHeight(28); + auto *sortMenu = new QMenu(sortButton); + auto *sortGroup = new QActionGroup(sortMenu); + sortGroup->setExclusive(true); + const auto addSortAction = [this, sortMenu, sortGroup](QString label, + SortCriterion value) { + QAction *action = sortMenu->addAction(std::move(label)); + action->setCheckable(true); + sortGroup->addAction(action); + connect(action, &QAction::triggered, this, + [this, value] { setSortCriterion(value); }); + return action; + }; + addSortAction(QStringLiteral("Alphanumeric"), SortCriterion::Alphanumeric); + addSortAction(QStringLiteral("Created"), SortCriterion::Created); + addSortAction(QStringLiteral("Last changed"), SortCriterion::LastChanged); + QAction *recent = + addSortAction(QStringLiteral("Recent"), SortCriterion::Recency); + recent->setChecked(true); + sortButton->setMenu(sortMenu); + sortButton->setToolTip(QStringLiteral("Sort threads")); + updateSortButton(); + toolbar->addWidget(sortButton); layout->addLayout(toolbar); - list = new QListWidget; + list = new ThreadListWidget; list->setObjectName(QStringLiteral("threadList")); + list->setItemDelegate(new ThreadItemDelegate(list)); list->setSelectionMode(QAbstractItemView::SingleSelection); list->setContextMenuPolicy(Qt::CustomContextMenu); list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); list->setTextElideMode(Qt::ElideRight); list->setStyleSheet(QStringLiteral( "QListWidget#threadList{background:transparent;border:0;outline:0;}" - "QListWidget#threadList::item{min-height:30px;border:0;border-radius:5px;" + "QListWidget#threadList::item{min-height:30px;background:#ffffff;" + "border:1px solid #d7dee8;border-radius:8px;margin:3px 0;" "padding:2px 8px;color:#344054;}" - "QListWidget#threadList::item:hover{background:#eef3fa;}" + "QListWidget#threadList::item:hover{background:#f1f5fb;" + "border-color:#b9c4d2;}" "QListWidget#threadList::item:selected{background:#e5eeff;" - "color:#1d2633;font-weight:600;}")); + "border-color:#bfd3f9;color:#1d2633;font-weight:600;}")); connect(list, &QListWidget::itemSelectionChanged, this, [this] { if (actions.select) { const std::string id = visiblySelectedThreadId(); @@ -175,9 +273,101 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { void ThreadPane::setActions(Actions next) { actions = std::move(next); } +void ThreadPane::setSortCriterion(SortCriterion criterion) { + if (sortCriterion == criterion) + return; + sortCriterion = criterion; + updateSortButton(); + visibleSnapshot.clear(); + if (currentModel) + refresh(*currentModel, projectedSelectedThreadId); +} + +ThreadPane::SortCriterion ThreadPane::currentSortCriterion() const noexcept { + return sortCriterion; +} + +void ThreadPane::updateSortButton() { + if (!sortButton) + return; + QString label; + switch (sortCriterion) { + case SortCriterion::Alphanumeric: + label = QStringLiteral("A–Z"); + break; + case SortCriterion::Created: + label = QStringLiteral("Created"); + break; + case SortCriterion::LastChanged: + label = QStringLiteral("Changed"); + break; + case SortCriterion::Recency: + label = QStringLiteral("Recent"); + break; + } + sortButton->setText(QStringLiteral("Sort: %1").arg(label)); + for (QAction *action : sortButton->menu()->actions()) + action->setChecked(action->text() == + (sortCriterion == SortCriterion::Alphanumeric + ? QStringLiteral("Alphanumeric") + : sortCriterion == SortCriterion::Created + ? QStringLiteral("Created") + : sortCriterion == SortCriterion::LastChanged + ? QStringLiteral("Last changed") + : QStringLiteral("Recent"))); +} + +void ThreadPane::sortVisibleThreads(std::vector &ids, + const PresentationModel &model) const { + QCollator collator; + collator.setCaseSensitivity(Qt::CaseInsensitive); + collator.setIgnorePunctuation(true); + collator.setNumericMode(true); + std::sort(ids.begin(), ids.end(), + [&](const std::string &leftId, const std::string &rightId) { + const ThreadPresentation *left = model.thread(leftId); + const ThreadPresentation *right = model.thread(rightId); + if (!left || !right) + return leftId < rightId; + if (sortCriterion == SortCriterion::Alphanumeric) { + const QString leftTitle = text(left->title).trimmed(); + const QString rightTitle = text(right->title).trimmed(); + const bool leftStartsWithNumber = + !leftTitle.isEmpty() && leftTitle.front().isDigit(); + const bool rightStartsWithNumber = + !rightTitle.isEmpty() && rightTitle.front().isDigit(); + if (leftStartsWithNumber != rightStartsWithNumber) + return leftStartsWithNumber; + const int comparison = collator.compare(leftTitle, rightTitle); + if (comparison != 0) + return comparison < 0; + } else { + const auto leftTimestamp = timestampFor(*left, sortCriterion); + const auto rightTimestamp = timestampFor(*right, sortCriterion); + if (leftTimestamp != rightTimestamp) { + if (!leftTimestamp) + return false; + if (!rightTimestamp) + return true; + return *leftTimestamp > *rightTimestamp; + } + } + return leftId < rightId; + }); +} + +void ThreadPane::setContextHighlight(const std::string &threadId, + bool highlighted) { + const auto found = rows.find(threadId); + if (found == rows.end()) + return; + found->second->setData(ContextMenuRole, highlighted); +} + void ThreadPane::refresh(const PresentationModel &model, const std::string &selectedThreadId) { currentModel = &model; + projectedSelectedThreadId = selectedThreadId; const std::vector &authoritativeOrder = model.threadOrder(); std::erase_if(retainedVisibleThreads, [&](const std::string &id) { return !model.thread(id) || @@ -195,6 +385,7 @@ void ThreadPane::refresh(const PresentationModel &model, std::vector visibleOrder = retainedVisibleThreads; visibleOrder.insert(visibleOrder.end(), authoritativeOrder.begin(), authoritativeOrder.end()); + sortVisibleThreads(visibleOrder, model); nlohmann::json visible = nlohmann::json::array(); for (const std::string &id : visibleOrder) { const ThreadPresentation *thread = model.thread(id); @@ -206,8 +397,10 @@ void ThreadPane::refresh(const PresentationModel &model, {"status", thread->status}, {"pending", model.pendingRequestCount(id)}}); } - const std::string serialized = - nlohmann::json{{"selected", selectedThreadId}, {"rows", visible}}.dump(); + const std::string serialized = nlohmann::json{ + {"selected", selectedThreadId}, + {"sort", static_cast(sortCriterion)}, + {"rows", visible}}.dump(); const QByteArray next(serialized.data(), static_cast(serialized.size())); if (next == visibleSnapshot) @@ -231,7 +424,7 @@ void ThreadPane::refresh(const PresentationModel &model, const auto found = rows.find(id); if (found == rows.end()) { item = new QListWidgetItem; - item->setSizeHint(QSize(0, 48)); + item->setSizeHint(QSize(0, 54)); item->setData(Qt::UserRole, text(id)); list->insertItem(wantedIndex, item); list->setItemWidget(item, createRow()); @@ -251,6 +444,8 @@ void ThreadPane::refresh(const PresentationModel &model, } item->setToolTip(text(thread->cwd)); updateRow(list->itemWidget(item), *thread, model.pendingRequestCount(id)); + if (id == contextThreadId) + setContextHighlight(id, true); if (id == selectedThreadId) list->setCurrentItem(item); ++wantedIndex; @@ -282,38 +477,52 @@ void ThreadPane::showContextMenu(const QPoint &position) { const ThreadPresentation *thread = currentModel->thread(id); if (!thread) return; - QMenu menu(list); - menu.addAction(QStringLiteral("Reload"), this, [this, id] { + if (contextMenu) + contextMenu->close(); + contextThreadId = id; + setContextHighlight(contextThreadId, true); + auto *menu = new QMenu(list); + contextMenu = menu; + connect(menu, &QMenu::aboutToHide, this, [this, menu] { + if (contextMenu == menu) { + setContextHighlight(contextThreadId, false); + contextThreadId.clear(); + contextMenu = nullptr; + } + menu->deleteLater(); + }); + menu->addAction(QStringLiteral("Reload"), this, [this, id] { if (actions.reload) actions.reload(id); }); const bool canControl = currentModel->connection().connected && currentModel->connection().role == "controller"; - QAction *rename = menu.addAction(QStringLiteral("Rename"), this, [this, id] { + QAction *rename = menu->addAction(QStringLiteral("Rename"), this, [this, id] { if (actions.rename) actions.rename(id); }); - QAction *fork = menu.addAction(QStringLiteral("Fork"), this, [this, id] { + QAction *fork = menu->addAction(QStringLiteral("Fork"), this, [this, id] { if (actions.fork) actions.fork(id); }); QAction *archive = - menu.addAction(thread->archived ? QStringLiteral("Unarchive") - : QStringLiteral("Archive"), - this, [this, id] { - if (actions.toggleArchive) - actions.toggleArchive(id); - }); - menu.addSeparator(); - QAction *remove = menu.addAction(QStringLiteral("Delete"), this, [this, id] { - if (actions.remove) - actions.remove(id); - }); + menu->addAction(thread->archived ? QStringLiteral("Unarchive") + : QStringLiteral("Archive"), + this, [this, id] { + if (actions.toggleArchive) + actions.toggleArchive(id); + }); + menu->addSeparator(); + QAction *remove = + menu->addAction(QStringLiteral("Delete"), this, [this, id] { + if (actions.remove) + actions.remove(id); + }); rename->setEnabled(canControl); fork->setEnabled(canControl); archive->setEnabled(canControl); remove->setEnabled(canControl); - menu.exec(list->viewport()->mapToGlobal(position)); + menu->popup(list->viewport()->mapToGlobal(position)); } } // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/ThreadPane.h b/src/greenfield/codex/middle/ThreadPane.h index 2f7ba18..1ad6cfb 100644 --- a/src/greenfield/codex/middle/ThreadPane.h +++ b/src/greenfield/codex/middle/ThreadPane.h @@ -13,6 +13,8 @@ class QListWidget; class QListWidgetItem; +class QMenu; +class QToolButton; namespace codexui::codex { class PresentationModel; @@ -21,6 +23,8 @@ namespace middle { class ThreadPane final : public QFrame { public: + enum class SortCriterion { Alphanumeric, Created, LastChanged, Recency }; + struct Actions { std::function newThread; std::function refresh; @@ -38,16 +42,27 @@ class ThreadPane final : public QFrame { void setActions(Actions actions); void refresh(const PresentationModel &model, const std::string &selectedThreadId); + void setSortCriterion(SortCriterion criterion); + [[nodiscard]] SortCriterion currentSortCriterion() const noexcept; [[nodiscard]] std::string visiblySelectedThreadId() const; private: + void updateSortButton(); + void sortVisibleThreads(std::vector &ids, + const PresentationModel &model) const; + void setContextHighlight(const std::string &threadId, bool highlighted); void showContextMenu(const QPoint &position); const PresentationModel *currentModel = nullptr; Actions actions; + SortCriterion sortCriterion = SortCriterion::Recency; + QToolButton *sortButton = nullptr; QListWidget *list = nullptr; std::unordered_map rows; std::vector retainedVisibleThreads; + std::string projectedSelectedThreadId; + std::string contextThreadId; + QMenu *contextMenu = nullptr; QByteArray visibleSnapshot; }; diff --git a/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/GreenfieldLayoutTest.cpp index 11ff93d..1eff074 100644 --- a/tests/codex/GreenfieldLayoutTest.cpp +++ b/tests/codex/GreenfieldLayoutTest.cpp @@ -12,21 +12,25 @@ #include #include +#include #include #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include #include +#include namespace codexui::codex::middle { namespace { @@ -88,6 +92,19 @@ QWheelEvent wheelFor(QWidget *target, int pixelDelta) { Qt::ScrollUpdate, false); } +std::vector threadOrder(const ThreadPane &pane) { + const auto *list = + pane.findChild(QStringLiteral("threadList")); + std::vector result; + if (!list) + return result; + result.reserve(static_cast(list->count())); + for (int row = 0; row < list->count(); ++row) + result.push_back( + list->item(row)->data(Qt::UserRole).toString().toStdString()); + return result; +} + bool testOverlayGeometryAndRegionRouting() { MiddleRegionWidget region; bool result = @@ -216,16 +233,24 @@ bool testThreadSelectionProjection() { row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; auto *status = row ? row->findChild(QStringLiteral("threadStatus")) : nullptr; + auto *dot = + row ? row->findChild(QStringLiteral("threadStatusDot")) + : nullptr; auto *rowLayout = row ? qobject_cast(row->layout()) : nullptr; + auto *sortButton = + pane.findChild(QStringLiteral("threadSortButton")); result &= expect( - selected && selected->sizeHint().height() == 48 && rowLayout && + selected && selected->sizeHint().height() == 54 && rowLayout && rowLayout->contentsMargins() == QMargins(5, 2, 5, 2) && - rowLayout->spacing() == 8 && title && status && + rowLayout->spacing() == 8 && title && status && dot && + rowLayout->indexOf(dot) >= 0 && sortButton && + sortButton->property("codexChevron").toBool() && title->property("kind").toString() == QStringLiteral("title") && status->property("kind").toString() == QStringLiteral("meta") && title->textInteractionFlags().testFlag(Qt::TextSelectableByMouse) && status->textInteractionFlags().testFlag(Qt::TextSelectableByMouse), - "thread row typography and 48-pixel card geometry match the UI contract"); + "thread cards keep their status dot and shared chevron styling inside " + "the UI contract"); pane.refresh(model, "thread-a"); bool retainedSupplement = false; if (list) { @@ -254,6 +279,85 @@ bool testThreadSelectionProjection() { return result; } +bool testThreadAlphanumericSort() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "alpha-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "alpha"}, {"name", "Alpha"}}, + {{"id", "ten"}, {"name", "10 Release"}}, + {{"id", "two"}, {"name", "2 Review"}}, + {{"id", "one"}, {"name", "1 Setup"}}, + {{"id", "beta"}, {"name", "beta"}}})}}, + presentation::Authority::Merge)); + ThreadPane pane; + pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); + pane.refresh(model, "two"); + return expect(threadOrder(pane) == + std::vector( + {"one", "two", "ten", "alpha", "beta"}) && + pane.visiblySelectedThreadId() == "two", + "Alphanumeric sorting is natural and preserves selection"); +} + +bool testThreadCreatedSort() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "created-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "old"}, {"createdAt", 10}}, + {{"id", "missing"}}, + {{"id", "new"}, {"createdAt", 30}}, + {{"id", "middle"}, {"createdAt", 20}}})}}, + presentation::Authority::Merge)); + ThreadPane pane; + pane.setSortCriterion(ThreadPane::SortCriterion::Created); + pane.refresh(model, {}); + return expect(threadOrder(pane) == std::vector( + {"new", "middle", "old", "missing"}), + "Created sorting is newest first with missing values last"); +} + +bool testThreadLastChangedSort() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "changed-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "first"}, {"updatedAt", 20}}, + {{"id", "second"}, {"updatedAt", 10}}, + {{"id", "third"}, {"updatedAt", 30}}})}}, + presentation::Authority::Merge)); + model.applyEvent(presentation::event( + 2, 1, "thread.upsert", + {{"thread", {{"id", "first"}, {"name", "Renamed"}}}}, + presentation::Authority::Merge, {{"threadId", "first"}})); + ThreadPane pane; + pane.setSortCriterion(ThreadPane::SortCriterion::LastChanged); + pane.refresh(model, {}); + return expect(threadOrder(pane) == + std::vector({"third", "first", "second"}), + "Last changed sorting uses retained updated timestamps"); +} + +bool testThreadRecencySort() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "recent-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "older"}, {"recencyAt", 10}}, + {{"id", "recent"}, {"recencyAt", 30}}, + {{"id", "middle"}, {"recencyAt", 20}}})}}, + presentation::Authority::Merge)); + ThreadPane pane; + pane.refresh(model, "older"); + return expect( + pane.currentSortCriterion() == ThreadPane::SortCriterion::Recency && + threadOrder(pane) == + std::vector({"recent", "middle", "older"}) && + pane.visiblySelectedThreadId() == "older", + "Recent is the default and preserves selection"); +} + bool testThreadRowReorderOwnership() { PresentationModel model; model.applyEvent(presentation::event( @@ -264,35 +368,62 @@ bool testThreadRowReorderOwnership() { presentation::Authority::Merge, {{"threadId", "thread-b"}})); ThreadPane pane; + int selectedByUser = 0; + ThreadPane::Actions actions; + actions.select = [&](const std::string &) { ++selectedByUser; }; + pane.setActions(std::move(actions)); + pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); pane.resize(320, 500); pane.show(); pane.refresh(model, "thread-a"); spin(20); auto *list = pane.findChild(QStringLiteral("threadList")); QListWidgetItem *threadA = nullptr; + QListWidgetItem *threadB = nullptr; if (list) { for (int row = 0; row < list->count(); ++row) { if (list->item(row)->data(Qt::UserRole).toString() == QStringLiteral("thread-a")) { threadA = list->item(row); - break; + } else if (list->item(row)->data(Qt::UserRole).toString() == + QStringLiteral("thread-b")) { + threadB = list->item(row); } } } - bool result = expect(list && threadA, + bool result = expect(list && threadA && threadB, "the stable thread row exists before list reordering"); - if (!list || !threadA) + if (!list || !threadA || !threadB) return false; - QPointer originalRow = list->itemWidget(threadA); + const QPoint rightClickPosition = list->visualItemRect(threadB).center(); + QMouseEvent rightClick(QEvent::MouseButtonPress, rightClickPosition, + list->viewport()->mapToGlobal(rightClickPosition), + Qt::RightButton, Qt::RightButton, Qt::NoModifier); + QApplication::sendEvent(list->viewport(), &rightClick); + QContextMenuEvent contextMenuEvent( + QContextMenuEvent::Mouse, rightClickPosition, + list->viewport()->mapToGlobal(rightClickPosition)); + QApplication::sendEvent(list->viewport(), &contextMenuEvent); + result &= expect(pane.visiblySelectedThreadId() == "thread-a" && + selectedByUser == 0 && + threadB->data(Qt::UserRole + 1).toBool(), + "right-click highlights row actions without selecting a " + "thread"); + if (QWidget *popup = QApplication::activePopupWidget()) + popup->close(); + spin(); + result &= expect(!threadB->data(Qt::UserRole + 1).toBool(), + "closing row actions clears the native context hover"); + QPointer originalRow = list->itemWidget(threadB); model.applyEvent(presentation::result( 3, 1, "threads.list", "reordered-threads", true, {{"threads", - nlohmann::json::array({{{"id", "thread-a"}, {"name", "A"}}, + nlohmann::json::array({{{"id", "thread-a"}, {"name", "Z"}}, {{"id", "thread-b"}, {"name", "B"}}})}}, presentation::Authority::Replace)); pane.refresh(model, "thread-a"); - QPointer movedRow = list->itemWidget(threadA); + QPointer movedRow = list->itemWidget(threadB); result &= expect(originalRow && movedRow && originalRow != movedRow, "moving an item never reattaches its deferred-delete row"); if (!originalRow || !movedRow || originalRow == movedRow) @@ -301,7 +432,7 @@ bool testThreadRowReorderOwnership() { QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); spin(20); result &= expect(originalRow.isNull() && movedRow && - list->itemWidget(threadA) == movedRow, + list->itemWidget(threadB) == movedRow, "deferred deletion cannot invalidate the moved thread row"); list->setCurrentItem(threadA); list->viewport()->repaint(); @@ -519,6 +650,10 @@ int main(int argc, char **argv) { using namespace codexui::codex::middle; bool result = testOverlayGeometryAndRegionRouting(); result &= testThreadSelectionProjection(); + result &= testThreadAlphanumericSort(); + result &= testThreadCreatedSort(); + result &= testThreadLastChangedSort(); + result &= testThreadRecencySort(); result &= testThreadRowReorderOwnership(); result &= testNestedCommandScrollOwnership(); result &= testInfoViewerLayout(); From 67c54514f96a9c5f737d068251f12d775f569f6b Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Tue, 25 Aug 2026 21:02:33 +0200 Subject: [PATCH 02/36] Unify popup menu styling --- docs/ui-behavior.md | 5 +++++ src/codex/ui/UiStyle.cpp | 32 ++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 5d24f0d..45b4ac2 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -4,6 +4,11 @@ This document defines the current CodexUI interaction contract. AISuite and the Codex app-server own protocol and domain semantics; CodexUI owns only local presentation, input, selection, and scroll state. +All Qt popup and context menus share one application-level visual contract, +including menus created automatically by text widgets. They use a compact +white rounded surface, neutral border, 30-pixel actions, neutral hover, +blue-tinted checked actions, muted disabled actions, and inset separators. + ## Conversation source and structure `PresentationModel` is the sole retained authoritative store for normalized UI diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 01af198..b0c54e2 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -362,11 +362,35 @@ QString applicationStyleSheet() { QTabBar::tab:selected { background: #e5eeff; color: #1d2633; font-weight: 600; } QTabBar::tab:hover:!selected { background: #f1f5fb; color: #1d2633; } QTabBar::tab:focus { border: 1px solid #2f6feb; } - QMenu { background: #ffffff; color: #1d2633; border: 1px solid #d7dee8; padding: 5px; } - QMenu::item { padding: 7px 28px 7px 10px; border-radius: 4px; } - QMenu::item:selected { background: #e5eeff; color: #1d2633; } + QMenu { + background: #ffffff; + color: #1d2633; + border: 1px solid #d7dee8; + border-radius: 8px; + padding: 4px; + } + QMenu::item { + min-height: 30px; + padding: 0 24px 0 10px; + border-radius: 5px; + font-weight: 400; + } + QMenu::item:selected { + background: #f1f5fb; + color: #1d2633; + } + QMenu::item:checked { + background: #e5eeff; + color: #285fca; + } + QMenu::item:checked:selected { background: #d8e7ff; } QMenu::item:disabled { color: #98a2b3; } - QMenu::separator { height: 1px; background: #d7dee8; margin: 5px 8px; } + QMenu::item:disabled:selected { background: transparent; } + QMenu::separator { + height: 1px; + background: #d7dee8; + margin: 4px 8px; + } QToolTip { background: #ffffff; color: #1d2633; border: 1px solid #b9c4d2; padding: 5px; } )QSS") .arg(compact, standard, section, heading); From 022082b16ac192bfe4f7295d919a55e845b74baf Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Tue, 25 Aug 2026 21:26:17 +0200 Subject: [PATCH 03/36] Define semantic UI palettes --- src/codex/ConnectionDialog.cpp | 2 +- src/codex/DiffViewer.cpp | 4 +- src/codex/FileSelectionDialog.cpp | 2 +- src/codex/NewThreadDialog.cpp | 2 +- src/codex/ShellWidget.cpp | 43 ++++++++----------- src/codex/WorkbenchWidget.cpp | 4 +- src/codex/ui/UiStyle.cpp | 19 +++++--- src/codex/ui/UiStyle.h | 26 ++++++++--- src/greenfield/codex/ShellWidget.cpp | 10 ++--- src/greenfield/codex/middle/ComposerPane.cpp | 8 +--- .../codex/middle/ConversationCards.cpp | 6 +-- .../codex/middle/MiddleRegionWidget.cpp | 12 +++--- src/greenfield/codex/middle/ThreadPane.cpp | 10 ++--- tests/codex/GreenfieldLayoutTest.cpp | 3 +- ui-review/UX-DESIGN-DECISIONS.md | 23 +++++++++- 15 files changed, 104 insertions(+), 70 deletions(-) diff --git a/src/codex/ConnectionDialog.cpp b/src/codex/ConnectionDialog.cpp index a746a48..ece1496 100644 --- a/src/codex/ConnectionDialog.cpp +++ b/src/codex/ConnectionDialog.cpp @@ -90,7 +90,7 @@ ConnectionDialog::ConnectionDialog(nlohmann::json settings, QWidget *parent) "meta"); root->addWidget(tlsNotice); errorLabel = dialogLabel({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); errorLabel->hide(); root->addWidget(errorLabel); root->addStretch(); diff --git a/src/codex/DiffViewer.cpp b/src/codex/DiffViewer.cpp index a42551f..cd8b360 100644 --- a/src/codex/DiffViewer.cpp +++ b/src/codex/DiffViewer.cpp @@ -37,8 +37,8 @@ class DiffHighlighter final : public QSyntaxHighlighter { format.setBackground(QColor(QStringLiteral("#e9f7f0"))); } else if (text.startsWith(QLatin1Char('-')) && !text.startsWith(QStringLiteral("---"))) { - format.setForeground(QColor(QStringLiteral("#9d2e2e"))); - format.setBackground(QColor(QStringLiteral("#fff1f1"))); + format.setForeground(QColor(QStringLiteral("#982f3d"))); + format.setBackground(QColor(QStringLiteral("#fff0f2"))); } else if (text.startsWith(QStringLiteral("diff --git")) || text.startsWith(QStringLiteral("---")) || text.startsWith(QStringLiteral("+++"))) { diff --git a/src/codex/FileSelectionDialog.cpp b/src/codex/FileSelectionDialog.cpp index 77ca9cc..d2baf81 100644 --- a/src/codex/FileSelectionDialog.cpp +++ b/src/codex/FileSelectionDialog.cpp @@ -143,7 +143,7 @@ FileSelectionDialog::FileSelectionDialog( } errorLabel = dialogLabel({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); errorLabel->hide(); root->addWidget(errorLabel); diff --git a/src/codex/NewThreadDialog.cpp b/src/codex/NewThreadDialog.cpp index 0341826..4637f13 100644 --- a/src/codex/NewThreadDialog.cpp +++ b/src/codex/NewThreadDialog.cpp @@ -115,7 +115,7 @@ NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) root->addWidget(scroll, 1); errorLabel = label({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); errorLabel->hide(); root->addWidget(errorLabel); diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index f3a7c7c..7e31870 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -122,7 +122,7 @@ class PendingPromptCard final : public QFrame { const QString foreground = awaiting || acknowledgedTransition ? QStringLiteral("#536b8f") - : failed ? QStringLiteral("#9b2c2c") + : failed ? QStringLiteral("#982f3d") : QStringLiteral("#1d2633"); auto *title = makeLabel(QStringLiteral("You"), "title"); title->setStyleSheet( @@ -177,11 +177,11 @@ class PendingPromptCard final : public QFrame { const QRectF bounds = QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5); const QColor background = isAwaiting || isAcknowledgedTransition ? QColor(QStringLiteral("#dbe7f8")) - : hasFailed ? QColor(QStringLiteral("#fff1f1")) + : hasFailed ? QColor(QStringLiteral("#fff0f2")) : QColor(QStringLiteral("#eaf2ff")); const QColor border = isAwaiting || isAcknowledgedTransition ? QColor(QStringLiteral("#9eb9df")) - : hasFailed ? QColor(QStringLiteral("#e5a3a3")) + : hasFailed ? QColor(QStringLiteral("#efb8c0")) : QColor(QStringLiteral("#bfd3f9")); painter.setBrush(background); painter.setPen(QPen(border, 1.0)); @@ -483,8 +483,8 @@ QFrame *makeDivider() { QFrame *makeStatusDot() { auto *dot = new QFrame; - dot->setFixedSize(8, 8); - dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:4px;")); + dot->setFixedSize(10, 10); + dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:5px;")); return dot; } @@ -1002,11 +1002,11 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) noticeBar = new QFrame; noticeBar->setStyleSheet(QStringLiteral( - "background:#fff4f2;border:1px solid #efc2bc;border-radius:6px;")); + "background:#fff0f2;border:1px solid #efb8c0;border-radius:6px;")); auto *noticeLayout = new QHBoxLayout(noticeBar); noticeLayout->setContentsMargins(10, 6, 8, 6); noticeLabel = makeLabel({}, "meta"); - noticeLabel->setStyleSheet(QStringLiteral("color:#9d2e2e;")); + noticeLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); auto *dismissNotice = new QPushButton(QStringLiteral("Dismiss")); dismissNotice->setProperty("kind", "subtle"); dismissNotice->setFixedHeight(28); @@ -1117,7 +1117,7 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) composerDockLayout->setSpacing(0); auto *attention = new QFrame; - attention->setProperty("kind", "amberBadge"); + attention->setProperty("kind", "orangeBadge"); auto *attentionLayout = new QHBoxLayout(attention); attentionLayout->setContentsMargins(10, 6, 10, 6); attentionLayout->addWidget(makeLabel( @@ -1721,12 +1721,12 @@ void ShellWidget::showNotice(QString message, bool error) { return; noticeLabel->setText(std::move(message)); noticeBar->setStyleSheet( - error ? QStringLiteral("background:#fff4f2;border:1px solid #efc2bc;" + error ? QStringLiteral("background:#fff0f2;border:1px solid #efb8c0;" "border-radius:6px;") - : QStringLiteral("background:#fff8e8;border:1px solid #e5c77d;" + : QStringLiteral("background:#fff6df;border:1px solid #e5c77d;" "border-radius:6px;")); - noticeLabel->setStyleSheet(error ? QStringLiteral("color:#9d2e2e;") - : QStringLiteral("color:#8a5a00;")); + noticeLabel->setStyleSheet(error ? QStringLiteral("color:#982f3d;") + : QStringLiteral("color:#8a5208;")); noticeBar->show(); } @@ -2121,13 +2121,13 @@ void ShellWidget::refreshThreads() { auto *dot = makeStatusDot(); QString dotColor = QStringLiteral("#98a2b3"); if (model.pendingRequestCount(threadId) != 0) - dotColor = QStringLiteral("#a76812"); + dotColor = QStringLiteral("#a85d0c"); else if (thread->status == "active" || thread->status == "inProgress") dotColor = QStringLiteral("#2f6feb"); else if (thread->status == "failed" || thread->status == "systemError") - dotColor = QStringLiteral("#b83a3a"); + dotColor = QStringLiteral("#c43d4d"); dot->setStyleSheet( - QStringLiteral("background:%1;border-radius:4px;").arg(dotColor)); + QStringLiteral("background:%1;border-radius:5px;").arg(dotColor)); rowLayout->addWidget(dot); auto *copy = new QVBoxLayout; copy->setContentsMargins(0, 0, 0, 0); @@ -2947,13 +2947,13 @@ void ShellWidget::refreshStatus() { QString dotStyle; QString dotToolTip; if (connection.connected) { - dotStyle = QStringLiteral("background:#23845a;border-radius:4px;"); + dotStyle = QStringLiteral("background:#18865e;border-radius:5px;"); dotToolTip = QStringLiteral("Connected"); } else if (connection.retrying) { - dotStyle = QStringLiteral("background:#d98e1c;border-radius:4px;"); + dotStyle = QStringLiteral("background:#a85d0c;border-radius:5px;"); dotToolTip = QStringLiteral("Disconnected, retrying"); } else { - dotStyle = QStringLiteral("background:#b83a3a;border-radius:4px;"); + dotStyle = QStringLiteral("background:#c43d4d;border-radius:5px;"); dotToolTip = QStringLiteral("Disconnected"); } connectionStatusDot->setStyleSheet(dotStyle); @@ -3500,12 +3500,7 @@ void ShellWidget::refreshAttachments() { remove->setAccessibleName(QStringLiteral("Remove %1").arg(attachment.name)); remove->setToolTip(QStringLiteral("Remove attachment")); remove->setFixedSize(18, 18); - remove->setStyleSheet( - QStringLiteral("QPushButton{background:#b83a3a;color:#ffffff;border:0;" - "border-radius:4px;padding:0;" - "font-weight:700;}" - "QPushButton:hover{background:#9f2f2f;}" - "QPushButton:pressed{background:#842626;}")); + remove->setProperty("kind", "destructiveCompact"); connect(remove, &QPushButton::clicked, this, [this, index] { attachmentDrafts.erase(attachmentDrafts.begin() + static_cast(index)); diff --git a/src/codex/WorkbenchWidget.cpp b/src/codex/WorkbenchWidget.cpp index ef02770..45d2b5e 100644 --- a/src/codex/WorkbenchWidget.cpp +++ b/src/codex/WorkbenchWidget.cpp @@ -369,7 +369,7 @@ WorkbenchWidget::WorkbenchWidget(FrontendSession &session, QWidget *parent) centerLayout->addWidget(conversationScroll, 1); auto *attention = new QFrame; - attention->setProperty("kind", "amberBadge"); + attention->setProperty("kind", "orangeBadge"); auto *attentionLayout = new QHBoxLayout(attention); attentionLayout->setContentsMargins(10, 6, 10, 6); attentionLayout->addWidget(makeLabel( @@ -621,7 +621,7 @@ void WorkbenchWidget::refreshThreads() { title.prepend(QStringLiteral("! ")); auto *item = new QListWidgetItem(title, threadList); if (model.pendingRequestCount(threadId) != 0) - item->setForeground(QColor(QStringLiteral("#8a5a00"))); + item->setForeground(QColor(QStringLiteral("#8a5208"))); item->setData(Qt::UserRole, text(threadId)); item->setToolTip(text(thread->cwd)); if (threadId == selectedThreadId) diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index b0c54e2..b044351 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -58,7 +58,7 @@ QString applicationStyleSheet() { font-weight: 600; } QLabel[kind="attentionSection"] { - color: #a76812; + color: #a85d0c; font-size: %1pt; font-weight: 600; } @@ -85,7 +85,7 @@ QString applicationStyleSheet() { QPushButton[kind="primary"]:hover { background: #285fca; border-color: #285fca; } QPushButton[kind="history"] { background: #e5eeff; border-color: #bfd3f9; color: #285fca; } QPushButton[kind="history"]:hover { background: #d8e7ff; border-color: #9ebcf3; } - QPushButton[kind="request"] { background: #fff6df; border-color: #e5c77d; color: #8a5a00; } + QPushButton[kind="request"] { background: #fff6df; border-color: #e5c77d; color: #8a5208; } QPushButton[kind="request"]:hover { background: #ffefc4; border-color: #d5ad50; } QPushButton[kind="steer"] { background: #ffffff; border-color: #2f6feb; color: #2f6feb; } QPushButton[kind="steer"]:hover { background: #e5eeff; border-color: #285fca; color: #285fca; } @@ -104,8 +104,15 @@ QString applicationStyleSheet() { } QToolButton[kind="composerAction"]:hover { background: #f1f5fb; border-color: #b9c4d2; } QPushButton[kind="agentLink"] { background: #e5eeff; border-color: #bfd3f9; color: #2f6feb; text-align: left; } - QPushButton[kind="stop"] { background: #ffffff; border-color: #b83a3a; color: #b83a3a; } - QPushButton[kind="stop"]:hover { background: #fff1f1; } + QPushButton[kind="success"] { background: #18865e; border-color: #18865e; color: white; } + QPushButton[kind="success"]:hover { background: #14734f; border-color: #14734f; } + QPushButton[kind="success"]:pressed { background: #105f41; border-color: #105f41; } + QPushButton[kind="destructive"], QPushButton[kind="stop"] { background: #c43d4d; border-color: #c43d4d; color: white; } + QPushButton[kind="destructive"]:hover, QPushButton[kind="stop"]:hover { background: #aa3342; border-color: #aa3342; } + QPushButton[kind="destructive"]:pressed, QPushButton[kind="stop"]:pressed { background: #8f2b38; border-color: #8f2b38; } + QPushButton[kind="destructiveCompact"] { background: #c43d4d; border: 0; color: white; border-radius: 4px; padding: 0; font-weight: 700; } + QPushButton[kind="destructiveCompact"]:hover { background: #aa3342; } + QPushButton[kind="destructiveCompact"]:pressed { background: #8f2b38; } QPushButton[codexChevron="true"] { padding-right: 26px; } QPushButton[codexChevron="true"]::menu-indicator { image: none; width: 0; } QToolButton[codexChevron="true"] { padding-right: 26px; } @@ -120,9 +127,9 @@ QString applicationStyleSheet() { QFrame[messageRole="user"] { background: #eaf2ff; border: 1px solid #bfd3f9; border-radius: 8px; } QFrame[messageRole="agent"] { background: #ffffff; border: 0; border-radius: 8px; } QFrame[kind="summary"] { background: #f8fafc; border: 1px solid #d7dee8; border-radius: 7px; } - QFrame[kind="greenBadge"] { background: #e9f7f0; border-radius: 6px; } + QFrame[kind="greenBadge"] { background: #e9f7f0; border: 1px solid #a9d8c1; border-radius: 6px; } QFrame[kind="blueBadge"] { background: #e5eeff; border-radius: 5px; } - QFrame[kind="amberBadge"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } + QFrame[kind="orangeBadge"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } QFrame[kind="composer"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } QFrame[kind="composer"][focused="true"] { border: 2px solid #2f6feb; } QPlainTextEdit { diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index e912ed7..4c33bb2 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -21,14 +21,30 @@ inline constexpr auto primary = "#1d2633"; inline constexpr auto secondary = "#667085"; inline constexpr auto placeholder = "#98a2b3"; inline constexpr auto blue = "#2f6feb"; +inline constexpr auto blueHover = "#285fca"; inline constexpr auto blueSelected = "#e5eeff"; inline constexpr auto blueBorder = "#bfd3f9"; inline constexpr auto hover = "#f1f5fb"; -inline constexpr auto green = "#23845a"; -inline constexpr auto amberSurface = "#fff6df"; -inline constexpr auto amberBorder = "#e5c77d"; -inline constexpr auto amber = "#a76812"; -inline constexpr auto destructive = "#b83a3a"; +inline constexpr auto green = "#18865e"; +inline constexpr auto greenHover = "#14734f"; +inline constexpr auto greenPressed = "#105f41"; +inline constexpr auto greenSurface = "#e9f7f0"; +inline constexpr auto greenBorder = "#a9d8c1"; +inline constexpr auto greenText = "#176b45"; +inline constexpr auto orange = "#a85d0c"; +inline constexpr auto orangeHover = "#8e4d09"; +inline constexpr auto orangePressed = "#743e07"; +inline constexpr auto orangeSurface = "#fff6df"; +inline constexpr auto orangeSurfaceHover = "#ffefc4"; +inline constexpr auto orangeBorder = "#e5c77d"; +inline constexpr auto orangeBorderStrong = "#d5ad50"; +inline constexpr auto orangeText = "#8a5208"; +inline constexpr auto red = "#c43d4d"; +inline constexpr auto redHover = "#aa3342"; +inline constexpr auto redPressed = "#8f2b38"; +inline constexpr auto redSurface = "#fff0f2"; +inline constexpr auto redBorder = "#efb8c0"; +inline constexpr auto redText = "#982f3d"; inline constexpr auto purple = "#6941c6"; QString applicationStyleSheet(); diff --git a/src/greenfield/codex/ShellWidget.cpp b/src/greenfield/codex/ShellWidget.cpp index 2ba5344..e5be1b7 100644 --- a/src/greenfield/codex/ShellWidget.cpp +++ b/src/greenfield/codex/ShellWidget.cpp @@ -125,8 +125,8 @@ QLabel *makeLabel(QString value, const char *kind = "body") { QFrame *statusDot() { auto *dot = new QFrame; - dot->setFixedSize(8, 8); - dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:4px;")); + dot->setFixedSize(10, 10); + dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:5px;")); return dot; } @@ -712,13 +712,13 @@ void ShellWidget::Impl::refreshStatus() { QString dotStyle; QString dotTip; if (connection.connected) { - dotStyle = QStringLiteral("background:#23845a;border-radius:4px;"); + dotStyle = QStringLiteral("background:#18865e;border-radius:5px;"); dotTip = QStringLiteral("Connected"); } else if (connection.retrying) { - dotStyle = QStringLiteral("background:#d98e1c;border-radius:4px;"); + dotStyle = QStringLiteral("background:#a85d0c;border-radius:5px;"); dotTip = QStringLiteral("Disconnected, retrying"); } else { - dotStyle = QStringLiteral("background:#b83a3a;border-radius:4px;"); + dotStyle = QStringLiteral("background:#c43d4d;border-radius:5px;"); dotTip = QStringLiteral("Disconnected"); } connectionStatusDot->setStyleSheet(dotStyle); diff --git a/src/greenfield/codex/middle/ComposerPane.cpp b/src/greenfield/codex/middle/ComposerPane.cpp index 8afcda6..e4f8d2d 100644 --- a/src/greenfield/codex/middle/ComposerPane.cpp +++ b/src/greenfield/codex/middle/ComposerPane.cpp @@ -73,7 +73,7 @@ ComposerPane::ComposerPane(QWidget *anchor) root->setSpacing(0); attention_ = new QFrame(this); - attention_->setProperty("kind", "amberBadge"); + attention_->setProperty("kind", "orangeBadge"); auto *attentionLayout = new QHBoxLayout(attention_); attentionLayout->setContentsMargins(10, 6, 10, 6); attentionLayout->addWidget(makeLabel( @@ -336,11 +336,7 @@ void ComposerPane::refreshAttachments() { remove->setAccessibleName(QStringLiteral("Remove %1").arg(attachment.name)); remove->setToolTip(QStringLiteral("Remove attachment")); remove->setFixedSize(18, 18); - remove->setStyleSheet( - QStringLiteral("QPushButton{background:#b83a3a;color:#ffffff;border:0;" - "border-radius:4px;padding:0;font-weight:700;}" - "QPushButton:hover{background:#9f2f2f;}" - "QPushButton:pressed{background:#842626;}")); + remove->setProperty("kind", "destructiveCompact"); connect(remove, &QPushButton::clicked, this, [this, index] { if (index >= attachments_.size()) return; diff --git a/src/greenfield/codex/middle/ConversationCards.cpp b/src/greenfield/codex/middle/ConversationCards.cpp index 544dfc3..51ca45e 100644 --- a/src/greenfield/codex/middle/ConversationCards.cpp +++ b/src/greenfield/codex/middle/ConversationCards.cpp @@ -534,7 +534,7 @@ class ConversationCard::Impl final { const bool failed = prompt->state == PromptState::Failed; const QString foreground = waiting || transitioning ? QStringLiteral("#536b8f") - : failed ? QStringLiteral("#9b2c2c") + : failed ? QStringLiteral("#982f3d") : QStringLiteral("#1d2633"); const QString style = QStringLiteral("background:transparent;color:%1;").arg(foreground); @@ -638,11 +638,11 @@ void ConversationCard::paintEvent(QPaintEvent *event) { const bool failed = prompt->state == PromptState::Failed; const QColor background = waiting || transitioning ? QColor(QStringLiteral("#dbe7f8")) - : failed ? QColor(QStringLiteral("#fff1f1")) + : failed ? QColor(QStringLiteral("#fff0f2")) : QColor(QStringLiteral("#eaf2ff")); const QColor border = waiting || transitioning ? QColor(QStringLiteral("#9eb9df")) - : failed ? QColor(QStringLiteral("#e5a3a3")) + : failed ? QColor(QStringLiteral("#efb8c0")) : QColor(QStringLiteral("#bfd3f9")); painter.setBrush(background); painter.setPen(QPen(border, 1.0)); diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.cpp b/src/greenfield/codex/middle/MiddleRegionWidget.cpp index 9292d10..f002be7 100644 --- a/src/greenfield/codex/middle/MiddleRegionWidget.cpp +++ b/src/greenfield/codex/middle/MiddleRegionWidget.cpp @@ -104,11 +104,11 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { noticeBar = new QFrame; noticeBar->setStyleSheet(QStringLiteral( - "background:#fff4f2;border:1px solid #efc2bc;border-radius:6px;")); + "background:#fff0f2;border:1px solid #efb8c0;border-radius:6px;")); auto *noticeLayout = new QHBoxLayout(noticeBar); noticeLayout->setContentsMargins(10, 6, 8, 6); noticeLabel = makeLabel({}, "meta"); - noticeLabel->setStyleSheet(QStringLiteral("color:#9d2e2e;")); + noticeLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); auto *dismiss = new QPushButton(QStringLiteral("Dismiss")); dismiss->setProperty("kind", "subtle"); dismiss->setFixedHeight(28); @@ -167,12 +167,12 @@ void MiddleRegionWidget::showNotice(QString message, bool error) { return; noticeLabel->setText(std::move(message)); noticeBar->setStyleSheet( - error ? QStringLiteral("background:#fff4f2;border:1px solid #efc2bc;" + error ? QStringLiteral("background:#fff0f2;border:1px solid #efb8c0;" "border-radius:6px;") - : QStringLiteral("background:#fff8e8;border:1px solid #e5c77d;" + : QStringLiteral("background:#fff6df;border:1px solid #e5c77d;" "border-radius:6px;")); - noticeLabel->setStyleSheet(error ? QStringLiteral("color:#9d2e2e;") - : QStringLiteral("color:#8a5a00;")); + noticeLabel->setStyleSheet(error ? QStringLiteral("color:#982f3d;") + : QStringLiteral("color:#8a5208;")); noticeBar->show(); } diff --git a/src/greenfield/codex/middle/ThreadPane.cpp b/src/greenfield/codex/middle/ThreadPane.cpp index ef8a316..09a41ae 100644 --- a/src/greenfield/codex/middle/ThreadPane.cpp +++ b/src/greenfield/codex/middle/ThreadPane.cpp @@ -105,7 +105,7 @@ QLabel *makeLabel(QString value, const char *kind = "body") { QFrame *statusDot() { auto *dot = new QFrame; dot->setObjectName(QStringLiteral("threadStatusDot")); - dot->setFixedSize(8, 8); + dot->setFixedSize(10, 10); return dot; } @@ -121,15 +121,15 @@ void updateRow(QWidget *row, const ThreadPresentation &thread, titleText.prepend(QStringLiteral("! ")); title->setText(titleText); status->setText(displayStatus(thread.status)); - QString color = QStringLiteral("#98a2b3"); + QString color = QStringLiteral("#cacccf"); if (requestCount != 0) - color = QStringLiteral("#a76812"); + color = QStringLiteral("#a85d0c"); else if (thread.status == "active" || thread.status == "inProgress") color = QStringLiteral("#2f6feb"); else if (thread.status == "failed" || thread.status == "systemError") - color = QStringLiteral("#b83a3a"); + color = QStringLiteral("#c43d4d"); dot->setStyleSheet( - QStringLiteral("background:%1;border-radius:4px;").arg(color)); + QStringLiteral("background:%1;border-radius:5px;").arg(color)); } QWidget *createRow() { diff --git a/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/GreenfieldLayoutTest.cpp index 1eff074..c2b8c19 100644 --- a/tests/codex/GreenfieldLayoutTest.cpp +++ b/tests/codex/GreenfieldLayoutTest.cpp @@ -243,7 +243,8 @@ bool testThreadSelectionProjection() { selected && selected->sizeHint().height() == 54 && rowLayout && rowLayout->contentsMargins() == QMargins(5, 2, 5, 2) && rowLayout->spacing() == 8 && title && status && dot && - rowLayout->indexOf(dot) >= 0 && sortButton && + dot->size() == QSize(10, 10) && rowLayout->indexOf(dot) >= 0 && + sortButton && sortButton->property("codexChevron").toBool() && title->property("kind").toString() == QStringLiteral("title") && status->property("kind").toString() == QStringLiteral("meta") && diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 6638ff8..22025d7 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -4,8 +4,10 @@ This document records the implemented CodexUI visual and interaction contract. ## Visual system -- CodexUI uses a light theme with neutral application surfaces and restrained - blue, green, amber, and red state colors. +- CodexUI uses a light theme with neutral application surfaces and four opaque + semantic color families. Blue remains the unchanged primary-action reference; + green, orange, and red use matching interaction steps and comparable + white-text contrast. - Hover, focus, selection, disabled, warning, error, pending, and active states remain visually distinct. - User messages are blue-tinted cards. Codex narrative is visually lighter. @@ -13,6 +15,23 @@ This document records the implemented CodexUI visual and interaction contract. - Scrollbars use one compact application style across conversation, nested output, State, Protocol, and Inspector surfaces. +| Family | Primary | Hover | Pressed | Soft surface | Border | Surface text | +|---|---|---|---|---|---|---| +| Blue | `#2f6feb` | `#285fca` | existing blue behavior | `#e5eeff` | `#bfd3f9` | `#285fca` | +| Green | `#18865e` | `#14734f` | `#105f41` | `#e9f7f0` | `#a9d8c1` | `#176b45` | +| Orange | `#a85d0c` | `#8e4d09` | `#743e07` | `#fff6df` | `#e5c77d` | `#8a5208` | +| Red | `#c43d4d` | `#aa3342` | `#8f2b38` | `#fff0f2` | `#efb8c0` | `#982f3d` | + +Filled semantic buttons use white text and the primary, hover, and pressed +steps without opacity changes. Their primary contrast against white ranges +from 4.55:1 to 5.09:1. Blue denotes primary action or active work, green +denotes success or connection, orange denotes warning or attention, and red +denotes failure, stop, removal, or another destructive action. Activity dots +use the same primary colors at 10 pixels so their state remains legible without +creating a separate indicator palette. The existing gray palette is unchanged; +only inactive thread dots use the lighter, less saturated `#cacccf` so active +blue threads retain clear visual priority. + ## Application layout The window consists of a 64-pixel identity/status bar, a hideable thread From 7805ebe999cdb58682e227e4dddad47644337a40 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Tue, 25 Aug 2026 21:51:05 +0200 Subject: [PATCH 04/36] Polish inspector navigation --- src/codex/ui/UiStyle.cpp | 28 +++- src/codex/ui/UiStyle.h | 4 +- src/greenfield/codex/middle/InspectorPane.cpp | 137 +++++++++++++++--- src/greenfield/codex/middle/InspectorPane.h | 3 +- tests/codex/GreenfieldLayoutTest.cpp | 20 ++- ui-review/UX-DESIGN-DECISIONS.md | 19 ++- 6 files changed, 168 insertions(+), 43 deletions(-) diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index b044351..1816dce 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -13,14 +13,20 @@ namespace codexui::UiStyle { void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, - bool highlighted) { + bool highlighted, ChevronDirection direction) { if (!indicator.isValid() || indicator.isEmpty()) return; const QPointF center = indicator.center(); QPainterPath chevron; - chevron.moveTo(center.x() - 3.5, center.y() - 1.5); - chevron.lineTo(center.x(), center.y() + 2.0); - chevron.lineTo(center.x() + 3.5, center.y() - 1.5); + if (direction == ChevronDirection::Right) { + chevron.moveTo(center.x() - 1.5, center.y() - 3.5); + chevron.lineTo(center.x() + 2.0, center.y()); + chevron.lineTo(center.x() - 1.5, center.y() + 3.5); + } else { + chevron.moveTo(center.x() - 3.5, center.y() - 1.5); + chevron.lineTo(center.x(), center.y() + 2.0); + chevron.lineTo(center.x() + 3.5, center.y() - 1.5); + } QColor color(QStringLiteral("#667085")); if (!enabled) @@ -96,6 +102,15 @@ QString applicationStyleSheet() { background: transparent; border-color: transparent; } + QPushButton[kind="infoChoice"] { + background: #ffffff; + border: 1px solid #d7dee8; + border-radius: 10px; + padding: 0; + text-align: left; + } + QPushButton[kind="infoChoice"]:hover { background: #f8fafc; border-color: #b9c4d2; } + QPushButton[kind="infoChoice"]:pressed { background: #f1f5fb; border-color: #9eabbc; } QToolButton[kind="composerAction"] { background: #ffffff; border: 1px solid #d7dee8; @@ -250,6 +265,11 @@ QString applicationStyleSheet() { QDialog { background: #ffffff; } QScrollArea { background: #f6f8fb; border: 0; } QTabWidget QScrollArea { background: #fbfcfe; } + QScrollArea[kind="inspectorScroll"], + QScrollArea[kind="inspectorScroll"] > QWidget > QWidget { + background: transparent; + border: 0; + } QDialog QScrollArea { background: #ffffff; } QScrollArea > QWidget > QWidget { background: transparent; } QAbstractScrollArea::corner { background: transparent; border: 0; } diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index 4c33bb2..fa5d066 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -48,8 +48,10 @@ inline constexpr auto redText = "#982f3d"; inline constexpr auto purple = "#6941c6"; QString applicationStyleSheet(); +enum class ChevronDirection { Down, Right }; void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, - bool highlighted); + bool highlighted, + ChevronDirection direction = ChevronDirection::Down); } // namespace codexui::UiStyle diff --git a/src/greenfield/codex/middle/InspectorPane.cpp b/src/greenfield/codex/middle/InspectorPane.cpp index d22ece8..6ca9e95 100644 --- a/src/greenfield/codex/middle/InspectorPane.cpp +++ b/src/greenfield/codex/middle/InspectorPane.cpp @@ -4,6 +4,7 @@ #include "codex/DiffViewer.h" #include "codex/PresentationModel.h" +#include "codex/ui/UiStyle.h" #include #include @@ -12,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -26,6 +29,9 @@ namespace codexui::codex::middle { namespace { constexpr int MaximumProtocolLines = 2000; +constexpr int InfoChoicePage = 0; +constexpr int StatePage = 1; +constexpr int ProtocolPage = 2; QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); @@ -104,6 +110,23 @@ QByteArray bytes(const nlohmann::json &value) { static_cast(serialized.size())); } +class InfoChoiceButton final : public QPushButton { +protected: + void paintEvent(QPaintEvent *event) override { + QPushButton::paintEvent(event); + QStyleOptionButton option; + initStyleOption(&option); + const QRect contents = style()->subElementRect( + QStyle::SE_PushButtonContents, &option, this); + const QRect indicator(contents.right() - 18, contents.top(), 18, + contents.height()); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus), + UiStyle::ChevronDirection::Right); + } +}; + QFrame *agentFrame(const AgentPresentation &agent) { auto *frame = new QFrame; frame->setProperty("kind", "raised"); @@ -145,6 +168,47 @@ QFrame *agentFrame(const AgentPresentation &agent) { return frame; } +QPushButton *infoChoice(const QString &title, const QString &description) { + auto *button = new InfoChoiceButton; + button->setProperty("kind", "infoChoice"); + button->setMinimumHeight(64); + button->setCursor(Qt::PointingHandCursor); + + auto *layout = new QHBoxLayout(button); + layout->setContentsMargins(12, 9, 30, 9); + layout->setSpacing(8); + auto *copy = new QVBoxLayout; + copy->setSpacing(2); + auto *titleLabel = makeLabel(title, "title"); + auto *descriptionLabel = makeLabel(description, "meta"); + titleLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + descriptionLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + titleLabel->setTextInteractionFlags(Qt::NoTextInteraction); + descriptionLabel->setTextInteractionFlags(Qt::NoTextInteraction); + copy->addWidget(titleLabel); + copy->addWidget(descriptionLabel); + layout->addLayout(copy, 1); + return button; +} + +QWidget *infoDetail(const QString &title, QWidget *content, + QPushButton **backButton) { + auto *page = new QWidget; + auto *layout = new QVBoxLayout(page); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(8); + auto *heading = new QHBoxLayout; + *backButton = new QPushButton(QStringLiteral("‹ Info")); + (*backButton)->setProperty("kind", "subtle"); + (*backButton)->setFixedHeight(28); + heading->addWidget(*backButton); + heading->addStretch(); + heading->addWidget(makeLabel(title, "title")); + layout->addLayout(heading); + layout->addWidget(content, 1); + return page; +} + struct ScrollPosition { bool followsTail = true; int value = 0; @@ -204,15 +268,14 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { const auto makeScroll = [](QWidget *content) { auto *scroll = new QScrollArea; + scroll->setProperty("kind", "inspectorScroll"); + scroll->setFrameShape(QFrame::NoFrame); scroll->setWidgetResizable(true); scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); scroll->setWidget(content); return scroll; }; - auto *stateContent = new QWidget; - auto *stateLayout = new QVBoxLayout(stateContent); - stateLayout->setContentsMargins(8, 8, 8, 8); stateView = new QPlainTextEdit; stateView->setObjectName(QStringLiteral("stateInfoView")); stateView->setProperty("kind", "infoViewer"); @@ -221,11 +284,9 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { stateView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); stateView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); stateView->verticalScrollBar()->setProperty("kind", "infoViewer"); - stateLayout->addWidget(stateView); - auto *protocolContent = new QWidget; auto *protocolLayout = new QVBoxLayout(protocolContent); - protocolLayout->setContentsMargins(8, 8, 8, 8); + protocolLayout->setContentsMargins(0, 0, 0, 0); protocolLayout->setSpacing(6); protocolLog = new QPlainTextEdit; protocolLog->setObjectName(QStringLiteral("protocolInfoLog")); @@ -250,27 +311,54 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { protocolLayout->addWidget(protocolLog, 1); protocolLayout->addWidget(protocolStats); - infoTabs = new QTabWidget; - infoTabs->setObjectName(QStringLiteral("infoTabs")); - infoTabs->setDocumentMode(true); - infoTabs->addTab(stateContent, QStringLiteral("State")); - infoTabs->addTab(protocolContent, QStringLiteral("Protocol")); + infoStack = new QStackedWidget; + infoStack->setObjectName(QStringLiteral("infoStack")); + auto *choices = new QWidget; + auto *choicesLayout = new QVBoxLayout(choices); + choicesLayout->setContentsMargins(8, 8, 8, 8); + choicesLayout->setSpacing(8); + auto *stateChoice = infoChoice( + QStringLiteral("State"), QStringLiteral("Current application state")); + stateChoice->setObjectName(QStringLiteral("stateInfoChoice")); + auto *protocolChoice = infoChoice( + QStringLiteral("Protocol"), QStringLiteral("App-server protocol messages")); + protocolChoice->setObjectName(QStringLiteral("protocolInfoChoice")); + choicesLayout->addWidget(stateChoice); + choicesLayout->addWidget(protocolChoice); + choicesLayout->addStretch(); + + QPushButton *stateBack = nullptr; + QPushButton *protocolBack = nullptr; + infoStack->addWidget(choices); + infoStack->addWidget(infoDetail(QStringLiteral("State"), stateView, + &stateBack)); + infoStack->addWidget(infoDetail(QStringLiteral("Protocol"), protocolContent, + &protocolBack)); + connect(stateChoice, &QPushButton::clicked, this, [this] { + infoStack->setCurrentIndex(StatePage); + refreshCurrentTab(); + }); + connect(protocolChoice, &QPushButton::clicked, this, [this] { + infoStack->setCurrentIndex(ProtocolPage); + showProtocolTail(); + refreshCurrentTab(); + }); + const auto showInfoChoices = [this] { + infoStack->setCurrentIndex(InfoChoicePage); + }; + connect(stateBack, &QPushButton::clicked, this, showInfoChoices); + connect(protocolBack, &QPushButton::clicked, this, showInfoChoices); inspectorTabs->addTab(makeScroll(planContent), QStringLiteral("Plan")); inspectorTabs->addTab(makeScroll(agentsContent), QStringLiteral("Agents")); inspectorTabs->addTab(diffViewer, QStringLiteral("Changes")); inspectorTabs->addTab(makeScroll(requestsContent), QStringLiteral("Requests")); - inspectorTabs->addTab(infoTabs, QStringLiteral("Info")); + inspectorTabs->addTab(infoStack, QStringLiteral("Info")); outer->addWidget(inspectorTabs, 1); connect(inspectorTabs, &QTabWidget::currentChanged, this, [this](int) { refreshCurrentTab(); }); - connect(infoTabs, &QTabWidget::currentChanged, this, [this](int index) { - if (index == 1) - showProtocolTail(); - refreshCurrentTab(); - }); } void InspectorPane::setHideAction(std::function hide) { @@ -307,9 +395,9 @@ void InspectorPane::refreshCurrentTab() { refreshRequests(); break; case 4: - if (infoTabs->currentIndex() == 0) + if (infoStack->currentIndex() == StatePage) refreshState(); - else { + else if (infoStack->currentIndex() == ProtocolPage) { showProtocolTail(); refreshProtocolStats(); } @@ -374,9 +462,10 @@ void InspectorPane::refreshPlan() { for (const auto &step : planTurn->plan.value("steps", nlohmann::json::array())) { auto *row = new QFrame; - row->setProperty("kind", "summary"); + row->setProperty("kind", "raised"); auto *layout = new QVBoxLayout(row); - layout->setContentsMargins(9, 7, 9, 7); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); layout->addWidget(makeLabel(text(stringValue(step, "step")))); layout->addWidget( makeLabel(displayStatus(stringValue(step, "status")), "meta")); @@ -524,10 +613,10 @@ void InspectorPane::refreshRequests() { for (const auto &[id, request] : currentModel->pendingRequestPresentations()) { auto *frame = new QFrame; - frame->setProperty("kind", "summary"); + frame->setProperty("kind", "raised"); auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(9, 7, 9, 7); - layout->setSpacing(5); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); layout->addWidget(makeLabel(text(request.kind), "title")); QString threadContext = text(request.threadId); if (const ThreadPresentation *thread = diff --git a/src/greenfield/codex/middle/InspectorPane.h b/src/greenfield/codex/middle/InspectorPane.h index b50dc7a..870f709 100644 --- a/src/greenfield/codex/middle/InspectorPane.h +++ b/src/greenfield/codex/middle/InspectorPane.h @@ -16,6 +16,7 @@ class QLabel; class QPlainTextEdit; +class QStackedWidget; class QTabWidget; class QVBoxLayout; @@ -61,7 +62,7 @@ class InspectorPane final : public QFrame { std::function hideAction; QTabWidget *inspectorTabs = nullptr; - QTabWidget *infoTabs = nullptr; + QStackedWidget *infoStack = nullptr; QWidget *planContent = nullptr; QVBoxLayout *planLayout = nullptr; QWidget *agentsContent = nullptr; diff --git a/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/GreenfieldLayoutTest.cpp index c2b8c19..f38c281 100644 --- a/tests/codex/GreenfieldLayoutTest.cpp +++ b/tests/codex/GreenfieldLayoutTest.cpp @@ -20,8 +20,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -498,19 +500,21 @@ bool testInfoViewerLayout() { PresentationModel model; inspector.refresh(model, {}); inspector.tabs()->setCurrentIndex(4); - auto *infoTabs = - inspector.findChild(QStringLiteral("infoTabs")); + auto *infoStack = + inspector.findChild(QStringLiteral("infoStack")); + auto *protocolChoice = inspector.findChild( + QStringLiteral("protocolInfoChoice")); auto *protocol = inspector.findChild(QStringLiteral("protocolInfoLog")); auto *state = inspector.findChild(QStringLiteral("stateInfoView")); auto *statistics = inspector.findChild(QStringLiteral("protocolInfoStats")); - bool result = expect(infoTabs && protocol && state && statistics, - "Info tab exposes retained State and Protocol viewers"); - if (!infoTabs || !protocol || !state || !statistics) + bool result = expect(infoStack && protocolChoice && protocol && state && statistics, + "Info exposes State and Protocol through choice navigation"); + if (!infoStack || !protocolChoice || !protocol || !state || !statistics) return false; - infoTabs->setCurrentIndex(1); + protocolChoice->click(); inspector.appendProtocolFrame( {{"kind", "event"}, {"type", "conversation.item.upsert"}, @@ -567,13 +571,13 @@ bool testInfoViewerLayout() { result &= expect(protocolScroll->value() == pausedValue, "a visible Protocol append preserves a user-paused position"); - infoTabs->setCurrentIndex(0); + infoStack->setCurrentIndex(0); inspector.appendProtocolFrame({{"kind", "event"}, {"type", "protocol.test.hidden-append"}, {"sequence", 92}, {"generation", 1}, {"authority", "app-server"}}); - infoTabs->setCurrentIndex(1); + protocolChoice->click(); spin(20); result &= expect(protocolScroll->value() == pausedValue, diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 22025d7..94ae220 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -91,11 +91,20 @@ pauses when the user scrolls upward. ## Inspector -The Inspector contains Plan, Agents, Changes, Requests, and Info. Info contains -State and Protocol viewers. Both use application scrollbars. In Protocol, the -log expands above a statistics summary placed at the bottom. Plan, Agents, -Changes, and Requests retain their last visible per-thread presentation across -thread and tab navigation. +The Inspector contains the peer primary tabs Plan, Agents, Changes, Requests, +and Info. Primary tabs use the shared full-size application typography and are +never nested. Info presents State and Protocol as raised choice rows with +chevrons; selecting one drills into its viewer, with an explicit back action to +the choices. This expresses hierarchy through navigation rather than smaller +text. Both viewers use application scrollbars. In Protocol, the log expands +above a statistics summary placed at the bottom. + +Plan steps, agents, and pending requests are peer records and therefore use the +same raised card surface, border, radius, and internal spacing. Summary surfaces +are reserved for subordinate content within a record. Inspector scroll areas +are frameless and transparent so the panel background remains continuous. +Plan, Agents, Changes, and Requests retain their last visible per-thread +presentation across thread and tab navigation. ## Desktop integration From 42b1f0ee35455cf20d4b827d65ae79d1a8318e74 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Tue, 25 Aug 2026 23:19:08 +0200 Subject: [PATCH 05/36] Add libgit2 change review --- CMakeLists.txt | 10 +- README.md | 6 +- docs/codex-architecture.md | 58 +- docs/ui-behavior.md | 17 +- src/codex/DiffViewer.cpp | 657 ++++++++++++++---- src/codex/DiffViewer.h | 46 +- src/codex/GitDiffProvider.cpp | 269 +++++++ src/codex/GitDiffProvider.h | 61 ++ src/codex/ShellWidget.cpp | 46 +- src/codex/ui/UiStyle.cpp | 12 + src/greenfield/codex/middle/InspectorPane.cpp | 53 +- src/greenfield/codex/middle/InspectorPane.h | 1 - tests/codex/GreenfieldLayoutTest.cpp | 87 +++ ui-review/UX-DESIGN-DECISIONS.md | 11 +- 14 files changed, 1037 insertions(+), 297 deletions(-) create mode 100644 src/codex/GitDiffProvider.cpp create mode 100644 src/codex/GitDiffProvider.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 475d7ec..69e9622 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,8 @@ find_package( COMPONENTS net-rc-stream-legacy net-rc-stream-tls ) find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(PkgConfig REQUIRED) +pkg_check_modules(LIBGIT2 REQUIRED IMPORTED_TARGET libgit2) find_package(Threads REQUIRED) set( @@ -37,6 +39,8 @@ set( src/codex/DiffViewer.h src/codex/FrontendSession.cpp src/codex/FrontendSession.h + src/codex/GitDiffProvider.cpp + src/codex/GitDiffProvider.h src/codex/FileSelectionDialog.cpp src/codex/FileSelectionDialog.h src/codex/MainWindow.cpp @@ -111,6 +115,7 @@ function(configure_codexui_target target) ${target} PRIVATE AISuite::OpenAICodex + PkgConfig::LIBGIT2 Qt6::Widgets Threads::Threads snodec::net-un-stream-legacy @@ -266,6 +271,8 @@ if(BUILD_TESTING) tests/codex/GreenfieldLayoutTest.cpp src/codex/DiffViewer.cpp src/codex/DiffViewer.h + src/codex/GitDiffProvider.cpp + src/codex/GitDiffProvider.h src/codex/FileSelectionDialog.cpp src/codex/FileSelectionDialog.h src/codex/PresentationModel.cpp @@ -300,7 +307,8 @@ if(BUILD_TESTING) codexui-greenfield-layout-test BEFORE PRIVATE src/greenfield src ) target_link_libraries( - codexui-greenfield-layout-test PRIVATE AISuite::OpenAICodex Qt6::Widgets + codexui-greenfield-layout-test + PRIVATE AISuite::OpenAICodex PkgConfig::LIBGIT2 Qt6::Widgets ) add_test( NAME codexui-greenfield-layout diff --git a/README.md b/README.md index 480f8f2..84a7ad8 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,10 @@ consumer differs. ## Build -Qt 6 Widgets, Threads, SNode.C `master`/HEAD, and an installed canonical -AISuite package exporting `AISuite::OpenAICodex` are required. +Qt 6 Widgets, Threads, libgit2 development files (discoverable as `libgit2` +through pkg-config), SNode.C `master`/HEAD, and an installed canonical AISuite +package exporting `AISuite::OpenAICodex` are required. On Debian and Ubuntu, +the libgit2 package is `libgit2-dev`. ```sh cmake -S . -B "${BUILD_DIR}" -G Ninja \ diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index f55d269..6b53cfe 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -287,7 +287,7 @@ reduced result payloads are: - `threads.list`: `threads`, `nextCursor`, and `backwardsCursor`, with `merge`; - `thread.read`: returned `thread`, with `replace` when no newer presentation event arrived after the read began, otherwise `merge` so a late snapshot - cannot erase newer live Plan, Agent, command, or Changes detail; + cannot erase newer live Plan, Agent, command, or turn-diff domain detail; - `thread.create`, `thread.resume`, and `thread.fork`: returned `thread`, with `merge`; - `thread.rename`, `thread.archive`, `thread.unarchive`, and `thread.delete`: @@ -429,8 +429,8 @@ requests are changing must not stop, reset, or reorder those lifecycles. Selecting a thread hydrates it once per bridge connection, including when the thread-list projection already reports materialized or active turns. The `thread.read` result is merge-authoritative: it fills reconstruction data but -does not erase retained live-only Plan, Agent, or Changes details that the -provider omits. This explicit hydration state prevents a partial discovery +does not erase retained live-only Plan, Agent, or turn-diff domain details that +the provider omits. This explicit hydration state prevents a partial discovery projection from being mistaken for an operation-ready thread. Reload is the explicit forced fresh-read operation. @@ -504,17 +504,22 @@ bounded protocol and security design. ### 7.4 Changes and Diff Presentation -The Changes inspector uses a dedicated diff viewer. It prefers the latest -authoritative `turn.diff.changed` domain for the selected thread. When that -live domain is absent after reconstruction, it may display diffs explicitly -retained in app-server `fileChange` items and labels that source as a fallback. -It never invents a patch by comparing local files. - -Unified diffs are separated by file, counted for additions and deletions, and -rendered read-only with fixed-width text and addition, deletion, header, and -hunk highlighting. A user can select a file, copy its patch, or open an -expanded viewer. The file list and patch view retain canonical CodexUI sizing, -colors, controls, and scrollbars. +The Changes inspector is authoritative over the local Git worktree containing +the selected thread's working directory. It does not use app-server +`turn.diff.changed` or `fileChange` messages as review content. The provider +uses libgit2 in-process and asynchronously discovers the enclosing repository, +then exposes Unstaged, Staged, and Since HEAD scopes. Untracked content, +renames, copies, deletions, type changes, conflicts, and binary metadata come +from libgit2. A folder outside Git remains a valid Codex workspace, but its +Changes tab reports that review requires a repository. + +The Inspector contains a compact unified preview with stable file selection, +addition/deletion counts, Copy, Open review, and file-double-click review. The +modeless Change Review window remains usable beside the conversation and offers +Unified or Side by side layout plus Compact or Expanded context. Preferences +persist across threads. Repository collection runs outside the UI thread, +superseded results are discarded, and rendered diff content is bounded to 16 +MiB with an explicit truncation state. ### 7.5 Conversation Projection and Prompt Admission @@ -617,9 +622,9 @@ retained across in-place output updates. The Info tab's State and Protocol viewers use the same scrollbar styling and show vertical scrollbars only when required. The Protocol log owns the tab's expanding region and its statistics summary is placed below the log. Inspector -content is read from retained per-thread presentation snapshots; selecting an -already materialized thread does not temporarily clear Plan, Agents, Changes, -or Requests while unrelated frames are processed. +Plan, Agents, and Requests content is read from retained per-thread +presentation snapshots. Changes is instead refreshed from the selected +thread's local Git worktree and is independent of protocol-frame retention. ## 8. Plans and Agents @@ -941,12 +946,13 @@ The implementation is divided into the following concrete components: | `ConversationCard` implementations | In-place typed card presentation, including pending prompts and bounded Command execution output | | `PromptCoordinator` | Per-thread prompt admission queues, callback-only acknowledgment, and authoritative-item correlation | | `ComposerPane` | Bottom-anchored upcoming-turn controls, attachments, prompt editor, and overlay-height reporting | -| `InspectorPane` | Retained Plan, Agents, Changes, Requests, State, and Protocol presentation | +| `InspectorPane` | Retained Plan, Agents, Requests, State, and Protocol presentation plus selected-workspace Git review | | `TurnSettingsWidget` | Codex-native transient settings draft and native thread/turn option encoder | | `NewThreadDialog` | Transient native thread-start draft with workspace selection and instructions | | `FileSelectionDialog` | Canonical directory or bounded multi-file browser shared by workspace and attachments | | `ConnectionDialog` | Session-only selector over effective compiled SNode.C client configurations | -| `DiffViewer` | Authoritative live or retained-provider unified-diff presentation | +| `GitDiffProvider` | Asynchronous in-process libgit2 repository discovery and scoped diff snapshots | +| `DiffViewer` | Compact repository summary/preview and modeless unified or side-by-side review | | `PendingRequestDialog` | Typed, generation-preserving UI for app-server server-request families | | `MainWindow` | Top-level Qt window ownership only | | `BrandMark` and desktop resources | Shared visual mark and the consistent `codex-ui` executable/application/window/icon identity | @@ -1014,12 +1020,14 @@ normalizer, protocol, and presentation-model sources. Only the top-level Qt consumer differs. Neither executable has a privileged transport or state path. The current build links the codex AISuite frontend library as -`AISuite::OpenAICodex`, Qt Widgets, Threads, and the selected SNode.C client -modules. CodexUI CI consumes AISuite from `master`/HEAD and does not pin a -particular AISuite revision. The canonical AISuite change must therefore be -merged before the dependent CodexUI change. The AISuite dependency build is -limited to two compiler jobs because its generated protocol translation units -can otherwise exceed the hosted runner's aggregate memory. +`AISuite::OpenAICodex`, Qt Widgets, Threads, libgit2 through pkg-config, and the +selected SNode.C client modules. Git review is performed through libgit2; the +application never launches a Git process. CodexUI CI consumes AISuite from +`master`/HEAD and does not pin a particular AISuite revision. The canonical +AISuite change must therefore be merged before the dependent CodexUI change. +The AISuite dependency build is limited to two compiler jobs because its +generated protocol translation units can otherwise exceed the hosted runner's +aggregate memory. ### 17.4 Shell settings and pending-request APIs diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 45b4ac2..e5d5211 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -63,8 +63,8 @@ bottom or is owned by the user. another frontend never change the user's selected thread. - Selecting a thread hydrates it once per bridge connection even when the discovery result already contains an active turn. The full read is merged - into the retained per-thread presentation, so live Plan, Agents, and Changes - state cannot be erased by an incomplete reconstruction. Reload remains the + into the retained per-thread presentation, so live Plan and Agents state + cannot be erased by an incomplete reconstruction. Reload remains the explicit forced fresh-read action. ## Prompt submission and acknowledgment @@ -193,9 +193,16 @@ The State and Protocol viewers use the common CodexUI scrollbar styling and show vertical scrollbars only when needed. The Protocol log occupies the expanding area of its tab; protocol statistics are displayed below the log. Protocol and State data are diagnostic presentation only and do not create -domain authority. Plan, Agents, Changes, and Requests use retained per-thread -presentation snapshots, so revisiting a materialized thread does not clear or -flash those surfaces while unrelated frames arrive. +domain authority. Plan, Agents, and Requests use retained per-thread +presentation snapshots. Changes instead discovers the local Git worktree from +the selected thread's working directory and refreshes it asynchronously through +libgit2. It offers Unstaged, Staged, and Since HEAD scopes; a non-repository +folder shows an explanatory unavailable state without preventing normal work. + +The Inspector shows a compact unified preview. Open review and double-clicking +a changed file open a modeless review window with Unified or Side by side +layout and Compact or Expanded context. These view preferences persist across +threads. ## Desktop identity diff --git a/src/codex/DiffViewer.cpp b/src/codex/DiffViewer.cpp index cd8b360..d1e38cc 100644 --- a/src/codex/DiffViewer.cpp +++ b/src/codex/DiffViewer.cpp @@ -1,24 +1,55 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT #include "codex/DiffViewer.h" +#include "codex/ui/UiStyle.h" #include +#include #include +#include #include #include +#include #include #include #include #include #include #include +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include +#include + namespace codexui::codex { namespace { +constexpr int RepositoryRefreshDelayMs = 120; +constexpr int RepositoryPollingIntervalMs = 2000; + +class ChevronComboBox final : public QComboBox { +protected: + void paintEvent(QPaintEvent *event) override { + QComboBox::paintEvent(event); + QStyleOptionComboBox option; + initStyleOption(&option); + const QRect indicator = style()->subControlRect( + QStyle::CC_ComboBox, &option, QStyle::SC_ComboBoxArrow, this); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)); + } +}; + class DiffHighlighter final : public QSyntaxHighlighter { public: explicit DiffHighlighter(QTextDocument *document) @@ -41,7 +72,9 @@ class DiffHighlighter final : public QSyntaxHighlighter { format.setBackground(QColor(QStringLiteral("#fff0f2"))); } else if (text.startsWith(QStringLiteral("diff --git")) || text.startsWith(QStringLiteral("---")) || - text.startsWith(QStringLiteral("+++"))) { + text.startsWith(QStringLiteral("+++")) || + text.startsWith(QStringLiteral("Binary files")) || + text.startsWith(QStringLiteral("GIT binary patch"))) { format.setForeground(QColor(QStringLiteral("#344054"))); format.setFontWeight(QFont::DemiBold); } else { @@ -55,35 +88,366 @@ QLabel *label(QString value, const char *kind) { auto *result = new QLabel(std::move(value)); result->setProperty("kind", kind); result->setWordWrap(true); + result->setTextInteractionFlags(Qt::TextSelectableByMouse); return result; } -QString pathFromHeader(QString line) { - if (line.startsWith(QStringLiteral("+++ "))) - line.remove(0, 4); - line = line.section(QLatin1Char('\t'), 0, 0).trimmed(); - if (line.startsWith(QStringLiteral("b/"))) - line.remove(0, 2); - return line == QStringLiteral("/dev/null") ? QString{} : line; +QPlainTextEdit *diffView(const QString &objectName) { + auto *view = new QPlainTextEdit; + view->setObjectName(objectName); + view->setProperty("kind", "infoViewer"); + view->setReadOnly(true); + view->setLineWrapMode(QPlainTextEdit::NoWrap); + view->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + view->verticalScrollBar()->setProperty("kind", "infoViewer"); + new DiffHighlighter(view->document()); + return view; +} + +GitDiffScope scopeValue(const QComboBox *scope) { + return static_cast(scope->currentData().toInt()); +} + +QString scopeName(GitDiffScope scope) { + switch (scope) { + case GitDiffScope::Staged: + return QStringLiteral("Staged changes"); + case GitDiffScope::Uncommitted: + return QStringLiteral("All changes since HEAD"); + default: + return QStringLiteral("Unstaged changes"); + } +} + +QString fileTitle(const GitDiffFile &file) { + return !file.previousPath.isEmpty() && file.previousPath != file.path + ? QStringLiteral("%1 → %2").arg(file.previousPath, file.path) + : file.path; } -void countLines(const QString &content, int &additions, int &deletions) { - additions = 0; - deletions = 0; - const QStringList lines = content.split(QLatin1Char('\n')); - for (const QString &line : lines) { +QByteArray fingerprint(const GitDiffSnapshot &snapshot) { + QByteArray value = snapshot.repositoryRoot.toUtf8(); + value += '\0'; + value += snapshot.error.toUtf8(); + value += static_cast(snapshot.scope); + value += static_cast(snapshot.context); + value += snapshot.repository ? '\1' : '\0'; + value += snapshot.truncated ? '\1' : '\0'; + for (const GitDiffFile &file : snapshot.files) { + value += '\0'; + value += file.path.toUtf8(); + value += '\0'; + value += file.previousPath.toUtf8(); + value += '\0'; + value += file.status.toUtf8(); + value += '\0'; + value += file.patch.toUtf8(); + } + return QCryptographicHash::hash(value, QCryptographicHash::Sha256); +} + +struct SideBySideText { + QString left; + QString right; +}; + +QString sideLine(QChar marker, int number, const QString &content) { + return QStringLiteral("%1%2 │ %3") + .arg(marker) + .arg(number > 0 ? QString::number(number).rightJustified(6) + : QString(6, QLatin1Char(' '))) + .arg(content); +} + +SideBySideText sideBySide(const QString &patch) { + QStringList left; + QStringList right; + const QStringList lines = patch.split(QLatin1Char('\n')); + static const QRegularExpression hunk( + QStringLiteral(R"(^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@)")); + int oldLine = 0; + int newLine = 0; + for (qsizetype index = 0; index < lines.size();) { + const QString &line = lines[index]; + const QRegularExpressionMatch match = hunk.match(line); + if (match.hasMatch()) { + oldLine = match.captured(1).toInt(); + newLine = match.captured(2).toInt(); + left << line; + right << line; + ++index; + continue; + } + if (line.startsWith(QLatin1Char('-')) && + !line.startsWith(QStringLiteral("---"))) { + QStringList removed; + QStringList added; + while (index < lines.size() && + lines[index].startsWith(QLatin1Char('-')) && + !lines[index].startsWith(QStringLiteral("---"))) + removed << lines[index++].mid(1); + while (index < lines.size() && + lines[index].startsWith(QLatin1Char('+')) && + !lines[index].startsWith(QStringLiteral("+++"))) + added << lines[index++].mid(1); + const qsizetype count = std::max(removed.size(), added.size()); + for (qsizetype row = 0; row < count; ++row) { + const bool hasOld = row < removed.size(); + const bool hasNew = row < added.size(); + left << sideLine(hasOld ? QLatin1Char('-') : QLatin1Char(' '), + hasOld ? oldLine++ : 0, + hasOld ? removed[row] : QString{}); + right << sideLine(hasNew ? QLatin1Char('+') : QLatin1Char(' '), + hasNew ? newLine++ : 0, + hasNew ? added[row] : QString{}); + } + continue; + } if (line.startsWith(QLatin1Char('+')) && - !line.startsWith(QStringLiteral("+++"))) - ++additions; - else if (line.startsWith(QLatin1Char('-')) && - !line.startsWith(QStringLiteral("---"))) - ++deletions; + !line.startsWith(QStringLiteral("+++"))) { + left << sideLine(QLatin1Char(' '), 0, {}); + right << sideLine(QLatin1Char('+'), newLine++, line.mid(1)); + } else if (line.startsWith(QLatin1Char(' ')) && oldLine > 0 && + newLine > 0) { + left << sideLine(QLatin1Char(' '), oldLine++, line.mid(1)); + right << sideLine(QLatin1Char(' '), newLine++, line.mid(1)); + } else { + left << line; + right << line; + } + ++index; } + return {left.join(QLatin1Char('\n')), right.join(QLatin1Char('\n'))}; +} + +QPushButton *modeButton(const QString &text) { + auto *button = new QPushButton(text); + button->setCheckable(true); + button->setProperty("kind", "segment"); + button->setFixedHeight(30); + return button; } } // namespace +class GitDiffReviewWindow final : public QDialog { +public: + explicit GitDiffReviewWindow(QWidget *parent = nullptr) : QDialog(parent) { + setWindowTitle(QStringLiteral("Change Review")); + setAttribute(Qt::WA_DeleteOnClose); + setWindowModality(Qt::NonModal); + resize(1200, 780); + provider = new GitDiffProvider(this); + repositoryTimer = new QTimer(this); + repositoryTimer->setInterval(RepositoryPollingIntervalMs); + repositoryTimer->start(); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(14, 14, 14, 14); + root->setSpacing(10); + auto *header = new QHBoxLayout; + title = label(QStringLiteral("Change Review"), "heading"); + subtitle = label({}, "meta"); + auto *titles = new QVBoxLayout; + titles->setSpacing(1); + titles->addWidget(title); + titles->addWidget(subtitle); + header->addLayout(titles, 1); + + unified = modeButton(QStringLiteral("Unified")); + split = modeButton(QStringLiteral("Side by side")); + auto *layoutModes = new QButtonGroup(this); + layoutModes->setExclusive(true); + layoutModes->addButton(unified); + layoutModes->addButton(split); + header->addWidget(unified); + header->addWidget(split); + header->addSpacing(8); + compact = modeButton(QStringLiteral("Compact")); + expanded = modeButton(QStringLiteral("Expanded")); + auto *contextModes = new QButtonGroup(this); + contextModes->setExclusive(true); + contextModes->addButton(compact); + contextModes->addButton(expanded); + header->addWidget(compact); + header->addWidget(expanded); + root->addLayout(header); + + auto *body = new QSplitter; + reviewFiles = new QListWidget; + reviewFiles->setMinimumWidth(230); + reviewFiles->setMaximumWidth(420); + body->addWidget(reviewFiles); + views = new QStackedWidget; + unifiedView = diffView(QStringLiteral("codexReviewUnified")); + views->addWidget(unifiedView); + auto *sides = new QSplitter; + leftView = diffView(QStringLiteral("codexReviewBefore")); + rightView = diffView(QStringLiteral("codexReviewAfter")); + sides->addWidget(leftView); + sides->addWidget(rightView); + sides->setSizes({600, 600}); + views->addWidget(sides); + body->addWidget(views); + body->setStretchFactor(1, 1); + root->addWidget(body, 1); + + connect(reviewFiles, &QListWidget::currentRowChanged, this, + [this] { renderSelected(); }); + connect(unified, &QPushButton::clicked, this, [this] { + views->setCurrentIndex(0); + QSettings().setValue(QStringLiteral("diff/layout"), + QStringLiteral("unified")); + renderSelected(); + }); + connect(split, &QPushButton::clicked, this, [this] { + views->setCurrentIndex(1); + QSettings().setValue(QStringLiteral("diff/layout"), + QStringLiteral("side-by-side")); + renderSelected(); + }); + connect(compact, &QPushButton::clicked, this, [this] { + context = GitDiffContext::Compact; + QSettings().setValue(QStringLiteral("diff/context"), + QStringLiteral("compact")); + reload(); + }); + connect(expanded, &QPushButton::clicked, this, [this] { + context = GitDiffContext::Expanded; + QSettings().setValue(QStringLiteral("diff/context"), + QStringLiteral("expanded")); + reload(); + }); + connect(provider, &GitDiffProvider::loadingChanged, this, [this](bool value) { + if (value && snapshot.files.empty()) + subtitle->setText(QStringLiteral("Loading repository changes…")); + }); + connect(provider, &GitDiffProvider::snapshotReady, this, + [this](const GitDiffSnapshot &value) { apply(value); }); + connect(repositoryTimer, &QTimer::timeout, this, [this] { + if (isVisible()) + reload(); + }); + connect(leftView->verticalScrollBar(), &QScrollBar::valueChanged, + rightView->verticalScrollBar(), &QScrollBar::setValue); + connect(rightView->verticalScrollBar(), &QScrollBar::valueChanged, + leftView->verticalScrollBar(), &QScrollBar::setValue); + connect(leftView->horizontalScrollBar(), &QScrollBar::valueChanged, + rightView->horizontalScrollBar(), &QScrollBar::setValue); + connect(rightView->horizontalScrollBar(), &QScrollBar::valueChanged, + leftView->horizontalScrollBar(), &QScrollBar::setValue); + + const QSettings settings; + const bool side = settings.value(QStringLiteral("diff/layout"), + QStringLiteral("unified")) == + QStringLiteral("side-by-side"); + unified->setChecked(!side); + split->setChecked(side); + views->setCurrentIndex(side ? 1 : 0); + const bool full = settings.value(QStringLiteral("diff/context"), + QStringLiteral("compact")) == + QStringLiteral("expanded"); + compact->setChecked(!full); + expanded->setChecked(full); + context = full ? GitDiffContext::Expanded : GitDiffContext::Compact; + } + + void setSource(QString nextWorkspace, GitDiffScope nextScope, + QString preferredPath) { + workspace = std::move(nextWorkspace); + scope = nextScope; + requestedPath = std::move(preferredPath); + reload(); + } + +private: + void reload() { provider->request(workspace, scope, context); } + + void apply(const GitDiffSnapshot &value) { + const QByteArray nextFingerprint = fingerprint(value); + if (nextFingerprint == snapshotFingerprint) + return; + snapshotFingerprint = nextFingerprint; + snapshot = value; + subtitle->setText(value.error.isEmpty() + ? QStringLiteral("%1 | %2") + .arg(scopeName(value.scope), + value.repositoryRoot) + : value.error); + reviewFiles->clear(); + int selected = -1; + for (std::size_t index = 0; index < value.files.size(); ++index) { + const GitDiffFile &file = value.files[index]; + auto *item = new QListWidgetItem( + QStringLiteral("%1\n%2 +%3 −%4") + .arg(fileTitle(file), file.status) + .arg(file.additions) + .arg(file.deletions)); + item->setToolTip(file.path); + reviewFiles->addItem(item); + if (file.path == requestedPath) + selected = static_cast(index); + } + if (!value.files.empty()) + reviewFiles->setCurrentRow(selected >= 0 ? selected : 0); + else { + unifiedView->setPlainText(value.error.isEmpty() + ? QStringLiteral("No file changes") + : value.error); + leftView->clear(); + rightView->clear(); + } + } + + void renderSelected() { + const int index = reviewFiles->currentRow(); + if (index < 0 || static_cast(index) >= snapshot.files.size()) + return; + const GitDiffFile &file = snapshot.files[static_cast(index)]; + requestedPath = file.path; + title->setText(fileTitle(file)); + const QString content = file.patch.isEmpty() + ? QStringLiteral("No textual patch is available for this file.") + : file.patch; + unifiedView->setPlainText(content); + unifiedView->moveCursor(QTextCursor::Start); + const SideBySideText sides = sideBySide(content); + leftView->setPlainText(sides.left); + rightView->setPlainText(sides.right); + leftView->moveCursor(QTextCursor::Start); + rightView->moveCursor(QTextCursor::Start); + } + + GitDiffProvider *provider = nullptr; + GitDiffSnapshot snapshot; + QString workspace; + QString requestedPath; + GitDiffScope scope = GitDiffScope::Unstaged; + GitDiffContext context = GitDiffContext::Compact; + QByteArray snapshotFingerprint; + QLabel *title = nullptr; + QLabel *subtitle = nullptr; + QListWidget *reviewFiles = nullptr; + QStackedWidget *views = nullptr; + QPlainTextEdit *unifiedView = nullptr; + QPlainTextEdit *leftView = nullptr; + QPlainTextEdit *rightView = nullptr; + QPushButton *unified = nullptr; + QPushButton *split = nullptr; + QPushButton *compact = nullptr; + QPushButton *expanded = nullptr; + QTimer *repositoryTimer = nullptr; +}; + DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { + provider = new GitDiffProvider(this); + refreshTimer = new QTimer(this); + refreshTimer->setSingleShot(true); + refreshTimer->setInterval(RepositoryRefreshDelayMs); + repositoryTimer = new QTimer(this); + repositoryTimer->setInterval(RepositoryPollingIntervalMs); + repositoryTimer->start(); + auto *root = new QVBoxLayout(this); root->setContentsMargins(10, 10, 10, 10); root->setSpacing(8); @@ -95,171 +459,180 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { headerText->addWidget(summary); headerText->addWidget(authority); header->addLayout(headerText, 1); - copyButton = new QPushButton(QStringLiteral("Copy")); - copyButton->setProperty("kind", "subtle"); - copyButton->setFixedHeight(28); - expandButton = new QPushButton(QStringLiteral("Expand")); - expandButton->setFixedHeight(28); - header->addWidget(copyButton); - header->addWidget(expandButton); + scope = new ChevronComboBox; + scope->setObjectName(QStringLiteral("codexDiffScope")); + scope->setProperty("codexChevron", true); + scope->addItem(QStringLiteral("Unstaged"), + static_cast(GitDiffScope::Unstaged)); + scope->addItem(QStringLiteral("Staged"), + static_cast(GitDiffScope::Staged)); + scope->addItem(QStringLiteral("Since HEAD"), + static_cast(GitDiffScope::Uncommitted)); + scope->setFixedHeight(30); + const int savedScope = + QSettings().value(QStringLiteral("diff/scope"), 0).toInt(); + scope->setCurrentIndex(std::clamp(savedScope, 0, scope->count() - 1)); + header->addWidget(scope); root->addLayout(header); files = new QListWidget; files->setObjectName(QStringLiteral("codexDiffFiles")); files->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - files->setMaximumHeight(150); + files->setMaximumHeight(170); root->addWidget(files); - diff = new QPlainTextEdit; - diff->setObjectName(QStringLiteral("codexDiffText")); - diff->setReadOnly(true); - diff->setLineWrapMode(QPlainTextEdit::NoWrap); - diff->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + auto *previewHeader = new QHBoxLayout; + selectedFile = label(QStringLiteral("Select a changed file"), "title"); + previewHeader->addWidget(selectedFile, 1); + copyButton = new QPushButton(QStringLiteral("Copy")); + copyButton->setProperty("kind", "subtle"); + copyButton->setFixedHeight(28); + reviewButton = new QPushButton(QStringLiteral("Open review")); + reviewButton->setFixedHeight(28); + previewHeader->addWidget(copyButton); + previewHeader->addWidget(reviewButton); + root->addLayout(previewHeader); + + diff = diffView(QStringLiteral("codexDiffText")); diff->setPlaceholderText(QStringLiteral("Select a changed file.")); - new DiffHighlighter(diff->document()); root->addWidget(diff, 1); + connect(refreshTimer, &QTimer::timeout, this, [this] { + provider->request(workspace, scopeValue(scope), GitDiffContext::Compact); + }); + connect(provider, &GitDiffProvider::loadingChanged, this, [this](bool loading) { + if (loading && snapshot.files.empty() && snapshot.error.isEmpty()) { + summary->setText(QStringLiteral("Loading changes…")); + } + }); + connect(provider, &GitDiffProvider::snapshotReady, this, + [this](const GitDiffSnapshot &value) { applySnapshot(value); }); + connect(repositoryTimer, &QTimer::timeout, this, [this] { + if (isVisible()) + provider->request(workspace, scopeValue(scope), GitDiffContext::Compact); + }); + connect(scope, &QComboBox::currentIndexChanged, this, [this](int index) { + QSettings().setValue(QStringLiteral("diff/scope"), index); + refreshRepository(); + }); connect(files, &QListWidget::currentRowChanged, this, [this] { showSelectedFile(); }); + connect(files, &QListWidget::itemDoubleClicked, this, + [this](QListWidgetItem *) { openReview(); }); connect(copyButton, &QPushButton::clicked, this, [this] { if (!diff->toPlainText().isEmpty()) QApplication::clipboard()->setText(diff->toPlainText()); }); - connect(expandButton, &QPushButton::clicked, this, - [this] { showExpanded(); }); + connect(reviewButton, &QPushButton::clicked, this, + [this] { openReview(); }); copyButton->setEnabled(false); - expandButton->setEnabled(false); + reviewButton->setEnabled(false); } -void DiffViewer::setChanges(QString liveDiff, - std::vector retainedChanges) { - QByteArray fingerprintInput = liveDiff.toUtf8(); - for (const DiffFilePresentation &change : retainedChanges) { - fingerprintInput += '\0'; - fingerprintInput += change.path.toUtf8(); - fingerprintInput += '\0'; - fingerprintInput += change.kind.toUtf8(); - fingerprintInput += '\0'; - fingerprintInput += change.diff.toUtf8(); - } - const QByteArray fingerprint = - QCryptographicHash::hash(fingerprintInput, QCryptographicHash::Sha256); - if (fingerprint == contentFingerprint) +void DiffViewer::setWorkspace(QString nextWorkspace) { + nextWorkspace = QDir::cleanPath(std::move(nextWorkspace)); + if (workspace == nextWorkspace) return; - contentFingerprint = fingerprint; - - const bool live = !liveDiff.isEmpty(); - fileDiffs = live ? parseUnifiedDiff(liveDiff) : std::vector{}; - if (!live) { - fileDiffs.reserve(retainedChanges.size()); - for (DiffFilePresentation &change : retainedChanges) { - FileDiff file{std::move(change.path), std::move(change.kind), - std::move(change.diff)}; - countLines(file.content, file.additions, file.deletions); - fileDiffs.push_back(std::move(file)); - } - } + workspace = std::move(nextWorkspace); + snapshot = {}; + snapshotFingerprint.clear(); + files->clear(); + diff->clear(); + refreshRepository(); +} + +void DiffViewer::refreshRepository() { + refreshTimer->start(); + if (reviewWindow) + reviewWindow->setSource(workspace, scopeValue(scope), selectedPath()); +} +QString DiffViewer::selectedPath() const { + const int index = files->currentRow(); + return index >= 0 && static_cast(index) < snapshot.files.size() + ? snapshot.files[static_cast(index)].path + : QString{}; +} + +void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { + const QByteArray nextFingerprint = fingerprint(value); + if (nextFingerprint == snapshotFingerprint) + return; + snapshotFingerprint = nextFingerprint; + const QString previous = selectedPath(); + const int previousScroll = diff->verticalScrollBar()->value(); + snapshot = value; files->clear(); int additions = 0; int deletions = 0; - for (const FileDiff &file : fileDiffs) { + int selected = -1; + for (std::size_t index = 0; index < value.files.size(); ++index) { + const GitDiffFile &file = value.files[index]; additions += file.additions; deletions += file.deletions; - const QString path = - file.path.isEmpty() ? QStringLiteral("Turn diff") : file.path; - auto *item = new QListWidgetItem(QStringLiteral("%1 +%2 -%3") - .arg(path) - .arg(file.additions) - .arg(file.deletions)); - item->setToolTip(path); + auto *item = new QListWidgetItem( + QStringLiteral("%1 %2 +%3 −%4") + .arg(fileTitle(file), file.status) + .arg(file.additions) + .arg(file.deletions)); + item->setToolTip(file.path); files->addItem(item); + if (file.path == previous) + selected = static_cast(index); } - summary->setText(fileDiffs.empty() ? QStringLiteral("No file changes") - : QStringLiteral("%1 files +%2 -%3") - .arg(fileDiffs.size()) - .arg(additions) - .arg(deletions)); - authority->setText( - fileDiffs.empty() ? QString{} - : live ? QStringLiteral("Authoritative live turn diff") - : QStringLiteral("Reconstructed from retained file-change items")); - if (!fileDiffs.empty()) - files->setCurrentRow(0); - else - diff->clear(); - copyButton->setEnabled(!fileDiffs.empty()); - expandButton->setEnabled(!fileDiffs.empty()); -} - -std::vector -DiffViewer::parseUnifiedDiff(const QString &diff) { - std::vector result; - FileDiff current; - const auto flush = [&] { - if (current.content.isEmpty()) - return; - countLines(current.content, current.additions, current.deletions); - result.push_back(std::move(current)); - current = FileDiff{}; - }; - const QStringList lines = diff.split(QLatin1Char('\n')); - for (const QString &line : lines) { - if (line.startsWith(QStringLiteral("diff --git ")) && - !current.content.isEmpty()) - flush(); - if (line.startsWith(QStringLiteral("+++ "))) { - const QString path = pathFromHeader(line); - if (!path.isEmpty()) - current.path = path; - } - current.content += line; - current.content += QLatin1Char('\n'); + if (!value.error.isEmpty()) { + summary->setText(QStringLiteral("Changes unavailable")); + authority->setText(value.error); + } else { + summary->setText(value.files.empty() + ? QStringLiteral("No file changes") + : QStringLiteral("%1 files +%2 −%3") + .arg(value.files.size()) + .arg(additions) + .arg(deletions)); + authority->setText( + value.truncated + ? QStringLiteral("%1 | display truncated | %2") + .arg(scopeName(value.scope), value.repositoryRoot) + : QStringLiteral("%1 | %2") + .arg(scopeName(value.scope), value.repositoryRoot)); } - flush(); - if (result.empty() && !diff.isEmpty()) { - FileDiff file{QStringLiteral("Turn diff"), {}, diff}; - countLines(file.content, file.additions, file.deletions); - result.push_back(std::move(file)); + if (!value.files.empty()) { + files->setCurrentRow(selected >= 0 ? selected : 0); + if (selected >= 0) + diff->verticalScrollBar()->setValue(previousScroll); + } else { + selectedFile->setText(QStringLiteral("Select a changed file")); + diff->setPlainText(value.error); } - return result; + copyButton->setEnabled(!value.files.empty()); + reviewButton->setEnabled(!value.files.empty()); } void DiffViewer::showSelectedFile() { const int index = files->currentRow(); - if (index < 0 || static_cast(index) >= fileDiffs.size()) { + if (index < 0 || static_cast(index) >= snapshot.files.size()) { + selectedFile->setText(QStringLiteral("Select a changed file")); diff->clear(); return; } - diff->setPlainText(fileDiffs[static_cast(index)].content); + const GitDiffFile &file = snapshot.files[static_cast(index)]; + selectedFile->setText(fileTitle(file)); + diff->setPlainText(file.patch.isEmpty() + ? QStringLiteral("No textual patch is available for this file.") + : file.patch); diff->moveCursor(QTextCursor::Start); } -void DiffViewer::showExpanded() { - const int index = files->currentRow(); - if (index < 0 || static_cast(index) >= fileDiffs.size()) +void DiffViewer::openReview() { + if (selectedPath().isEmpty()) return; - const FileDiff &file = fileDiffs[static_cast(index)]; - QDialog dialog(this); - dialog.setWindowTitle(file.path.isEmpty() ? QStringLiteral("Turn diff") - : file.path); - dialog.resize(1100, 760); - auto *layout = new QVBoxLayout(&dialog); - layout->setContentsMargins(16, 16, 16, 16); - auto *view = new QPlainTextEdit(file.content); - view->setReadOnly(true); - view->setLineWrapMode(QPlainTextEdit::NoWrap); - view->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - new DiffHighlighter(view->document()); - layout->addWidget(view, 1); - auto *close = new QPushButton(QStringLiteral("Close")); - close->setFixedHeight(34); - auto *footer = new QHBoxLayout; - footer->addStretch(); - footer->addWidget(close); - layout->addLayout(footer); - connect(close, &QPushButton::clicked, &dialog, &QDialog::accept); - dialog.exec(); + if (!reviewWindow) + reviewWindow = new GitDiffReviewWindow(window()); + reviewWindow->setSource(workspace, scopeValue(scope), selectedPath()); + reviewWindow->show(); + reviewWindow->raise(); + reviewWindow->activateWindow(); } } // namespace codexui::codex diff --git a/src/codex/DiffViewer.h b/src/codex/DiffViewer.h index 1d14686..73e65da 100644 --- a/src/codex/DiffViewer.h +++ b/src/codex/DiffViewer.h @@ -3,52 +3,50 @@ #ifndef CODEXUI_CODEX_DIFFVIEWER_H #define CODEXUI_CODEX_DIFFVIEWER_H -#include -#include +#include "codex/GitDiffProvider.h" -#include +#include +#include +class QComboBox; class QLabel; class QListWidget; class QPlainTextEdit; class QPushButton; +class QTimer; namespace codexui::codex { -struct DiffFilePresentation { - QString path; - QString kind; - QString diff; -}; +class GitDiffReviewWindow; class DiffViewer final : public QWidget { public: explicit DiffViewer(QWidget *parent = nullptr); - void setChanges(QString liveDiff, - std::vector retainedChanges); + void setWorkspace(QString workspace); + void refreshRepository(); private: - struct FileDiff { - QString path; - QString kind; - QString content; - int additions = 0; - int deletions = 0; - }; - - static std::vector parseUnifiedDiff(const QString &diff); + void applySnapshot(const GitDiffSnapshot &snapshot); void showSelectedFile(); - void showExpanded(); - + void openReview(); + [[nodiscard]] QString selectedPath() const; + + GitDiffProvider *provider = nullptr; + QTimer *refreshTimer = nullptr; + QTimer *repositoryTimer = nullptr; + QString workspace; + GitDiffSnapshot snapshot; + QByteArray snapshotFingerprint; + QComboBox *scope = nullptr; QLabel *summary = nullptr; QLabel *authority = nullptr; + QLabel *selectedFile = nullptr; QListWidget *files = nullptr; QPlainTextEdit *diff = nullptr; QPushButton *copyButton = nullptr; - QPushButton *expandButton = nullptr; - std::vector fileDiffs; - QByteArray contentFingerprint; + QPushButton *reviewButton = nullptr; + QPointer reviewWindow; }; } // namespace codexui::codex diff --git a/src/codex/GitDiffProvider.cpp b/src/codex/GitDiffProvider.cpp new file mode 100644 index 0000000..4270bf8 --- /dev/null +++ b/src/codex/GitDiffProvider.cpp @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/GitDiffProvider.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +constexpr std::size_t MaximumDiffBytes = 16U * 1024U * 1024U; + +void ensureLibGit() { + static std::once_flag initialized; + std::call_once(initialized, [] { git_libgit2_init(); }); +} + +template +using GitPointer = std::unique_ptr; + +QString gitError(const QString &fallback) { + const git_error *error = git_error_last(); + return error && error->message + ? QString::fromUtf8(error->message) + : fallback; +} + +QString text(const char *value) { + return value ? QString::fromUtf8(value) : QString{}; +} + +QString statusName(git_delta_t status) { + switch (status) { + case GIT_DELTA_ADDED: + return QStringLiteral("Added"); + case GIT_DELTA_DELETED: + return QStringLiteral("Deleted"); + case GIT_DELTA_RENAMED: + return QStringLiteral("Renamed"); + case GIT_DELTA_COPIED: + return QStringLiteral("Copied"); + case GIT_DELTA_UNTRACKED: + return QStringLiteral("Untracked"); + case GIT_DELTA_TYPECHANGE: + return QStringLiteral("Type changed"); + case GIT_DELTA_UNREADABLE: + return QStringLiteral("Unreadable"); + case GIT_DELTA_CONFLICTED: + return QStringLiteral("Conflict"); + default: + return QStringLiteral("Modified"); + } +} + +GitPointer headTree(git_repository *repository, + QString &error) { + git_reference *rawReference = nullptr; + const int headResult = git_repository_head(&rawReference, repository); + GitPointer reference(rawReference, + git_reference_free); + if (headResult == GIT_EUNBORNBRANCH || headResult == GIT_ENOTFOUND) + return {nullptr, git_tree_free}; + if (headResult < 0) { + error = gitError(QStringLiteral("Unable to read repository HEAD.")); + return {nullptr, git_tree_free}; + } + git_object *rawObject = nullptr; + if (git_reference_peel(&rawObject, reference.get(), GIT_OBJECT_TREE) < 0) { + error = gitError(QStringLiteral("Unable to read the HEAD tree.")); + return {nullptr, git_tree_free}; + } + return {reinterpret_cast(rawObject), git_tree_free}; +} + +GitDiffSnapshot collect(QString workspace, GitDiffScope scope, + GitDiffContext context, + const std::shared_ptr> &clock, + std::uint64_t generation) { + GitDiffSnapshot snapshot; + snapshot.workspace = QDir::cleanPath(std::move(workspace)); + snapshot.scope = scope; + snapshot.context = context; + if (snapshot.workspace.isEmpty()) { + snapshot.error = QStringLiteral("Select a thread to inspect changes."); + return snapshot; + } + + git_buf discovered = GIT_BUF_INIT; + const QByteArray start = QFile::encodeName(snapshot.workspace); + if (git_repository_discover(&discovered, start.constData(), 0, nullptr) < 0) { + snapshot.error = QStringLiteral("Change review requires a Git repository."); + git_buf_dispose(&discovered); + return snapshot; + } + const QString repositoryPath = + QDir::cleanPath(QString::fromUtf8(discovered.ptr, + static_cast(discovered.size))); + git_repository *rawRepository = nullptr; + const QByteArray encodedRepository = QFile::encodeName(repositoryPath); + const int openResult = + git_repository_open(&rawRepository, encodedRepository.constData()); + git_buf_dispose(&discovered); + if (openResult < 0) { + snapshot.error = gitError(QStringLiteral("Unable to open Git repository.")); + return snapshot; + } + GitPointer repository(rawRepository, + git_repository_free); + snapshot.repository = true; + snapshot.repositoryRoot = + QDir::cleanPath(text(git_repository_workdir(repository.get()))); + if (git_repository_is_bare(repository.get())) { + snapshot.error = QStringLiteral("Change review requires a working tree."); + return snapshot; + } + + if (clock->load() != generation) + return snapshot; + + git_diff_options options = GIT_DIFF_OPTIONS_INIT; + options.flags = GIT_DIFF_INCLUDE_UNTRACKED | + GIT_DIFF_RECURSE_UNTRACKED_DIRS | + GIT_DIFF_SHOW_UNTRACKED_CONTENT | + GIT_DIFF_INCLUDE_TYPECHANGE | + GIT_DIFF_INCLUDE_TYPECHANGE_TREES | + GIT_DIFF_INCLUDE_UNREADABLE; + options.context_lines = + context == GitDiffContext::Compact + ? 3 + : std::numeric_limits::max(); + options.interhunk_lines = context == GitDiffContext::Compact ? 0 : 3; + + QString treeError; + GitPointer tree = + headTree(repository.get(), treeError); + if (!treeError.isEmpty()) { + snapshot.error = treeError; + return snapshot; + } + + git_diff *rawDiff = nullptr; + int diffResult = 0; + if (scope == GitDiffScope::Unstaged) { + diffResult = git_diff_index_to_workdir(&rawDiff, repository.get(), nullptr, + &options); + } else if (scope == GitDiffScope::Staged) { + diffResult = git_diff_tree_to_index(&rawDiff, repository.get(), tree.get(), + nullptr, &options); + } else { + diffResult = git_diff_tree_to_workdir_with_index( + &rawDiff, repository.get(), tree.get(), &options); + } + if (diffResult < 0) { + snapshot.error = gitError(QStringLiteral("Unable to calculate Git changes.")); + return snapshot; + } + GitPointer diff(rawDiff, git_diff_free); + + git_diff_find_options findOptions = GIT_DIFF_FIND_OPTIONS_INIT; + findOptions.flags = GIT_DIFF_FIND_RENAMES | GIT_DIFF_FIND_COPIES | + GIT_DIFF_FIND_FOR_UNTRACKED; + git_diff_find_similar(diff.get(), &findOptions); + + std::size_t retainedBytes = 0; + const std::size_t count = git_diff_num_deltas(diff.get()); + snapshot.files.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + if (clock->load() != generation) + return snapshot; + const git_diff_delta *delta = git_diff_get_delta(diff.get(), index); + if (!delta || delta->status == GIT_DELTA_UNMODIFIED || + delta->status == GIT_DELTA_IGNORED) + continue; + GitDiffFile file; + file.path = text(delta->new_file.path); + if (file.path.isEmpty()) + file.path = text(delta->old_file.path); + file.previousPath = text(delta->old_file.path); + if (file.previousPath == file.path) + file.previousPath.clear(); + file.status = statusName(delta->status); + file.binary = (delta->flags & GIT_DIFF_FLAG_BINARY) != 0; + + git_patch *rawPatch = nullptr; + const int patchResult = git_patch_from_diff(&rawPatch, diff.get(), index); + GitPointer patch(rawPatch, git_patch_free); + if (patchResult == 0 && patch) { + std::size_t additions = 0; + std::size_t deletions = 0; + git_patch_line_stats(nullptr, &additions, &deletions, patch.get()); + file.additions = static_cast(std::min( + additions, static_cast(std::numeric_limits::max()))); + file.deletions = static_cast(std::min( + deletions, static_cast(std::numeric_limits::max()))); + git_buf rendered = GIT_BUF_INIT; + if (git_patch_to_buf(&rendered, patch.get()) == 0) { + if (retainedBytes + rendered.size <= MaximumDiffBytes) { + file.patch = QString::fromUtf8( + rendered.ptr, static_cast(rendered.size)); + retainedBytes += rendered.size; + } else { + snapshot.truncated = true; + file.patch = QStringLiteral( + "Diff omitted because the review exceeds the 16 MiB display limit."); + } + } + git_buf_dispose(&rendered); + } + snapshot.files.push_back(std::move(file)); + } + std::sort(snapshot.files.begin(), snapshot.files.end(), + [](const GitDiffFile &left, const GitDiffFile &right) { + return QString::localeAwareCompare(left.path, right.path) < 0; + }); + return snapshot; +} + +} // namespace + +GitDiffProvider::GitDiffProvider(QObject *parent) + : QObject(parent), + generation(std::make_shared>(0)) { + ensureLibGit(); +} + +GitDiffProvider::~GitDiffProvider() { cancel(); } + +void GitDiffProvider::cancel() { + generation->fetch_add(1); + emit loadingChanged(false); +} + +void GitDiffProvider::request(QString workspace, GitDiffScope scope, + GitDiffContext context) { + const std::uint64_t requested = generation->fetch_add(1) + 1; + const auto clock = generation; + const QPointer receiver(this); + emit loadingChanged(true); + QThreadPool::globalInstance()->start( + [receiver, clock, requested, workspace = std::move(workspace), scope, + context]() mutable { + GitDiffSnapshot snapshot = + collect(std::move(workspace), scope, context, clock, requested); + if (clock->load() != requested) + return; + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [receiver, clock, requested, snapshot = std::move(snapshot)]() { + if (!receiver || clock->load() != requested) + return; + emit receiver->loadingChanged(false); + emit receiver->snapshotReady(snapshot); + }, + Qt::QueuedConnection); + }); +} + +} // namespace codexui::codex diff --git a/src/codex/GitDiffProvider.h b/src/codex/GitDiffProvider.h new file mode 100644 index 0000000..1250438 --- /dev/null +++ b/src/codex/GitDiffProvider.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_GITDIFFPROVIDER_H +#define CODEXUI_CODEX_GITDIFFPROVIDER_H + +#include +#include + +#include +#include +#include +#include + +namespace codexui::codex { + +enum class GitDiffScope { Unstaged, Staged, Uncommitted }; +enum class GitDiffContext { Compact, Expanded }; + +struct GitDiffFile { + QString path; + QString previousPath; + QString status; + QString patch; + int additions = 0; + int deletions = 0; + bool binary = false; +}; + +struct GitDiffSnapshot { + QString workspace; + QString repositoryRoot; + QString error; + GitDiffScope scope = GitDiffScope::Unstaged; + GitDiffContext context = GitDiffContext::Compact; + std::vector files; + bool repository = false; + bool truncated = false; +}; + +class GitDiffProvider final : public QObject { + Q_OBJECT + +public: + explicit GitDiffProvider(QObject *parent = nullptr); + ~GitDiffProvider() override; + + void request(QString workspace, GitDiffScope scope, + GitDiffContext context); + void cancel(); + +signals: + void loadingChanged(bool loading); + void snapshotReady(const codexui::codex::GitDiffSnapshot &snapshot); + +private: + std::shared_ptr> generation; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_GITDIFFPROVIDER_H diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 7e31870..44e96f4 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -2579,50 +2579,8 @@ void ShellWidget::refreshInspector() { activeLayout = requestsLayout; const ThreadPresentation *thread = model.thread(selectedThreadId); if (activeTab == 2) { - QString liveDiff; - std::vector retained; - if (thread) { - for (auto turnId = thread->turnOrder.rbegin(); - turnId != thread->turnOrder.rend() && liveDiff.isEmpty(); ++turnId) { - const auto turn = thread->turns.find(*turnId); - if (turn == thread->turns.end()) - continue; - const auto domain = turn->second.domains.find("turn.diff.changed"); - if (domain != turn->second.domains.end()) - liveDiff = text(stringValue(domain->second, "diff")); - } - if (liveDiff.isEmpty()) { - for (auto turnId = thread->turnOrder.rbegin(); - turnId != thread->turnOrder.rend() && retained.empty(); ++turnId) { - const auto turn = thread->turns.find(*turnId); - if (turn == thread->turns.end()) - continue; - for (auto itemId = turn->second.itemOrder.rbegin(); - itemId != turn->second.itemOrder.rend(); ++itemId) { - const auto item = turn->second.items.find(*itemId); - if (item == turn->second.items.end() || - stringValue(item->second.raw, "type") != "fileChange") - continue; - const nlohmann::json changes = - item->second.raw.value("changes", nlohmann::json::array()); - if (!changes.is_array()) - continue; - for (const auto &change : changes) { - QString kind = text(stringValue(change, "kind")); - if (kind.isEmpty() && change.contains("kind") && - change["kind"].is_object()) - kind = text(stringValue(change["kind"], "type")); - retained.push_back({text(stringValue(change, "path")), - std::move(kind), - text(stringValue(change, "diff"))}); - } - if (!retained.empty()) - break; - } - } - } - } - diffViewer->setChanges(std::move(liveDiff), std::move(retained)); + diffViewer->setWorkspace(thread ? text(thread->cwd) : QString{}); + diffViewer->refreshRepository(); return; } if (!activeLayout) diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 1816dce..8c9ab50 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -111,6 +111,18 @@ QString applicationStyleSheet() { } QPushButton[kind="infoChoice"]:hover { background: #f8fafc; border-color: #b9c4d2; } QPushButton[kind="infoChoice"]:pressed { background: #f1f5fb; border-color: #9eabbc; } + QPushButton[kind="segment"] { + background: #ffffff; + border-color: #d7dee8; + border-radius: 7px; + padding: 0 10px; + } + QPushButton[kind="segment"]:checked { + background: #e5eeff; + border-color: #bfd3f9; + color: #1d2633; + } + QPushButton[kind="segment"]:hover:!checked { background: #f1f5fb; } QToolButton[kind="composerAction"] { background: #ffffff; border: 1px solid #d7dee8; diff --git a/src/greenfield/codex/middle/InspectorPane.cpp b/src/greenfield/codex/middle/InspectorPane.cpp index 6ca9e95..73d09f9 100644 --- a/src/greenfield/codex/middle/InspectorPane.cpp +++ b/src/greenfield/codex/middle/InspectorPane.cpp @@ -535,57 +535,8 @@ void InspectorPane::refreshAgents() { void InspectorPane::refreshChanges() { const ThreadPresentation *thread = currentModel->thread(currentThreadId); - QString liveDiff; - std::vector retained; - if (thread) { - for (auto id = thread->turnOrder.rbegin(); - id != thread->turnOrder.rend() && liveDiff.isEmpty(); ++id) { - const auto turn = thread->turns.find(*id); - if (turn == thread->turns.end()) - continue; - const auto domain = turn->second.domains.find("turn.diff.changed"); - if (domain != turn->second.domains.end()) - liveDiff = text(stringValue(domain->second, "diff")); - } - if (liveDiff.isEmpty()) { - for (auto id = thread->turnOrder.rbegin(); - id != thread->turnOrder.rend() && retained.empty(); ++id) { - const auto turn = thread->turns.find(*id); - if (turn == thread->turns.end()) - continue; - for (auto itemId = turn->second.itemOrder.rbegin(); - itemId != turn->second.itemOrder.rend(); ++itemId) { - const auto item = turn->second.items.find(*itemId); - if (item == turn->second.items.end() || - stringValue(item->second.raw, "type") != "fileChange") - continue; - for (const auto &change : - item->second.raw.value("changes", nlohmann::json::array())) { - QString kind = text(stringValue(change, "kind")); - if (kind.isEmpty() && change.contains("kind") && - change["kind"].is_object()) - kind = text(stringValue(change["kind"], "type")); - retained.push_back({text(stringValue(change, "path")), - std::move(kind), - text(stringValue(change, "diff"))}); - } - if (!retained.empty()) - break; - } - } - } - } - nlohmann::json signature{{"threadId", currentThreadId}, - {"live", liveDiff.toStdString()}}; - for (const auto &change : retained) - signature["retained"].push_back({change.path.toStdString(), - change.kind.toStdString(), - change.diff.toStdString()}); - const QByteArray next = bytes(signature); - if (next == changesSnapshot) - return; - changesSnapshot = next; - diffViewer->setChanges(std::move(liveDiff), std::move(retained)); + diffViewer->setWorkspace(thread ? text(thread->cwd) : QString{}); + diffViewer->refreshRepository(); } void InspectorPane::refreshRequests() { diff --git a/src/greenfield/codex/middle/InspectorPane.h b/src/greenfield/codex/middle/InspectorPane.h index 870f709..fcd2688 100644 --- a/src/greenfield/codex/middle/InspectorPane.h +++ b/src/greenfield/codex/middle/InspectorPane.h @@ -76,7 +76,6 @@ class InspectorPane final : public QFrame { QByteArray planSnapshot; QByteArray agentsSnapshot; - QByteArray changesSnapshot; QByteArray requestsSnapshot; QByteArray stateSnapshot; QByteArray protocolStatsSnapshot; diff --git a/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/GreenfieldLayoutTest.cpp index f38c281..f2a3259 100644 --- a/tests/codex/GreenfieldLayoutTest.cpp +++ b/tests/codex/GreenfieldLayoutTest.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT +#include "codex/GitDiffProvider.h" #include "codex/PresentationModel.h" #include "codex/PresentationProtocol.h" #include "codex/middle/ComposerPane.h" @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -25,10 +27,13 @@ #include #include #include +#include #include #include #include +#include + #include #include #include @@ -647,6 +652,87 @@ bool testInspectorDetailParity() { return result; } +bool testGitDiffScopes() { + QTemporaryDir repositoryDirectory; + if (!expect(repositoryDirectory.isValid(), + "Git diff test creates a temporary workspace")) + return false; + GitDiffProvider provider; + git_repository *repository = nullptr; + if (!expect(git_repository_init(&repository, + repositoryDirectory.path().toUtf8().constData(), + 0) == 0, + "Git diff test initializes an in-process repository")) + return false; + QFile file(repositoryDirectory.filePath(QStringLiteral("notes.txt"))); + if (!expect(file.open(QIODevice::WriteOnly | QIODevice::Truncate), + "Git diff test creates an untracked file")) { + git_repository_free(repository); + return false; + } + file.write("first line\nsecond line\n"); + file.close(); + + GitDiffSnapshot received; + bool ready = false; + QObject::connect(&provider, &GitDiffProvider::snapshotReady, + [&received, &ready](const GitDiffSnapshot &snapshot) { + received = snapshot; + ready = true; + }); + const auto request = [&](GitDiffScope scope) { + ready = false; + provider.request(repositoryDirectory.path(), scope, + GitDiffContext::Compact); + QElapsedTimer timeout; + timeout.start(); + while (!ready && timeout.elapsed() < 3000) + spin(1); + return ready; + }; + + bool result = expect(request(GitDiffScope::Unstaged) && + received.repository && received.error.isEmpty() && + received.files.size() == 1 && + received.files.front().status == + QStringLiteral("Untracked") && + received.files.front().patch.contains( + QStringLiteral("+first line")), + "Unstaged scope includes untracked file content"); + + git_index *index = nullptr; + if (git_repository_index(&index, repository) == 0) { + git_index_add_bypath(index, "notes.txt"); + git_index_write(index); + git_index_free(index); + } + result &= expect(request(GitDiffScope::Staged) && + received.files.size() == 1 && + received.files.front().status == + QStringLiteral("Added"), + "Staged scope compares the index with HEAD"); + result &= expect(request(GitDiffScope::Uncommitted) && + received.files.size() == 1 && + received.files.front().patch.contains( + QStringLiteral("+second line")), + "Since-HEAD scope combines index and worktree state"); + + QTemporaryDir ordinaryDirectory; + ready = false; + provider.request(ordinaryDirectory.path(), GitDiffScope::Unstaged, + GitDiffContext::Compact); + QElapsedTimer timeout; + timeout.start(); + while (!ready && timeout.elapsed() < 3000) + spin(1); + result &= expect(ordinaryDirectory.isValid() && ready && + !received.repository && + received.error.contains(QStringLiteral("Git repository")), + "ordinary folders expose an explicit non-repository state"); + git_repository_free(repository); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -663,6 +749,7 @@ int main(int argc, char **argv) { result &= testNestedCommandScrollOwnership(); result &= testInfoViewerLayout(); result &= testInspectorDetailParity(); + result &= testGitDiffScopes(); if (result) std::cout << "Greenfield layout tests passed\n"; return result ? 0 : 1; diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 94ae220..693b5a2 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -103,8 +103,15 @@ Plan steps, agents, and pending requests are peer records and therefore use the same raised card surface, border, radius, and internal spacing. Summary surfaces are reserved for subordinate content within a record. Inspector scroll areas are frameless and transparent so the panel background remains continuous. -Plan, Agents, Changes, and Requests retain their last visible per-thread -presentation across thread and tab navigation. +Plan, Agents, and Requests retain their last visible per-thread presentation +across thread and tab navigation. + +Changes reflects the local Git worktree containing the selected thread's +workspace, never a patch reconstructed from conversation messages. Its compact +Inspector surface contains scope, file summary/list, and a unified preview. +Copy and Open review belong to the selected-file preview; double-clicking a file +also opens review. The modeless review window provides Unified or Side by side +layout and Compact or Expanded context without blocking conversation use. ## Desktop integration From cf1975f505003425816154941050037328cac812 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 00:10:29 +0200 Subject: [PATCH 06/36] Resolve and monitor thread repositories --- CMakeLists.txt | 24 ++ docs/codex-architecture.md | 63 +++- docs/ui-behavior.md | 23 +- src/codex/DiffViewer.cpp | 334 ++++++++++++++++-- src/codex/DiffViewer.h | 17 +- src/codex/GitDiffProvider.cpp | 283 +++++++++++---- src/codex/GitDiffProvider.h | 8 +- src/codex/PresentationModel.cpp | 31 +- src/codex/PresentationModel.h | 2 + src/codex/ShellWidget.cpp | 12 +- src/greenfield/codex/middle/InspectorPane.cpp | 13 +- tests/codex/GitChangesLiveTest.cpp | 244 +++++++++++++ tests/codex/GreenfieldLayoutTest.cpp | 189 +++++++++- tests/codex/PresentationPipelineTest.cpp | 38 ++ ui-review/UI-INVENTORY.md | 3 +- ui-review/UX-DESIGN-DECISIONS.md | 19 +- 16 files changed, 1162 insertions(+), 141 deletions(-) create mode 100644 tests/codex/GitChangesLiveTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 69e9622..8b76b4e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -319,6 +319,30 @@ if(BUILD_TESTING) PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) + qt_add_executable( + codexui-git-changes-live-test + tests/codex/GitChangesLiveTest.cpp + src/codex/DiffViewer.cpp + src/codex/DiffViewer.h + src/codex/GitDiffProvider.cpp + src/codex/GitDiffProvider.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h + ) + target_compile_features(codexui-git-changes-live-test PRIVATE cxx_std_20) + target_include_directories(codexui-git-changes-live-test PRIVATE src) + target_link_libraries( + codexui-git-changes-live-test PRIVATE PkgConfig::LIBGIT2 Qt6::Widgets + ) + add_test( + NAME codexui-git-changes-live + COMMAND codexui-git-changes-live-test + ) + set_tests_properties( + codexui-git-changes-live + PROPERTIES TIMEOUT 30 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) + set(CODEXUI_GREENFIELD_SHELL_TEST_SOURCES ${CODEXUI_CODEX_COMMON_SOURCES}) list( REMOVE_ITEM CODEXUI_GREENFIELD_SHELL_TEST_SOURCES diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 6b53cfe..3bc86f4 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -504,14 +504,30 @@ bounded protocol and security design. ### 7.4 Changes and Diff Presentation -The Changes inspector is authoritative over the local Git worktree containing -the selected thread's working directory. It does not use app-server -`turn.diff.changed` or `fileChange` messages as review content. The provider -uses libgit2 in-process and asynchronously discovers the enclosing repository, -then exposes Unstaged, Staged, and Since HEAD scopes. Untracked content, -renames, copies, deletions, type changes, conflicts, and binary metadata come -from libgit2. A folder outside Git remains a valid Codex workspace, but its -Changes tab reports that review requires a repository. +The Changes inspector is authoritative over the local Git worktrees associated +with the selected thread. It does not use app-server `turn.diff.changed` or +`fileChange` messages as review content. `ThreadPresentation` retains bounded, +deduplicated command working directories and changed-path hints from the +thread's authoritative items. The provider resolves each directory upward with +libgit2, deduplicates repository roots, validates ambiguous paths against the +worktree, index, and HEAD, and persists the resolved roots per thread. A path +that is currently changed ranks above the same clean tracked path; equal-rank +matches remain available together. It never performs a recursive downward +workspace search. + +Resolved roots are synchronously persisted in QSettings and loaded from either +the native string-list representation or the scalar representation used by the +INI backend for a single root. Consequently, restart hydration does not depend +on historical command items being present in `thread.read`. + +The repository selector defaults to All repositories when several candidates +match. Candidate paths containing a dot-prefixed directory are excluded by +default; the persistent Hidden option explicitly includes them. The provider +exposes Unstaged, Staged, and Since HEAD scopes. Untracked +content—including files created outside CodexUI—renames, copies, deletions, +type changes, conflicts, and binary metadata come from libgit2. A folder +outside Git remains a valid Codex workspace, but its Changes tab reports that +review requires a repository. The Inspector contains a compact unified preview with stable file selection, addition/deletion counts, Copy, Open review, and file-double-click review. The @@ -519,7 +535,14 @@ modeless Change Review window remains usable beside the conversation and offers Unified or Side by side layout plus Compact or Expanded context. Preferences persist across threads. Repository collection runs outside the UI thread, superseded results are discarded, and rendered diff content is bounded to 16 -MiB with an explicit truncation state. +MiB with an explicit truncation state. Every returned file carries its resolved +absolute pathname. CodexUI watches existing changed files and their parent +directories, then debounces filesystem events into a fresh libgit2 snapshot. +Parent-directory watches keep deletion, recreation, rename, and atomic file +replacement consistent. A visible-only two-second refresh remains the safety +net for newly created files in previously unwatched nested directories and for +index-only changes. Files disappear from selection as soon as libgit2 reports +that they are clean again. ### 7.5 Conversation Projection and Prompt Admission @@ -1089,7 +1112,7 @@ codex suite only when it validates a boundary whose failure would undermine the application architecture independently of the particular symptom that revealed it. -Six focused CTest executables form the essential suite. They use production +Seven focused CTest executables form the essential suite. They use production classes directly and are built when standard CMake `BUILD_TESTING` is enabled. CTest enables that option by default; disabling it remains the conventional packaging choice and does not select a different runtime implementation. @@ -1186,6 +1209,19 @@ acknowledgment, background completion, retained Plan/Agents state, monotonic hydration across reconnect, terminal callbacks, failed-hydration draft retention, bounded child-thread reads, and one-shot thread-not-found recovery. +#### Git Changes Integration + +`codexui-git-changes-live-test` uses production `DiffViewer`, +`GitDiffProvider`, QFileSystemWatcher, and libgit2 against a temporary real Git +repository. It performs filesystem writes rather than UI interaction. The test +verifies polling discovery of a manually created nested untracked file and +native watcher refresh after removal, content reversion, deletion restoration, +and atomic replacement. `codexui-greenfield-layout-test` complements it with +in-process repository-resolution coverage for all scopes, duplicate candidates, +ambiguous and absolute paths, All and individual repository selection, hidden +repository exclusion/inclusion, stale hints/selections, and preference for an +actually changed path over an identical clean tracked path. + #### Explicit Exclusions The permanent automated suite does not include: @@ -1207,7 +1243,7 @@ deterministic CI test would be misleading. The persistent live topology and independent bridge observer provide that evidence without introducing a fake bridge into the CodexUI repository. -The six focused tests can be built and run directly: +The seven focused tests can be built and run directly: ```sh cmake --build "${BUILD_DIR}" --parallel 8 \ @@ -1216,12 +1252,13 @@ cmake --build "${BUILD_DIR}" --parallel 8 \ codexui-greenfield-projection-test \ codexui-greenfield-middle-test \ codexui-greenfield-layout-test \ + codexui-git-changes-live-test \ codexui-greenfield-shell-test ctest --test-dir "${BUILD_DIR}" --output-on-failure \ - -R '^codexui-(socketpair-contract|presentation-pipeline|greenfield-(projection|middle|layout|shell))$' + -R '^codexui-(socketpair-contract|presentation-pipeline|git-changes-live|greenfield-(projection|middle|layout|shell))$' ``` -Each test has a 10-to-20-second CTest ceiling. Normal successful execution is +Each test has a 10-to-30-second CTest ceiling. Normal successful execution is substantially shorter and requires no network listener, credentials, isolated Codex home, or user interaction. diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index e5d5211..312da6a 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -194,15 +194,28 @@ show vertical scrollbars only when needed. The Protocol log occupies the expanding area of its tab; protocol statistics are displayed below the log. Protocol and State data are diagnostic presentation only and do not create domain authority. Plan, Agents, and Requests use retained per-thread -presentation snapshots. Changes instead discovers the local Git worktree from -the selected thread's working directory and refreshes it asynchronously through -libgit2. It offers Unstaged, Staged, and Since HEAD scopes; a non-repository -folder shows an explanatory unavailable state without preventing normal work. +presentation snapshots. Changes instead resolves local Git repositories upward +from the selected thread's retained command working directories and refreshes +them asynchronously through libgit2. When several repositories match, All +repositories is the default and a selector can narrow the view. Resolution +considers only repositories reached through visible directory paths by +default. The persistent Hidden option also includes paths containing +dot-prefixed directories. When identical hinted paths occur in several +repositories, repositories where the path is currently changed are preferred +over clean tracked matches. Changes offers Unstaged, Staged, and Since HEAD +scopes; a thread without a resolvable +repository shows an explanatory unavailable state without preventing normal +work. Manual and Codex-created changes are treated identically. The Inspector shows a compact unified preview. Open review and double-clicking a changed file open a modeless review window with Unified or Side by side layout and Compact or Expanded context. These view preferences persist across -threads. +threads. Diff scrollbars show proportional overview marks using canonical +green for additions, red for deletions, and blue for hunk boundaries. Existing +changed files and their parent directories are watched; +reverted or restored files disappear after libgit2 confirms they are clean, +while a short visible-only refresh discovers new untracked files and catches +index-only changes. ## Desktop identity diff --git a/src/codex/DiffViewer.cpp b/src/codex/DiffViewer.cpp index d1e38cc..bb38312 100644 --- a/src/codex/DiffViewer.cpp +++ b/src/codex/DiffViewer.cpp @@ -10,19 +10,25 @@ #include #include #include +#include +#include #include #include #include #include +#include #include #include #include #include #include +#include #include #include #include +#include #include +#include #include #include #include @@ -36,6 +42,48 @@ namespace { constexpr int RepositoryRefreshDelayMs = 120; constexpr int RepositoryPollingIntervalMs = 2000; +struct DiffMark { + qreal position = 0; + QColor color; +}; + +class DiffScrollBar final : public QScrollBar { +public: + explicit DiffScrollBar(QWidget *parent = nullptr) + : QScrollBar(Qt::Vertical, parent) {} + + void setMarks(std::vector nextMarks) { + marks = std::move(nextMarks); + update(); + } + +protected: + void paintEvent(QPaintEvent *event) override { + QScrollBar::paintEvent(event); + if (marks.empty()) + return; + QStyleOptionSlider option; + initStyleOption(&option); + const QRect groove = style()->subControlRect( + QStyle::CC_ScrollBar, &option, QStyle::SC_ScrollBarGroove, this); + if (!groove.isValid()) + return; + QPainter painter(this); + painter.setPen(Qt::NoPen); + const int width = std::min(4, groove.width()); + for (const DiffMark &mark : marks) { + painter.setBrush(mark.color); + const int y = groove.top() + qRound( + mark.position * + std::max(0, groove.height() - 2)); + painter.drawRoundedRect(groove.right() - width + 1, y, width, 2, 1, 1); + } + } + +private: + std::vector marks; +}; + class ChevronComboBox final : public QComboBox { protected: void paintEvent(QPaintEvent *event) override { @@ -99,8 +147,37 @@ QPlainTextEdit *diffView(const QString &objectName) { view->setReadOnly(true); view->setLineWrapMode(QPlainTextEdit::NoWrap); view->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + auto *scrollBar = new DiffScrollBar(view); + view->setVerticalScrollBar(scrollBar); view->verticalScrollBar()->setProperty("kind", "infoViewer"); new DiffHighlighter(view->document()); + QObject::connect(view, &QPlainTextEdit::textChanged, view, + [view, scrollBar] { + std::vector marks; + const int blockCount = view->document()->blockCount(); + for (QTextBlock block = view->document()->begin(); + block.isValid(); block = block.next()) { + const QString text = block.text(); + QColor color; + if (text.startsWith(QStringLiteral("@@"))) + color = QColor(QStringLiteral("#2f6feb")); + else if (text.startsWith(QLatin1Char('+')) && + !text.startsWith(QStringLiteral("+++"))) + color = QColor(QStringLiteral("#18865e")); + else if (text.startsWith(QLatin1Char('-')) && + !text.startsWith(QStringLiteral("---"))) + color = QColor(QStringLiteral("#c43d4d")); + if (!color.isValid()) + continue; + const qreal position = + blockCount > 1 + ? static_cast(block.blockNumber()) / + static_cast(blockCount - 1) + : 0; + marks.push_back({position, color}); + } + scrollBar->setMarks(std::move(marks)); + }); return view; } @@ -119,10 +196,48 @@ QString scopeName(GitDiffScope scope) { } } -QString fileTitle(const GitDiffFile &file) { - return !file.previousPath.isEmpty() && file.previousPath != file.path - ? QStringLiteral("%1 → %2").arg(file.previousPath, file.path) - : file.path; +QString repositoryName(const QString &root) { + const QString name = QFileInfo(QDir::cleanPath(root)).fileName(); + return name.isEmpty() ? root : name; +} + +QString fileTitle(const GitDiffFile &file, bool includeRepository = false) { + QString result = + !file.previousPath.isEmpty() && file.previousPath != file.path + ? QStringLiteral("%1 → %2").arg(file.previousPath, file.path) + : file.path; + if (includeRepository) + result = QStringLiteral("%1 / %2") + .arg(repositoryName(file.repositoryRoot), result); + return result; +} + +QString repositorySummary(const GitDiffSnapshot &snapshot) { + return snapshot.repositoryRoots.size() > 1 + ? QStringLiteral("%1 repositories") + .arg(snapshot.repositoryRoots.size()) + : snapshot.repositoryRoots.isEmpty() + ? QString{} + : snapshot.repositoryRoots.front(); +} + +QString settingsBase(const QString &threadId) { + return QStringLiteral("diff/threads/%1") + .arg(QString::fromLatin1(QCryptographicHash::hash( + threadId.toUtf8(), QCryptographicHash::Sha256) + .toHex())); +} + +QStringList stringListSetting(const QString &key) { + const QVariant stored = QSettings().value(key); + QStringList result = stored.toStringList(); + if (result.isEmpty()) { + const QString scalar = stored.toString(); + if (!scalar.isEmpty()) + result.push_back(scalar); + } + result.removeDuplicates(); + return result; } QByteArray fingerprint(const GitDiffSnapshot &snapshot) { @@ -134,9 +249,13 @@ QByteArray fingerprint(const GitDiffSnapshot &snapshot) { value += snapshot.repository ? '\1' : '\0'; value += snapshot.truncated ? '\1' : '\0'; for (const GitDiffFile &file : snapshot.files) { + value += '\0'; + value += file.repositoryRoot.toUtf8(); value += '\0'; value += file.path.toUtf8(); value += '\0'; + value += file.absolutePath.toUtf8(); + value += '\0'; value += file.previousPath.toUtf8(); value += '\0'; value += file.status.toUtf8(); @@ -352,16 +471,26 @@ class GitDiffReviewWindow final : public QDialog { context = full ? GitDiffContext::Expanded : GitDiffContext::Compact; } - void setSource(QString nextWorkspace, GitDiffScope nextScope, - QString preferredPath) { + void setSource(QString nextWorkspace, QStringList nextDirectories, + QStringList nextPaths, QString nextRepository, + bool nextIncludeHiddenRepositories, + GitDiffScope nextScope, QString preferredPath) { workspace = std::move(nextWorkspace); + commandDirectories = std::move(nextDirectories); + changedPaths = std::move(nextPaths); + selectedRepository = std::move(nextRepository); + includeHiddenRepositories = nextIncludeHiddenRepositories; scope = nextScope; requestedPath = std::move(preferredPath); reload(); } private: - void reload() { provider->request(workspace, scope, context); } + void reload() { + provider->request(workspace, commandDirectories, changedPaths, + selectedRepository, includeHiddenRepositories, scope, + context); + } void apply(const GitDiffSnapshot &value) { const QByteArray nextFingerprint = fingerprint(value); @@ -372,7 +501,7 @@ class GitDiffReviewWindow final : public QDialog { subtitle->setText(value.error.isEmpty() ? QStringLiteral("%1 | %2") .arg(scopeName(value.scope), - value.repositoryRoot) + repositorySummary(value)) : value.error); reviewFiles->clear(); int selected = -1; @@ -380,12 +509,13 @@ class GitDiffReviewWindow final : public QDialog { const GitDiffFile &file = value.files[index]; auto *item = new QListWidgetItem( QStringLiteral("%1\n%2 +%3 −%4") - .arg(fileTitle(file), file.status) + .arg(fileTitle(file, value.repositoryRoots.size() > 1), + file.status) .arg(file.additions) .arg(file.deletions)); - item->setToolTip(file.path); + item->setToolTip(file.absolutePath); reviewFiles->addItem(item); - if (file.path == requestedPath) + if (file.absolutePath == requestedPath) selected = static_cast(index); } if (!value.files.empty()) @@ -404,8 +534,8 @@ class GitDiffReviewWindow final : public QDialog { if (index < 0 || static_cast(index) >= snapshot.files.size()) return; const GitDiffFile &file = snapshot.files[static_cast(index)]; - requestedPath = file.path; - title->setText(fileTitle(file)); + requestedPath = file.absolutePath; + title->setText(fileTitle(file, snapshot.repositoryRoots.size() > 1)); const QString content = file.patch.isEmpty() ? QStringLiteral("No textual patch is available for this file.") : file.patch; @@ -421,6 +551,10 @@ class GitDiffReviewWindow final : public QDialog { GitDiffProvider *provider = nullptr; GitDiffSnapshot snapshot; QString workspace; + QStringList commandDirectories; + QStringList changedPaths; + QString selectedRepository; + bool includeHiddenRepositories = false; QString requestedPath; GitDiffScope scope = GitDiffScope::Unstaged; GitDiffContext context = GitDiffContext::Compact; @@ -441,6 +575,7 @@ class GitDiffReviewWindow final : public QDialog { DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { provider = new GitDiffProvider(this); + fileWatcher = new QFileSystemWatcher(this); refreshTimer = new QTimer(this); refreshTimer->setSingleShot(true); refreshTimer->setInterval(RepositoryRefreshDelayMs); @@ -451,14 +586,34 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { auto *root = new QVBoxLayout(this); root->setContentsMargins(10, 10, 10, 10); root->setSpacing(8); - auto *header = new QHBoxLayout; summary = label(QStringLiteral("No file changes"), "title"); authority = label({}, "meta"); auto *headerText = new QVBoxLayout; headerText->setSpacing(1); headerText->addWidget(summary); headerText->addWidget(authority); - header->addLayout(headerText, 1); + root->addLayout(headerText); + auto *filters = new QHBoxLayout; + filters->addStretch(); + repositories = new ChevronComboBox; + repositories->setObjectName(QStringLiteral("codexDiffRepository")); + repositories->setProperty("codexChevron", true); + repositories->addItem(QStringLiteral("Repository"), QString{}); + repositories->setFixedHeight(30); + repositories->setMinimumWidth(130); + filters->addWidget(repositories); + hiddenRepositories = new QPushButton(QStringLiteral("Hidden")); + hiddenRepositories->setObjectName( + QStringLiteral("codexDiffHiddenRepositories")); + hiddenRepositories->setProperty("kind", "segment"); + hiddenRepositories->setCheckable(true); + hiddenRepositories->setChecked( + QSettings().value(QStringLiteral("diff/includeHiddenRepositories"), false) + .toBool()); + hiddenRepositories->setToolTip( + QStringLiteral("Also include hidden repositories")); + hiddenRepositories->setFixedHeight(30); + filters->addWidget(hiddenRepositories); scope = new ChevronComboBox; scope->setObjectName(QStringLiteral("codexDiffScope")); scope->setProperty("codexChevron", true); @@ -472,8 +627,8 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { const int savedScope = QSettings().value(QStringLiteral("diff/scope"), 0).toInt(); scope->setCurrentIndex(std::clamp(savedScope, 0, scope->count() - 1)); - header->addWidget(scope); - root->addLayout(header); + filters->addWidget(scope); + root->addLayout(filters); files = new QListWidget; files->setObjectName(QStringLiteral("codexDiffFiles")); @@ -498,7 +653,9 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { root->addWidget(diff, 1); connect(refreshTimer, &QTimer::timeout, this, [this] { - provider->request(workspace, scopeValue(scope), GitDiffContext::Compact); + provider->request(workspace, repositoryCandidates(), changedPaths, + selectedRepository, hiddenRepositories->isChecked(), + scopeValue(scope), GitDiffContext::Compact); }); connect(provider, &GitDiffProvider::loadingChanged, this, [this](bool loading) { if (loading && snapshot.files.empty() && snapshot.error.isEmpty()) { @@ -507,9 +664,30 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { }); connect(provider, &GitDiffProvider::snapshotReady, this, [this](const GitDiffSnapshot &value) { applySnapshot(value); }); + connect(fileWatcher, &QFileSystemWatcher::fileChanged, this, + [this](const QString &) { refreshRepository(); }); + connect(fileWatcher, &QFileSystemWatcher::directoryChanged, this, + [this](const QString &) { refreshRepository(); }); connect(repositoryTimer, &QTimer::timeout, this, [this] { if (isVisible()) - provider->request(workspace, scopeValue(scope), GitDiffContext::Compact); + provider->request(workspace, repositoryCandidates(), changedPaths, + selectedRepository, hiddenRepositories->isChecked(), + scopeValue(scope), + GitDiffContext::Compact); + }); + connect(repositories, &QComboBox::currentIndexChanged, this, + [this](int) { + selectedRepository = repositories->currentData().toString(); + if (!threadId.isEmpty()) + QSettings().setValue(settingsBase(threadId) + + QStringLiteral("/selected"), + selectedRepository); + refreshRepository(); + }); + connect(hiddenRepositories, &QPushButton::toggled, this, [this](bool value) { + QSettings().setValue(QStringLiteral("diff/includeHiddenRepositories"), + value); + refreshRepository(); }); connect(scope, &QComboBox::currentIndexChanged, this, [this](int index) { QSettings().setValue(QStringLiteral("diff/scope"), index); @@ -529,39 +707,100 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { reviewButton->setEnabled(false); } -void DiffViewer::setWorkspace(QString nextWorkspace) { - nextWorkspace = QDir::cleanPath(std::move(nextWorkspace)); - if (workspace == nextWorkspace) +void DiffViewer::setRepositoryContext(QString nextThreadId, + QString nextWorkspace, + QStringList nextCommandDirectories, + QStringList nextChangedPaths) { + if (!nextWorkspace.isEmpty()) + nextWorkspace = QDir::cleanPath(std::move(nextWorkspace)); + nextCommandDirectories.removeDuplicates(); + nextChangedPaths.removeDuplicates(); + if (threadId == nextThreadId && workspace == nextWorkspace && + commandDirectories == nextCommandDirectories && + changedPaths == nextChangedPaths) return; + const bool changedThread = threadId != nextThreadId; + threadId = std::move(nextThreadId); workspace = std::move(nextWorkspace); + commandDirectories = std::move(nextCommandDirectories); + changedPaths = std::move(nextChangedPaths); + if (changedThread) { + const QString base = settingsBase(threadId); + persistedRepositoryRoots = + stringListSetting(base + QStringLiteral("/roots")); + selectedRepository = + QSettings().value(base + QStringLiteral("/selected")).toString(); + } snapshot = {}; + updateFileWatches(); snapshotFingerprint.clear(); files->clear(); diff->clear(); refreshRepository(); } +const GitDiffSnapshot &DiffViewer::currentSnapshot() const noexcept { + return snapshot; +} + void DiffViewer::refreshRepository() { refreshTimer->start(); if (reviewWindow) - reviewWindow->setSource(workspace, scopeValue(scope), selectedPath()); + reviewWindow->setSource(workspace, repositoryCandidates(), changedPaths, + selectedRepository, + hiddenRepositories->isChecked(), scopeValue(scope), + selectedPath()); +} + +QStringList DiffViewer::repositoryCandidates() const { + QStringList result = commandDirectories; + result.append(persistedRepositoryRoots); + result.removeDuplicates(); + return result; } QString DiffViewer::selectedPath() const { const int index = files->currentRow(); return index >= 0 && static_cast(index) < snapshot.files.size() - ? snapshot.files[static_cast(index)].path + ? snapshot.files[static_cast(index)].absolutePath : QString{}; } void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { const QByteArray nextFingerprint = fingerprint(value); - if (nextFingerprint == snapshotFingerprint) + if (nextFingerprint == snapshotFingerprint) { + snapshot = value; + updateFileWatches(); return; + } snapshotFingerprint = nextFingerprint; const QString previous = selectedPath(); const int previousScroll = diff->verticalScrollBar()->value(); snapshot = value; + updateFileWatches(); + if (!threadId.isEmpty() && !value.repositoryRoots.isEmpty()) { + persistedRepositoryRoots = value.repositoryRoots; + QSettings settings; + settings.setValue(settingsBase(threadId) + QStringLiteral("/roots"), + persistedRepositoryRoots); + settings.sync(); + } + { + const QSignalBlocker blocked(repositories); + repositories->clear(); + if (value.repositoryRoots.size() > 1) + repositories->addItem(QStringLiteral("All repositories"), QString{}); + for (const QString &root : value.repositoryRoots) { + repositories->addItem(repositoryName(root), root); + repositories->setItemData(repositories->count() - 1, root, + Qt::ToolTipRole); + } + int selectedIndex = repositories->findData(selectedRepository); + if (selectedIndex < 0) + selectedIndex = 0; + repositories->setCurrentIndex(selectedIndex); + selectedRepository = repositories->currentData().toString(); + } files->clear(); int additions = 0; int deletions = 0; @@ -572,12 +811,12 @@ void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { deletions += file.deletions; auto *item = new QListWidgetItem( QStringLiteral("%1 %2 +%3 −%4") - .arg(fileTitle(file), file.status) + .arg(fileTitle(file, value.repositoryRoots.size() > 1), file.status) .arg(file.additions) .arg(file.deletions)); - item->setToolTip(file.path); + item->setToolTip(file.absolutePath); files->addItem(item); - if (file.path == previous) + if (file.absolutePath == previous) selected = static_cast(index); } if (!value.error.isEmpty()) { @@ -593,9 +832,9 @@ void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { authority->setText( value.truncated ? QStringLiteral("%1 | display truncated | %2") - .arg(scopeName(value.scope), value.repositoryRoot) + .arg(scopeName(value.scope), repositorySummary(value)) : QStringLiteral("%1 | %2") - .arg(scopeName(value.scope), value.repositoryRoot)); + .arg(scopeName(value.scope), repositorySummary(value))); } if (!value.files.empty()) { files->setCurrentRow(selected >= 0 ? selected : 0); @@ -609,6 +848,34 @@ void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { reviewButton->setEnabled(!value.files.empty()); } +void DiffViewer::updateFileWatches() { + QStringList desired; + for (const GitDiffFile &file : snapshot.files) { + const QFileInfo info(file.absolutePath); + if (info.exists()) + desired.push_back(info.absoluteFilePath()); + const QString parent = info.absolutePath(); + if (!parent.isEmpty() && QFileInfo(parent).isDir()) + desired.push_back(parent); + } + desired.removeDuplicates(); + const QStringList existing = fileWatcher->files() + fileWatcher->directories(); + QStringList removed; + for (const QString &path : existing) { + if (!desired.contains(path)) + removed.push_back(path); + } + if (!removed.isEmpty()) + fileWatcher->removePaths(removed); + QStringList added; + for (const QString &path : desired) { + if (!existing.contains(path)) + added.push_back(path); + } + if (!added.isEmpty()) + fileWatcher->addPaths(added); +} + void DiffViewer::showSelectedFile() { const int index = files->currentRow(); if (index < 0 || static_cast(index) >= snapshot.files.size()) { @@ -617,7 +884,8 @@ void DiffViewer::showSelectedFile() { return; } const GitDiffFile &file = snapshot.files[static_cast(index)]; - selectedFile->setText(fileTitle(file)); + selectedFile->setText( + fileTitle(file, snapshot.repositoryRoots.size() > 1)); diff->setPlainText(file.patch.isEmpty() ? QStringLiteral("No textual patch is available for this file.") : file.patch); @@ -629,7 +897,9 @@ void DiffViewer::openReview() { return; if (!reviewWindow) reviewWindow = new GitDiffReviewWindow(window()); - reviewWindow->setSource(workspace, scopeValue(scope), selectedPath()); + reviewWindow->setSource(workspace, repositoryCandidates(), changedPaths, + selectedRepository, hiddenRepositories->isChecked(), + scopeValue(scope), selectedPath()); reviewWindow->show(); reviewWindow->raise(); reviewWindow->activateWindow(); diff --git a/src/codex/DiffViewer.h b/src/codex/DiffViewer.h index 73e65da..3b7e58f 100644 --- a/src/codex/DiffViewer.h +++ b/src/codex/DiffViewer.h @@ -6,9 +6,11 @@ #include "codex/GitDiffProvider.h" #include +#include #include class QComboBox; +class QFileSystemWatcher; class QLabel; class QListWidget; class QPlainTextEdit; @@ -23,22 +25,35 @@ class DiffViewer final : public QWidget { public: explicit DiffViewer(QWidget *parent = nullptr); - void setWorkspace(QString workspace); + void setRepositoryContext(QString threadId, QString workspace, + QStringList commandDirectories, + QStringList changedPaths); void refreshRepository(); + [[nodiscard]] const GitDiffSnapshot ¤tSnapshot() const noexcept; private: void applySnapshot(const GitDiffSnapshot &snapshot); void showSelectedFile(); void openReview(); + void updateFileWatches(); [[nodiscard]] QString selectedPath() const; + [[nodiscard]] QStringList repositoryCandidates() const; GitDiffProvider *provider = nullptr; + QFileSystemWatcher *fileWatcher = nullptr; QTimer *refreshTimer = nullptr; QTimer *repositoryTimer = nullptr; QString workspace; + QString threadId; + QStringList commandDirectories; + QStringList changedPaths; + QStringList persistedRepositoryRoots; + QString selectedRepository; GitDiffSnapshot snapshot; QByteArray snapshotFingerprint; QComboBox *scope = nullptr; + QComboBox *repositories = nullptr; + QPushButton *hiddenRepositories = nullptr; QLabel *summary = nullptr; QLabel *authority = nullptr; QLabel *selectedFile = nullptr; diff --git a/src/codex/GitDiffProvider.cpp b/src/codex/GitDiffProvider.cpp index 4270bf8..dda32f1 100644 --- a/src/codex/GitDiffProvider.cpp +++ b/src/codex/GitDiffProvider.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -31,9 +33,7 @@ using GitPointer = std::unique_ptr; QString gitError(const QString &fallback) { const git_error *error = git_error_last(); - return error && error->message - ? QString::fromUtf8(error->message) - : fallback; + return error && error->message ? QString::fromUtf8(error->message) : fallback; } QString text(const char *value) { @@ -63,6 +63,31 @@ QString statusName(git_delta_t status) { } } +QString discoverRoot(const QString &directory) { + if (directory.isEmpty()) + return {}; + QString start = directory; + if (QFileInfo(start).isFile()) + start = QFileInfo(start).absolutePath(); + git_buf discovered = GIT_BUF_INIT; + const QByteArray encoded = QFile::encodeName(start); + if (git_repository_discover(&discovered, encoded.constData(), 0, nullptr) < + 0) { + git_buf_dispose(&discovered); + return {}; + } + git_repository *raw = nullptr; + const int opened = git_repository_open(&raw, discovered.ptr); + git_buf_dispose(&discovered); + if (opened < 0) + return {}; + GitPointer repository(raw, + git_repository_free); + if (git_repository_is_bare(repository.get())) + return {}; + return QDir::cleanPath(text(git_repository_workdir(repository.get()))); +} + GitPointer headTree(git_repository *repository, QString &error) { git_reference *rawReference = nullptr; @@ -83,51 +108,86 @@ GitPointer headTree(git_repository *repository, return {reinterpret_cast(rawObject), git_tree_free}; } -GitDiffSnapshot collect(QString workspace, GitDiffScope scope, - GitDiffContext context, - const std::shared_ptr> &clock, - std::uint64_t generation) { - GitDiffSnapshot snapshot; - snapshot.workspace = QDir::cleanPath(std::move(workspace)); - snapshot.scope = scope; - snapshot.context = context; - if (snapshot.workspace.isEmpty()) { - snapshot.error = QStringLiteral("Select a thread to inspect changes."); - return snapshot; - } +QString normalizedHint(QString path) { + path = QDir::fromNativeSeparators(std::move(path)); + if (path.startsWith(QStringLiteral("a/")) || + path.startsWith(QStringLiteral("b/"))) + path.remove(0, 2); + return QDir::cleanPath(path); +} - git_buf discovered = GIT_BUF_INIT; - const QByteArray start = QFile::encodeName(snapshot.workspace); - if (git_repository_discover(&discovered, start.constData(), 0, nullptr) < 0) { - snapshot.error = QStringLiteral("Change review requires a Git repository."); - git_buf_dispose(&discovered); - return snapshot; +bool containsHiddenDirectory(const QString &path) { + const QStringList parts = QDir::fromNativeSeparators(path).split( + QLatin1Char('/'), Qt::SkipEmptyParts); + return std::any_of(parts.begin(), parts.end(), [](const QString &part) { + return part.size() > 1 && part.startsWith(QLatin1Char('.')); + }); +} + +int repositoryPathScore(git_repository *repository, const QString &root, + QString path) { + path = normalizedHint(std::move(path)); + if (QDir::isAbsolutePath(path)) { + path = QDir(root).relativeFilePath(path); + if (path == QStringLiteral("..") || path.startsWith(QStringLiteral("../"))) + return 0; } - const QString repositoryPath = - QDir::cleanPath(QString::fromUtf8(discovered.ptr, - static_cast(discovered.size))); - git_repository *rawRepository = nullptr; - const QByteArray encodedRepository = QFile::encodeName(repositoryPath); - const int openResult = - git_repository_open(&rawRepository, encodedRepository.constData()); - git_buf_dispose(&discovered); - if (openResult < 0) { - snapshot.error = gitError(QStringLiteral("Unable to open Git repository.")); - return snapshot; + const QByteArray encoded = QFile::encodeName(path); + unsigned int status = 0; + if (git_status_file(&status, repository, encoded.constData()) == 0) + return status == GIT_STATUS_CURRENT ? 1 : 2; + git_index *rawIndex = nullptr; + if (git_repository_index(&rawIndex, repository) == 0) { + GitPointer index(rawIndex, git_index_free); + if (git_index_get_bypath(index.get(), encoded.constData(), 0)) + return 1; } - GitPointer repository(rawRepository, + QString error; + GitPointer tree = headTree(repository, error); + if (!tree) + return 0; + git_tree_entry *entry = nullptr; + const bool found = + git_tree_entry_bypath(&entry, tree.get(), encoded.constData()) == 0; + git_tree_entry_free(entry); + return found ? 1 : 0; +} + +std::vector rootHintScores(const QString &root, + const QStringList &directories, + const QStringList &paths) { + std::vector scores(static_cast(paths.size()), 0); + git_repository *raw = nullptr; + const QByteArray encodedRoot = QFile::encodeName(root); + if (git_repository_open(&raw, encodedRoot.constData()) < 0) + return scores; + GitPointer repository(raw, git_repository_free); - snapshot.repository = true; - snapshot.repositoryRoot = - QDir::cleanPath(text(git_repository_workdir(repository.get()))); - if (git_repository_is_bare(repository.get())) { - snapshot.error = QStringLiteral("Change review requires a working tree."); - return snapshot; + for (qsizetype index = 0; index < paths.size(); ++index) { + const QString &path = paths[index]; + int score = repositoryPathScore(repository.get(), root, path); + for (const QString &directory : directories) { + if (score == 2) + break; + const QString absolute = QDir(directory).absoluteFilePath(path); + score = std::max( + score, repositoryPathScore(repository.get(), root, absolute)); + } + scores[static_cast(index)] = score; } + return scores; +} - if (clock->load() != generation) - return snapshot; - +bool appendRepository(GitDiffSnapshot &snapshot, const QString &root, + GitDiffScope scope, GitDiffContext context, + const std::shared_ptr> &clock, + std::uint64_t generation, std::size_t &retainedBytes) { + git_repository *rawRepository = nullptr; + const QByteArray encodedRoot = QFile::encodeName(root); + if (git_repository_open(&rawRepository, encodedRoot.constData()) < 0) + return false; + GitPointer repository(rawRepository, + git_repository_free); git_diff_options options = GIT_DIFF_OPTIONS_INIT; options.flags = GIT_DIFF_INCLUDE_UNTRACKED | GIT_DIFF_RECURSE_UNTRACKED_DIRS | @@ -146,52 +206,48 @@ GitDiffSnapshot collect(QString workspace, GitDiffScope scope, headTree(repository.get(), treeError); if (!treeError.isEmpty()) { snapshot.error = treeError; - return snapshot; + return false; } - git_diff *rawDiff = nullptr; - int diffResult = 0; - if (scope == GitDiffScope::Unstaged) { - diffResult = git_diff_index_to_workdir(&rawDiff, repository.get(), nullptr, - &options); - } else if (scope == GitDiffScope::Staged) { - diffResult = git_diff_tree_to_index(&rawDiff, repository.get(), tree.get(), - nullptr, &options); - } else { - diffResult = git_diff_tree_to_workdir_with_index( + int result = 0; + if (scope == GitDiffScope::Unstaged) + result = git_diff_index_to_workdir(&rawDiff, repository.get(), nullptr, + &options); + else if (scope == GitDiffScope::Staged) + result = git_diff_tree_to_index(&rawDiff, repository.get(), tree.get(), + nullptr, &options); + else + result = git_diff_tree_to_workdir_with_index( &rawDiff, repository.get(), tree.get(), &options); - } - if (diffResult < 0) { + if (result < 0) { snapshot.error = gitError(QStringLiteral("Unable to calculate Git changes.")); - return snapshot; + return false; } GitPointer diff(rawDiff, git_diff_free); - git_diff_find_options findOptions = GIT_DIFF_FIND_OPTIONS_INIT; findOptions.flags = GIT_DIFF_FIND_RENAMES | GIT_DIFF_FIND_COPIES | GIT_DIFF_FIND_FOR_UNTRACKED; git_diff_find_similar(diff.get(), &findOptions); - std::size_t retainedBytes = 0; const std::size_t count = git_diff_num_deltas(diff.get()); - snapshot.files.reserve(count); for (std::size_t index = 0; index < count; ++index) { if (clock->load() != generation) - return snapshot; + return false; const git_diff_delta *delta = git_diff_get_delta(diff.get(), index); if (!delta || delta->status == GIT_DELTA_UNMODIFIED || delta->status == GIT_DELTA_IGNORED) continue; GitDiffFile file; + file.repositoryRoot = root; file.path = text(delta->new_file.path); if (file.path.isEmpty()) file.path = text(delta->old_file.path); + file.absolutePath = QDir(root).absoluteFilePath(file.path); file.previousPath = text(delta->old_file.path); if (file.previousPath == file.path) file.previousPath.clear(); file.status = statusName(delta->status); file.binary = (delta->flags & GIT_DIFF_FLAG_BINARY) != 0; - git_patch *rawPatch = nullptr; const int patchResult = git_patch_from_diff(&rawPatch, diff.get(), index); GitPointer patch(rawPatch, git_patch_free); @@ -219,8 +275,94 @@ GitDiffSnapshot collect(QString workspace, GitDiffScope scope, } snapshot.files.push_back(std::move(file)); } + return true; +} + +GitDiffSnapshot collect(QString workspace, QStringList directories, + QStringList paths, QString selectedRepository, + bool includeHiddenRepositories, GitDiffScope scope, + GitDiffContext context, + const std::shared_ptr> &clock, + std::uint64_t generation) { + GitDiffSnapshot snapshot; + snapshot.workspace = workspace.isEmpty() + ? QString{} + : QDir::cleanPath(std::move(workspace)); + snapshot.scope = scope; + snapshot.context = context; + if (!snapshot.workspace.isEmpty()) + directories.prepend(snapshot.workspace); + directories.removeDuplicates(); + + QStringList roots; + QHash rootDirectories; + for (const QString &directory : directories) { + if (clock->load() != generation) + return snapshot; + if (!includeHiddenRepositories && containsHiddenDirectory(directory)) + continue; + const QString root = discoverRoot(directory); + if (root.isEmpty() || + (!includeHiddenRepositories && containsHiddenDirectory(root))) + continue; + rootDirectories[root].push_back(directory); + if (!roots.contains(root)) + roots.push_back(root); + } + if (roots.isEmpty()) { + snapshot.error = snapshot.workspace.isEmpty() + ? QStringLiteral("Select a thread to inspect changes.") + : QStringLiteral("Change review requires a Git repository."); + return snapshot; + } + + QStringList matched; + QHash> hintScores; + for (const QString &root : roots) + hintScores.insert(root, + rootHintScores(root, rootDirectories[root], paths)); + for (qsizetype pathIndex = 0; pathIndex < paths.size(); ++pathIndex) { + QStringList pathMatches; + int bestScore = 0; + for (const QString &root : roots) { + const int score = + hintScores[root][static_cast(pathIndex)]; + if (score > bestScore) { + bestScore = score; + pathMatches.clear(); + } + if (score != 0 && score == bestScore) + pathMatches.push_back(root); + } + for (const QString &root : pathMatches) { + if (!matched.contains(root)) + matched.push_back(root); + } + } + if (!matched.isEmpty()) + roots = std::move(matched); + std::sort(roots.begin(), roots.end(), [](const QString &left, + const QString &right) { + return QString::localeAwareCompare(left, right) < 0; + }); + snapshot.repositoryRoots = roots; + snapshot.repository = true; + + if (!selectedRepository.isEmpty() && roots.contains(selectedRepository)) + roots = {selectedRepository}; + snapshot.repositoryRoot = roots.size() == 1 ? roots.front() : QString{}; + std::size_t retainedBytes = 0; + for (const QString &root : roots) { + if (clock->load() != generation) + return snapshot; + appendRepository(snapshot, root, scope, context, clock, generation, + retainedBytes); + } std::sort(snapshot.files.begin(), snapshot.files.end(), [](const GitDiffFile &left, const GitDiffFile &right) { + if (left.repositoryRoot != right.repositoryRoot) + return QString::localeAwareCompare(left.repositoryRoot, + right.repositoryRoot) < 0; return QString::localeAwareCompare(left.path, right.path) < 0; }); return snapshot; @@ -241,17 +383,28 @@ void GitDiffProvider::cancel() { emit loadingChanged(false); } -void GitDiffProvider::request(QString workspace, GitDiffScope scope, +void GitDiffProvider::request(QString workspace, + QStringList candidateDirectories, + QStringList changedPaths, + QString selectedRepository, + bool includeHiddenRepositories, + GitDiffScope scope, GitDiffContext context) { const std::uint64_t requested = generation->fetch_add(1) + 1; const auto clock = generation; const QPointer receiver(this); emit loadingChanged(true); QThreadPool::globalInstance()->start( - [receiver, clock, requested, workspace = std::move(workspace), scope, + [receiver, clock, requested, workspace = std::move(workspace), + candidateDirectories = std::move(candidateDirectories), + changedPaths = std::move(changedPaths), + selectedRepository = std::move(selectedRepository), + includeHiddenRepositories, scope, context]() mutable { - GitDiffSnapshot snapshot = - collect(std::move(workspace), scope, context, clock, requested); + GitDiffSnapshot snapshot = collect( + std::move(workspace), std::move(candidateDirectories), + std::move(changedPaths), std::move(selectedRepository), + includeHiddenRepositories, scope, context, clock, requested); if (clock->load() != requested) return; QMetaObject::invokeMethod( diff --git a/src/codex/GitDiffProvider.h b/src/codex/GitDiffProvider.h index 1250438..2a19203 100644 --- a/src/codex/GitDiffProvider.h +++ b/src/codex/GitDiffProvider.h @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -17,7 +18,9 @@ enum class GitDiffScope { Unstaged, Staged, Uncommitted }; enum class GitDiffContext { Compact, Expanded }; struct GitDiffFile { + QString repositoryRoot; QString path; + QString absolutePath; QString previousPath; QString status; QString patch; @@ -29,6 +32,7 @@ struct GitDiffFile { struct GitDiffSnapshot { QString workspace; QString repositoryRoot; + QStringList repositoryRoots; QString error; GitDiffScope scope = GitDiffScope::Unstaged; GitDiffContext context = GitDiffContext::Compact; @@ -44,7 +48,9 @@ class GitDiffProvider final : public QObject { explicit GitDiffProvider(QObject *parent = nullptr); ~GitDiffProvider() override; - void request(QString workspace, GitDiffScope scope, + void request(QString workspace, QStringList candidateDirectories, + QStringList changedPaths, QString selectedRepository, + bool includeHiddenRepositories, GitDiffScope scope, GitDiffContext context); void cancel(); diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 05a838e..9d47421 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -60,6 +60,30 @@ std::string requestKey(const nlohmann::json &value) { return value.is_null() ? std::string{} : value.dump(); } +void appendUnique(std::vector &values, const std::string &value, + std::size_t maximum) { + if (value.empty() || + std::find(values.begin(), values.end(), value) != values.end()) + return; + if (values.size() == maximum) + values.erase(values.begin()); + values.push_back(value); +} + +void retainRepositoryHints(ThreadPresentation &thread, + const nlohmann::json &item) { + const std::string type = stringValue(item, "type"); + if (type == "commandExecution") + appendUnique(thread.commandCwds, stringValue(item, "cwd"), 64); + if (type != "fileChange") + return; + const auto changes = item.find("changes"); + if (changes == item.end() || !changes->is_array()) + return; + for (const auto &change : *changes) + appendUnique(thread.changedPaths, stringValue(change, "path"), 512); +} + bool isSpawnActivity(const nlohmann::json &activity) { const std::string type = stringValue(activity, "type"); if (type == "subAgentActivity") @@ -445,9 +469,11 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { return; } if (type == "conversation.file-change.patch-replaced") { - if (ItemPresentation *item = findItem(scope)) + if (ItemPresentation *item = findItem(scope)) { item->raw["changes"] = memberValue(data, "changes", nlohmann::json::array()); + retainRepositoryHints(thread, item->raw); + } return; } if (type == "conversation.mcp.progress") { @@ -606,6 +632,8 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, result.turns.clear(); result.agentOrder.clear(); result.agents.clear(); + result.commandCwds.clear(); + result.changedPaths.clear(); } for (const auto &turn : *turns) upsertTurn(result, turn, replaceTurns); @@ -667,6 +695,7 @@ ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, mergePreservingCompleteness(result.raw, raw); } const std::string type = stringValue(result.raw, "type"); + retainRepositoryHints(thread, result.raw); if (type == "subAgentActivity" || type == "collabAgentToolCall") { upsertAgentActivity( thread, diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 3a6f5d3..bfa0bf9 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -47,6 +47,8 @@ struct ThreadPresentation { std::optional createdAt; std::optional updatedAt; std::optional recencyAt; + std::vector commandCwds; + std::vector changedPaths; std::vector turnOrder; std::unordered_map turns; nlohmann::json raw = nlohmann::json::object(); diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 44e96f4..415497f 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -2579,7 +2579,17 @@ void ShellWidget::refreshInspector() { activeLayout = requestsLayout; const ThreadPresentation *thread = model.thread(selectedThreadId); if (activeTab == 2) { - diffViewer->setWorkspace(thread ? text(thread->cwd) : QString{}); + QStringList commandCwds; + QStringList changedPaths; + if (thread) { + for (const std::string &value : thread->commandCwds) + commandCwds.push_back(text(value)); + for (const std::string &value : thread->changedPaths) + changedPaths.push_back(text(value)); + } + diffViewer->setRepositoryContext( + text(selectedThreadId), thread ? text(thread->cwd) : QString{}, + std::move(commandCwds), std::move(changedPaths)); diffViewer->refreshRepository(); return; } diff --git a/src/greenfield/codex/middle/InspectorPane.cpp b/src/greenfield/codex/middle/InspectorPane.cpp index 73d09f9..1b58b5f 100644 --- a/src/greenfield/codex/middle/InspectorPane.cpp +++ b/src/greenfield/codex/middle/InspectorPane.cpp @@ -37,6 +37,14 @@ QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } +QStringList texts(const std::vector &values) { + QStringList result; + result.reserve(static_cast(values.size())); + for (const std::string &value : values) + result.push_back(text(value)); + return result; +} + QString joinedStrings(const nlohmann::json &value) { if (!value.is_array()) return {}; @@ -535,7 +543,10 @@ void InspectorPane::refreshAgents() { void InspectorPane::refreshChanges() { const ThreadPresentation *thread = currentModel->thread(currentThreadId); - diffViewer->setWorkspace(thread ? text(thread->cwd) : QString{}); + diffViewer->setRepositoryContext( + text(currentThreadId), thread ? text(thread->cwd) : QString{}, + thread ? texts(thread->commandCwds) : QStringList{}, + thread ? texts(thread->changedPaths) : QStringList{}); diffViewer->refreshRepository(); } diff --git a/tests/codex/GitChangesLiveTest.cpp b/tests/codex/GitChangesLiveTest.cpp new file mode 100644 index 0000000..b21108f --- /dev/null +++ b/tests/codex/GitChangesLiveTest.cpp @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/DiffViewer.h" +#include "codex/GitDiffProvider.h" +#include "codex/ui/UiStyle.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace { + +using codexui::codex::DiffViewer; +using codexui::codex::GitDiffFile; +using codexui::codex::GitDiffSnapshot; + +bool expect(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +bool writeFile(const QString &path, const QByteArray &contents) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + return file.write(contents) == contents.size(); +} + +bool replaceFile(const QString &path, const QByteArray &contents) { + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || + file.write(contents) != contents.size()) + return false; + return file.commit(); +} + +bool waitFor(const std::function &condition, int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (!condition() && timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(2); + } + return condition(); +} + +bool createInitialCommit(git_repository *repository, const QString &root) { + if (!writeFile(QDir(root).filePath(QStringLiteral("tracked.txt")), + QByteArray("original\n"))) + return false; + git_index *index = nullptr; + if (git_repository_index(&index, repository) < 0) + return false; + const bool indexed = git_index_add_bypath(index, "tracked.txt") == 0 && + git_index_write(index) == 0; + git_oid treeId{}; + const bool wroteTree = indexed && git_index_write_tree(&treeId, index) == 0; + git_index_free(index); + if (!wroteTree) + return false; + git_tree *tree = nullptr; + git_signature *signature = nullptr; + if (git_tree_lookup(&tree, repository, &treeId) < 0 || + git_signature_now(&signature, "CodexUI Test", "codexui@example.invalid") < + 0) { + git_tree_free(tree); + git_signature_free(signature); + return false; + } + git_oid commitId{}; + const bool committed = + git_commit_create(&commitId, repository, "HEAD", signature, signature, + nullptr, "initial", tree, 0, nullptr) == 0; + git_signature_free(signature); + git_tree_free(tree); + return committed; +} + +bool hasFile(const GitDiffSnapshot &snapshot, const QString &path, + const QString &status = {}) { + for (const GitDiffFile &file : snapshot.files) { + if (file.path == path && (status.isEmpty() || file.status == status)) + return true; + } + return false; +} + +bool testLiveWorkingTreeChanges() { + QTemporaryDir directory; + if (!expect(directory.isValid(), "creates a temporary repository")) + return false; + git_repository *repository = nullptr; + if (!expect(git_repository_init(&repository, + directory.path().toUtf8().constData(), 0) == + 0, + "initializes a repository with libgit2")) + return false; + if (!expect(createInitialCommit(repository, directory.path()), + "creates an initial commit with libgit2")) { + git_repository_free(repository); + return false; + } + + DiffViewer viewer; + viewer.resize(700, 500); + viewer.show(); + const QString threadId = + QStringLiteral("live-%1").arg(directory.path()); + viewer.setRepositoryContext( + threadId, directory.path(), {directory.path()}, {}); + bool result = expect( + waitFor([&] { return viewer.currentSnapshot().repository; }, 1500) && + viewer.currentSnapshot().files.empty(), + "starts from the clean real working tree"); + + const QString manual = + directory.filePath(QStringLiteral("nested/manual.txt")); + QDir().mkpath(QFileInfo(manual).absolutePath()); + result &= expect(writeFile(manual, QByteArray("created by hand\n")) && + waitFor( + [&] { + return hasFile(viewer.currentSnapshot(), + QStringLiteral("nested/manual.txt"), + QStringLiteral("Untracked")); + }, + 3500), + "discovers a manually created untracked file"); + for (QTimer *timer : viewer.findChildren()) { + if (!timer->isSingleShot()) + timer->stop(); + } + result &= expect(QFile::remove(manual) && + waitFor( + [&] { + return !hasFile(viewer.currentSnapshot(), + QStringLiteral("nested/manual.txt")); + }, + 1500), + "removes a reverted untracked file after a filesystem event"); + + const QString tracked = + directory.filePath(QStringLiteral("tracked.txt")); + const bool modified = writeFile(tracked, QByteArray("modified\n")); + viewer.refreshRepository(); + result &= expect(modified && + waitFor( + [&] { + return hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt"), + QStringLiteral("Modified")); + }, + 3500), + "discovers a modified tracked file"); + result &= expect(writeFile(tracked, QByteArray("original\n")) && + waitFor( + [&] { + return !hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt")); + }, + 1500), + "removes a content reversion after a filesystem event"); + + const bool atomicallyModified = + replaceFile(tracked, QByteArray("atomic modification\n")); + viewer.refreshRepository(); + result &= expect(atomicallyModified && + waitFor( + [&] { + return hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt"), + QStringLiteral("Modified")); + }, + 1500), + "refreshes after an atomic file replacement"); + result &= expect(replaceFile(tracked, QByteArray("original\n")) && + waitFor( + [&] { + return !hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt")); + }, + 1500), + "re-registers watches and removes an atomic reversion"); + + const bool deleted = QFile::remove(tracked); + viewer.refreshRepository(); + result &= expect(deleted && + waitFor( + [&] { + return hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt"), + QStringLiteral("Deleted")); + }, + 1500), + "represents a deleted tracked file consistently"); + result &= expect(writeFile(tracked, QByteArray("original\n")) && + waitFor( + [&] { + return !hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt")); + }, + 1500), + "removes a restored deletion after a directory event"); + + DiffViewer restartedViewer; + restartedViewer.resize(700, 500); + restartedViewer.show(); + restartedViewer.setRepositoryContext( + threadId, QFileInfo(directory.path()).absolutePath(), {}, {}); + result &= expect( + waitFor( + [&] { + return restartedViewer.currentSnapshot().repositoryRoots == + QStringList{QDir::cleanPath(directory.path())}; + }, + 1500), + "restores a one-repository thread from persisted resolution after viewer recreation"); + + git_repository_free(repository); + return result; +} + +} // namespace + +int main(int argc, char **argv) { + QApplication application(argc, argv); + git_libgit2_init(); + const bool result = testLiveWorkingTreeChanges(); + git_libgit2_shutdown(); + return result ? 0 : 1; +} diff --git a/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/GreenfieldLayoutTest.cpp index f2a3259..72aca53 100644 --- a/tests/codex/GreenfieldLayoutTest.cpp +++ b/tests/codex/GreenfieldLayoutTest.cpp @@ -49,6 +49,43 @@ bool expect(bool condition, const char *message) { return false; } +bool commitPath(git_repository *repository, const char *path) { + git_index *index = nullptr; + if (git_repository_index(&index, repository) < 0) + return false; + const bool indexed = git_index_add_bypath(index, path) == 0 && + git_index_write(index) == 0; + git_oid treeId{}; + const bool wroteTree = indexed && git_index_write_tree(&treeId, index) == 0; + git_index_free(index); + if (!wroteTree) + return false; + git_tree *tree = nullptr; + git_signature *signature = nullptr; + if (git_tree_lookup(&tree, repository, &treeId) < 0 || + git_signature_now(&signature, "CodexUI Test", "codexui@example.invalid") < + 0) { + git_tree_free(tree); + git_signature_free(signature); + return false; + } + git_oid commitId{}; + git_reference *head = nullptr; + git_commit *parent = nullptr; + if (git_repository_head(&head, repository) == 0) + git_commit_lookup(&parent, repository, git_reference_target(head)); + const git_commit *parents[] = {parent}; + const bool committed = + git_commit_create(&commitId, repository, "HEAD", signature, signature, + nullptr, "path baseline", tree, parent ? 1 : 0, + parent ? parents : nullptr) == 0; + git_commit_free(parent); + git_reference_free(head); + git_signature_free(signature); + git_tree_free(tree); + return committed; +} + bool hasLabelContaining(const QWidget &root, const QString &text) { for (const QLabel *label : root.findChildren()) { if (label->text().contains(text)) @@ -680,10 +717,15 @@ bool testGitDiffScopes() { received = snapshot; ready = true; }); - const auto request = [&](GitDiffScope scope) { + const auto request = [&](const QString &workspace, + const QStringList &directories, + const QStringList &paths, + const QString &selectedRepository, + GitDiffScope scope, + bool includeHiddenRepositories = false) { ready = false; - provider.request(repositoryDirectory.path(), scope, - GitDiffContext::Compact); + provider.request(workspace, directories, paths, selectedRepository, + includeHiddenRepositories, scope, GitDiffContext::Compact); QElapsedTimer timeout; timeout.start(); while (!ready && timeout.elapsed() < 3000) @@ -691,7 +733,8 @@ bool testGitDiffScopes() { return ready; }; - bool result = expect(request(GitDiffScope::Unstaged) && + bool result = expect(request(repositoryDirectory.path(), {}, {}, {}, + GitDiffScope::Unstaged) && received.repository && received.error.isEmpty() && received.files.size() == 1 && received.files.front().status == @@ -706,29 +749,147 @@ bool testGitDiffScopes() { git_index_write(index); git_index_free(index); } - result &= expect(request(GitDiffScope::Staged) && + result &= expect(request(repositoryDirectory.path(), {}, {}, {}, + GitDiffScope::Staged) && received.files.size() == 1 && received.files.front().status == QStringLiteral("Added"), "Staged scope compares the index with HEAD"); - result &= expect(request(GitDiffScope::Uncommitted) && + result &= expect(request(repositoryDirectory.path(), {}, {}, {}, + GitDiffScope::Uncommitted) && received.files.size() == 1 && received.files.front().patch.contains( QStringLiteral("+second line")), "Since-HEAD scope combines index and worktree state"); QTemporaryDir ordinaryDirectory; - ready = false; - provider.request(ordinaryDirectory.path(), GitDiffScope::Unstaged, - GitDiffContext::Compact); - QElapsedTimer timeout; - timeout.start(); - while (!ready && timeout.elapsed() < 3000) - spin(1); - result &= expect(ordinaryDirectory.isValid() && ready && + result &= expect(ordinaryDirectory.isValid() && + request(ordinaryDirectory.path(), {}, {}, {}, + GitDiffScope::Unstaged) && !received.repository && received.error.contains(QStringLiteral("Git repository")), "ordinary folders expose an explicit non-repository state"); + + QTemporaryDir multiWorkspace; + const QString firstRoot = multiWorkspace.filePath(QStringLiteral("first")); + const QString secondRoot = multiWorkspace.filePath(QStringLiteral("second")); + const QString hiddenRoot = + multiWorkspace.filePath(QStringLiteral(".hidden/repository")); + git_repository *firstRepository = nullptr; + git_repository *secondRepository = nullptr; + git_repository *hiddenRepository = nullptr; + git_repository_init(&firstRepository, firstRoot.toUtf8().constData(), 0); + git_repository_init(&secondRepository, secondRoot.toUtf8().constData(), 0); + QDir().mkpath(hiddenRoot); + git_repository_init(&hiddenRepository, hiddenRoot.toUtf8().constData(), 0); + for (const QString &root : {firstRoot, secondRoot, hiddenRoot}) { + QFile shared(QDir(root).filePath(QStringLiteral("shared.txt"))); + if (shared.open(QIODevice::WriteOnly | QIODevice::Truncate)) + shared.write("shared path\n"); + } + QFile firstOnly(QDir(firstRoot).filePath(QStringLiteral("first-only.txt"))); + if (firstOnly.open(QIODevice::WriteOnly | QIODevice::Truncate)) + firstOnly.write("first repository\n"); + firstOnly.close(); + result &= expect( + request(multiWorkspace.path(), + {firstRoot, firstRoot, hiddenRoot, secondRoot}, + {QStringLiteral("shared.txt")}, {}, GitDiffScope::Unstaged) && + received.repositoryRoots.size() == 2 && received.files.size() == 3 && + !received.repositoryRoots.contains(QDir::cleanPath(hiddenRoot)), + "duplicate directories are deduplicated, hidden roots are excluded, and ambiguous paths retain visible matches"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot, hiddenRoot}, + {QStringLiteral("shared.txt")}, {}, GitDiffScope::Unstaged, + true) && + received.repositoryRoots.size() == 3 && received.files.size() == 4 && + received.repositoryRoots.contains(QDir::cleanPath(hiddenRoot)), + "the explicit hidden-repository option includes hidden candidates"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {QStringLiteral("shared.txt")}, firstRoot, + GitDiffScope::Unstaged) && + received.repositoryRoots.size() == 2 && received.files.size() == 2 && + received.files.front().repositoryRoot == QDir::cleanPath(firstRoot), + "repository selection filters files without losing the candidate set"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {QStringLiteral("first-only.txt")}, {}, + GitDiffScope::Unstaged) && + received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && + received.files.size() == 2, + "a unique relative path resolves one repository and includes all of its changes"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {QDir(secondRoot).filePath(QStringLiteral("shared.txt"))}, {}, + GitDiffScope::Unstaged) && + received.repositoryRoots == + QStringList{QDir::cleanPath(secondRoot)} && + received.files.size() == 1, + "an absolute path resolves only its owning repository"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {QStringLiteral("not-applied-yet.txt")}, + QStringLiteral("/stale/repository"), GitDiffScope::Unstaged) && + received.repositoryRoots.size() == 2 && received.files.size() == 3, + "an unmatched early path and stale selection safely fall back to all candidate repositories"); + const QString priorityPath = QStringLiteral("priority.txt"); + QFile firstPriority(QDir(firstRoot).filePath(priorityPath)); + QFile secondPriority(QDir(secondRoot).filePath(priorityPath)); + const bool priorityFiles = + firstPriority.open(QIODevice::WriteOnly | QIODevice::Truncate) && + firstPriority.write("baseline\n") > 0; + firstPriority.close(); + const bool secondPriorityFile = + secondPriority.open(QIODevice::WriteOnly | QIODevice::Truncate) && + secondPriority.write("baseline\n") > 0; + secondPriority.close(); + const bool priorityCommitted = + priorityFiles && secondPriorityFile && + commitPath(firstRepository, "priority.txt") && + commitPath(secondRepository, "priority.txt"); + if (firstPriority.open(QIODevice::WriteOnly | QIODevice::Truncate)) + firstPriority.write("changed\n"); + firstPriority.close(); + result &= expect( + priorityCommitted && + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {priorityPath}, {}, GitDiffScope::Unstaged) && + received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && + received.files.size() == 3 && + std::any_of(received.files.begin(), received.files.end(), + [&](const GitDiffFile &file) { + return file.path == priorityPath && + file.status == QStringLiteral("Modified"); + }), + "a currently changed path is preferred over the same clean tracked path"); + const QString secondCleanPath = QStringLiteral("second-clean.txt"); + QFile secondClean(QDir(secondRoot).filePath(secondCleanPath)); + const bool secondCleanCreated = + secondClean.open(QIODevice::WriteOnly | QIODevice::Truncate) && + secondClean.write("clean unique path\n") > 0; + secondClean.close(); + result &= expect( + secondCleanCreated && commitPath(secondRepository, "second-clean.txt") && + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {priorityPath, secondCleanPath}, {}, + GitDiffScope::Unstaged) && + received.repositoryRoots.size() == 2 && received.files.size() == 4, + "changed-file preference is applied independently for every hinted path"); + QFile::remove(QDir(firstRoot).filePath(priorityPath)); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, {priorityPath}, + {}, GitDiffScope::Unstaged) && + received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && + std::any_of(received.files.begin(), received.files.end(), + [&](const GitDiffFile &file) { + return file.path == priorityPath && + file.status == QStringLiteral("Deleted"); + }), + "a deleted path is resolved from Git state and preferred over a clean tracked match"); + git_repository_free(firstRepository); + git_repository_free(secondRepository); + git_repository_free(hiddenRepository); git_repository_free(repository); return result; } diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index 30dd8a4..50393b6 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -205,6 +205,44 @@ int main() { "incomplete thread reads preserve live plan and inspector state"); passed &= expect(!model.activeTurnId("thread-1").has_value(), "completed stream leaves no active turn"); + + PresentationModel hydratedModel; + ProtocolNormalizer hydratedNormalizer( + [&](const nlohmann::json &frame) { + hydratedModel.applyEvent(frame); + return true; + }); + hydratedNormalizer.transportEvent("connected"); + hydratedNormalizer.operationResult( + "thread.read", "repository-hints", {{"threadId", "repository-thread"}}, + {{"id", "repository-hints"}, + {"result", + {{"thread", + {{"id", "repository-thread"}, + {"cwd", "/workspace"}, + {"turns", + nlohmann::json::array( + {{{"id", "repository-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "repository-command"}, + {"type", "commandExecution"}, + {"cwd", "/workspace/project/src"}}, + {{"id", "repository-change"}, + {"type", "fileChange"}, + {"changes", + nlohmann::json::array( + {{{"path", "lib/example.cpp"}}, + {{"path", "removed.txt"}}})}}})}}})}}}}}}); + const auto *repositoryThread = hydratedModel.thread("repository-thread"); + passed &= expect( + repositoryThread != nullptr && + repositoryThread->commandCwds == + std::vector{"/workspace/project/src"} && + repositoryThread->changedPaths == + std::vector{"lib/example.cpp", "removed.txt"}, + "authoritative thread hydration retains compact repository hints"); + normalizer.bridgeEvent({{"kind", "bridge.provider"}, {"state", "disconnected"}, {"providerGeneration", std::uint64_t{1}}, diff --git a/ui-review/UI-INVENTORY.md b/ui-review/UI-INVENTORY.md index eb81ce2..034417b 100644 --- a/ui-review/UI-INVENTORY.md +++ b/ui-review/UI-INVENTORY.md @@ -69,7 +69,8 @@ - **Plan:** structured current plan or authoritative textual plan fallback. - **Agents:** identified collaboration and subagent activity. -- **Changes:** per-file unified diff with addition/deletion counts, copy, and +- **Changes:** multi-repository selector, per-file unified diff with + addition/deletion counts, live filesystem refresh, copy, and expanded viewing. - **Requests:** typed approval and input requests with explicit resolution. - **Info / State:** retained normalized presentation domains. diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 693b5a2..d0407c5 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -106,12 +106,19 @@ are frameless and transparent so the panel background remains continuous. Plan, Agents, and Requests retain their last visible per-thread presentation across thread and tab navigation. -Changes reflects the local Git worktree containing the selected thread's -workspace, never a patch reconstructed from conversation messages. Its compact -Inspector surface contains scope, file summary/list, and a unified preview. -Copy and Open review belong to the selected-file preview; double-clicking a file -also opens review. The modeless review window provides Unified or Side by side -layout and Compact or Expanded context without blocking conversation use. +Changes reflects the local Git worktrees resolved from the selected thread's +retained command directories, never a patch reconstructed from conversation +messages. When several repositories match, the compact Inspector surface +defaults to All repositories and offers a repository selector beside scope, +file summary/list, and unified preview. Repository-qualified file labels remove +ambiguity. Copy and Open review belong to the selected-file preview; +double-clicking a file also opens review. The modeless review window provides +Unified or Side by side layout and Compact or Expanded context without blocking +conversation use. Manual filesystem changes use the same libgit2 authority as +Codex changes; filesystem watches and a short safety refresh remove clean files +and discover new untracked files. The compact and review scrollbars provide an +overview ruler: canonical green marks additions, red marks deletions, and blue +marks hunk boundaries. ## Desktop integration From 693e9978fcee87cd7018a7e6f8c74d2b1102272c Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 00:42:20 +0200 Subject: [PATCH 07/36] Polish changes inspector layout --- docs/ui-behavior.md | 8 +-- src/codex/DiffViewer.cpp | 92 +++++++++++++++++++++----------- src/codex/DiffViewer.h | 3 ++ src/codex/ui/UiStyle.cpp | 4 ++ ui-review/UX-DESIGN-DECISIONS.md | 18 ++++++- 5 files changed, 91 insertions(+), 34 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 312da6a..5fc5f70 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -210,9 +210,11 @@ work. Manual and Codex-created changes are treated identically. The Inspector shows a compact unified preview. Open review and double-clicking a changed file open a modeless review window with Unified or Side by side layout and Compact or Expanded context. These view preferences persist across -threads. Diff scrollbars show proportional overview marks using canonical -green for additions, red for deletions, and blue for hunk boundaries. Existing -changed files and their parent directories are watched; +threads. The changed-file list ends with a compact footer containing the file +count and semantic green/red totals; a standard gray divider separates that +selection area from the preview. Diff scrollbars show proportional overview +marks using canonical green for additions, red for deletions, and blue for hunk +boundaries. Existing changed files and their parent directories are watched; reverted or restored files disappear after libgit2 confirms they are clean, while a short visible-only refresh discovers new untracked files and catches index-only changes. diff --git a/src/codex/DiffViewer.cpp b/src/codex/DiffViewer.cpp index bb38312..f47e2eb 100644 --- a/src/codex/DiffViewer.cpp +++ b/src/codex/DiffViewer.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -584,35 +585,28 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { repositoryTimer->start(); auto *root = new QVBoxLayout(this); - root->setContentsMargins(10, 10, 10, 10); + root->setContentsMargins(0, 0, 0, 0); root->setSpacing(8); - summary = label(QStringLiteral("No file changes"), "title"); - authority = label({}, "meta"); - auto *headerText = new QVBoxLayout; - headerText->setSpacing(1); - headerText->addWidget(summary); - headerText->addWidget(authority); - root->addLayout(headerText); auto *filters = new QHBoxLayout; - filters->addStretch(); + filters->setContentsMargins(10, 10, 10, 0); + filters->setSpacing(8); repositories = new ChevronComboBox; repositories->setObjectName(QStringLiteral("codexDiffRepository")); repositories->setProperty("codexChevron", true); repositories->addItem(QStringLiteral("Repository"), QString{}); - repositories->setFixedHeight(30); - repositories->setMinimumWidth(130); - filters->addWidget(repositories); + repositories->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + filters->addWidget(repositories, 1); hiddenRepositories = new QPushButton(QStringLiteral("Hidden")); hiddenRepositories->setObjectName( QStringLiteral("codexDiffHiddenRepositories")); hiddenRepositories->setProperty("kind", "segment"); + hiddenRepositories->setProperty("comboPeer", true); hiddenRepositories->setCheckable(true); hiddenRepositories->setChecked( QSettings().value(QStringLiteral("diff/includeHiddenRepositories"), false) .toBool()); hiddenRepositories->setToolTip( QStringLiteral("Also include hidden repositories")); - hiddenRepositories->setFixedHeight(30); filters->addWidget(hiddenRepositories); scope = new ChevronComboBox; scope->setObjectName(QStringLiteral("codexDiffScope")); @@ -623,34 +617,63 @@ DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { static_cast(GitDiffScope::Staged)); scope->addItem(QStringLiteral("Since HEAD"), static_cast(GitDiffScope::Uncommitted)); - scope->setFixedHeight(30); const int savedScope = QSettings().value(QStringLiteral("diff/scope"), 0).toInt(); scope->setCurrentIndex(std::clamp(savedScope, 0, scope->count() - 1)); - filters->addWidget(scope); + scope->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + filters->addWidget(scope, 1); root->addLayout(filters); files = new QListWidget; files->setObjectName(QStringLiteral("codexDiffFiles")); files->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); files->setMaximumHeight(170); - root->addWidget(files); + auto *fileList = new QVBoxLayout; + fileList->setContentsMargins(10, 0, 10, 0); + fileList->addWidget(files); + root->addLayout(fileList); + + auto *fileSummary = new QHBoxLayout; + fileSummary->setContentsMargins(10, 0, 10, 0); + fileSummary->setSpacing(8); + summary = label(QStringLiteral("No changes"), "meta"); + authority = label({}, "meta"); + authority->setWordWrap(false); + authority->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + truncationSummary = label({}, "attentionSection"); + additionSummary = label({}, "diffAdditionMeta"); + deletionSummary = label({}, "diffDeletionMeta"); + fileSummary->addWidget(summary); + fileSummary->addWidget(authority, 1); + fileSummary->addWidget(truncationSummary); + fileSummary->addWidget(additionSummary); + fileSummary->addWidget(deletionSummary); + root->addLayout(fileSummary); + + auto *previewDivider = new QFrame; + previewDivider->setProperty("kind", "standardDivider"); + previewDivider->setFixedHeight(1); + root->addWidget(previewDivider); auto *previewHeader = new QHBoxLayout; + previewHeader->setContentsMargins(10, 0, 10, 0); selectedFile = label(QStringLiteral("Select a changed file"), "title"); previewHeader->addWidget(selectedFile, 1); copyButton = new QPushButton(QStringLiteral("Copy")); copyButton->setProperty("kind", "subtle"); copyButton->setFixedHeight(28); reviewButton = new QPushButton(QStringLiteral("Open review")); - reviewButton->setFixedHeight(28); + reviewButton->setProperty("comboPeer", true); previewHeader->addWidget(copyButton); previewHeader->addWidget(reviewButton); root->addLayout(previewHeader); diff = diffView(QStringLiteral("codexDiffText")); diff->setPlaceholderText(QStringLiteral("Select a changed file.")); - root->addWidget(diff, 1); + auto *diffArea = new QVBoxLayout; + diffArea->setContentsMargins(10, 0, 10, 10); + diffArea->addWidget(diff); + root->addLayout(diffArea, 1); connect(refreshTimer, &QTimer::timeout, this, [this] { provider->request(workspace, repositoryCandidates(), changedPaths, @@ -822,19 +845,28 @@ void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { if (!value.error.isEmpty()) { summary->setText(QStringLiteral("Changes unavailable")); authority->setText(value.error); + authority->setToolTip(value.error); + truncationSummary->clear(); + additionSummary->clear(); + deletionSummary->clear(); } else { - summary->setText(value.files.empty() - ? QStringLiteral("No file changes") - : QStringLiteral("%1 files +%2 −%3") - .arg(value.files.size()) - .arg(additions) - .arg(deletions)); - authority->setText( - value.truncated - ? QStringLiteral("%1 | display truncated | %2") - .arg(scopeName(value.scope), repositorySummary(value)) - : QStringLiteral("%1 | %2") - .arg(scopeName(value.scope), repositorySummary(value))); + summary->setText( + value.files.empty() + ? QStringLiteral("No changes") + : value.files.size() == 1 + ? QStringLiteral("1 changed file") + : QStringLiteral("%1 changed files").arg(value.files.size())); + authority->clear(); + authority->setToolTip(QString{}); + truncationSummary->setText(value.truncated + ? QStringLiteral("Display truncated") + : QString{}); + additionSummary->setText(value.files.empty() + ? QString{} + : QStringLiteral("+%1").arg(additions)); + deletionSummary->setText(value.files.empty() + ? QString{} + : QStringLiteral("−%1").arg(deletions)); } if (!value.files.empty()) { files->setCurrentRow(selected >= 0 ? selected : 0); diff --git a/src/codex/DiffViewer.h b/src/codex/DiffViewer.h index 3b7e58f..0f3f20b 100644 --- a/src/codex/DiffViewer.h +++ b/src/codex/DiffViewer.h @@ -56,6 +56,9 @@ class DiffViewer final : public QWidget { QPushButton *hiddenRepositories = nullptr; QLabel *summary = nullptr; QLabel *authority = nullptr; + QLabel *truncationSummary = nullptr; + QLabel *additionSummary = nullptr; + QLabel *deletionSummary = nullptr; QLabel *selectedFile = nullptr; QListWidget *files = nullptr; QPlainTextEdit *diff = nullptr; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 8c9ab50..94900e0 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -75,6 +75,8 @@ QString applicationStyleSheet() { QLabel[kind="body"] { font-size: %2pt; } QLabel[kind="meta"] { color: #667085; font-size: %1pt; } QLabel[kind="small"] { color: #667085; font-size: %1pt; } + QLabel[kind="diffAdditionMeta"] { color: #176b45; font-size: %1pt; font-weight: 600; } + QLabel[kind="diffDeletionMeta"] { color: #982f3d; font-size: %1pt; font-weight: 600; } QPushButton, QToolButton { background: #ffffff; border: 1px solid #d7dee8; @@ -83,6 +85,7 @@ QString applicationStyleSheet() { font-size: %1pt; font-weight: 600; } + QPushButton[comboPeer="true"] { min-height: 30px; max-height: 30px; } QPushButton:hover, QToolButton:hover { background: #f1f5fb; border-color: #b9c4d2; } QPushButton:pressed, QToolButton:pressed { background: #e5eeff; border-color: #bfd3f9; } QPushButton:focus, QToolButton:focus { border: 2px solid #2f6feb; } @@ -154,6 +157,7 @@ QString applicationStyleSheet() { QFrame[messageRole="user"] { background: #eaf2ff; border: 1px solid #bfd3f9; border-radius: 8px; } QFrame[messageRole="agent"] { background: #ffffff; border: 0; border-radius: 8px; } QFrame[kind="summary"] { background: #f8fafc; border: 1px solid #d7dee8; border-radius: 7px; } + QFrame[kind="standardDivider"] { background: #d7dee8; border: none; } QFrame[kind="greenBadge"] { background: #e9f7f0; border: 1px solid #a9d8c1; border-radius: 6px; } QFrame[kind="blueBadge"] { background: #e5eeff; border-radius: 5px; } QFrame[kind="orangeBadge"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index d0407c5..c1177cb 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -22,6 +22,18 @@ This document records the implemented CodexUI visual and interaction contract. | Orange | `#a85d0c` | `#8e4d09` | `#743e07` | `#fff6df` | `#e5c77d` | `#8a5208` | | Red | `#c43d4d` | `#aa3342` | `#8f2b38` | `#fff0f2` | `#efb8c0` | `#982f3d` | +Neutral separators and borders use three canonical intensity steps: + +| Intensity | Color | Role | +|---|---|---| +| Soft | `#eef1f5` | Subordinate internal separation | +| Standard | `#d7dee8` | Ordinary dividers and card/control borders | +| Strong | `#b9c4d2` | Hover, emphasis, and stronger structural separation | + +Ordinary one-pixel lines use Standard. Soft is reserved for deliberately +subordinate structure, while Strong must communicate interaction or hierarchy +rather than decorate a normal boundary. + Filled semantic buttons use white text and the primary, hover, and pressed steps without opacity changes. Their primary contrast against white ranges from 4.55:1 to 5.09:1. Blue denotes primary action or active work, green @@ -118,7 +130,11 @@ conversation use. Manual filesystem changes use the same libgit2 authority as Codex changes; filesystem watches and a short safety refresh remove clean files and discover new untracked files. The compact and review scrollbars provide an overview ruler: canonical green marks additions, red marks deletions, and blue -marks hunk boundaries. +marks hunk boundaries. The file list owns a compact muted footer with semantic +addition/deletion totals, followed by a standard gray divider before the selected +file preview. The divider spans the full tab page and aligns with the tab +underline, while adjacent content retains its normal inset. Repository and +scope are not repeated outside their controls. ## Desktop integration From 58e407f248b97fb89065614a3a1b9e052ddb96c9 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 01:03:54 +0200 Subject: [PATCH 08/36] Unify primary panel headers --- src/codex/ui/UiStyle.cpp | 8 ++++++- src/greenfield/codex/middle/InspectorPane.cpp | 12 ++++++++-- .../codex/middle/MiddleRegionWidget.cpp | 15 ++++++------ src/greenfield/codex/middle/ThreadPane.cpp | 13 ++++++++-- ui-review/UX-DESIGN-DECISIONS.md | 24 +++++++++++++++++++ 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 94900e0..e52000b 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -48,6 +48,7 @@ QString applicationStyleSheet() { QString::number(std::max(1.0, baseSize - 1.0), 'f', 1); const QString standard = QString::number(baseSize, 'f', 1); const QString section = QString::number(baseSize + 1.0, 'f', 1); + const QString panelHeader = QString::number(baseSize + 1.0, 'f', 1); const QString heading = QString::number(baseSize + 3.0, 'f', 1); return QStringLiteral(R"QSS( @@ -63,6 +64,11 @@ QString applicationStyleSheet() { font-size: %3pt; font-weight: 600; } + QLabel[kind="panelHeader"] { + color: #475467; + font-size: %5pt; + font-weight: 700; + } QLabel[kind="attentionSection"] { color: #a85d0c; font-size: %1pt; @@ -436,7 +442,7 @@ QString applicationStyleSheet() { } QToolTip { background: #ffffff; color: #1d2633; border: 1px solid #b9c4d2; padding: 5px; } )QSS") - .arg(compact, standard, section, heading); + .arg(compact, standard, section, heading, panelHeader); } } // namespace codexui::UiStyle diff --git a/src/greenfield/codex/middle/InspectorPane.cpp b/src/greenfield/codex/middle/InspectorPane.cpp index 1b58b5f..27da816 100644 --- a/src/greenfield/codex/middle/InspectorPane.cpp +++ b/src/greenfield/codex/middle/InspectorPane.cpp @@ -245,7 +245,11 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { outer->setContentsMargins(18, 14, 20, 0); outer->setSpacing(0); auto *heading = new QHBoxLayout; - heading->addWidget(makeLabel(QStringLiteral("INSPECTOR"), "section")); + heading->addStrut(24); + auto *sectionTitle = makeLabel(QStringLiteral("INSPECTOR"), "panelHeader"); + sectionTitle->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + sectionTitle->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); + heading->addWidget(sectionTitle); heading->addStretch(); auto *hide = new QPushButton(QStringLiteral("Hide")); hide->setProperty("kind", "subtle"); @@ -256,7 +260,11 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { }); heading->addWidget(hide); outer->addLayout(heading); - outer->addSpacing(7); + auto *headerDivider = new QFrame; + headerDivider->setProperty("kind", "standardDivider"); + headerDivider->setFixedHeight(1); + outer->addWidget(headerDivider); + outer->addSpacing(8); inspectorTabs = new QTabWidget; inspectorTabs->setDocumentMode(true); diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.cpp b/src/greenfield/codex/middle/MiddleRegionWidget.cpp index f002be7..188123e 100644 --- a/src/greenfield/codex/middle/MiddleRegionWidget.cpp +++ b/src/greenfield/codex/middle/MiddleRegionWidget.cpp @@ -83,15 +83,16 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { center->setContentsMargins(24, 14, 24, 12); center->setSpacing(0); auto *context = new QHBoxLayout; - auto *badge = makeLabel(QStringLiteral("THREAD"), "small"); - badge->setAlignment(Qt::AlignCenter); - badge->setFixedSize(58, 20); - badge->setStyleSheet(QStringLiteral( - "background:#e5eeff;color:#2f6feb;border-radius:5px;font-weight:600;")); - context->addWidget(badge); + context->addStrut(24); + auto *sectionTitle = + makeLabel(QStringLiteral("CONVERSATION"), "panelHeader"); + sectionTitle->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + sectionTitle->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); + context->addWidget(sectionTitle); context->addStretch(); center->addLayout(context); - center->addSpacing(2); + center->addWidget(divider()); + center->addSpacing(8); conversationTitle = makeLabel(QStringLiteral("No synchronized thread"), "heading"); conversationMetadata = makeLabel({}, "meta"); diff --git a/src/greenfield/codex/middle/ThreadPane.cpp b/src/greenfield/codex/middle/ThreadPane.cpp index 09a41ae..180469b 100644 --- a/src/greenfield/codex/middle/ThreadPane.cpp +++ b/src/greenfield/codex/middle/ThreadPane.cpp @@ -174,8 +174,12 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { layout->setContentsMargins(10, 14, 10, 17); layout->setSpacing(0); auto *header = new QHBoxLayout; - header->setContentsMargins(8, 0, 6, 8); - header->addWidget(makeLabel(QStringLiteral("WORK"), "section")); + header->setContentsMargins(8, 0, 6, 0); + header->addStrut(24); + auto *sectionTitle = makeLabel(QStringLiteral("THREADS"), "panelHeader"); + sectionTitle->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + sectionTitle->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); + header->addWidget(sectionTitle); header->addStretch(); auto *hide = new QPushButton(QStringLiteral("Hide")); hide->setProperty("kind", "subtle"); @@ -186,6 +190,11 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { }); header->addWidget(hide); layout->addLayout(header); + auto *headerDivider = new QFrame; + headerDivider->setProperty("kind", "standardDivider"); + headerDivider->setFixedHeight(1); + layout->addWidget(headerDivider); + layout->addSpacing(8); auto *create = new QPushButton(QStringLiteral("+ New thread")); create->setFixedHeight(36); diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index c1177cb..3d05167 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -14,6 +14,30 @@ This document records the implemented CodexUI visual and interaction contract. Commands, tool activity, files, and collaboration activity use raised cards. - Scrollbars use one compact application style across conversation, nested output, State, Protocol, and Inspector surfaces. +- The three primary panels use one prominent neutral 24 px header row: + `THREADS`, `CONVERSATION`, and `INSPECTOR`, followed by a standard-intensity + divider and an 8 px content gap. Accent-filled labels are reserved for + interactive or selected states. Panel headers are one typographic level + below the active thread title so structure never competes with content. + +Canonical application typography is derived from the platform/application +base font size `B`; fixed absolute point sizes are not used for UI chrome. + +| Level | Size | Canonical roles | +|---|---:|---| +| Compact | `B - 1 pt` | Metadata, tabs, buttons, table headers, code and diff text | +| Standard | `B` | Body text, controls, editors, list content | +| Structural | `B + 1 pt` | Panel headers, section labels, subordinate brand labels | +| Content heading | `B + 3 pt` | Active thread title and primary in-panel headings | + +Weight and color may distinguish roles that share a size. In particular, +uppercase panel headers use Structural size with bold weight and a stronger +neutral color; the mixed-case active thread title uses Content heading size +with semibold weight. + +Markdown is authored content rather than application chrome. Its semantic +heading levels intentionally retain Qt's native relative rich-text sizes and +are not mapped to the canonical application scale. | Family | Primary | Hover | Pressed | Soft surface | Border | Surface text | |---|---|---|---|---|---|---| From b9f1935984644a0b61be60ea8bb3a26e8183a0f8 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 04:20:21 +0200 Subject: [PATCH 09/36] Polish command execution cards --- docs/ui-behavior.md | 16 +- src/codex/ui/UiStyle.cpp | 3 +- .../codex/middle/ConversationCards.cpp | 151 +++++++++++------- .../codex/middle/ConversationCards.h | 27 +++- .../codex/middle/ConversationView.cpp | 24 ++- src/greenfield/codex/middle/MiddleTypes.cpp | 27 ++++ src/greenfield/codex/middle/MiddleTypes.h | 1 + tests/codex/ConversationScrollTest.cpp | 7 +- tests/codex/GreenfieldMiddleTest.cpp | 116 ++++++++++++-- tests/codex/GreenfieldProjectionTest.cpp | 11 ++ 10 files changed, 288 insertions(+), 95 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 5fc5f70..86d9f99 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -180,12 +180,16 @@ non-whitespace text after terminal control sequences are ignored; empty, whitespace-only, and ANSI/control-only output has no output surface. A shown box has no non-content minimum height, grows from zero to a maximum of 220 pixels, and exposes a styled vertical scrollbar only when content exceeds that limit. -Its content height is measured synchronously during the outer layout -transaction. Streaming output, completion status, and metadata update the -retained outer Command execution card in place; they do not replace it. Output -follows its bottom while already at the bottom. A manual upward scroll pauses -following until the user returns to the bottom. Each output card retains its -own follow/pause position across in-place output updates. +The command surface uses the same content-height behavior with its existing +90-pixel maximum. Trailing empty lines are omitted from both displayed texts. +Their wrapped content height is measured at the final viewport width during the +outer layout transaction. While the conversation follows its bottom, streaming +output growth holds the card bottom and metadata in place and expands upward. +Streaming output, completion status, and metadata update the retained outer +Command execution card in place; they do not replace it. Output follows its +bottom while already at the bottom. A manual upward scroll pauses following +until the user returns to the bottom. Each output card retains its own +follow/pause position across in-place output updates. ## Inspector and Info presentation diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index e52000b..e104140 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -169,7 +169,7 @@ QString applicationStyleSheet() { QFrame[kind="orangeBadge"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } QFrame[kind="composer"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } QFrame[kind="composer"][focused="true"] { border: 2px solid #2f6feb; } - QPlainTextEdit { + QPlainTextEdit, QTextEdit { background: transparent; border: 0; color: #1d2633; @@ -180,6 +180,7 @@ QString applicationStyleSheet() { } QPlainTextEdit[empty="true"] { color: #98a2b3; } QPlainTextEdit[kind="code"], QPlainTextEdit[kind="command"], + QTextEdit[kind="code"], QTextEdit[kind="command"], QPlainTextEdit[kind="infoViewer"] { font-family: monospace; font-size: %1pt; diff --git a/src/greenfield/codex/middle/ConversationCards.cpp b/src/greenfield/codex/middle/ConversationCards.cpp index 51ca45e..fe71056 100644 --- a/src/greenfield/codex/middle/ConversationCards.cpp +++ b/src/greenfield/codex/middle/ConversationCards.cpp @@ -26,6 +26,8 @@ namespace codexui::codex::middle { namespace { constexpr int MaximumCommandOutputHeight = 220; +constexpr int MaximumCommandTextHeight = 90; +constexpr int CommandTextPadding = 7; constexpr int PendingAnimationIntervalMilliseconds = 32; constexpr qint64 PendingHalfCycleMilliseconds = 850; @@ -175,20 +177,72 @@ bool presentationEquals(const VisibleCardData &left, } // namespace -CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) - : QPlainTextEdit(parent) { +ContentSizedTextView::ContentSizedTextView(int maximumContentHeight, + QWidget *parent) + : QTextEdit(parent) { setReadOnly(true); + setAcceptRichText(false); setMinimumHeight(0); - setMaximumHeight(MaximumCommandOutputHeight); - setLineWrapMode(QPlainTextEdit::WidgetWidth); + setMaximumHeight(maximumContentHeight); + setLineWrapMode(QTextEdit::WidgetWidth); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + document()->setDocumentMargin(CommandTextPadding); +} + +bool ContentSizedTextView::setContent(const QString &content) { + if (toPlainText() == content) + return false; + setPlainText(content); + measureAtCurrentWidth(true); + return true; +} + +QSize ContentSizedTextView::sizeHint() const { + QSize result = QTextEdit::sizeHint(); + result.setHeight(preferredHeight_); + return result; +} + +QSize ContentSizedTextView::minimumSizeHint() const { + QSize result = QTextEdit::minimumSizeHint(); + result.setHeight(0); + return result; +} + +void ContentSizedTextView::resizeEvent(QResizeEvent *event) { + QTextEdit::resizeEvent(event); + // Wrapping is authoritative only after QTextEdit has assigned its + // viewport width. Propagate a changed hint immediately so a multiline view + // cannot remain at an earlier one-line height with a premature scrollbar. + measureAtCurrentWidth(true); +} + +void ContentSizedTextView::measureAtCurrentWidth(bool notifyParent) { + const QString content = toPlainText(); + int wantedHeight = 0; + if (!content.isEmpty()) { + const int frame = 2 * frameWidth(); + document()->setTextWidth(std::max(1, viewport()->width())); + wantedHeight = + frame + static_cast(std::ceil(document()->size().height())); + } + wantedHeight = std::clamp(wantedHeight, 0, maximumHeight()); + if (wantedHeight == preferredHeight_) + return; + preferredHeight_ = wantedHeight; + if (notifyParent) + updateGeometry(); +} + +CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) + : ContentSizedTextView(MaximumCommandOutputHeight, parent) { setProperty("kind", "code"); setObjectName(QStringLiteral("commandOutputView")); setStyleSheet(QStringLiteral( - "background:#111827;color:#e5e7eb;border-radius:6px;padding:7px;" - "font-family:monospace;")); + "QTextEdit#commandOutputView{background:#111827;color:#e5e7eb;" + "border-radius:6px;}")); connect(verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) { @@ -214,22 +268,23 @@ bool CommandOutputView::followsLatest() const noexcept { } bool CommandOutputView::setOutput(const QString &output) { - if (currentOutput_ == output) + const QString displayOutput = trimTrailingEmptyLines(output); + if (currentOutput_ == displayOutput) return false; const bool retainedFollow = followsLatest_; const int retainedValue = preservedScrollValue_; - const bool appendOnly = !currentOutput_.isEmpty() && - output.startsWith(currentOutput_); + const bool appendOnly = + !currentOutput_.isEmpty() && displayOutput.startsWith(currentOutput_); programmaticScroll_ = true; if (appendOnly) { QTextCursor cursor = textCursor(); cursor.movePosition(QTextCursor::End); - cursor.insertText(output.sliced(currentOutput_.size())); + cursor.insertText(displayOutput.sliced(currentOutput_.size())); } else { - setPlainText(output); + setPlainText(displayOutput); } - currentOutput_ = output; + currentOutput_ = displayOutput; followsLatest_ = retainedFollow; preservedScrollValue_ = retainedValue; programmaticScroll_ = false; @@ -247,26 +302,6 @@ void CommandOutputView::restoreScrollState(const ScrollState &state) { settleScroll(); } -QSize CommandOutputView::sizeHint() const { - QSize result = QPlainTextEdit::sizeHint(); - result.setHeight(preferredHeight_); - return result; -} - -QSize CommandOutputView::minimumSizeHint() const { - QSize result = QPlainTextEdit::minimumSizeHint(); - result.setHeight(0); - return result; -} - -void CommandOutputView::resizeEvent(QResizeEvent *event) { - QPlainTextEdit::resizeEvent(event); - // A parent layout is already assigning this width. Refresh the preferred - // height synchronously without scheduling a second outer layout pass. - measureAtCurrentWidth(false); - settleScroll(); -} - void CommandOutputView::wheelEvent(QWheelEvent *event) { QScrollBar *bar = verticalScrollBar(); const int delta = !event->pixelDelta().isNull() ? event->pixelDelta().y() @@ -274,40 +309,34 @@ void CommandOutputView::wheelEvent(QWheelEvent *event) { if (bar->maximum() <= bar->minimum() || (delta > 0 && bar->value() <= bar->minimum()) || (delta < 0 && bar->value() >= bar->maximum())) { - event->ignore(); + event->accept(); return; } if (delta > 0) followsLatest_ = false; - QPlainTextEdit::wheelEvent(event); + QTextEdit::wheelEvent(event); preservedScrollValue_ = bar->value(); followsLatest_ = isAtBottom(); } -void CommandOutputView::measureAtCurrentWidth(bool notifyParent) { - const int contentHeight = static_cast( - std::ceil(document()->documentLayout()->documentSize().height())); - const int wantedHeight = std::clamp(contentHeight + 2 * frameWidth() + 14, 0, - MaximumCommandOutputHeight); - if (wantedHeight != preferredHeight_) { - preferredHeight_ = wantedHeight; - if (notifyParent) - updateGeometry(); - } -} - void CommandOutputView::settleScroll() { if (settlingScroll_) return; settlingScroll_ = true; QScrollBar *bar = verticalScrollBar(); + const bool wasProgrammatic = programmaticScroll_; + programmaticScroll_ = true; + if (followsLatest_) { + QTextCursor cursor = textCursor(); + cursor.movePosition(QTextCursor::End); + setTextCursor(cursor); + ensureCursorVisible(); + } const int target = followsLatest_ ? bar->maximum() : std::clamp(preservedScrollValue_, bar->minimum(), bar->maximum()); - const bool wasProgrammatic = programmaticScroll_; - programmaticScroll_ = true; bar->setValue(target); preservedScrollValue_ = target; programmaticScroll_ = wasProgrammatic; @@ -369,17 +398,12 @@ class ConversationCard::Impl final { break; case CardKind::CommandExecution: title = makeLabel(QStringLiteral("Command execution"), "title", owner); - command = new QPlainTextEdit(owner); - command->setReadOnly(true); - command->setMaximumHeight(90); - command->setLineWrapMode(QPlainTextEdit::WidgetWidth); - command->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - command->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + command = new ContentSizedTextView(MaximumCommandTextHeight, owner); command->setProperty("kind", "command"); command->setObjectName(QStringLiteral("commandTextView")); command->setStyleSheet(QStringLiteral( - "background:#f8fafc;border:1px solid #d7dee8;border-radius:6px;" - "padding:7px;font-family:monospace;")); + "QTextEdit#commandTextView{background:#f8fafc;" + "border:1px solid #d7dee8;border-radius:6px;}")); output = new CommandOutputView({}, owner); output->hide(); metadata = makeLabel({}, "meta", owner); @@ -455,17 +479,20 @@ class ConversationCard::Impl final { const auto &message = std::get(data.payload); title->setText(message.finalAnswer ? QStringLiteral("Codex") : QStringLiteral("Codex activity")); + layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, + message.finalAnswer ? 10 : 8); setVisibleMarkdown(body, message.text); break; } case CardKind::CommandExecution: { const auto &execution = std::get(data.payload); - if (command->toPlainText() != execution.command) - command->setPlainText(execution.command); - command->setVisible(!execution.command.isEmpty()); - const bool visibleOutput = terminalOutputHasVisibleText(execution.output); + const QString displayCommand = trimTrailingEmptyLines(execution.command); + command->setContent(displayCommand); + command->setVisible(!displayCommand.isEmpty()); + const QString displayOutput = trimTrailingEmptyLines(execution.output); + const bool visibleOutput = terminalOutputHasVisibleText(displayOutput); if (visibleOutput) { - output->setOutput(execution.output); + output->setOutput(displayOutput); output->show(); } else { output->hide(); @@ -585,7 +612,7 @@ class ConversationCard::Impl final { QLabel *body = nullptr; QLabel *metadata = nullptr; QLabel *detail = nullptr; - QPlainTextEdit *command = nullptr; + ContentSizedTextView *command = nullptr; CommandOutputView *output = nullptr; QTimer *animationTimer = nullptr; }; diff --git a/src/greenfield/codex/middle/ConversationCards.h b/src/greenfield/codex/middle/ConversationCards.h index 1cad853..6eb5021 100644 --- a/src/greenfield/codex/middle/ConversationCards.h +++ b/src/greenfield/codex/middle/ConversationCards.h @@ -6,7 +6,7 @@ #include "codex/middle/MiddleTypes.h" #include -#include +#include #include #include @@ -20,7 +20,24 @@ class QWheelEvent; namespace codexui::codex::middle { -class CommandOutputView final : public QPlainTextEdit { +class ContentSizedTextView : public QTextEdit { +public: + explicit ContentSizedTextView(int maximumContentHeight, + QWidget *parent = nullptr); + + bool setContent(const QString &content); + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +protected: + void resizeEvent(QResizeEvent *event) override; + void measureAtCurrentWidth(bool notifyParent); + +private: + int preferredHeight_ = 0; +}; + +class CommandOutputView final : public ContentSizedTextView { public: struct ScrollState { bool followsLatest = true; @@ -39,15 +56,10 @@ class CommandOutputView final : public QPlainTextEdit { bool setOutput(const QString &output); void restoreScrollState(const ScrollState &state); - QSize sizeHint() const override; - QSize minimumSizeHint() const override; - protected: - void resizeEvent(QResizeEvent *event) override; void wheelEvent(QWheelEvent *event) override; private: - void measureAtCurrentWidth(bool notifyParent); void settleScroll(); [[nodiscard]] bool isAtBottom() const; @@ -55,7 +67,6 @@ class CommandOutputView final : public QPlainTextEdit { bool programmaticScroll_ = false; bool settlingScroll_ = false; int preservedScrollValue_ = 0; - int preferredHeight_ = 0; QString currentOutput_; }; diff --git a/src/greenfield/codex/middle/ConversationView.cpp b/src/greenfield/codex/middle/ConversationView.cpp index 824b5c1..06cc602 100644 --- a/src/greenfield/codex/middle/ConversationView.cpp +++ b/src/greenfield/codex/middle/ConversationView.cpp @@ -9,10 +9,11 @@ #include #include #include +#include #include #include -#include #include +#include #include #include #include @@ -216,6 +217,19 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { } } const bool follow = mode_ == Mode::Following; + const auto visibleOutputFootprint = [this] { + int height = 0; + for (const auto &[key, card] : cards_) { + static_cast(key); + auto *output = + dynamic_cast(card->findChild( + QStringLiteral("commandOutputView"))); + if (output && !output->isHidden()) + height += output->height(); + } + return height; + }; + const int outputFootprintBefore = visibleOutputFootprint(); stopFollowingAnimation(); applying_ = true; @@ -355,10 +369,11 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { snapshot_ = snapshot; recomputeGeometry(); + const bool outputGrew = visibleOutputFootprint() > outputFootprintBefore; for (const auto &[card, state] : commandOutputRestorations) card->restoreCommandOutputScrollState(state); if (follow) { - if (switchedThread) { + if (switchedThread || outputGrew) { setScrollValue(verticalScrollBar()->maximum()); } else { // Reflow above the viewport must preserve the same painted card/pixel @@ -374,7 +389,7 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { viewport()->setUpdatesEnabled(true); viewport()->update(); - if (follow && !switchedThread) { + if (follow && !switchedThread && !outputGrew) { const int stableValue = verticalScrollBar()->value(); if (verticalScrollBar()->maximum() > stableValue + 3) animateToBottom(stableValue); @@ -640,8 +655,7 @@ bool ConversationView::applyWheel(QWheelEvent *event) { // redispatch through ShellWidget's application event filter, which routes // the same gesture back into this method recursively. QScrollBar *bar = verticalScrollBar(); - const QPointF local = - bar->mapFromGlobal(event->globalPosition().toPoint()); + const QPointF local = bar->mapFromGlobal(event->globalPosition().toPoint()); QWheelEvent forwarded(local, event->globalPosition(), event->pixelDelta(), event->angleDelta(), event->buttons(), event->modifiers(), event->phase(), event->inverted()); diff --git a/src/greenfield/codex/middle/MiddleTypes.cpp b/src/greenfield/codex/middle/MiddleTypes.cpp index 614e719..9dc9556 100644 --- a/src/greenfield/codex/middle/MiddleTypes.cpp +++ b/src/greenfield/codex/middle/MiddleTypes.cpp @@ -92,6 +92,33 @@ bool terminalOutputHasVisibleText(QStringView output) { return false; } +QString trimTrailingEmptyLines(QStringView text) { + qsizetype end = text.size(); + while (end > 0) { + while (end > 0 && (text[end - 1] == QLatin1Char('\n') || + text[end - 1] == QLatin1Char('\r'))) + --end; + if (end == 0) + break; + + qsizetype lineStart = end; + while (lineStart > 0 && text[lineStart - 1] != QLatin1Char('\n') && + text[lineStart - 1] != QLatin1Char('\r')) + --lineStart; + bool emptyLine = true; + for (qsizetype index = lineStart; index < end; ++index) { + if (!text[index].isSpace()) { + emptyLine = false; + break; + } + } + if (!emptyLine) + break; + end = lineStart; + } + return text.first(end).toString(); +} + std::vector ConversationSnapshot::cardKeys() const { std::vector result; for (const TurnSection §ion : sections) diff --git a/src/greenfield/codex/middle/MiddleTypes.h b/src/greenfield/codex/middle/MiddleTypes.h index 220dee0..2eec985 100644 --- a/src/greenfield/codex/middle/MiddleTypes.h +++ b/src/greenfield/codex/middle/MiddleTypes.h @@ -43,6 +43,7 @@ using CardKey = std::variant; [[nodiscard]] std::string stableKey(const CardKey &key); [[nodiscard]] bool terminalOutputHasVisibleText(QStringView output); +[[nodiscard]] QString trimTrailingEmptyLines(QStringView text); enum class PromptState { Queued, InFlight, Accepted, Failed }; diff --git a/tests/codex/ConversationScrollTest.cpp b/tests/codex/ConversationScrollTest.cpp index 8d5ca28..5eb92c5 100644 --- a/tests/codex/ConversationScrollTest.cpp +++ b/tests/codex/ConversationScrollTest.cpp @@ -16,7 +16,7 @@ #include #include #include -#include +#include #include #include #include @@ -200,10 +200,9 @@ class ShellWidgetScrollTest { const auto card = shell.conversationCards.find(key); if (card == shell.conversationCards.end()) return 0; - const QList views = - card->second->findChildren(); + const QList views = card->second->findChildren(); return static_cast( - std::count_if(views.begin(), views.end(), [](QPlainTextEdit *view) { + std::count_if(views.begin(), views.end(), [](QTextEdit *view) { return view->property("kind").toString() == QStringLiteral("code"); })); }; diff --git a/tests/codex/GreenfieldMiddleTest.cpp b/tests/codex/GreenfieldMiddleTest.cpp index 1e99ffc..8559f43 100644 --- a/tests/codex/GreenfieldMiddleTest.cpp +++ b/tests/codex/GreenfieldMiddleTest.cpp @@ -8,8 +8,11 @@ #include #include #include +#include #include #include +#include +#include #include #include @@ -351,8 +354,8 @@ bool testMutableCardsAndCommandOutput() { AgentMessageData{QStringLiteral("answer"), false}}, {AuthoritativeItemKey{thread, "turn", "command"}, CardKind::CommandExecution, thread, "turn", "command", - CommandExecutionData{QStringLiteral("printf test"), - QStringLiteral(" \n\t\x1b[0m"), + CommandExecutionData{QStringLiteral("printf test\n\n \t"), + QStringLiteral(" \n\t"), QStringLiteral("inProgress"), {}, std::nullopt}}, @@ -397,10 +400,19 @@ bool testMutableCardsAndCommandOutput() { auto *commandCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "command"}})]; auto *output = dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))); + auto *commandText = dynamic_cast( + commandCard->findChild( + QStringLiteral("commandTextView"))); bool result = expect(output && output->isHidden(), - "control-only command output has no black surface"); + "empty-line command output has no black surface"); + result &= expect( + commandText && + commandText->toPlainText() == QStringLiteral("printf test") && + commandText->height() < commandText->maximumHeight() && + commandText->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, + "short command text trims empty lines and uses its content height"); auto &cards = snapshot.sections.front().cards; std::get(cards[0].payload).text += @@ -408,7 +420,8 @@ bool testMutableCardsAndCommandOutput() { std::get(cards[1].payload).text += QStringLiteral(" updated"); auto &command = std::get(cards[2].payload); - command.output = QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible"); + command.output = + QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible\n\n \t"); command.status = QStringLiteral("completed"); std::get(cards[3].payload).resultText = QStringLiteral("result"); @@ -438,8 +451,9 @@ bool testMutableCardsAndCommandOutput() { result &= expect(!output->isHidden() && output->minimumHeight() == 0 && output->maximumHeight() == 220 && + output->toPlainText().endsWith(QStringLiteral("visible")) && output->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, - "visible command output grows from zero with the 220px cap"); + "visible output trims empty lines and grows with the 220px cap"); QString longOutput; for (int line = 0; line < 80; ++line) @@ -519,7 +533,7 @@ bool testInitialCommandGeometrySettlement() { "initial visible command output is inserted"); ConversationCard *commandCard = card(view, stableKey(command.key)); auto *outputView = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= expect(commandCard && outputView && !outputView->isHidden() && @@ -537,6 +551,89 @@ bool testInitialCommandGeometrySettlement() { outputView->height() == immediateOutputHeight && outputView->sizeHint().height() == immediateHint, "initial wrapped output has no delayed geometry settlement"); + + const int glyphWidth = + std::max(1, outputView->fontMetrics().horizontalAdvance(QLatin1Char('W'))); + const int charactersPerLine = + std::max(1, outputView->viewport()->width() / glyphWidth); + auto &execution = std::get( + snapshot.sections.front().cards.front().payload); + execution.output = QString(charactersPerLine + 1, QLatin1Char('W')); + result &= expect(view.reconcile(snapshot), + "single logical output line changes to two visual lines"); + spin(); + const QTextBlock wrappedBlock = outputView->document()->firstBlock(); + result &= expect( + wrappedBlock.layout() && wrappedBlock.layout()->lineCount() == 2 && + outputView->verticalScrollBar()->maximum() == 0 && + outputView->viewport()->height() >= + static_cast(std::ceil(outputView->document()->size().height())), + "two visual output lines are fully visible without inner scrolling"); + return result; +} + +bool testBottomAnchoredCommandOutputGrowth() { + const std::string thread = "bottom-anchored-output"; + ConversationSnapshot snapshot = conversation(thread, 14); + VisibleCardData command{ + AuthoritativeItemKey{thread, "turn-2", "live-command"}, + CardKind::CommandExecution, + thread, + "turn-2", + "live-command", + CommandExecutionData{QStringLiteral("run live command"), + {}, + QStringLiteral("inProgress"), + {}, + std::nullopt}}; + snapshot.sections.back().cards.push_back(command); + + ConversationView view; + view.resize(620, 360); + view.show(); + view.reconcile(snapshot); + spin(); + ConversationCard *commandCard = card(view, stableKey(command.key)); + auto *metadata = + commandCard + ? commandCard->findChild(QStringLiteral("commandMetadata")) + : nullptr; + auto *output = commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + bool result = expect(commandCard && metadata && output && + output->isHidden() && view.isAtBottom(), + "live command starts with a hidden zero-line output"); + if (!commandCard || !metadata || !output) + return false; + const int metadataBottomBefore = + metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y(); + + auto &live = std::get( + snapshot.sections.back().cards.back().payload); + live.output = QStringLiteral( + "first wrapped output line with enough words to use real width\n" + "second output line\nthird output line\n\n"); + result &= expect(view.reconcile(snapshot), "live output becomes visible"); + const int metadataBottomAfter = + metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y(); + result &= expect(!output->isHidden() && output->height() > 2 * 20 && + output->height() == output->sizeHint().height() && + metadataBottomAfter == metadataBottomBefore && + view.isAtBottom(), + "multiline output takes its needed height and grows upward"); + + QString cappedOutput; + for (int line = 0; line < 80; ++line) + cappedOutput += QStringLiteral("scrollable line %1\n").arg(line); + live.output = cappedOutput; + result &= expect(view.reconcile(snapshot), "live output reaches its cap"); + result &= expect( + output->height() == 220 && output->verticalScrollBar()->maximum() > 0 && + metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y() == + metadataBottomBefore, + "capped output keeps its scrollbar and fixed card bottom"); return result; } @@ -567,7 +664,7 @@ bool testCommandOutputStateAcrossNavigation() { ConversationCard *commandCard = card(view, stableKey(command.key)); auto *initialOutput = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; bool result = @@ -588,7 +685,7 @@ bool testCommandOutputStateAcrossNavigation() { commandCard = card(view, stableKey(command.key)); auto *restoredOutput = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= @@ -645,6 +742,7 @@ int main(int argc, char **argv) { result &= testPromptAdmissionFollowOwnership(); result &= testMutableCardsAndCommandOutput(); result &= testInitialCommandGeometrySettlement(); + result &= testBottomAnchoredCommandOutputGrowth(); result &= testCommandOutputStateAcrossNavigation(); result &= testPendingPromptAnimation(); if (result) diff --git a/tests/codex/GreenfieldProjectionTest.cpp b/tests/codex/GreenfieldProjectionTest.cpp index 188e49a..156f2bc 100644 --- a/tests/codex/GreenfieldProjectionTest.cpp +++ b/tests/codex/GreenfieldProjectionTest.cpp @@ -348,6 +348,17 @@ bool testCommandOutputVisibility() { result &= expect( terminalOutputHasVisibleText(QStringView{QStringLiteral("done\n")}), "printable command output is visible"); + result &= expect(trimTrailingEmptyLines(QStringView{ + QStringLiteral("first\nsecond\n\n \t\r\n")}) == + QStringLiteral("first\nsecond"), + "trailing empty terminal lines are removed"); + result &= expect(trimTrailingEmptyLines( + QStringView{QStringLiteral(" meaningful spacing ")}) == + QStringLiteral(" meaningful spacing "), + "spacing on a non-empty final line is retained"); + result &= expect( + trimTrailingEmptyLines(QStringView{QStringLiteral(" \t\r\n")}).isEmpty(), + "an entirely empty-line display normalizes to zero lines"); return result; } From 955e042034cc5774a6c6bc7a835fc35790ffb3f5 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 04:38:28 +0200 Subject: [PATCH 10/36] Retain composer draft across threads --- src/greenfield/codex/ShellWidget.cpp | 1 - tests/codex/GreenfieldShellIntegrationTest.cpp | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/greenfield/codex/ShellWidget.cpp b/src/greenfield/codex/ShellWidget.cpp index e5be1b7..83a0de5 100644 --- a/src/greenfield/codex/ShellWidget.cpp +++ b/src/greenfield/codex/ShellWidget.cpp @@ -819,7 +819,6 @@ void ShellWidget::Impl::selectThread(std::string threadId) { newThreadOptions = nlohmann::json::object(); newThreadName.clear(); newThreadWorkspace.clear(); - middleRegion->composer().clearDraft(); historyWindows.try_emplace(selectedThreadId); ensureThreadHydrated(selectedThreadId); render(); diff --git a/tests/codex/GreenfieldShellIntegrationTest.cpp b/tests/codex/GreenfieldShellIntegrationTest.cpp index 0659bed..3ca700f 100644 --- a/tests/codex/GreenfieldShellIntegrationTest.cpp +++ b/tests/codex/GreenfieldShellIntegrationTest.cpp @@ -357,8 +357,12 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { expect(beforeAck && beforeAck->state == middle::PromptState::InFlight, "materialization alone cannot acknowledge A1"); + editor->setPlainText(QStringLiteral("unsent shared draft")); result &= expect(selectThread(list, "thread-b"), "B can be selected while A remains active"); + result &= expect(editor->toPlainText() == + QStringLiteral("unsent shared draft"), + "thread navigation retains the shared composer draft"); const auto readB = peer.waitFor("thread.read", "thread-b"); result &= expect(readB.has_value(), "selecting B requests its own hydration"); if (!readB) From 10524a4b5a25f4943ee72dc75040542548cee039 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 04:44:10 +0200 Subject: [PATCH 11/36] Keep nested output scroll ownership --- docs/ui-behavior.md | 7 ++++++ .../codex/middle/MiddleRegionWidget.cpp | 5 ++++ src/greenfield/codex/middle/ThreadPane.cpp | 4 +++- tests/codex/GreenfieldLayoutTest.cpp | 23 ++++++++++++------- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 86d9f99..f04d69c 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -89,6 +89,10 @@ explicit error state. The composer is cleared immediately after local admission and remains enabled. Users may enter additional prompts while earlier prompts await acknowledgment. +Unsubmitted composer text and attachments form one shared local draft: ordinary +thread navigation retains them, and submission sends them to the thread that is +visibly selected at that moment. Explicit new-thread creation still starts with +a deliberately cleared composer. CodexUI queues submissions per thread and dispatches them in order: only one unacknowledged prompt operation is in flight for a thread. After each result, the next queued prompt is sent using the app-server state produced by the @@ -119,6 +123,9 @@ Returning to the bottom re-enables following. Follow/pause mode and the visible-card/pixel-offset anchor are retained per thread and restored when the user switches back. +Scrollable Command output owns wheel and touchpad gestures while the pointer is +over it, including overscroll at either boundary; those gestures never chain to +the outer message view. This policy applies to new messages, streaming updates, pending prompt cards, and card reconstruction. It is based on the scroll bar's actual bottom state, diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.cpp b/src/greenfield/codex/middle/MiddleRegionWidget.cpp index 188123e..814dd8b 100644 --- a/src/greenfield/codex/middle/MiddleRegionWidget.cpp +++ b/src/greenfield/codex/middle/MiddleRegionWidget.cpp @@ -3,6 +3,7 @@ #include "codex/middle/MiddleRegionWidget.h" #include "codex/middle/ComposerPane.h" +#include "codex/middle/ConversationCards.h" #include "codex/middle/ConversationView.h" #include "codex/middle/InspectorPane.h" #include "codex/middle/ThreadPane.h" @@ -233,6 +234,8 @@ bool MiddleRegionWidget::routeScrollEvent(QObject *watched, QEvent *event) { ancestor = ancestor->parentWidget()) { if (auto *nested = qobject_cast(ancestor); nested && nested != conversationView) { + if (dynamic_cast(nested)) + return false; if (canConsume(nested, verticalIntent(wheel))) return false; break; @@ -245,6 +248,8 @@ bool MiddleRegionWidget::routeScrollEvent(QObject *watched, QEvent *event) { ancestor && ancestor != conversationRegion; ancestor = ancestor->parentWidget()) { if (auto *nested = qobject_cast(ancestor)) { + if (dynamic_cast(nested)) + return false; if (canConsume(nested, verticalIntent(wheel))) return false; break; diff --git a/src/greenfield/codex/middle/ThreadPane.cpp b/src/greenfield/codex/middle/ThreadPane.cpp index 180469b..fa827e8 100644 --- a/src/greenfield/codex/middle/ThreadPane.cpp +++ b/src/greenfield/codex/middle/ThreadPane.cpp @@ -328,7 +328,9 @@ void ThreadPane::updateSortButton() { void ThreadPane::sortVisibleThreads(std::vector &ids, const PresentationModel &model) const { - QCollator collator; + QCollator collator(QLocale::system().language() == QLocale::C + ? QLocale(QLocale::English) + : QLocale::system()); collator.setCaseSensitivity(Qt::CaseInsensitive); collator.setIgnorePunctuation(true); collator.setNumericMode(true); diff --git a/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/GreenfieldLayoutTest.cpp index 72aca53..981f563 100644 --- a/tests/codex/GreenfieldLayoutTest.cpp +++ b/tests/codex/GreenfieldLayoutTest.cpp @@ -338,10 +338,17 @@ bool testThreadAlphanumericSort() { ThreadPane pane; pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); pane.refresh(model, "two"); - return expect(threadOrder(pane) == - std::vector( - {"one", "two", "ten", "alpha", "beta"}) && - pane.visiblySelectedThreadId() == "two", + const std::vector order = threadOrder(pane); + const bool correct = order == std::vector( + {"one", "two", "ten", "alpha", "beta"}) && + pane.visiblySelectedThreadId() == "two"; + if (!correct) { + std::cerr << "Observed alphanumeric order:"; + for (const std::string &id : order) + std::cerr << ' ' << id; + std::cerr << "; selected=" << pane.visiblySelectedThreadId() << '\n'; + } + return expect(correct, "Alphanumeric sorting is natural and preserves selection"); } @@ -527,11 +534,11 @@ bool testNestedCommandScrollOwnership() { commandOutput->verticalScrollBar()->minimum()); spin(); const int outerBefore = region.conversation().verticalScrollBar()->value(); - QWheelEvent handedOff = wheelFor(commandOutput, 120); - result &= expect(region.routeScrollEvent(commandOutput, &handedOff) && - region.conversation().verticalScrollBar()->value() < + QWheelEvent boundary = wheelFor(commandOutput, 120); + result &= expect(!region.routeScrollEvent(commandOutput, &boundary) && + region.conversation().verticalScrollBar()->value() == outerBefore, - "nested output hands input to the message view at its edge"); + "nested output retains input at its scroll boundary"); return result; } From 44639642b9f6969f1cad39f71f190e72a742d98c Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 04:53:27 +0200 Subject: [PATCH 12/36] Render user messages as Markdown --- docs/ui-behavior.md | 4 ++++ src/greenfield/codex/middle/ConversationCards.cpp | 4 ++-- tests/codex/GreenfieldMiddleTest.cpp | 13 ++++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index f04d69c..e8189a6 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -113,6 +113,10 @@ For an explicit new-thread draft, prompts entered while `thread.create` is in flight remain attached to that draft. When creation succeeds, all pending prompts move to the returned stable thread ID and are dispatched in order. +Authoritative user-message text is rendered as Markdown through the same safe +`MarkdownNoHTML` path as agent messages. The locally admitted prompt remains a +plain-text transitional card until its authoritative item arrives. + ## Conversation scrolling The message view smoothly follows incoming content only while it is already at diff --git a/src/greenfield/codex/middle/ConversationCards.cpp b/src/greenfield/codex/middle/ConversationCards.cpp index fe71056..7b9cf40 100644 --- a/src/greenfield/codex/middle/ConversationCards.cpp +++ b/src/greenfield/codex/middle/ConversationCards.cpp @@ -385,7 +385,7 @@ class ConversationCard::Impl final { case CardKind::UserMessage: owner->setProperty("messageRole", "user"); title = makeLabel(QStringLiteral("You"), "title", owner); - body = makeLabel({}, "body", owner); + body = makeMarkdownLabel({}, owner); layout->addWidget(title); layout->addWidget(body); break; @@ -472,7 +472,7 @@ class ConversationCard::Impl final { switch (data.kind) { case CardKind::UserMessage: { const auto &message = std::get(data.payload); - setVisibleText(body, message.text); + setVisibleMarkdown(body, message.text); break; } case CardKind::AgentMessage: { diff --git a/tests/codex/GreenfieldMiddleTest.cpp b/tests/codex/GreenfieldMiddleTest.cpp index 8559f43..08ce960 100644 --- a/tests/codex/GreenfieldMiddleTest.cpp +++ b/tests/codex/GreenfieldMiddleTest.cpp @@ -348,7 +348,8 @@ bool testMutableCardsAndCommandOutput() { TurnSection section{"turn:cards", "turn", {}}; section.cards = { {AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, - thread, "turn", "user", UserMessageData{QStringLiteral("hello")}}, + thread, "turn", "user", + UserMessageData{QStringLiteral("hello **Markdown**")}}, {AuthoritativeItemKey{thread, "turn", "agent"}, CardKind::AgentMessage, thread, "turn", "agent", AgentMessageData{QStringLiteral("answer"), false}}, @@ -407,6 +408,16 @@ bool testMutableCardsAndCommandOutput() { QStringLiteral("commandTextView"))); bool result = expect(output && output->isHidden(), "empty-line command output has no black surface"); + auto *userCard = identities[stableKey( + CardKey{AuthoritativeItemKey{thread, "turn", "user"}})]; + const auto userLabels = userCard->findChildren(); + result &= expect( + std::ranges::any_of(userLabels, [](QLabel *label) { + return label->property("markdownSource").toString() == + QStringLiteral("hello **Markdown**") && + label->textFormat() == Qt::RichText; + }), + "authoritative user messages use the shared Markdown renderer"); result &= expect( commandText && commandText->toPlainText() == QStringLiteral("printf test") && From 4c51325ddf678a640d2f0bb7d2619b521581bfd6 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 05:04:30 +0200 Subject: [PATCH 13/36] Promote production shell to canonical application --- CMakeLists.txt | 211 +- README.md | 11 +- docs/codex-architecture.md | 112 +- src/codex/MainWindow.cpp | 12 - src/codex/ShellWidget.cpp | 4052 ++++------------- src/codex/ShellWidget.h | 266 +- src/codex/WorkbenchWidget.cpp | 1001 ---- src/codex/WorkbenchWidget.h | 104 - .../codex/middle/ComposerPane.cpp | 0 .../codex/middle/ComposerPane.h | 6 +- .../codex/middle/ConversationCards.cpp | 0 .../codex/middle/ConversationCards.h | 6 +- .../codex/middle/ConversationProjection.cpp | 0 .../codex/middle/ConversationProjection.h | 6 +- .../codex/middle/ConversationView.cpp | 0 .../codex/middle/ConversationView.h | 6 +- .../codex/middle/InspectorPane.cpp | 0 .../codex/middle/InspectorPane.h | 4 +- .../codex/middle/MiddleRegionWidget.cpp | 0 .../codex/middle/MiddleRegionWidget.h | 4 +- .../codex/middle/MiddleTypes.cpp | 0 .../codex/middle/MiddleTypes.h | 6 +- .../codex/middle/PromptCoordinator.cpp | 0 .../codex/middle/PromptCoordinator.h | 6 +- .../codex/middle/ThreadPane.cpp | 0 .../codex/middle/ThreadPane.h | 4 +- src/codex/ui/UiStyle.cpp | 2 +- src/greenfield/codex/ShellWidget.cpp | 1391 ------ src/greenfield/codex/ShellWidget.h | 35 - ...youtTest.cpp => ApplicationLayoutTest.cpp} | 2 +- ...ddleTest.cpp => ConversationCardsTest.cpp} | 2 +- ...est.cpp => ConversationProjectionTest.cpp} | 2 +- tests/codex/ConversationScrollTest.cpp | 518 --- ...ationTest.cpp => ShellIntegrationTest.cpp} | 2 +- 34 files changed, 1124 insertions(+), 6647 deletions(-) delete mode 100644 src/codex/WorkbenchWidget.cpp delete mode 100644 src/codex/WorkbenchWidget.h rename src/{greenfield => }/codex/middle/ComposerPane.cpp (100%) rename src/{greenfield => }/codex/middle/ComposerPane.h (94%) rename src/{greenfield => }/codex/middle/ConversationCards.cpp (100%) rename src/{greenfield => }/codex/middle/ConversationCards.h (93%) rename src/{greenfield => }/codex/middle/ConversationProjection.cpp (100%) rename src/{greenfield => }/codex/middle/ConversationProjection.h (86%) rename src/{greenfield => }/codex/middle/ConversationView.cpp (100%) rename src/{greenfield => }/codex/middle/ConversationView.h (95%) rename src/{greenfield => }/codex/middle/InspectorPane.cpp (100%) rename src/{greenfield => }/codex/middle/InspectorPane.h (95%) rename src/{greenfield => }/codex/middle/MiddleRegionWidget.cpp (100%) rename src/{greenfield => }/codex/middle/MiddleRegionWidget.h (94%) rename src/{greenfield => }/codex/middle/MiddleTypes.cpp (100%) rename src/{greenfield => }/codex/middle/MiddleTypes.h (96%) rename src/{greenfield => }/codex/middle/PromptCoordinator.cpp (100%) rename src/{greenfield => }/codex/middle/PromptCoordinator.h (96%) rename src/{greenfield => }/codex/middle/ThreadPane.cpp (100%) rename src/{greenfield => }/codex/middle/ThreadPane.h (95%) delete mode 100644 src/greenfield/codex/ShellWidget.cpp delete mode 100644 src/greenfield/codex/ShellWidget.h rename tests/codex/{GreenfieldLayoutTest.cpp => ApplicationLayoutTest.cpp} (99%) rename tests/codex/{GreenfieldMiddleTest.cpp => ConversationCardsTest.cpp} (99%) rename tests/codex/{GreenfieldProjectionTest.cpp => ConversationProjectionTest.cpp} (99%) delete mode 100644 tests/codex/ConversationScrollTest.cpp rename tests/codex/{GreenfieldShellIntegrationTest.cpp => ShellIntegrationTest.cpp} (99%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b76b4e..5e70ee8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,45 +69,38 @@ set( ) set( - CODEXUI_GREENFIELD_MIDDLE_SOURCES - src/greenfield/codex/ShellWidget.cpp - src/greenfield/codex/ShellWidget.h - src/greenfield/codex/middle/ComposerPane.cpp - src/greenfield/codex/middle/ComposerPane.h - src/greenfield/codex/middle/ConversationCards.cpp - src/greenfield/codex/middle/ConversationCards.h - src/greenfield/codex/middle/ConversationProjection.cpp - src/greenfield/codex/middle/ConversationProjection.h - src/greenfield/codex/middle/ConversationView.cpp - src/greenfield/codex/middle/ConversationView.h - src/greenfield/codex/middle/InspectorPane.cpp - src/greenfield/codex/middle/InspectorPane.h - src/greenfield/codex/middle/MiddleRegionWidget.cpp - src/greenfield/codex/middle/MiddleRegionWidget.h - src/greenfield/codex/middle/MiddleTypes.cpp - src/greenfield/codex/middle/MiddleTypes.h - src/greenfield/codex/middle/PromptCoordinator.cpp - src/greenfield/codex/middle/PromptCoordinator.h - src/greenfield/codex/middle/ThreadPane.cpp - src/greenfield/codex/middle/ThreadPane.h + CODEXUI_SHELL_SOURCES + src/codex/ShellWidget.cpp + src/codex/ShellWidget.h + src/codex/middle/ComposerPane.cpp + src/codex/middle/ComposerPane.h + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationProjection.cpp + src/codex/middle/ConversationProjection.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/InspectorPane.cpp + src/codex/middle/InspectorPane.h + src/codex/middle/MiddleRegionWidget.cpp + src/codex/middle/MiddleRegionWidget.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/middle/PromptCoordinator.cpp + src/codex/middle/PromptCoordinator.h + src/codex/middle/ThreadPane.cpp + src/codex/middle/ThreadPane.h ) qt_add_executable( codex-ui ${CODEXUI_CODEX_COMMON_SOURCES} - ${CODEXUI_GREENFIELD_MIDDLE_SOURCES} + ${CODEXUI_SHELL_SOURCES} src/codex/PendingRequestDialog.cpp src/codex/PendingRequestDialog.h src/codex/TurnSettingsWidget.cpp src/codex/TurnSettingsWidget.h ) -qt_add_executable( - codex-ui-harness - ${CODEXUI_CODEX_COMMON_SOURCES} - src/codex/WorkbenchWidget.cpp - src/codex/WorkbenchWidget.h -) - function(configure_codexui_target target) target_compile_features(${target} PRIVATE cxx_std_20) target_include_directories(${target} PRIVATE src) @@ -125,33 +118,24 @@ function(configure_codexui_target target) endfunction() configure_codexui_target(codex-ui) -target_include_directories(codex-ui BEFORE PRIVATE src/greenfield) -configure_codexui_target(codex-ui-harness) -target_compile_definitions(codex-ui-harness PRIVATE CODEXUI_DEVELOPMENT_HARNESS=1) if(TARGET snodec::net-in-stream-tls AND TARGET snodec::net-in6-stream-tls) - foreach(target codex-ui codex-ui-harness) - target_compile_definitions(${target} PRIVATE CODEXUI_CODEX_FRONTEND_TLS=1) - target_link_libraries( - ${target} PRIVATE snodec::net-in-stream-tls snodec::net-in6-stream-tls - ) - endforeach() + target_compile_definitions(codex-ui PRIVATE CODEXUI_CODEX_FRONTEND_TLS=1) + target_link_libraries( + codex-ui PRIVATE snodec::net-in-stream-tls snodec::net-in6-stream-tls + ) endif() if(TARGET snodec::net-rc-stream-legacy AND TARGET snodec::net-rc-stream-tls) - foreach(target codex-ui codex-ui-harness) - target_compile_definitions(${target} PRIVATE CODEXUI_CODEX_FRONTEND_RFCOMM=1) - target_link_libraries( - ${target} PRIVATE snodec::net-rc-stream-legacy snodec::net-rc-stream-tls - ) - endforeach() + target_compile_definitions(codex-ui PRIVATE CODEXUI_CODEX_FRONTEND_RFCOMM=1) + target_link_libraries( + codex-ui PRIVATE snodec::net-rc-stream-legacy snodec::net-rc-stream-tls + ) endif() if(TARGET snodec::http-client AND TARGET snodec::websocket-client) - foreach(target codex-ui codex-ui-harness) - target_compile_definitions(${target} PRIVATE CODEXUI_CODEX_FRONTEND_WEBSOCKET=1) - target_link_libraries(${target} PRIVATE snodec::http-client snodec::websocket-client) - endforeach() + target_compile_definitions(codex-ui PRIVATE CODEXUI_CODEX_FRONTEND_WEBSOCKET=1) + target_link_libraries(codex-ui PRIVATE snodec::http-client snodec::websocket-client) endif() if(BUILD_TESTING) @@ -212,63 +196,59 @@ if(BUILD_TESTING) ) add_executable( - codexui-greenfield-projection-test - tests/codex/GreenfieldProjectionTest.cpp - src/greenfield/codex/middle/ConversationProjection.cpp - src/greenfield/codex/middle/ConversationProjection.h - src/greenfield/codex/middle/MiddleTypes.cpp - src/greenfield/codex/middle/MiddleTypes.h - src/greenfield/codex/middle/PromptCoordinator.cpp - src/greenfield/codex/middle/PromptCoordinator.h + codexui-conversation-projection-test + tests/codex/ConversationProjectionTest.cpp + src/codex/middle/ConversationProjection.cpp + src/codex/middle/ConversationProjection.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/middle/PromptCoordinator.cpp + src/codex/middle/PromptCoordinator.h ) target_compile_features( - codexui-greenfield-projection-test PRIVATE cxx_std_20 - ) - target_include_directories( - codexui-greenfield-projection-test BEFORE PRIVATE src/greenfield src + codexui-conversation-projection-test PRIVATE cxx_std_20 ) + target_include_directories(codexui-conversation-projection-test PRIVATE src) target_link_libraries( - codexui-greenfield-projection-test PRIVATE Qt6::Widgets + codexui-conversation-projection-test PRIVATE Qt6::Widgets ) add_test( - NAME codexui-greenfield-projection - COMMAND codexui-greenfield-projection-test + NAME codexui-conversation-projection + COMMAND codexui-conversation-projection-test ) set_tests_properties( - codexui-greenfield-projection PROPERTIES TIMEOUT 10 + codexui-conversation-projection PROPERTIES TIMEOUT 10 ) qt_add_executable( - codexui-greenfield-middle-test - tests/codex/GreenfieldMiddleTest.cpp - src/greenfield/codex/middle/ConversationCards.cpp - src/greenfield/codex/middle/ConversationCards.h - src/greenfield/codex/middle/ConversationView.cpp - src/greenfield/codex/middle/ConversationView.h - src/greenfield/codex/middle/MiddleTypes.cpp - src/greenfield/codex/middle/MiddleTypes.h + codexui-conversation-cards-test + tests/codex/ConversationCardsTest.cpp + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h ) target_compile_features( - codexui-greenfield-middle-test PRIVATE cxx_std_20 - ) - target_include_directories( - codexui-greenfield-middle-test BEFORE PRIVATE src/greenfield src + codexui-conversation-cards-test PRIVATE cxx_std_20 ) + target_include_directories(codexui-conversation-cards-test PRIVATE src) target_link_libraries( - codexui-greenfield-middle-test PRIVATE Qt6::Widgets + codexui-conversation-cards-test PRIVATE Qt6::Widgets ) add_test( - NAME codexui-greenfield-middle - COMMAND codexui-greenfield-middle-test + NAME codexui-conversation-cards + COMMAND codexui-conversation-cards-test ) set_tests_properties( - codexui-greenfield-middle + codexui-conversation-cards PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) qt_add_executable( - codexui-greenfield-layout-test - tests/codex/GreenfieldLayoutTest.cpp + codexui-application-layout-test + tests/codex/ApplicationLayoutTest.cpp src/codex/DiffViewer.cpp src/codex/DiffViewer.h src/codex/GitDiffProvider.cpp @@ -285,37 +265,35 @@ if(BUILD_TESTING) src/codex/ui/ExpandingPromptEditor.h src/codex/ui/UiStyle.cpp src/codex/ui/UiStyle.h - src/greenfield/codex/middle/ComposerPane.cpp - src/greenfield/codex/middle/ComposerPane.h - src/greenfield/codex/middle/ConversationCards.cpp - src/greenfield/codex/middle/ConversationCards.h - src/greenfield/codex/middle/ConversationView.cpp - src/greenfield/codex/middle/ConversationView.h - src/greenfield/codex/middle/InspectorPane.cpp - src/greenfield/codex/middle/InspectorPane.h - src/greenfield/codex/middle/MiddleRegionWidget.cpp - src/greenfield/codex/middle/MiddleRegionWidget.h - src/greenfield/codex/middle/MiddleTypes.cpp - src/greenfield/codex/middle/MiddleTypes.h - src/greenfield/codex/middle/ThreadPane.cpp - src/greenfield/codex/middle/ThreadPane.h + src/codex/middle/ComposerPane.cpp + src/codex/middle/ComposerPane.h + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/InspectorPane.cpp + src/codex/middle/InspectorPane.h + src/codex/middle/MiddleRegionWidget.cpp + src/codex/middle/MiddleRegionWidget.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/middle/ThreadPane.cpp + src/codex/middle/ThreadPane.h ) target_compile_features( - codexui-greenfield-layout-test PRIVATE cxx_std_20 - ) - target_include_directories( - codexui-greenfield-layout-test BEFORE PRIVATE src/greenfield src + codexui-application-layout-test PRIVATE cxx_std_20 ) + target_include_directories(codexui-application-layout-test PRIVATE src) target_link_libraries( - codexui-greenfield-layout-test + codexui-application-layout-test PRIVATE AISuite::OpenAICodex PkgConfig::LIBGIT2 Qt6::Widgets ) add_test( - NAME codexui-greenfield-layout - COMMAND codexui-greenfield-layout-test + NAME codexui-application-layout + COMMAND codexui-application-layout-test ) set_tests_properties( - codexui-greenfield-layout + codexui-application-layout PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) @@ -343,40 +321,37 @@ if(BUILD_TESTING) PROPERTIES TIMEOUT 30 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) - set(CODEXUI_GREENFIELD_SHELL_TEST_SOURCES ${CODEXUI_CODEX_COMMON_SOURCES}) + set(CODEXUI_SHELL_TEST_SOURCES ${CODEXUI_CODEX_COMMON_SOURCES}) list( - REMOVE_ITEM CODEXUI_GREENFIELD_SHELL_TEST_SOURCES + REMOVE_ITEM CODEXUI_SHELL_TEST_SOURCES src/codex/main.cpp src/codex/MainWindow.cpp src/codex/MainWindow.h ) qt_add_executable( - codexui-greenfield-shell-test - tests/codex/GreenfieldShellIntegrationTest.cpp - ${CODEXUI_GREENFIELD_SHELL_TEST_SOURCES} - ${CODEXUI_GREENFIELD_MIDDLE_SOURCES} + codexui-shell-integration-test + tests/codex/ShellIntegrationTest.cpp + ${CODEXUI_SHELL_TEST_SOURCES} + ${CODEXUI_SHELL_SOURCES} src/codex/PendingRequestDialog.cpp src/codex/PendingRequestDialog.h src/codex/TurnSettingsWidget.cpp src/codex/TurnSettingsWidget.h ) - configure_codexui_target(codexui-greenfield-shell-test) - target_include_directories( - codexui-greenfield-shell-test BEFORE PRIVATE src/greenfield - ) + configure_codexui_target(codexui-shell-integration-test) add_test( - NAME codexui-greenfield-shell - COMMAND codexui-greenfield-shell-test + NAME codexui-shell-integration + COMMAND codexui-shell-integration-test ) set_tests_properties( - codexui-greenfield-shell + codexui-shell-integration PROPERTIES TIMEOUT 20 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) endif() install( - TARGETS codex-ui codex-ui-harness + TARGETS codex-ui RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} BUNDLE DESTINATION . ) diff --git a/README.md b/README.md index 84a7ad8..836daf9 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,11 @@ event loop, selected transport, `AISuite::OpenAICodex` frontend proxy SDK, native protocol normalization, and connection/controller telemetry. They exchange only bounded `codexui.presentation` JSONL commands and events. -## Applications +## Application -- `codex-ui`: the normal visual application. -- `codex-ui-harness`: the permanent protocol and reducer development harness. - -Both applications use the same transport, socketpair, normalization, -presentation protocol, and model implementation. Only their top-level Qt -consumer differs. +`codex-ui` is the canonical visual application. Its production shell consumes +the normalized presentation protocol and model directly; there is no parallel +legacy UI or alternate application target. ## Build diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 3bc86f4..15735b4 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -484,8 +484,7 @@ context menu is created for the stable thread ID under the pointer and exposes Reload, Rename, Fork, Archive/Unarchive, and Delete. Read-only Reload remains available to an observer; mutations require the connected controller role. Opening or invoking the menu does not select the row or disturb the thread -currently being reviewed. The permanent development harness may retain compact -diagnostic controls that are not part of the product UI/UX. +currently being reviewed. ### 7.3 Message Attachments @@ -900,51 +899,20 @@ The app-server wire is JSON-RPC-shaped but may omit the optional `"jsonrpc": "2.0"` member. The frontend SDK owns that compatibility; Qt never depends on the member's presence. -## 16. Permanent Development Harness - -The codex workbench is retained as a deliberately plain development harness -alongside the final visual UI/UX shell. Both consumers use the same -`codexui.presentation` contract and `PresentationModel`; the harness does not -receive native app-server JSON through a privileged path. - -The harness provides: - -- explicit thread selection with no automatic selection changes; -- conversation, plan, correlated agent-activity, and generation-aware - pending-request inspection; -- read-only State inspection for model, account, configuration, permission, - feature, skills, hooks, plugin, app, MCP, and other retained domains; -- controller/observer role and connection-generation visibility; -- a bounded protocol log containing timestamp, sequence, generation, frame - kind, action/event type, authority, stable scope IDs, correlation ID, and - result status; -- explicit sequence-gap and non-monotonic-sequence diagnostics; -- bounded model counters for discovered threads and selected-thread turns and - items. - -The protocol log retains at most 2,000 display records. It is telemetry only: -it cannot replay frames, hydrate the presentation model, supply deletion -authority, or conceal a missing app-server result. `thread/list` discovery -preserves the order supplied by app-server for listed IDs, while IDs omitted -from a non-authoritative discovery page remain retained after that page. - -The presentation model separately retains at most 256 authority-free telemetry -records for status and warning presentation. These records cannot mutate or -hydrate conversation state. Authoritative normalized domains not requiring a -specialized reducer remain accessible at their stable global, thread, turn, or -item scope; their `merge`, `replace`, and `remove` semantics are applied before -the visual shell consumes them. - -The harness is used first when extending normalization or reduction. Once a -path is proven there, the visual shell consumes the same model and command -surface through narrow Qt adapters. The shell has no semantic snapshot or -parallel state authority. +## 16. Application Presentation + +The production `ShellWidget` is the sole Qt consumer of the +`codexui.presentation` contract and `PresentationModel`. Conversation, Plan, +Agents, Changes, Requests, retained State, and bounded Protocol diagnostics are +integrated into that shell. Diagnostic presentation is telemetry only: it +cannot replay frames, hydrate state, supply deletion authority, or conceal a +missing app-server result. The shell has no semantic snapshot or parallel state +authority. ## 17. Implemented Components and APIs -The canonical CodexUI implementation contains both the complete visual shell -and the permanent functional presentation harness. `ExpandingPromptEditor` and -the visual style helpers live under +The canonical CodexUI implementation contains one complete visual shell. +`ExpandingPromptEditor` and the visual style helpers live under `src/codex/ui` because they contain no protocol authority. The implementation is divided into the following concrete components: @@ -960,7 +928,6 @@ The implementation is divided into the following concrete components: | `ProtocolNormalizer` | Native app-server/bridge input to `codexui.presentation` result/event conversion | | `PresentationProtocol` | Frame construction, validation, authority, sequence, generation, and scope utilities | | `PresentationModel` | Qt-owned stable-ID reducer for threads, turns, items, plans, agents, requests, global domains, and telemetry | -| `WorkbenchWidget` | Permanent development harness and user-intent adapter | | `ShellWidget` | Product shell and protocol/application coordinator; owns stable selection, hydration, recovery, and command dispatch | | `MiddleRegionWidget` | Three-pane visual composition and center-region wheel routing | | `ThreadPane` | Stable-ID thread-list projection and thread actions | @@ -1033,14 +1000,10 @@ certificate, timeout, queue, reconnect, and instance-enable options come from the corresponding SNode.C client configuration; CodexUI adds no duplicate transport configuration. -The build produces two independently launchable applications: - -- `codex-ui` is the normal visual UI/UX shell; -- `codex-ui-harness` is the permanent plain protocol/reducer harness. - -They compile the same `FrontendSession`, `ClientRuntime`, socketpair, -normalizer, protocol, and presentation-model sources. Only the top-level Qt -consumer differs. Neither executable has a privileged transport or state path. +The build produces one application, `codex-ui`. It integrates the production +shell with `FrontendSession`, `ClientRuntime`, the socketpair, normalizer, +presentation protocol, and presentation model; no alternate UI target has a +privileged transport or state path. The current build links the codex AISuite frontend library as `AISuite::OpenAICodex`, Qt Widgets, Threads, libgit2 through pkg-config, and the @@ -1178,7 +1141,7 @@ instead of repeating both mechanisms in every case. #### Conversation Projection -`codexui-greenfield-projection-test` verifies the pure typed projection and +`codexui-conversation-projection-test` verifies the pure typed projection and prompt coordinator: one section per app-server turn, stable card identity and server ordering, per-thread prompt queues, dispatch-time Start/Steer choice, callback-only acknowledgment, exact `clientUserMessageId` correlation, @@ -1187,13 +1150,13 @@ Command execution output visibility. #### Middle-Region Behavior -`codexui-greenfield-middle-test` exercises the actual conversation widgets +`codexui-conversation-cards-test` exercises the actual conversation widgets programmatically. It verifies smooth follow, user-owned pause, stable card-and-pixel anchoring across every card type and width-dependent reflow, per-thread restoration, composer trailing space, pending-prompt animation, and independent Command execution output sizing and scroll ownership. -`codexui-greenfield-layout-test` verifies the three-pane constraints, composer +`codexui-application-layout-test` verifies the three-pane constraints, composer overlay geometry, complete center-region wheel routing, thread-list selection projection, nested-scroll handoff, and retained Inspector/Info behavior. These are state and geometry assertions over Qt widgets, not golden-screenshot or @@ -1202,7 +1165,7 @@ transient card rasters only to prove that motion exists. #### Shell Integration -`codexui-greenfield-shell-test` drives the production `ShellWidget` and +`codexui-shell-integration-test` drives the production `ShellWidget` and `FrontendSession` across their real socketpair presentation boundary. It verifies exact visible-thread routing, independent prompt queues, real result acknowledgment, background completion, retained Plan/Agents state, monotonic @@ -1216,7 +1179,7 @@ retention, bounded child-thread reads, and one-shot thread-not-found recovery. repository. It performs filesystem writes rather than UI interaction. The test verifies polling discovery of a manually created nested untracked file and native watcher refresh after removal, content reversion, deletion restoration, -and atomic replacement. `codexui-greenfield-layout-test` complements it with +and atomic replacement. `codexui-application-layout-test` complements it with in-process repository-resolution coverage for all scopes, duplicate candidates, ambiguous and absolute paths, All and individual repository selection, hidden repository exclusion/inclusion, stale hints/selections, and preference for an @@ -1249,27 +1212,27 @@ The seven focused tests can be built and run directly: cmake --build "${BUILD_DIR}" --parallel 8 \ --target codexui-socketpair-contract-test \ codexui-presentation-pipeline-test \ - codexui-greenfield-projection-test \ - codexui-greenfield-middle-test \ - codexui-greenfield-layout-test \ + codexui-conversation-projection-test \ + codexui-conversation-cards-test \ + codexui-application-layout-test \ codexui-git-changes-live-test \ - codexui-greenfield-shell-test + codexui-shell-integration-test ctest --test-dir "${BUILD_DIR}" --output-on-failure \ - -R '^codexui-(socketpair-contract|presentation-pipeline|git-changes-live|greenfield-(projection|middle|layout|shell))$' + -R '^codexui-(socketpair-contract|presentation-pipeline|conversation-projection|conversation-cards|application-layout|git-changes-live|shell-integration)$' ``` Each test has a 10-to-30-second CTest ceiling. Normal successful execution is substantially shorter and requires no network listener, credentials, isolated Codex home, or user interaction. -## 18. Live Harness Validation +## 18. Live Application Validation -The harness was exercised against one persistent real topology: +The application was exercised against one persistent real topology: ```text Codex app-server over IPv4 WebSocket <-> codex-bridge over IPv4 WebSocket - <-> CodexUI harness over IPv4 WebSocket + <-> CodexUI over IPv4 WebSocket ``` An independent `codex-bridge-client` observer remained connected to the same @@ -1391,9 +1354,7 @@ separate explicit authority and retention decision. ## 20. Visual Shell Integration Boundary The CodexUI shell is implemented in codex-owned Qt widgets. Those widgets -consume only `PresentationModel` and call only `FrontendSession`. The permanent -harness remains available as the protocol/reducer diagnostic surface and is not -itself the product shell. +consume only `PresentationModel` and call only `FrontendSession`. The implemented shell contains the 64-pixel top bar, hideable work sidebar, thread list, conversation timeline and composer, hideable inspector, Plan, @@ -1402,16 +1363,17 @@ connection lifecycle/configuration, canonical new-thread/workspace/attachment dialogs, per-thread context actions, complete upcoming-turn settings, a first-class diff viewer, pending-prompt cards, request status, and the 40-pixel status bar. Agent -messages, plans, reasoning summaries, and agent results are -rendered with Qt Markdown parsing while embedded HTML is disabled. User text, -commands, and command output remain literal. State and Protocol diagnostics -remain nested under Info rather than dominating normal use. +messages, plans, reasoning summaries, agent results, and authoritative user +messages are rendered with Qt Markdown parsing while embedded HTML is disabled. +The transitional local prompt, commands, and command output remain literal. +State and Protocol diagnostics remain nested under Info rather than dominating +normal use. Pending-request presentation exposes category, stable request ID, connection generation, owning thread, and a bounded set of safe typed details. The native request object remains transiently available to the typed response dialog but -is never dumped to the shell, harness State view, notice banner, or protocol -log. Secret answers are held only by password editors until the dialog is +is never dumped to the shell, Info/State view, notice banner, or protocol log. +Secret answers are held only by password editors until the dialog is destroyed. Operation errors, provider notices, protocol diagnostics, and connection diff --git a/src/codex/MainWindow.cpp b/src/codex/MainWindow.cpp index b98a37f..a580053 100644 --- a/src/codex/MainWindow.cpp +++ b/src/codex/MainWindow.cpp @@ -2,11 +2,7 @@ #include "codex/MainWindow.h" -#ifdef CODEXUI_DEVELOPMENT_HARNESS -#include "codex/WorkbenchWidget.h" -#else #include "codex/ShellWidget.h" -#endif #include "codex/ui/BrandMark.h" #include "codex/ui/UiStyle.h" @@ -16,11 +12,7 @@ namespace codexui::codex { MainWindow::MainWindow(FrontendSession &session, QWidget *parent) : QMainWindow(parent) { -#ifdef CODEXUI_DEVELOPMENT_HARNESS - setWindowTitle(QStringLiteral("CodexUI - codex Harness")); -#else setWindowTitle(QStringLiteral("CodexUI")); -#endif setMinimumSize(1100, 700); resize(1536, 960); @@ -28,11 +20,7 @@ MainWindow::MainWindow(FrontendSession &session, QWidget *parent) qApp->setWindowIcon(applicationIcon); setWindowIcon(applicationIcon); qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); -#ifdef CODEXUI_DEVELOPMENT_HARNESS - setCentralWidget(new WorkbenchWidget(session, this)); -#else setCentralWidget(new ShellWidget(session, this)); -#endif } } // namespace codexui::codex diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 415497f..16c831d 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -3,414 +3,56 @@ #include "codex/ShellWidget.h" #include "codex/ConnectionDialog.h" -#include "codex/DiffViewer.h" #include "codex/FileSelectionDialog.h" #include "codex/FrontendSession.h" #include "codex/NewThreadDialog.h" #include "codex/PendingRequestDialog.h" +#include "codex/PresentationModel.h" #include "codex/TurnSettingsWidget.h" +#include "codex/middle/ComposerPane.h" +#include "codex/middle/ConversationProjection.h" +#include "codex/middle/ConversationView.h" +#include "codex/middle/InspectorPane.h" +#include "codex/middle/MiddleRegionWidget.h" +#include "codex/middle/PromptCoordinator.h" +#include "codex/middle/ThreadPane.h" #include "codex/ui/BrandMark.h" #include "codex/ui/ExpandingPromptEditor.h" -#include -#include -#include #include #include -#include #include #include -#include #include -#include #include #include -#include #include #include #include -#include -#include #include #include -#include -#include -#include -#include +#include #include -#include -#include -#include +#include #include -#include -#include #include #include #include -#include -#include -#include #include -#include #include -#include #include #include +#include +#include +#include +#include +#include namespace codexui::codex { namespace { -constexpr int UpcomingControlHeight = 32; -constexpr int MaximumCommandOutputHeight = 220; -constexpr int AcknowledgementTransitionMilliseconds = 500; -constexpr auto ConversationAnchorProperty = "conversationAnchorKey"; - -using CommandOutputScrollState = std::pair; - -QLabel *makeLabel(QString value, const char *kind = "body"); - -bool commandOutputIsVisible(QStringView output) { - for (qsizetype index = 0; index < output.size(); ++index) { - const ushort code = output[index].unicode(); - if (code == 0x1b && index + 1 < output.size()) { - const ushort introducer = output[index + 1].unicode(); - if (introducer == '[') { - index += 2; - while (index < output.size()) { - const ushort candidate = output[index].unicode(); - if (candidate >= 0x40 && candidate <= 0x7e) - break; - ++index; - } - continue; - } - if (introducer == ']') { - index += 2; - while (index < output.size()) { - if (output[index].unicode() == 0x07) - break; - if (output[index].unicode() == 0x1b && index + 1 < output.size() && - output[index + 1].unicode() == '\\') { - ++index; - break; - } - ++index; - } - continue; - } - ++index; - continue; - } - if (output[index].isPrint() && !output[index].isSpace()) - return true; - } - return false; -} - -class PendingPromptCard final : public QFrame { -public: - PendingPromptCard(const QString &prompt, int attachmentCount, bool awaiting, - bool acknowledgedTransition, qint64 acknowledgedAt, - bool failed, const QString &error) { - setObjectName(QStringLiteral("pendingPromptCard")); - setStyleSheet(QStringLiteral( - "QFrame#pendingPromptCard{background:transparent;border:0;}")); - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(6); - - const QString foreground = awaiting || acknowledgedTransition - ? QStringLiteral("#536b8f") - : failed ? QStringLiteral("#982f3d") - : QStringLiteral("#1d2633"); - auto *title = makeLabel(QStringLiteral("You"), "title"); - title->setStyleSheet( - QStringLiteral("background:transparent;color:%1;").arg(foreground)); - layout->addWidget(title); - auto *body = makeLabel(prompt); - body->setStyleSheet( - QStringLiteral("background:transparent;color:%1;").arg(foreground)); - layout->addWidget(body); - - QString status; - if (awaiting) - status = QStringLiteral("Waiting for app-server acknowledgment"); - else if (acknowledgedTransition) - status = QStringLiteral("Accepted by app-server"); - else if (failed) - status = error.isEmpty() ? QStringLiteral("Not sent") - : QStringLiteral("Not sent: %1").arg(error); - if (attachmentCount > 0) { - const QString attachments = - QStringLiteral("%1 attachment%2") - .arg(attachmentCount) - .arg(attachmentCount == 1 ? QString{} : QStringLiteral("s")); - status = status.isEmpty() - ? attachments - : status + QStringLiteral(" | ") + attachments; - } - if (!status.isEmpty()) { - auto *metadata = makeLabel(status, "meta"); - metadata->setStyleSheet( - QStringLiteral("background:transparent;color:%1;").arg(foreground)); - layout->addWidget(metadata); - } - - if (awaiting || acknowledgedTransition) { - animationTimer.setInterval(32); - connect(&animationTimer, &QTimer::timeout, this, - qOverload<>(&PendingPromptCard::update)); - animationTimer.start(); - } - isAwaiting = awaiting; - isAcknowledgedTransition = acknowledgedTransition; - acknowledgedAtMilliseconds = acknowledgedAt; - hasFailed = failed; - } - -protected: - void paintEvent(QPaintEvent *event) override { - QFrame::paintEvent(event); - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - const QRectF bounds = QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5); - const QColor background = isAwaiting || isAcknowledgedTransition - ? QColor(QStringLiteral("#dbe7f8")) - : hasFailed ? QColor(QStringLiteral("#fff0f2")) - : QColor(QStringLiteral("#eaf2ff")); - const QColor border = isAwaiting || isAcknowledgedTransition - ? QColor(QStringLiteral("#9eb9df")) - : hasFailed ? QColor(QStringLiteral("#efb8c0")) - : QColor(QStringLiteral("#bfd3f9")); - painter.setBrush(background); - painter.setPen(QPen(border, 1.0)); - painter.drawRoundedRect(bounds, 8.0, 8.0); - - if (!isAwaiting && !isAcknowledgedTransition) - return; - constexpr qreal HalfSweepWidth = 0.24; - constexpr qint64 HalfCycleMilliseconds = 850; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const qint64 phase = now % (2 * HalfCycleMilliseconds); - const qreal progress = - isAwaiting ? phase <= HalfCycleMilliseconds - ? qreal(phase) / HalfCycleMilliseconds - : qreal(2 * HalfCycleMilliseconds - phase) / - HalfCycleMilliseconds - : std::clamp(qreal(now - acknowledgedAtMilliseconds) / - AcknowledgementTransitionMilliseconds, - 0.0, 1.0); - const qreal center = bounds.left() + progress * bounds.width(); - const qreal radius = std::max(28.0, bounds.width() * HalfSweepWidth); - QLinearGradient sweep(center - radius, 0.0, center + radius, 0.0); - sweep.setColorAt(0.0, QColor(47, 111, 235, 0)); - sweep.setColorAt(0.5, QColor(117, 160, 239, 105)); - sweep.setColorAt(1.0, QColor(47, 111, 235, 0)); - QPainterPath clip; - clip.addRoundedRect(bounds, 8.0, 8.0); - painter.save(); - painter.setClipPath(clip); - painter.fillRect(bounds, sweep); - painter.restore(); - - painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(QColor(QStringLiteral("#79a0d7")), 1.5)); - painter.drawRoundedRect(bounds, 8.0, 8.0); - } - -private: - QTimer animationTimer; - bool isAwaiting = false; - bool isAcknowledgedTransition = false; - qint64 acknowledgedAtMilliseconds = 0; - bool hasFailed = false; -}; - -class CommandOutputView final : public QPlainTextEdit { -public: - explicit CommandOutputView( - const QString &output, - std::optional restoredState = std::nullopt) - : followsLatest(restoredState ? restoredState->first : true), - preservedScrollValue(restoredState ? restoredState->second : 0) { - setReadOnly(true); - setMinimumHeight(0); - setMaximumHeight(MaximumCommandOutputHeight); - setLineWrapMode(QPlainTextEdit::WidgetWidth); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - setProperty("kind", "code"); - setObjectName(QStringLiteral("commandOutputView")); - setStyleSheet(QStringLiteral( - "background:#111827;color:#e5e7eb;border-radius:6px;padding:7px;" - "font-family:monospace;")); - setPlainText(output); - - connect(verticalScrollBar(), &QScrollBar::valueChanged, this, - [this](int value) { - if (adjustingScroll) - return; - preservedScrollValue = value; - followsLatest = value >= verticalScrollBar()->maximum() - 1; - }); - connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, - [this](int, int) { scheduleScrollSettlement(); }); - connect(document()->documentLayout(), - &QAbstractTextDocumentLayout::documentSizeChanged, this, - [this](const QSizeF &) { remeasure(); }); - // Establish a content-derived height before the card enters the - // conversation layout. Otherwise it first appears at zero height and then - // changes the outer scroll range in a visibly separate pass. - remeasure(); - QTimer::singleShot(0, this, [this] { settleScroll(); }); - } - - [[nodiscard]] CommandOutputScrollState scrollState() const { - return {followsLatest, verticalScrollBar()->value()}; - } - - void setOutput(QString output) { - if (toPlainText() == output) - return; - setPlainText(std::move(output)); - remeasure(); - settleScroll(); - } - - QSize sizeHint() const override { - QSize result = QPlainTextEdit::sizeHint(); - result.setHeight(preferredHeight); - return result; - } - - QSize minimumSizeHint() const override { - QSize result = QPlainTextEdit::minimumSizeHint(); - result.setHeight(0); - return result; - } - -protected: - void resizeEvent(QResizeEvent *event) override { - QPlainTextEdit::resizeEvent(event); - remeasure(); - } - -private: - void remeasure() { - if (remeasuring) - return; - remeasuring = true; - const int contentHeight = static_cast( - std::ceil(document()->documentLayout()->documentSize().height())); - const int wantedHeight = std::clamp(contentHeight + 2 * frameWidth() + 14, - 0, MaximumCommandOutputHeight); - if (wantedHeight != preferredHeight) { - preferredHeight = wantedHeight; - updateGeometry(); - } - scheduleScrollSettlement(); - remeasuring = false; - } - - void scheduleScrollSettlement() { - if (scrollSettlementPending) - return; - scrollSettlementPending = true; - QTimer::singleShot(0, this, [this] { - scrollSettlementPending = false; - settleScroll(); - }); - } - - void settleScroll() { - adjustingScroll = true; - QScrollBar *scrollBar = verticalScrollBar(); - scrollBar->setValue( - followsLatest ? scrollBar->maximum() - : std::min(preservedScrollValue, scrollBar->maximum())); - adjustingScroll = false; - } - - bool followsLatest = true; - bool adjustingScroll = false; - bool remeasuring = false; - bool scrollSettlementPending = false; - int preservedScrollValue = 0; - int preferredHeight = 0; -}; - -std::optional -commandOutputScrollState(QWidget *card) { - if (!card) - return std::nullopt; - for (QPlainTextEdit *editor : card->findChildren()) { - if (auto *output = dynamic_cast(editor)) - return output->scrollState(); - } - return std::nullopt; -} - -class BottomOverlayDock final : public QWidget { -public: - BottomOverlayDock(QWidget *anchor, std::function heightChanged) - : QWidget(anchor), anchor(anchor), - heightChanged(std::move(heightChanged)) { - anchor->installEventFilter(this); - } - - void synchronizeGeometry() { - if (!layout()) - return; - layout()->activate(); - constexpr int HorizontalInset = 24; - constexpr int BottomInset = 12; - const int availableHeight = std::max(0, anchor->height() - BottomInset); - const int wantedHeight = std::min(sizeHint().height(), availableHeight); - if (wantedHeight != reportedHeight) { - reportedHeight = wantedHeight; - if (heightChanged) - heightChanged(wantedHeight); - } - setGeometry(HorizontalInset, availableHeight - wantedHeight, - std::max(0, anchor->width() - 2 * HorizontalInset), - wantedHeight); - raise(); - } - -protected: - bool event(QEvent *event) override { - const bool accepted = QWidget::event(event); - if (event->type() == QEvent::LayoutRequest || event->type() == QEvent::Show) - scheduleSynchronization(); - return accepted; - } - - bool eventFilter(QObject *watched, QEvent *event) override { - if (watched == anchor && - (event->type() == QEvent::Resize || event->type() == QEvent::Show || - event->type() == QEvent::LayoutRequest)) - scheduleSynchronization(); - return QWidget::eventFilter(watched, event); - } - -private: - void scheduleSynchronization() { - if (synchronizationPending) - return; - synchronizationPending = true; - QTimer::singleShot(0, this, [this] { - synchronizationPending = false; - synchronizeGeometry(); - }); - } - - QWidget *anchor = nullptr; - std::function heightChanged; - int reportedHeight = -1; - bool synchronizationPending = false; -}; +constexpr auto DraftThreadId = "draft:new-thread"; QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); @@ -419,10 +61,9 @@ QString text(const std::string &value) { std::string stringValue(const nlohmann::json &object, const char *key) { if (!object.is_object()) return {}; - const auto iterator = object.find(key); - return iterator != object.end() && iterator->is_string() - ? iterator->get() - : std::string{}; + const auto found = object.find(key); + return found != object.end() && found->is_string() ? found->get() + : std::string{}; } QString displayStatus(const std::string &status) { @@ -432,105 +73,7 @@ QString displayStatus(const std::string &status) { return QStringLiteral("Completed"); if (status == "failed" || status == "systemError") return QStringLiteral("Failed"); - if (status.empty()) - return QStringLiteral("Unknown"); - return text(status); -} - -QString commandExecutionMetadata(const nlohmann::json &item) { - QStringList metadata; - metadata << displayStatus(stringValue(item, "status")); - if (item.contains("exitCode") && item["exitCode"].is_number_integer()) - metadata << QStringLiteral("exit %1").arg(item["exitCode"].get()); - const QString cwd = text(stringValue(item, "cwd")); - if (!cwd.isEmpty()) - metadata << cwd; - return metadata.join(QStringLiteral(" | ")); -} - -QLabel *makeLabel(QString value, const char *kind) { - auto *label = new QLabel(std::move(value)); - label->setProperty("kind", kind); - label->setTextFormat(Qt::PlainText); - label->setWordWrap(true); - label->setMinimumWidth(0); - label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - label->setTextInteractionFlags(Qt::TextSelectableByMouse); - return label; -} - -QLabel *makeMarkdownLabel(const QString &value) { - QTextDocument document; - document.setMarkdown(value, QTextDocument::MarkdownNoHTML); - auto *label = new QLabel(document.toHtml()); - label->setProperty("kind", "body"); - label->setTextFormat(Qt::RichText); - label->setWordWrap(true); - label->setMinimumWidth(0); - label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - label->setOpenExternalLinks(true); - label->setTextInteractionFlags(Qt::TextSelectableByMouse | - Qt::LinksAccessibleByMouse); - return label; -} - -QFrame *makeDivider() { - auto *divider = new QFrame; - divider->setFixedHeight(1); - divider->setStyleSheet(QStringLiteral("background:#d7dee8;")); - return divider; -} - -QFrame *makeStatusDot() { - auto *dot = new QFrame; - dot->setFixedSize(10, 10); - dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:5px;")); - return dot; -} - -void clearLayout(QLayout *layout) { - while (QLayoutItem *item = layout->takeAt(0)) { - if (QWidget *widget = item->widget()) { - widget->hide(); - widget->deleteLater(); - } - if (QLayout *child = item->layout()) { - clearLayout(child); - delete child; - } - delete item; - } -} - -QString joinedStrings(const nlohmann::json &value) { - if (!value.is_array()) - return {}; - QStringList result; - for (const auto &item : value) { - if (item.is_string()) - result.push_back(text(item.get())); - } - return result.join(QStringLiteral(", ")); -} - -QString messageText(const nlohmann::json &item) { - const std::string type = stringValue(item, "type"); - if (type == "agentMessage" || type == "plan") - return text(stringValue(item, "text")); - if (type == "userMessage") { - QStringList parts; - const nlohmann::json content = - item.value("content", nlohmann::json::array()); - if (content.is_array()) { - for (const auto &entry : content) { - const std::string value = stringValue(entry, "text"); - if (!value.empty()) - parts.push_back(text(value)); - } - } - return parts.join(QStringLiteral("\n")); - } - return {}; + return status.empty() ? QStringLiteral("Unknown") : text(status); } std::string safeMessage(const nlohmann::json &value) { @@ -555,216 +98,162 @@ bool isThreadNotFoundResult(const nlohmann::json &result) { message.contains(QStringLiteral("not found")); } -QFrame *itemFrame( - const ItemPresentation &presentation, - std::optional outputScrollState = std::nullopt) { - const nlohmann::json &item = presentation.raw; - const std::string typeName = stringValue(item, "type"); - auto *frame = new QFrame; - frame->setProperty("kind", "raised"); - if (typeName == "userMessage") - frame->setProperty("messageRole", "user"); - else if (typeName == "agentMessage") - frame->setProperty("messageRole", "agent"); - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(6); - - QString title; - if (typeName == "userMessage") - title = QStringLiteral("You"); - else if (typeName == "agentMessage") - title = stringValue(item, "phase") == "final_answer" - ? QStringLiteral("Codex") - : QStringLiteral("Codex activity"); - else if (typeName == "commandExecution") - title = QStringLiteral("Command execution"); - else if (typeName == "collabAgentToolCall" || typeName == "subAgentActivity") - title = QStringLiteral("Agent activity"); - else if (typeName == "reasoning") - title = QStringLiteral("Reasoning"); - else if (typeName == "fileChange") - title = QStringLiteral("File changes"); - else - title = text(typeName.empty() ? std::string("Activity") : typeName); - layout->addWidget(makeLabel(title, "title")); - - const QString body = messageText(item); - if (!body.isEmpty()) { - layout->addWidget(typeName == "agentMessage" || typeName == "plan" - ? makeMarkdownLabel(body) - : makeLabel(body)); - } - - if (typeName == "commandExecution") { - const QString command = text(stringValue(item, "command")); - if (!command.isEmpty()) { - auto *commandView = new QPlainTextEdit(command); - commandView->setReadOnly(true); - commandView->setMaximumHeight(90); - commandView->setLineWrapMode(QPlainTextEdit::WidgetWidth); - commandView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - commandView->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - commandView->setProperty("kind", "command"); - commandView->setObjectName(QStringLiteral("commandTextView")); - commandView->setStyleSheet(QStringLiteral( - "background:#f8fafc;border:1px solid #d7dee8;border-radius:6px;" - "padding:7px;font-family:monospace;")); - layout->addWidget(commandView); - } - const QString output = text(stringValue(item, "aggregatedOutput")); - if (commandOutputIsVisible(output)) { - layout->addWidget(new CommandOutputView(output, outputScrollState)); - } - auto *metadata = makeLabel(commandExecutionMetadata(item), "meta"); - metadata->setObjectName(QStringLiteral("commandMetadata")); - layout->addWidget(metadata); - } else if (typeName == "collabAgentToolCall" || - typeName == "subAgentActivity") { - QStringList metadata; - const QString tool = text(stringValue(item, "tool")); - if (!tool.isEmpty()) - metadata << tool; - std::string status = stringValue(item, "status"); - if (status.empty()) - status = stringValue(item, "kind"); - metadata << displayStatus(status); - const QString receivers = - joinedStrings(item.value("receiverThreadIds", nlohmann::json::array())); - if (!receivers.isEmpty()) - metadata << receivers; - layout->addWidget( - makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); - const QString prompt = text(stringValue(item, "prompt")); - if (!prompt.isEmpty()) - layout->addWidget(makeLabel(prompt)); - const QString result = text(stringValue(item, "resultText")); - if (!result.isEmpty()) - layout->addWidget(makeMarkdownLabel(result)); - } else if (typeName == "reasoning") { - const QString summaries = - joinedStrings(item.value("summary", nlohmann::json::array())); - if (!summaries.isEmpty()) - layout->addWidget(makeMarkdownLabel(summaries)); - } else if (typeName == "fileChange") { - QStringList metadata; - metadata << displayStatus(stringValue(item, "status")); - const nlohmann::json changes = - item.value("changes", nlohmann::json::array()); - if (changes.is_array()) - metadata << QStringLiteral("%1 paths") - .arg(static_cast(changes.size())); - layout->addWidget( - makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); - } else if (body.isEmpty()) { - layout->addWidget(makeLabel(text(item.dump(2)), "meta")); - } - return frame; +std::optional resultTurnId(const nlohmann::json &result) { + const nlohmann::json scope = result.value("scope", nlohmann::json::object()); + std::string id = stringValue(scope, "turnId"); + if (!id.empty()) + return id; + const nlohmann::json data = result.value("data", nlohmann::json::object()); + id = stringValue(data, "turnId"); + if (!id.empty()) + return id; + const nlohmann::json turn = data.value("turn", nlohmann::json::object()); + id = stringValue(turn, "id"); + return id.empty() ? std::nullopt : std::optional(std::move(id)); } -bool updateCommandExecutionFrame(QWidget *frame, - const ItemPresentation &presentation) { - if (!frame || stringValue(presentation.raw, "type") != "commandExecution") - return false; - auto *layout = qobject_cast(frame->layout()); - auto *metadata = - frame->findChild(QStringLiteral("commandMetadata")); - if (!layout || !metadata) - return false; - - const QString command = text(stringValue(presentation.raw, "command")); - auto *commandView = - frame->findChild(QStringLiteral("commandTextView")); - if (commandView && !command.isEmpty() && - commandView->toPlainText() != command) - commandView->setPlainText(command); - else if ((!commandView && !command.isEmpty()) || - (commandView && command.isEmpty())) - return false; +QLabel *makeLabel(QString value, const char *kind = "body") { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} - const QString output = - text(stringValue(presentation.raw, "aggregatedOutput")); - auto *outputView = dynamic_cast( - frame->findChild(QStringLiteral("commandOutputView"))); - if (commandOutputIsVisible(output)) { - if (outputView) { - outputView->setOutput(output); - } else { - outputView = new CommandOutputView(output); - const int metadataIndex = layout->indexOf(metadata); - layout->insertWidget(std::max(0, metadataIndex), outputView); - } - } else if (outputView) { - layout->removeWidget(outputView); - outputView->hide(); - outputView->deleteLater(); - } - metadata->setText(commandExecutionMetadata(presentation.raw)); - frame->updateGeometry(); - return true; +QFrame *statusDot() { + auto *dot = new QFrame; + dot->setFixedSize(10, 10); + dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:5px;")); + return dot; } -QFrame *agentFrame(const AgentPresentation &agent) { - const nlohmann::json &activity = agent.raw; - auto *frame = new QFrame; - frame->setProperty("kind", "raised"); - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(6); - - const std::string tool = stringValue(activity, "tool"); - const bool childAgent = !agent.childThreadId.empty(); - const QString title = childAgent ? QStringLiteral("Subagent") - : tool.empty() - ? QStringLiteral("Agent activity") - : QStringLiteral("Agent %1").arg(text(tool)); - layout->addWidget(makeLabel(title, "title")); - - QStringList metadata; - metadata << displayStatus(agent.status); - const QString path = text(stringValue(activity, "agentPath")); - if (!path.isEmpty()) - metadata << path; - if (!tool.empty()) - metadata << text(tool); - const QString model = text(stringValue(activity, "model")); - if (!model.isEmpty()) - metadata << model; - const QString effort = text(stringValue(activity, "reasoningEffort")); - if (!effort.isEmpty()) - metadata << effort; - layout->addWidget(makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); - - const QString prompt = text(stringValue(activity, "prompt")); - if (!prompt.isEmpty()) - layout->addWidget(makeLabel(prompt)); - - const QString result = text(stringValue(activity, "resultText")); - if (!result.isEmpty()) - layout->addWidget(makeMarkdownLabel(result)); - - QStringList identities; - if (!agent.childThreadId.empty()) - identities << QStringLiteral("thread %1").arg(text(agent.childThreadId)); - const QString sender = text(stringValue(activity, "senderThreadId")); - if (!sender.isEmpty()) - identities << QStringLiteral("sender %1").arg(sender); - const QString receivers = joinedStrings( - activity.value("receiverThreadIds", nlohmann::json::array())); - if (!receivers.isEmpty()) - identities << QStringLiteral("receivers %1").arg(receivers); - if (!identities.isEmpty()) - layout->addWidget( - makeLabel(identities.join(QStringLiteral(" | ")), "meta")); - return frame; +std::string recoveryKey(const std::string &threadId, + std::uint64_t submissionId) { + return threadId + ':' + std::to_string(submissionId); } } // namespace -ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) - : QWidget(parent), session(session) { - setObjectName(QStringLiteral("workbench")); - auto *root = new QVBoxLayout(this); +struct ShellWidget::Impl final { + enum class Hydration { NotHydrated, InFlight, Hydrated, Failed }; + struct HistoryWindow { + std::size_t requested = + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + std::size_t effective = + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + std::size_t lastAuthoritativeCount = 0; + }; + + Impl(ShellWidget *owner, FrontendSession &session) + : owner(owner), session(session), alive(std::make_shared(true)) { + buildUi(); + connectUi(); + const auto token = alive; + session.setEventHandler([this, token](const nlohmann::json &event) { + if (*token) + handleEvent(event); + }); + render(); + } + + ~Impl() { + *alive = false; + session.setEventHandler({}); + qApp->removeEventFilter(owner); + } + + void buildUi(); + void connectUi(); + void handleEvent(const nlohmann::json &event); + void scheduleRender(); + void render(); + void renderConversation(); + void refreshSettings(); + void refreshStatus(); + void hydrateHistoricalAgents(); + void showNotice(QString message, bool error = true); + + void selectThread(std::string threadId); + void beginNewThread(); + void readThread(const std::string &threadId, bool forced = false); + void ensureThreadHydrated(const std::string &threadId); + [[nodiscard]] bool threadIsHydrated(const std::string &threadId) const; + [[nodiscard]] bool threadRequiresResume(const std::string &threadId) const; + void renameThread(const std::string &threadId); + void forkThread(const std::string &threadId); + void toggleThreadArchive(const std::string &threadId); + void deleteThread(const std::string &threadId); + + [[nodiscard]] bool submitPrompt(QString prompt, + std::vector attachments); + void startThreadForDraft(); + void dispatchNextPrompt(const std::string &threadId); + void dispatchPrompt(middle::PromptDispatch dispatch); + void resumePromptQueue(const std::string &threadId); + void completePrompt(const std::string &threadId, std::uint64_t submissionId, + const nlohmann::json &result); + [[nodiscard]] bool attemptThreadRecovery(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result); + void scheduleAcceptedTransition(const std::string &threadId, + std::uint64_t submissionId); + + void chooseAttachments(); + void interruptTurn(); + void reviewPending(const std::string &requestKey); + void rejectPending(const std::string &requestKey); + void respondToFirstPending(bool approve); + + ShellWidget *owner = nullptr; + FrontendSession &session; + PresentationModel model; + middle::PromptCoordinator prompts; + std::shared_ptr alive; + + std::string selectedThreadId; + bool newThreadIntent = false; + bool newThreadCreationInFlight = false; + nlohmann::json newThreadOptions = nlohmann::json::object(); + QString newThreadName; + QString newThreadWorkspace; + + std::unordered_map hydration; + std::unordered_map readRevisions; + std::unordered_set staleReadResultCorrelations; + std::uint64_t nextReadRevision = 1; + std::unordered_set operationReadyThreads; + std::unordered_set resumeInFlightThreads; + std::unordered_set dispatchScheduledThreads; + std::unordered_set promptRecoveryAttempted; + std::unordered_map historyWindows; + std::uint64_t observedConnectionGeneration = 0; + std::uint64_t observedProviderGeneration = 0; + QByteArray settingsSnapshot; + QByteArray statusSnapshot; + bool renderScheduled = false; + + middle::MiddleRegionWidget *middleRegion = nullptr; + QPushButton *restoreSidebarButton = nullptr; + QPushButton *restoreInspectorButton = nullptr; + QLabel *workspaceBreadcrumb = nullptr; + QPushButton *requestButton = nullptr; + QFrame *connectionStatusDot = nullptr; + QToolButton *connectionButton = nullptr; + QAction *connectAction = nullptr; + QAction *disconnectAction = nullptr; + QAction *reconnectAction = nullptr; + QPushButton *controllerButton = nullptr; + QLabel *threadContextStatus = nullptr; + QLabel *agentActivityStatus = nullptr; + QLabel *controllerLabel = nullptr; +}; + +void ShellWidget::Impl::buildUi() { + owner->setObjectName(QStringLiteral("applicationShell")); + auto *root = new QVBoxLayout(owner); root->setContentsMargins(0, 0, 0, 0); root->setSpacing(0); @@ -777,6 +266,7 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) topLayout->setContentsMargins(18, 0, 18, 0); topLayout->setSpacing(12); topLayout->addWidget(codexui::BrandMark::createLockup()); + restoreSidebarButton = new QPushButton(QStringLiteral("Show threads")); restoreSidebarButton->setProperty("kind", "subtle"); restoreSidebarButton->setFixedHeight(32); @@ -790,17 +280,23 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) workspaceBreadcrumb->setStyleSheet( QStringLiteral("color:#667085;font-weight:500;")); topLayout->addWidget(workspaceBreadcrumb); + topLayout->addStretch(); + + restoreInspectorButton = new QPushButton(QStringLiteral("Show inspector")); + restoreInspectorButton->setProperty("kind", "subtle"); + restoreInspectorButton->setFixedHeight(32); + restoreInspectorButton->hide(); requestButton = new QPushButton; requestButton->setProperty("kind", "request"); requestButton->setFixedHeight(32); requestButton->hide(); - connect(requestButton, &QPushButton::clicked, this, [this] { - inspector->show(); - restoreInspectorButton->hide(); - inspectorTabs->setCurrentIndex(3); - }); - topLayout->addStretch(); - connectionStatusDot = makeStatusDot(); + controllerButton = new QPushButton(QStringLiteral("Claim control")); + controllerButton->setFixedHeight(32); + topLayout->addWidget(restoreInspectorButton); + topLayout->addWidget(requestButton); + topLayout->addWidget(controllerButton); + + connectionStatusDot = statusDot(); connectionStatusDot->setToolTip(QStringLiteral("Not connected")); connectionButton = new QToolButton; connectionButton->setText(QStringLiteral("Connection")); @@ -808,18 +304,19 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) connectionButton->setPopupMode(QToolButton::InstantPopup); connectionButton->setFixedHeight(32); auto *connectionMenu = new QMenu(connectionButton); - connectionMenu->addAction(QStringLiteral("Configure..."), this, [this] { + connectionMenu->addAction(QStringLiteral("Configure..."), owner, [this] { if (!model.connection().settings.is_object() || model.connection().settings.empty()) { showNotice(QStringLiteral("Connection settings are not available yet.")); return; } - ConnectionDialog dialog(model.connection().settings, this); + ConnectionDialog dialog(model.connection().settings, owner); if (dialog.exec() != QDialog::Accepted) return; - this->session.configureConnection( - dialog.selection(), [this](const nlohmann::json &result) { - if (result.value("ok", false)) + const auto token = alive; + session.configureConnection( + dialog.selection(), [this, token](const nlohmann::json &result) { + if (!*token || result.value("ok", false)) return; const std::string message = safeMessage(result.value("error", nlohmann::json::object())); @@ -829,31 +326,14 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) }); }); connectionMenu->addSeparator(); - connectAction = - connectionMenu->addAction(QStringLiteral("Connect"), this, - [this] { this->session.connectTransport(); }); + connectAction = connectionMenu->addAction( + QStringLiteral("Connect"), owner, [this] { session.connectTransport(); }); disconnectAction = - connectionMenu->addAction(QStringLiteral("Disconnect"), this, [this] { - this->session.disconnectTransport(); - }); + connectionMenu->addAction(QStringLiteral("Disconnect"), owner, + [this] { session.disconnectTransport(); }); reconnectAction = connectionMenu->addAction( - QStringLiteral("Reconnect"), this, [this] { this->session.reconnect(); }); + QStringLiteral("Reconnect"), owner, [this] { session.reconnect(); }); connectionButton->setMenu(connectionMenu); - controllerButton = new QPushButton(QStringLiteral("Claim control")); - controllerButton->setFixedHeight(32); - restoreInspectorButton = new QPushButton(QStringLiteral("Show inspector")); - restoreInspectorButton->setProperty("kind", "subtle"); - restoreInspectorButton->setFixedHeight(32); - restoreInspectorButton->hide(); - connect(controllerButton, &QPushButton::clicked, this, [this] { - if (model.connection().role == "controller") - this->session.releaseController(); - else - this->session.claimController(); - }); - topLayout->addWidget(restoreInspectorButton); - topLayout->addWidget(requestButton); - topLayout->addWidget(controllerButton); auto *connectionControl = new QWidget; auto *connectionLayout = new QHBoxLayout(connectionControl); connectionLayout->setContentsMargins(0, 0, 0, 0); @@ -863,466 +343,14 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) topLayout->addWidget(connectionControl); root->addWidget(top); - splitter = new QSplitter(Qt::Horizontal); - splitter->setChildrenCollapsible(false); - splitter->setHandleWidth(8); - - sidebar = new QFrame; - sidebar->setObjectName(QStringLiteral("sidebar")); - sidebar->setStyleSheet(QStringLiteral("QFrame#sidebar{background:#f8fafc;}")); - sidebar->setMinimumWidth(220); - sidebar->setMaximumWidth(440); - auto *sidebarLayout = new QVBoxLayout(sidebar); - sidebarLayout->setContentsMargins(10, 14, 10, 17); - sidebarLayout->setSpacing(0); - auto *sidebarHeader = new QHBoxLayout; - sidebarHeader->setContentsMargins(8, 0, 6, 8); - sidebarHeader->addWidget(makeLabel(QStringLiteral("WORK"), "section")); - sidebarHeader->addStretch(); - auto *hideSidebarButton = new QPushButton(QStringLiteral("Hide")); - hideSidebarButton->setProperty("kind", "subtle"); - hideSidebarButton->setFixedSize(52, 24); - sidebarHeader->addWidget(hideSidebarButton); - sidebarLayout->addLayout(sidebarHeader); - - auto *newButton = new QPushButton(QStringLiteral("+ New thread")); - newButton->setFixedHeight(36); - newButton->setStyleSheet(QStringLiteral( - "QPushButton{background:#ffffff;color:#2f6feb;border:1px solid #bfd3f9;" - "border-radius:8px;text-align:left;padding-left:14px;font-weight:600;}" - "QPushButton:hover{background:#e5eeff;border-color:#2f6feb;}" - "QPushButton:disabled{background:#f6f8fb;color:#98a2b3;" - "border-color:#d7dee8;}")); - sidebarLayout->addWidget(newButton); - sidebarLayout->addSpacing(8); - - auto *threadToolbar = new QHBoxLayout; - threadToolbar->setContentsMargins(4, 0, 4, 6); - auto *refreshButton = new QPushButton(QStringLiteral("Refresh")); - refreshButton->setProperty("kind", "subtle"); - refreshButton->setFixedHeight(28); - threadToolbar->addWidget(refreshButton); - threadToolbar->addStretch(); - sidebarLayout->addLayout(threadToolbar); - threadList = new QListWidget; - threadList->setObjectName(QStringLiteral("threadList")); - threadList->setSelectionMode(QAbstractItemView::SingleSelection); - threadList->setContextMenuPolicy(Qt::CustomContextMenu); - threadList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - threadList->setTextElideMode(Qt::ElideRight); - threadList->setStyleSheet(QStringLiteral( - "QListWidget#threadList{background:transparent;border:0;outline:0;}" - "QListWidget#threadList::item{min-height:30px;border:0;border-radius:5px;" - "padding:2px 8px;color:#344054;}" - "QListWidget#threadList::item:hover{background:#eef3fa;}" - "QListWidget#threadList::item:selected{background:#e5eeff;" - "color:#1d2633;font-weight:600;}")); - sidebarLayout->addWidget(threadList); - connect(refreshButton, &QPushButton::clicked, this, - [this] { requestThreads(); }); - connect(newButton, &QPushButton::clicked, this, [this] { beginNewThread(); }); - connect(hideSidebarButton, &QPushButton::clicked, this, [this] { - sidebar->hide(); - restoreSidebarButton->show(); - }); - connect(restoreSidebarButton, &QPushButton::clicked, this, [this] { - sidebar->show(); - restoreSidebarButton->hide(); - }); - connect(threadList, &QListWidget::itemSelectionChanged, this, [this] { - const std::string threadId = visiblySelectedThreadId(); - if (!threadId.empty() && threadId != selectedThreadId) - selectThread(threadId); - }); - connect(threadList, &QListWidget::customContextMenuRequested, this, - [this](const QPoint &position) { - QListWidgetItem *item = threadList->itemAt(position); - if (!item) - return; - const std::string threadId = - item->data(Qt::UserRole).toString().toStdString(); - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - return; - QMenu menu(threadList); - menu.addAction(QStringLiteral("Reload"), this, - [this, threadId] { readThread(threadId); }); - const bool canControl = model.connection().connected && - model.connection().role == "controller"; - QAction *rename = - menu.addAction(QStringLiteral("Rename"), this, - [this, threadId] { renameThread(threadId); }); - QAction *fork = - menu.addAction(QStringLiteral("Fork"), this, - [this, threadId] { forkThread(threadId); }); - QAction *archive = menu.addAction( - thread->archived ? QStringLiteral("Unarchive") - : QStringLiteral("Archive"), - this, [this, threadId] { toggleThreadArchive(threadId); }); - menu.addSeparator(); - QAction *remove = - menu.addAction(QStringLiteral("Delete"), this, - [this, threadId] { deleteThread(threadId); }); - rename->setEnabled(canControl); - fork->setEnabled(canControl); - archive->setEnabled(canControl); - remove->setEnabled(canControl); - menu.exec(threadList->viewport()->mapToGlobal(position)); - }); - splitter->addWidget(sidebar); - - conversationRegion = new QFrame; - conversationRegion->setObjectName(QStringLiteral("conversation")); - conversationRegion->setStyleSheet( - QStringLiteral("QFrame#conversation{background:#f6f8fb;}")); - conversationRegion->setMinimumWidth(480); - auto *centerLayout = new QVBoxLayout(conversationRegion); - centerLayout->setContentsMargins(24, 14, 24, 12); - centerLayout->setSpacing(0); - auto *context = new QHBoxLayout; - auto *threadBadge = makeLabel(QStringLiteral("THREAD"), "small"); - threadBadge->setAlignment(Qt::AlignCenter); - threadBadge->setFixedSize(58, 20); - threadBadge->setStyleSheet( - QStringLiteral("background:#e5eeff;color:#2f6feb;border-radius:5px;" - "font-weight:600;")); - context->addWidget(threadBadge); - context->addStretch(); - centerLayout->addLayout(context); - centerLayout->addSpacing(2); - conversationTitle = - makeLabel(QStringLiteral("No synchronized thread"), "heading"); - conversationMeta = makeLabel({}, "meta"); - centerLayout->addWidget(conversationTitle); - centerLayout->addSpacing(2); - centerLayout->addWidget(conversationMeta); - centerLayout->addSpacing(7); - centerLayout->addWidget(makeDivider()); - centerLayout->addSpacing(7); - - noticeBar = new QFrame; - noticeBar->setStyleSheet(QStringLiteral( - "background:#fff0f2;border:1px solid #efb8c0;border-radius:6px;")); - auto *noticeLayout = new QHBoxLayout(noticeBar); - noticeLayout->setContentsMargins(10, 6, 8, 6); - noticeLabel = makeLabel({}, "meta"); - noticeLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); - auto *dismissNotice = new QPushButton(QStringLiteral("Dismiss")); - dismissNotice->setProperty("kind", "subtle"); - dismissNotice->setFixedHeight(28); - noticeLayout->addWidget(noticeLabel, 1); - noticeLayout->addWidget(dismissNotice); - noticeBar->hide(); - connect(dismissNotice, &QPushButton::clicked, noticeBar, &QWidget::hide); - centerLayout->addWidget(noticeBar); - - conversationScroll = new QScrollArea; - conversationScroll->setWidgetResizable(true); - conversationScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - conversationScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); - conversationContent = new QWidget; - conversationContent->setMinimumWidth(0); - conversationContent->setSizePolicy(QSizePolicy::Ignored, - QSizePolicy::Preferred); - conversationLayout = new QVBoxLayout(conversationContent); - conversationLayout->setContentsMargins(0, 0, 0, 16); - conversationLayout->setSpacing(8); - emptyConversation = - makeLabel(QStringLiteral("Conversation activity appears here."), "muted"); - conversationLayout->addWidget(emptyConversation); - addConversationTrailingSpace(); - conversationLayout->addStretch(); - conversationScroll->setWidget(conversationContent); - QScrollBar *conversationScrollBar = conversationScroll->verticalScrollBar(); - conversationScrollAnimation = new QVariantAnimation(this); - conversationScrollAnimation->setEasingCurve(QEasingCurve::OutCubic); - connect(conversationScrollAnimation, &QVariantAnimation::valueChanged, this, - [this, conversationScrollBar](const QVariant &value) { - if (!conversationFollowsLatest || conversationScrollRebuilding || - conversationSpacerAdjusting) { - conversationScrollAnimation->stop(); - return; - } - conversationScrollProgrammatic = true; - conversationScrollBar->setValue( - std::max(value.toInt(), conversationSmoothScrollFloor)); - conversationScrollProgrammatic = false; - }); - const auto stopSmoothFollowForUser = [this] { - stopConversationScrollAnimation(); - conversationSmoothScrollFloor = 0; - }; - connect(conversationScrollBar, &QScrollBar::actionTriggered, this, - [this, stopSmoothFollowForUser](int) { - conversationUserScrollPending = true; - stopSmoothFollowForUser(); - }); - connect(conversationScrollBar, &QScrollBar::sliderPressed, this, - [this, stopSmoothFollowForUser] { - conversationUserScrollInteraction = true; - stopSmoothFollowForUser(); - }); - connect(conversationScrollBar, &QScrollBar::sliderReleased, this, [this] { - conversationUserScrollInteraction = false; - conversationUserScrollPending = false; - }); - connect(conversationScrollBar, &QScrollBar::valueChanged, this, - [this, conversationScrollBar](int value) { - if (conversationScrollRebuilding || - conversationScrollProgrammatic || conversationSpacerAdjusting) - return; - const bool userInitiated = conversationUserScrollPending || - conversationUserScrollInteraction; - if (!conversationUserScrollInteraction) - conversationUserScrollPending = false; - if (!userInitiated) { - if (conversationFollowsLatest) - scheduleConversationFollowLatest(); - else - scheduleConversationPausedAnchorRestore(); - return; - } - conversationFollowsLatest = - value >= conversationScrollBar->maximum() - 1; - if (!conversationFollowsLatest) { - conversationSmoothScrollFloor = 0; - conversationPausedAnchor = captureConversationScrollAnchor(); - conversationPausedAnchorValid = true; - } else { - conversationPausedAnchorValid = false; - } - }); - connect(conversationScrollBar, &QScrollBar::rangeChanged, this, - [this](int, int) { - if (conversationScrollRebuilding || conversationSpacerAdjusting) - return; - if (conversationFollowsLatest) - scheduleConversationFollowLatest(); - else - scheduleConversationPausedAnchorRestore(); - }); - centerLayout->addWidget(conversationScroll, 1); - - composerReserve = new QWidget; - composerReserve->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - composerReserve->setFixedHeight(0); - centerLayout->addWidget(composerReserve); - - auto *composerDock = - new BottomOverlayDock(conversationRegion, [this](int height) { - updateComposerDockHeight(height); - }); - auto *composerDockLayout = new QVBoxLayout(composerDock); - composerDockLayout->setContentsMargins(0, 8, 0, 0); - composerDockLayout->setSpacing(0); - - auto *attention = new QFrame; - attention->setProperty("kind", "orangeBadge"); - auto *attentionLayout = new QHBoxLayout(attention); - attentionLayout->setContentsMargins(10, 6, 10, 6); - attentionLayout->addWidget(makeLabel( - QStringLiteral("A Codex request needs attention"), "attentionSection")); - attentionLayout->addStretch(); - approveButton = new QPushButton(QStringLiteral("Review")); - denyButton = new QPushButton(QStringLiteral("Deny")); - attentionLayout->addWidget(denyButton); - attentionLayout->addWidget(approveButton); - connect(approveButton, &QPushButton::clicked, this, - [this] { respondToFirstPending(true); }); - connect(denyButton, &QPushButton::clicked, this, - [this] { respondToFirstPending(false); }); - attention->hide(); - composerDockLayout->addWidget(attention); - - turnSettings = new TurnSettingsWidget; - composerDockLayout->addWidget(turnSettings); - - auto *composer = new QFrame; - composer->setProperty("kind", "composer"); - auto *composerLayout = new QVBoxLayout(composer); - composerLayout->setContentsMargins(10, 8, 8, 8); - composerLayout->setSpacing(6); - attachmentPanel = new QFrame; - attachmentPanel->setProperty("kind", "summary"); - auto *attachmentPanelLayout = new QVBoxLayout(attachmentPanel); - attachmentPanelLayout->setContentsMargins(6, 6, 6, 6); - attachmentPanelLayout->setSpacing(4); - attachmentListScroll = new QScrollArea; - attachmentListScroll->setWidgetResizable(true); - attachmentListScroll->setFrameShape(QFrame::NoFrame); - attachmentListScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - auto *attachmentListContent = new QWidget; - attachmentListLayout = new QVBoxLayout(attachmentListContent); - attachmentListLayout->setContentsMargins(0, 0, 0, 0); - attachmentListLayout->setSpacing(4); - attachmentListScroll->setWidget(attachmentListContent); - attachmentPanelLayout->addWidget(attachmentListScroll); - attachmentPanel->hide(); - composerLayout->addWidget(attachmentPanel); - - composerBody = new QWidget; - composerBody->installEventFilter(this); - composerGrid = new QGridLayout(composerBody); - composerGrid->setContentsMargins(0, 0, 0, 0); - composerGrid->setHorizontalSpacing(8); - composerGrid->setVerticalSpacing(6); - composerGrid->setColumnStretch(1, 1); - - attachmentButton = new QToolButton; - attachmentButton->setProperty("kind", "composerAction"); - attachmentButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::MailAttachment)); - attachmentButton->setIconSize(QSize(16, 16)); - attachmentButton->setToolTip(QStringLiteral("Attach files")); - attachmentButton->setAccessibleName(QStringLiteral("Attach files")); - attachmentButton->setFixedSize(UpcomingControlHeight, UpcomingControlHeight); - promptEditor = new codexui::ExpandingPromptEditor; - sendButton = new QPushButton(QStringLiteral("Send")); - sendButton->setProperty("kind", "primary"); - sendButton->setFixedSize(62, UpcomingControlHeight); - interruptButton = new QPushButton(QStringLiteral("Stop")); - interruptButton->setProperty("kind", "stop"); - interruptButton->setFixedSize(54, UpcomingControlHeight); - interruptButton->hide(); - composerGrid->addWidget(attachmentButton, 0, 0); - composerGrid->addWidget(promptEditor, 0, 1); - composerGrid->addWidget(sendButton, 0, 2); - composerLayout->addWidget(composerBody); - composerDockLayout->addWidget(composer); - connect(promptEditor, &codexui::ExpandingPromptEditor::editorHeightChanged, - composerDock, - [composerDock] { composerDock->synchronizeGeometry(); }); - QTimer::singleShot(0, composerDock, [composerDock] { - composerDock->layout()->activate(); - composerDock->synchronizeGeometry(); - }); - connect(sendButton, &QPushButton::clicked, this, [this] { submitPrompt(); }); - connect(promptEditor, &codexui::ExpandingPromptEditor::submitRequested, this, - [this] { submitPrompt(); }); - connect(promptEditor, &QPlainTextEdit::textChanged, this, - [this] { scheduleComposerLayout(); }); - connect(interruptButton, &QPushButton::clicked, this, - [this] { interruptActiveTurn(); }); - connect(attachmentButton, &QPushButton::clicked, this, - [this] { chooseAttachments(); }); - splitter->addWidget(conversationRegion); - - inspector = new QFrame; - inspector->setObjectName(QStringLiteral("inspector")); - inspector->setStyleSheet( - QStringLiteral("QFrame#inspector{background:#fbfcfe;}")); - inspector->setMinimumWidth(300); - inspector->setMaximumWidth(520); - auto *inspectorLayout = new QVBoxLayout(inspector); - inspectorLayout->setContentsMargins(18, 14, 20, 0); - inspectorLayout->setSpacing(0); - auto *inspectorHeader = new QHBoxLayout; - inspectorHeader->addWidget(makeLabel(QStringLiteral("INSPECTOR"), "section")); - inspectorHeader->addStretch(); - auto *hideInspectorButton = new QPushButton(QStringLiteral("Hide")); - hideInspectorButton->setProperty("kind", "subtle"); - hideInspectorButton->setFixedSize(58, 24); - inspectorHeader->addWidget(hideInspectorButton); - inspectorLayout->addLayout(inspectorHeader); - inspectorLayout->addSpacing(7); - inspectorTabs = new QTabWidget; - inspectorTabs->setDocumentMode(true); - planContent = new QWidget; - planLayout = new QVBoxLayout(planContent); - planLayout->setContentsMargins(12, 12, 12, 12); - planLayout->setSpacing(8); - agentsContent = new QWidget; - agentsLayout = new QVBoxLayout(agentsContent); - agentsLayout->setContentsMargins(12, 12, 12, 12); - agentsLayout->setSpacing(8); - diffViewer = new DiffViewer; - requestsContent = new QWidget; - requestsLayout = new QVBoxLayout(requestsContent); - requestsLayout->setContentsMargins(12, 12, 12, 12); - requestsLayout->setSpacing(8); - auto *planScroll = new QScrollArea; - planScroll->setWidgetResizable(true); - planScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - planScroll->setWidget(planContent); - auto *agentsScroll = new QScrollArea; - agentsScroll->setWidgetResizable(true); - agentsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - agentsScroll->setWidget(agentsContent); - auto *requestsScroll = new QScrollArea; - requestsScroll->setWidgetResizable(true); - requestsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - requestsScroll->setWidget(requestsContent); - auto *protocolContent = new QWidget; - auto *protocolLayout = new QVBoxLayout(protocolContent); - protocolLayout->setContentsMargins(8, 8, 8, 8); - protocolLayout->setSpacing(6); - protocolStats = makeLabel({}, "meta"); - protocolLog = new QPlainTextEdit; - protocolLog->setProperty("kind", "infoViewer"); - protocolLog->setReadOnly(true); - protocolLog->setLineWrapMode(QPlainTextEdit::WidgetWidth); - protocolLog->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - protocolLog->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - protocolLog->verticalScrollBar()->setProperty("kind", "infoViewer"); - protocolLog->document()->setMaximumBlockCount(200); - protocolLayout->addWidget(protocolLog, 1); - protocolLayout->addWidget(protocolStats); - auto *stateContent = new QWidget; - auto *stateLayout = new QVBoxLayout(stateContent); - stateLayout->setContentsMargins(8, 8, 8, 8); - stateView = new QPlainTextEdit; - stateView->setProperty("kind", "infoViewer"); - stateView->setReadOnly(true); - stateView->setLineWrapMode(QPlainTextEdit::WidgetWidth); - stateView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - stateView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - stateView->verticalScrollBar()->setProperty("kind", "infoViewer"); - stateLayout->addWidget(stateView); - infoTabs = new QTabWidget; - infoTabs->setDocumentMode(true); - infoTabs->addTab(stateContent, QStringLiteral("State")); - infoTabs->addTab(protocolContent, QStringLiteral("Protocol")); - connect(infoTabs, &QTabWidget::currentChanged, this, [this](int index) { - if (index == 0) { - scheduleRefresh(RefreshState); - return; - } - showProtocolTail(); - scheduleRefresh(RefreshProtocolStats); - }); - inspectorTabs->addTab(planScroll, QStringLiteral("Plan")); - inspectorTabs->addTab(agentsScroll, QStringLiteral("Agents")); - inspectorTabs->addTab(diffViewer, QStringLiteral("Changes")); - inspectorTabs->addTab(requestsScroll, QStringLiteral("Requests")); - inspectorTabs->addTab(infoTabs, QStringLiteral("Info")); - connect(inspectorTabs, &QTabWidget::currentChanged, this, [this](int index) { - if (index == 4) { - if (infoTabs && infoTabs->currentIndex() == 1) - showProtocolTail(); - } - scheduleRefresh(RefreshInspector | RefreshState | RefreshProtocolStats); - }); - inspectorLayout->addWidget(inspectorTabs, 1); - connect(hideInspectorButton, &QPushButton::clicked, this, [this] { - inspector->hide(); - restoreInspectorButton->show(); - }); - connect(restoreInspectorButton, &QPushButton::clicked, this, [this] { - inspector->show(); - restoreInspectorButton->hide(); - }); - splitter->addWidget(inspector); - splitter->setStretchFactor(0, 0); - splitter->setStretchFactor(1, 1); - splitter->setStretchFactor(2, 0); - splitter->setSizes({282, 834, 404}); - qApp->installEventFilter(this); - root->addWidget(splitter, 1); + middleRegion = new middle::MiddleRegionWidget; + root->addWidget(middleRegion, 1); auto *statusBar = new QFrame; statusBar->setObjectName(QStringLiteral("customStatusBar")); - statusBar->setStyleSheet( - QStringLiteral("QFrame#customStatusBar{background:#f8fafc;" - "border-top:1px solid #d7dee8;}")); + statusBar->setStyleSheet(QStringLiteral( + "QFrame#customStatusBar{background:#f8fafc;border-top:1px solid " + "#d7dee8;}")); statusBar->setFixedHeight(40); auto *statusLayout = new QHBoxLayout(statusBar); statusLayout->setContentsMargins(18, 0, 24, 0); @@ -1336,143 +364,125 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) controllerLabel = makeLabel(QStringLiteral("Observer"), "meta"); statusLayout->addWidget(controllerLabel); root->addWidget(statusBar); +} - refreshTimer = new QTimer(this); - refreshTimer->setSingleShot(true); - refreshTimer->setInterval(32); - connect(refreshTimer, &QTimer::timeout, this, [this] { refresh(); }); +void ShellWidget::Impl::connectUi() { + middle::ThreadPane::Actions threadActions; + threadActions.newThread = [this] { beginNewThread(); }; + threadActions.refresh = [this] { session.listThreads(); }; + threadActions.hide = [this] { middleRegion->showSidebar(false); }; + threadActions.select = [this](const std::string &id) { + if (id != selectedThreadId) + selectThread(id); + }; + threadActions.reload = [this](const std::string &id) { + readThread(id, true); + }; + threadActions.rename = [this](const std::string &id) { renameThread(id); }; + threadActions.fork = [this](const std::string &id) { forkThread(id); }; + threadActions.toggleArchive = [this](const std::string &id) { + toggleThreadArchive(id); + }; + threadActions.remove = [this](const std::string &id) { deleteThread(id); }; + middleRegion->threads().setActions(std::move(threadActions)); - session.setEventHandler( - [this](const nlohmann::json &event) { handleEvent(event); }); - refresh(); -} + middle::ComposerPane::Actions composerActions; + composerActions.submit = [this](QString prompt, + std::vector attachments) { + return submitPrompt(std::move(prompt), std::move(attachments)); + }; + composerActions.stop = [this] { interruptTurn(); }; + composerActions.attach = [this] { chooseAttachments(); }; + composerActions.review = [this] { respondToFirstPending(true); }; + composerActions.deny = [this] { respondToFirstPending(false); }; + middleRegion->composer().setActions(std::move(composerActions)); + + middleRegion->conversation().setLoadMoreAction([this] { + const std::string key = selectedThreadId.empty() + ? std::string(DraftThreadId) + : selectedThreadId; + HistoryWindow &history = historyWindows[key]; + history.requested += + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + history.effective += + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + renderConversation(); + }); + middleRegion->inspector().setRequestActions( + [this](const std::string &id) { reviewPending(id); }, + [this](const std::string &id) { rejectPending(id); }); + middleRegion->setPaneVisibilityAction( + [this](bool sidebarVisible, bool inspectorVisible) { + restoreSidebarButton->setVisible(!sidebarVisible); + restoreInspectorButton->setVisible(!inspectorVisible); + }); -bool ShellWidget::eventFilter(QObject *watched, QEvent *event) { - if (event->type() == QEvent::Wheel && conversationRegion && - conversationScroll) { - auto *target = qobject_cast(watched); - const bool inConversationRegion = - target && (target == conversationRegion || - conversationRegion->isAncestorOf(target)); - const bool onSplitterHandle = - target && splitter && - (target == splitter->handle(1) || target == splitter->handle(2)); - if (inConversationRegion || onSplitterHandle) { - bool insideScrollableChild = false; - if (inConversationRegion) { - for (QWidget *ancestor = target; - ancestor && ancestor != conversationRegion; - ancestor = ancestor->parentWidget()) { - if (qobject_cast(ancestor)) { - insideScrollableChild = true; - break; - } - } - } - if (!insideScrollableChild) { - auto *wheel = static_cast(event); - QWidget *viewport = conversationScroll->viewport(); - const QPointF localPosition = - viewport->mapFromGlobal(wheel->globalPosition().toPoint()); - QWheelEvent forwarded(localPosition, wheel->globalPosition(), - wheel->pixelDelta(), wheel->angleDelta(), - wheel->buttons(), wheel->modifiers(), - wheel->phase(), wheel->inverted(), - wheel->source(), wheel->pointingDevice()); - QApplication::sendEvent(viewport, &forwarded); - event->accept(); - return true; - } - } - } - if (watched == composerBody && - (event->type() == QEvent::Resize || event->type() == QEvent::Show || - event->type() == QEvent::LayoutRequest)) - scheduleComposerLayout(); - return QWidget::eventFilter(watched, event); + connect(restoreSidebarButton, &QPushButton::clicked, owner, + [this] { middleRegion->showSidebar(true); }); + connect(restoreInspectorButton, &QPushButton::clicked, owner, + [this] { middleRegion->showInspector(true); }); + connect(requestButton, &QPushButton::clicked, owner, [this] { + middleRegion->showInspector(true); + middleRegion->inspector().tabs()->setCurrentIndex(3); + }); + connect(controllerButton, &QPushButton::clicked, owner, [this] { + if (model.connection().role == "controller") + session.releaseController(); + else + session.claimController(); + }); + qApp->installEventFilter(owner); } -void ShellWidget::scheduleComposerLayout() { - if (composerLayoutRefreshPending) - return; - composerLayoutRefreshPending = true; - QTimer::singleShot(0, this, [this] { - composerLayoutRefreshPending = false; - refreshComposerLayout(); - }); +void ShellWidget::Impl::showNotice(QString message, bool error) { + middleRegion->showNotice(std::move(message), error); } -void ShellWidget::refreshComposerLayout() { - if (!composerBody || !composerGrid || composerBody->width() <= 0) - return; +void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { + middleRegion->inspector().appendProtocolFrame(event); - const bool active = interruptButton->isVisible(); - const int visibleControls = active ? 3 : 2; - const int controlsWidth = attachmentButton->width() + sendButton->width() + - (active ? interruptButton->width() : 0); - const int compactEditorWidth = - composerBody->contentsRect().width() - controlsWidth - - (visibleControls * composerGrid->horizontalSpacing()); - const bool expand = promptEditor->requiresExpandedLayout(compactEditorWidth); - if (expand == composerExpanded && active == composerActive) - return; + const std::string kind = stringValue(event, "kind"); + const std::string action = stringValue(event, "action"); + const std::string correlationId = stringValue(event, "correlationId"); + const bool staleReadResult = + kind == "result" && action == "thread.read" && !correlationId.empty() && + staleReadResultCorrelations.erase(correlationId) > 0; + if (!staleReadResult) + model.applyEvent(event); - composerExpanded = expand; - composerActive = active; - composerGrid->removeWidget(attachmentButton); - composerGrid->removeWidget(promptEditor); - composerGrid->removeWidget(sendButton); - composerGrid->removeWidget(interruptButton); - if (composerExpanded) { - composerGrid->addWidget(promptEditor, 0, 0, 1, 4); - composerGrid->addWidget(attachmentButton, 1, 0); - composerGrid->addWidget(sendButton, 1, 2); - if (active) - composerGrid->addWidget(interruptButton, 1, 3); - } else { - composerGrid->addWidget(attachmentButton, 0, 0); - composerGrid->addWidget(promptEditor, 0, 1); - composerGrid->addWidget(sendButton, 0, 2); - if (active) - composerGrid->addWidget(interruptButton, 0, 3); + const ConnectionPresentation &connection = model.connection(); + if (connection.generation != observedConnectionGeneration) { + observedConnectionGeneration = connection.generation; + hydration.clear(); + readRevisions.clear(); + operationReadyThreads.clear(); + dispatchScheduledThreads.clear(); } - composerGrid->invalidate(); -} - -void ShellWidget::handleEvent(const nlohmann::json &event) { - appendProtocolFrame(event); - const std::string incomingKind = stringValue(event, "kind"); - const std::string incomingType = stringValue(event, "type"); - const nlohmann::json incomingData = - event.value("data", nlohmann::json::object()); - if (incomingKind == "event" && incomingType == "connection.lifecycle" && - stringValue(incomingData, "state") == "connected") { - threadHydration.clear(); + if (connection.providerGeneration != observedProviderGeneration) { + observedProviderGeneration = connection.providerGeneration; + hydration.clear(); + readRevisions.clear(); operationReadyThreads.clear(); + dispatchScheduledThreads.clear(); } - model.applyEvent(event); - const std::string kind = stringValue(event, "kind"); + const std::string type = stringValue(event, "type"); const nlohmann::json data = event.value("data", nlohmann::json::object()); - const nlohmann::json incomingScope = - event.value("scope", nlohmann::json::object()); - const std::string incomingThreadId = stringValue(incomingScope, "threadId"); - bool recoveringThreadNotFound = false; - if (isThreadNotFoundResult(event)) { - const auto prompts = pendingPrompts.find(incomingThreadId); - recoveringThreadNotFound = - prompts != pendingPrompts.end() && - std::any_of(prompts->second.begin(), prompts->second.end(), - [](const PendingPrompt &candidate) { - return candidate.status == - PendingPromptStatus::Awaiting && - candidate.readinessRetryAttempted; - }); + const nlohmann::json scope = event.value("scope", nlohmann::json::object()); + const std::string eventThreadId = stringValue(scope, "threadId"); + if (kind == "event" && type == "connection.provider" && + stringValue(data, "state") == "disconnected") { + hydration.clear(); + readRevisions.clear(); + operationReadyThreads.clear(); + dispatchScheduledThreads.clear(); } - if (kind == "result" && !event.value("ok", false) && - !recoveringThreadNotFound) { - const nlohmann::json error = event.value("error", nlohmann::json::object()); - const std::string message = safeMessage(error); + + if (kind == "result" && !event.value("ok", false) && action != "turn.start" && + action != "turn.steer" && action != "thread.read" && + action != "thread.resume") { + const std::string message = + safeMessage(event.value("error", nlohmann::json::object())); showNotice(text(message.empty() ? std::string("Codex operation failed") : message)); } else if (kind == "event" && type == "notice.added") { @@ -1484,1448 +494,235 @@ void ShellWidget::handleEvent(const nlohmann::json &event) { } else if (kind == "event" && type == "system.diagnostic") { const std::string message = safeMessage(data); if (!message.empty()) - showNotice(QStringLiteral("Protocol diagnostic: %1").arg(text(message))); - } else if (kind == "event" && type == "connection.lifecycle" && - (stringValue(data, "state") == "failure" || - stringValue(data, "state") == "disconnected")) { - const std::string detail = stringValue(data, "detail"); - if (!detail.starts_with("local-")) - showNotice(detail.empty() ? QStringLiteral("Codex bridge disconnected") - : text(detail)); - } - if (event.value("kind", std::string{}) == "event" && - event.value("type", std::string{}) == "connection.bridge" && - event.value("data", nlohmann::json::object()) - .value("state", std::string{}) == "opened") { - requestThreads(); - requestModels(); - ensureThreadHydrated(selectedThreadId); - session.listPermissionProfiles( - {{"cwd", QDir::currentPath().toStdString()}}); - } - - hydrateHistoricalAgents(); - - if (!selectedThreadId.empty() && !model.thread(selectedThreadId)) { - selectedThreadId.clear(); - resetComposer(); - } - - const nlohmann::json scope = event.value("scope", nlohmann::json::object()); - const std::string eventThreadId = stringValue(scope, "threadId"); - const std::string turnId = stringValue(scope, "turnId"); - const std::string itemId = stringValue(scope, "itemId"); - if (type == "thread.removed" && !eventThreadId.empty()) { - pendingPrompts.erase(eventThreadId); - materializedPromptItemIds.erase(eventThreadId); - threadHydration.erase(eventThreadId); - operationReadyThreads.erase(eventThreadId); - } else if (!eventThreadId.empty()) { - reconcileAcknowledgedPrompts(eventThreadId); - } - if (eventThreadId == selectedThreadId && !turnId.empty() && !itemId.empty() && - (type == "conversation.item.upsert" || - type == "conversation.item.append" || - type == "conversation.reasoning.part-added" || - type == "conversation.file-change.output-appended" || - type == "conversation.file-change.patch-replaced" || - type == "conversation.mcp.progress")) { - const std::string key = turnId + '\x1f' + itemId; - dirtyConversationItems[key] = {turnId, itemId}; - conversationSmoothFollowRequested = true; - } - - const std::string action = stringValue(event, "action"); - if (kind == "result" && !eventThreadId.empty() && action == "thread.read") { - const bool readSucceeded = event.value("ok", false); - threadHydration[eventThreadId] = readSucceeded - ? ThreadHydrationState::Hydrated - : ThreadHydrationState::NotHydrated; - if (readSucceeded) { - if (!threadRequiresResume(eventThreadId)) - operationReadyThreads.insert(eventThreadId); - QTimer::singleShot(0, this, [this, eventThreadId] { - dispatchNextPrompt(eventThreadId); - }); - } else { - const auto prompts = pendingPrompts.find(eventThreadId); - if (prompts != pendingPrompts.end()) { - const auto waiting = std::find_if( - prompts->second.begin(), prompts->second.end(), - [](const PendingPrompt &candidate) { - return candidate.status == PendingPromptStatus::Awaiting && - !candidate.dispatched; - }); - if (waiting != prompts->second.end()) { - const std::uint64_t submissionId = waiting->id; - QTimer::singleShot( - 0, this, [this, eventThreadId, submissionId, event] { - completePromptSubmission(eventThreadId, submissionId, event); - }); - } - } - } - } else if (kind == "result" && !eventThreadId.empty() && - action == "thread.resume" && event.value("ok", false)) { - threadHydration[eventThreadId] = ThreadHydrationState::Hydrated; - operationReadyThreads.insert(eventThreadId); - } - if ((event.value("kind", std::string{}) == "result" && - action == "thread.read" && eventThreadId == selectedThreadId) || - type == "thread.removed") { - conversationRebuildPending = true; - } - scheduleRefresh(refreshAreasForEvent(event)); -} - -std::uint32_t -ShellWidget::refreshAreasForEvent(const nlohmann::json &event) const { - const std::string kind = stringValue(event, "kind"); - if (kind == "result") { - const std::string action = stringValue(event, "action"); - if (action == "thread.read") - return RefreshAll; - if (action == "threads.list") - return RefreshThreads | RefreshProtocolStats | RefreshStatus; - if (action == "thread.create" || action == "thread.resume" || - action == "thread.fork") - return RefreshThreads | RefreshTurnSettings | RefreshStatus; - if (action == "turn.start") - return RefreshThreads | RefreshInspector | RefreshProtocolStats | - RefreshStatus; - if (action == "models.list" || action == "permission-profiles.list") - return RefreshState | RefreshProtocolStats | RefreshTurnSettings; - return RefreshState | RefreshProtocolStats; - } - - const std::string type = stringValue(event, "type"); - if (type.starts_with("connection.")) - return RefreshThreads | RefreshInspector | RefreshProtocolStats | - RefreshStatus; - if (type == "thread.upsert" || type == "thread.name.changed" || - type == "thread.status.changed" || type == "thread.lifecycle") - return RefreshThreads | RefreshTurnSettings | RefreshProtocolStats | - RefreshStatus; - if (type == "thread.removed") - return RefreshThreads | RefreshConversation | RefreshInspector | - RefreshTurnSettings | RefreshProtocolStats | RefreshStatus; - if (type == "turn.upsert") - return RefreshThreads | RefreshInspector | RefreshProtocolStats | - RefreshStatus; - if (type == "plan.replaced") - return RefreshInspector | RefreshProtocolStats; - if (type.starts_with("conversation.")) - return RefreshConversation | RefreshInspector | RefreshProtocolStats; - if (type == "agents.activity.upsert") - return RefreshInspector | RefreshProtocolStats | RefreshStatus; - if (type.starts_with("pending-request.")) - return RefreshThreads | RefreshInspector | RefreshProtocolStats | - RefreshStatus; - if (type == "thread.token-usage.changed") - return RefreshProtocolStats | RefreshStatus; - return RefreshState | RefreshProtocolStats; -} - -void ShellWidget::hydrateHistoricalAgents() { - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) - return; - for (const std::string &agentId : thread->agentOrder) { - const auto agent = thread->agents.find(agentId); - if (agent == thread->agents.end() || agent->second.childThreadId.empty() || - agent->second.status != "started") - continue; - if (!requestedAgentThreads.insert(agent->second.childThreadId).second) - continue; - session.readThread(agent->second.childThreadId); - } -} - -void ShellWidget::scheduleRefresh(std::uint32_t areas) { - pendingRefreshAreas |= areas; - if (!refreshTimer->isActive()) - refreshTimer->start(); -} - -void ShellWidget::refresh() { - const std::uint32_t areas = pendingRefreshAreas; - pendingRefreshAreas = RefreshNone; - if ((areas & RefreshThreads) != 0) - refreshThreads(); - if ((areas & RefreshConversation) != 0) { - if (conversationRebuildPending) - refreshConversation(); - else - refreshConversationItems(); - dirtyConversationItems.clear(); - conversationRebuildPending = false; - } - if ((areas & RefreshInspector) != 0) - refreshInspector(); - if ((areas & RefreshState) != 0) - refreshStateInspector(); - if ((areas & RefreshProtocolStats) != 0) - refreshProtocolStats(); - if ((areas & RefreshTurnSettings) != 0) - refreshTurnSettings(); - if ((areas & RefreshStatus) != 0) - refreshStatus(); -} - -std::string ShellWidget::conversationItemFingerprint( - const ItemPresentation &presentation) const { - const nlohmann::json &item = presentation.raw; - const std::string typeName = stringValue(item, "type"); - nlohmann::json projected{{"type", typeName}}; - if (typeName == "agentMessage") - projected["phase"] = stringValue(item, "phase"); - const QString body = messageText(item); - if (!body.isEmpty()) - projected["body"] = body.toStdString(); - - if (typeName == "commandExecution") { - projected["command"] = stringValue(item, "command"); - const QString output = text(stringValue(item, "aggregatedOutput")); - projected["output"] = - commandOutputIsVisible(output) ? output.toStdString() : std::string{}; - projected["status"] = stringValue(item, "status"); - projected["cwd"] = stringValue(item, "cwd"); - if (item.contains("exitCode") && item["exitCode"].is_number_integer()) - projected["exitCode"] = item["exitCode"]; - } else if (typeName == "collabAgentToolCall" || - typeName == "subAgentActivity") { - projected["tool"] = stringValue(item, "tool"); - projected["status"] = stringValue(item, "status"); - projected["kind"] = stringValue(item, "kind"); - projected["receivers"] = - item.value("receiverThreadIds", nlohmann::json::array()); - projected["prompt"] = stringValue(item, "prompt"); - projected["resultText"] = stringValue(item, "resultText"); - } else if (typeName == "reasoning") { - projected["summary"] = - joinedStrings(item.value("summary", nlohmann::json::array())) - .toStdString(); - } else if (typeName == "fileChange") { - projected["status"] = stringValue(item, "status"); - const nlohmann::json changes = - item.value("changes", nlohmann::json::array()); - projected["pathCount"] = changes.is_array() ? changes.size() : 0U; - } else if (body.isEmpty()) { - projected["raw"] = item; - } - return projected.dump(); -} - -void ShellWidget::showNotice(QString message, bool error) { - if (message.trimmed().isEmpty()) - return; - noticeLabel->setText(std::move(message)); - noticeBar->setStyleSheet( - error ? QStringLiteral("background:#fff0f2;border:1px solid #efb8c0;" - "border-radius:6px;") - : QStringLiteral("background:#fff6df;border:1px solid #e5c77d;" - "border-radius:6px;")); - noticeLabel->setStyleSheet(error ? QStringLiteral("color:#982f3d;") - : QStringLiteral("color:#8a5208;")); - noticeBar->show(); -} - -void ShellWidget::refreshProtocolStats() { - if (!infoTabs || inspectorTabs->currentIndex() != 4 || - infoTabs->currentIndex() != 1) - return; - std::size_t turns = 0; - std::size_t items = 0; - if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { - turns = thread->turnOrder.size(); - for (const auto &[turnId, turn] : thread->turns) { - static_cast(turnId); - items += turn.itemOrder.size(); - } - } - protocolStats->setText( - QStringLiteral("seq %1 | threads %2 | models %3 | turns %4 | " - "items %5 | pending %6 | telemetry %7") - .arg(static_cast(observedPresentationSequence)) - .arg(static_cast(model.threadOrder().size())) - .arg(static_cast(model.modelCatalog().size())) - .arg(static_cast(turns)) - .arg(static_cast(items)) - .arg(static_cast(model.pendingRequestCount())) - .arg(static_cast(model.telemetry().size()))); -} - -void ShellWidget::showProtocolTail() { - if (!protocolLog) - return; - QStringList lines; - lines.reserve(static_cast(protocolLines.size())); - for (const QString &line : protocolLines) - lines.push_back(line); - protocolLog->setPlainText(lines.join(QLatin1Char('\n'))); - protocolLog->moveCursor(QTextCursor::End); -} - -void ShellWidget::refreshTurnSettings() { - nlohmann::json canonical = nlohmann::json::object(); - std::string identity = "new-thread"; - if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { - identity = thread->id; - canonical = thread->raw; - const auto settings = thread->domains.find("thread.settings.changed"); - if (settings != thread->domains.end() && settings->second.is_object()) { - nlohmann::json update = settings->second; - if (update.contains("threadSettings") && - update["threadSettings"].is_object()) - update = update["threadSettings"]; - canonical.merge_patch(update); - } - } else { - canonical["cwd"] = - (localNewThreadIntent && !newThreadDraftWorkspace.isEmpty() - ? newThreadDraftWorkspace - : QDir::currentPath()) - .toStdString(); - } - - nlohmann::json permissionProfiles = nlohmann::json::array(); - const auto profiles = - model.globalDomains().find("operation.permission-profiles.list"); - if (profiles != model.globalDomains().end()) - permissionProfiles = profiles->second; - turnSettings->setContext(identity, canonical, model.modelCatalog(), - permissionProfiles); -} - -std::string ShellWidget::visiblySelectedThreadId() const { - if (!threadList) - return {}; - const QList selected = threadList->selectedItems(); - if (selected.size() != 1 || !selected.front()) - return {}; - return selected.front()->data(Qt::UserRole).toString().toStdString(); -} - -void ShellWidget::addConversationTrailingSpace() { - // The spacer belongs to the scroll-area content so QScrollArea derives its - // extended range from normal layout geometry. It is recreated with the - // conversation and never replaces the canonical composer reservation. - conversationTrailingSpace = new QWidget; - conversationTrailingSpace->setObjectName( - QStringLiteral("conversationTrailingSpace")); - conversationTrailingSpace->setSizePolicy(QSizePolicy::Preferred, - QSizePolicy::Fixed); - conversationTrailingSpace->setFixedHeight(conversationTrailingSpaceHeight); - conversationLayout->addWidget(conversationTrailingSpace); -} - -void ShellWidget::updateComposerDockHeight(int height) { - if (!composerReserve || !conversationScroll || !conversationContent || - height <= 0) - return; - - if (composerCanonicalHeight == 0) { - // Only the compact surface participates in the center layout. Later - // growth remains an overlay and is represented by trailing scroll space. - composerCanonicalHeight = height; - composerReserve->setFixedHeight(composerCanonicalHeight); - return; - } - - const int trailingHeight = std::max(0, height - composerCanonicalHeight); - if (trailingHeight == conversationTrailingSpaceHeight) - return; - - QScrollBar *scrollBar = conversationScroll->verticalScrollBar(); - stopConversationScrollAnimation(); - conversationSmoothScrollFloor = 0; - const int preservedValue = scrollBar->value(); - const bool spacerGrew = trailingHeight > conversationTrailingSpaceHeight; - conversationTrailingSpaceHeight = trailingHeight; - const std::uint64_t revision = ++conversationSpacerRevision; - conversationSpacerAdjusting = true; - // A larger range must not pull content toward the newly exposed bottom. The - // user explicitly reaching that bottom will restore follow-latest below. - if (spacerGrew) - conversationFollowsLatest = false; - - if (conversationTrailingSpace) - conversationTrailingSpace->setFixedHeight(trailingHeight); - conversationLayout->invalidate(); - conversationContent->updateGeometry(); - - const auto settle = [this, revision, preservedValue] { - if (revision != conversationSpacerRevision) - return; - QScrollBar *currentScrollBar = conversationScroll->verticalScrollBar(); - conversationScrollProgrammatic = true; - currentScrollBar->setValue( - std::min(preservedValue, currentScrollBar->maximum())); - conversationScrollProgrammatic = false; - }; - QTimer::singleShot(0, this, [this, revision, settle] { - if (revision != conversationSpacerRevision) - return; - settle(); - QTimer::singleShot(0, this, [this, revision, settle] { - if (revision != conversationSpacerRevision) - return; - settle(); - conversationSpacerAdjusting = false; - QScrollBar *currentScrollBar = conversationScroll->verticalScrollBar(); - conversationFollowsLatest = - currentScrollBar->value() >= currentScrollBar->maximum() - 1; - }); - }); -} - -void ShellWidget::stopConversationScrollAnimation() { - if (conversationScrollAnimation) - conversationScrollAnimation->stop(); -} - -ShellWidget::ConversationScrollAnchor -ShellWidget::captureConversationScrollAnchor() const { - ConversationScrollAnchor anchor; - if (!conversationScroll || !conversationLayout) - return anchor; - anchor.absoluteValue = conversationScroll->verticalScrollBar()->value(); - for (int index = 0; index < conversationLayout->count(); ++index) { - QWidget *widget = conversationLayout->itemAt(index)->widget(); - if (!widget) - continue; - const QString key = widget->property(ConversationAnchorProperty).toString(); - if (key.isEmpty() || widget->geometry().bottom() < anchor.absoluteValue) - continue; - anchor.key = key; - anchor.viewportOffset = widget->geometry().top() - anchor.absoluteValue; - break; - } - return anchor; -} - -void ShellWidget::restoreConversationScrollAnchor( - const ConversationScrollAnchor &anchor) { - if (!conversationScroll || !conversationLayout) - return; - int value = anchor.absoluteValue; - if (!anchor.key.isEmpty()) { - for (int index = 0; index < conversationLayout->count(); ++index) { - QWidget *widget = conversationLayout->itemAt(index)->widget(); - if (!widget || - widget->property(ConversationAnchorProperty).toString() != anchor.key) - continue; - value = widget->geometry().top() - anchor.viewportOffset; - break; - } - } - QScrollBar *scrollBar = conversationScroll->verticalScrollBar(); - conversationScrollProgrammatic = true; - scrollBar->setValue( - std::clamp(value, scrollBar->minimum(), scrollBar->maximum())); - conversationScrollProgrammatic = false; -} - -void ShellWidget::scheduleConversationPausedAnchorRestore() { - if (!conversationPausedAnchorValid || conversationPausedAnchorRestorePending) - return; - conversationPausedAnchorRestorePending = true; - QTimer::singleShot(0, this, [this] { - conversationPausedAnchorRestorePending = false; - if (conversationFollowsLatest || conversationScrollRebuilding || - conversationSpacerAdjusting || !conversationPausedAnchorValid) - return; - conversationLayout->activate(); - restoreConversationScrollAnchor(conversationPausedAnchor); - }); -} - -void ShellWidget::scrollConversationToLatest(bool smoothly) { - if (!conversationScroll) - return; - QScrollBar *scrollBar = conversationScroll->verticalScrollBar(); - const int destination = scrollBar->maximum(); - stopConversationScrollAnimation(); - const int start = std::max( - scrollBar->value(), std::min(conversationSmoothScrollFloor, destination)); - conversationScrollProgrammatic = true; - scrollBar->setValue(start); - conversationScrollProgrammatic = false; - const int distance = destination - start; - if (!smoothly || distance <= 3 || !conversationScrollAnimation) { - conversationScrollProgrammatic = true; - scrollBar->setValue(destination); - conversationScrollProgrammatic = false; - return; - } - conversationScrollAnimation->setDuration( - std::clamp(110 + distance / 3, 130, 260)); - conversationScrollAnimation->setStartValue(start); - conversationScrollAnimation->setEndValue(destination); - conversationScrollAnimation->start(); -} - -void ShellWidget::scheduleConversationFollowLatest() { - if (conversationFollowScrollPending) - return; - conversationFollowScrollPending = true; - // Wrapping labels and command output can report several closely spaced - // geometry changes. Retarget one animation after the burst instead of - // moving the viewport for every intermediate range. - QTimer::singleShot(16, this, [this] { - conversationFollowScrollPending = false; - if (conversationFollowsLatest && !conversationScrollRebuilding && - !conversationSpacerAdjusting) - scrollConversationToLatest(true); - }); -} - -void ShellWidget::settleConversationScroll(bool followLatest, - ConversationScrollAnchor anchor, - bool smoothly) { - conversationSmoothScrollFloor = - followLatest - ? std::max(conversationSmoothScrollFloor, anchor.absoluteValue) - : 0; - const std::uint64_t revision = ++conversationScrollSettlementRevision; - const auto settle = [this, revision, followLatest, anchor] { - if (revision != conversationScrollSettlementRevision) - return false; - conversationLayout->activate(); - if (followLatest) { - QScrollBar *scrollBar = conversationScroll->verticalScrollBar(); - conversationScrollProgrammatic = true; - scrollBar->setValue(std::min(anchor.absoluteValue, scrollBar->maximum())); - conversationScrollProgrammatic = false; - } else { - restoreConversationScrollAnchor(anchor); - } - return true; - }; - // Restore the stable coordinate before returning to the event loop. Painting - // remains disabled until the two deferred Qt layout passes settle. - settle(); - QTimer::singleShot( - 0, this, [this, revision, followLatest, anchor, smoothly, settle] { - if (!settle()) - return; - QTimer::singleShot( - 0, this, [this, revision, followLatest, anchor, smoothly, settle] { - if (!settle()) - return; - conversationScrollRebuilding = false; - if (followLatest) { - conversationFollowsLatest = true; - scrollConversationToLatest(smoothly); - } else { - conversationFollowsLatest = false; - conversationPausedAnchor = anchor; - conversationPausedAnchorValid = true; - } - conversationScroll->viewport()->setUpdatesEnabled(true); - conversationScroll->viewport()->update(); - }); - }); -} - -void ShellWidget::appendProtocolFrame(const nlohmann::json &frame) { - if (!protocolLog) - return; - - const auto recordLine = [this](QString line) { - if (protocolLines.size() == 200) - protocolLines.pop_front(); - protocolLines.push_back(line); - if (inspectorTabs->currentIndex() == 4 && infoTabs && - infoTabs->currentIndex() == 1) - protocolLog->appendPlainText(std::move(line)); - }; - - const std::uint64_t sequence = frame.value("sequence", 0ULL); - if (sequence != 0) { - if (observedPresentationSequence != 0 && - sequence != observedPresentationSequence + 1) { - const QString relation = sequence <= observedPresentationSequence - ? QStringLiteral("NON-MONOTONIC") - : QStringLiteral("SEQUENCE GAP"); - recordLine( - QStringLiteral("[%1] %2 expected=%3 received=%4") - .arg(QDateTime::currentDateTime().toString( - QStringLiteral("HH:mm:ss.zzz")), - relation) - .arg(static_cast(observedPresentationSequence + 1)) - .arg(static_cast(sequence))); - } - observedPresentationSequence = - std::max(observedPresentationSequence, sequence); - } - - const std::string kind = stringValue(frame, "kind"); - const std::string subject = kind == "result" ? stringValue(frame, "action") - : stringValue(frame, "type"); - const nlohmann::json scope = frame.value("scope", nlohmann::json::object()); - QStringList parts; - parts << QStringLiteral("[%1]").arg( - QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz"))); - if (sequence != 0) - parts << QStringLiteral("#%1").arg(static_cast(sequence)); - parts << QStringLiteral("g%1").arg( - static_cast(frame.value("generation", 0ULL))); - parts << text(kind); - parts << text(subject); - parts << text(stringValue(frame, "authority")); - if (kind == "result") - parts << (frame.value("ok", false) ? QStringLiteral("ok") - : QStringLiteral("ERROR")); - for (const char *key : - {"threadId", "turnId", "itemId", "requestId", "processId"}) { - const std::string value = stringValue(scope, key); - if (!value.empty()) - parts << QStringLiteral("%1=%2").arg(QString::fromLatin1(key), - text(value)); - } - const std::string correlationId = stringValue(frame, "correlationId"); - if (!correlationId.empty()) - parts << QStringLiteral("correlation=%1").arg(text(correlationId)); - if (kind == "result" && !frame.value("ok", false)) { - const nlohmann::json error = frame.value("error", nlohmann::json::object()); - const std::string message = stringValue(error, "message"); - if (!message.empty()) - parts << text(message); - } - recordLine(parts.join(QStringLiteral(" "))); -} - -void ShellWidget::refreshThreads() { - threadList->blockSignals(true); - threadList->clear(); - for (const std::string &threadId : model.threadOrder()) { - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - continue; - QString title = text(thread->title); - if (title.isEmpty()) - title = text(threadId.substr(0, 12)); - if (model.pendingRequestCount(threadId) != 0) - title.prepend(QStringLiteral("! ")); - auto *item = new QListWidgetItem(threadList); - item->setSizeHint(QSize(0, 48)); - item->setData(Qt::UserRole, text(threadId)); - item->setToolTip(text(thread->cwd)); - auto *row = new QWidget; - row->setAttribute(Qt::WA_TransparentForMouseEvents); - row->setStyleSheet(QStringLiteral("background:transparent;")); - auto *rowLayout = new QHBoxLayout(row); - rowLayout->setContentsMargins(5, 2, 5, 2); - rowLayout->setSpacing(8); - auto *dot = makeStatusDot(); - QString dotColor = QStringLiteral("#98a2b3"); - if (model.pendingRequestCount(threadId) != 0) - dotColor = QStringLiteral("#a85d0c"); - else if (thread->status == "active" || thread->status == "inProgress") - dotColor = QStringLiteral("#2f6feb"); - else if (thread->status == "failed" || thread->status == "systemError") - dotColor = QStringLiteral("#c43d4d"); - dot->setStyleSheet( - QStringLiteral("background:%1;border-radius:5px;").arg(dotColor)); - rowLayout->addWidget(dot); - auto *copy = new QVBoxLayout; - copy->setContentsMargins(0, 0, 0, 0); - copy->setSpacing(1); - auto *titleLabel = makeLabel(title, "title"); - titleLabel->setStyleSheet(QStringLiteral("font-weight:500;")); - copy->addWidget(titleLabel); - copy->addWidget(makeLabel(displayStatus(thread->status), "meta")); - rowLayout->addLayout(copy, 1); - threadList->setItemWidget(item, row); - if (threadId == selectedThreadId) - threadList->setCurrentItem(item); - } - threadList->blockSignals(false); -} - -void ShellWidget::refreshConversation() { - const bool followLatest = conversationFollowsLatest; - const ConversationScrollAnchor anchor = captureConversationScrollAnchor(); - const bool smoothly = conversationSmoothFollowRequested; - conversationSmoothFollowRequested = false; - stopConversationScrollAnimation(); - ++conversationSpacerRevision; - conversationSpacerAdjusting = false; - conversationScrollRebuilding = true; - conversationScroll->viewport()->setUpdatesEnabled(false); - for (const auto &[key, card] : conversationCards) { - if (const auto state = commandOutputScrollState(card)) - commandOutputScrollStates[key] = *state; - } - conversationCards.clear(); - conversationCardFingerprints.clear(); - conversationTrailingSpace = nullptr; - clearLayout(conversationLayout); - - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) { - conversationTitle->setText(localNewThreadIntent - ? QStringLiteral("New thread") - : QStringLiteral("Select a thread")); - conversationMeta->setText(localNewThreadIntent ? QDir::currentPath() - : QString{}); - if (localNewThreadIntent && !newThreadPendingPrompts.empty()) { - emptyConversation = nullptr; - for (const PendingPrompt &pending : newThreadPendingPrompts) { - const bool acknowledged = - pending.status == PendingPromptStatus::Acknowledged && - QDateTime::currentMSecsSinceEpoch() - - pending.acknowledgedAtMilliseconds < - AcknowledgementTransitionMilliseconds; - auto *card = new PendingPromptCard( - pending.prompt, static_cast(pending.attachments.size()), - pending.status == PendingPromptStatus::Awaiting, acknowledged, - pending.acknowledgedAtMilliseconds, - pending.status == PendingPromptStatus::Failed, pending.error); - card->setProperty(ConversationAnchorProperty, - QStringLiteral("pending:new:%1").arg(pending.id)); - conversationLayout->addWidget(card); - } - } else { - emptyConversation = makeLabel( - localNewThreadIntent - ? QStringLiteral("Send a message to create this thread.") - : QStringLiteral("Conversation activity appears here."), - "muted"); - conversationLayout->addWidget(emptyConversation); - } - addConversationTrailingSpace(); - conversationLayout->addStretch(); - settleConversationScroll(followLatest, anchor, smoothly); - return; - } - - reconcileAcknowledgedPrompts(selectedThreadId); - conversationTitle->setText(text(thread->title)); - conversationMeta->setText(text(thread->cwd) + QStringLiteral(" | ") + - displayStatus(thread->status)); - struct VisibleItem { - std::string key; - const ItemPresentation *item = nullptr; - }; - std::vector items; - for (const std::string &turnId : thread->turnOrder) { - const auto turn = thread->turns.find(turnId); - if (turn == thread->turns.end()) - continue; - for (const std::string &itemId : turn->second.itemOrder) { - const auto item = turn->second.items.find(itemId); - if (item == turn->second.items.end()) - continue; - items.push_back({turnId + '\x1f' + itemId, &item->second}); - } - } - std::unordered_set transitioningMaterializedItems; - if (const auto submissions = pendingPrompts.find(selectedThreadId); - submissions != pendingPrompts.end()) { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - for (const PendingPrompt &submission : submissions->second) { - if (!submission.materializedIdentity.empty() && - submission.status == PendingPromptStatus::Acknowledged && - now - submission.acknowledgedAtMilliseconds < - AcknowledgementTransitionMilliseconds) - transitioningMaterializedItems.insert(submission.materializedIdentity); - } - } - std::size_t first = items.size() > conversationItemLimit - ? items.size() - conversationItemLimit - : 0; - if (!followLatest && !anchor.key.isEmpty()) { - const auto anchored = std::find_if( - items.begin(), items.end(), [this, &anchor](const VisibleItem &item) { - const auto mapped = promptAnchorKeys.find(item.key); - const QString key = mapped == promptAnchorKeys.end() ? text(item.key) - : mapped->second; - return key == anchor.key; - }); - if (anchored != items.end()) - first = - std::min(first, static_cast(anchored - items.begin())); - } - if (first != 0) { - const std::size_t page = std::min(80, first); - auto *loadEarlier = - new QPushButton(QStringLiteral("Load %1 more activities") - .arg(static_cast(page))); - loadEarlier->setProperty("kind", "history"); - loadEarlier->setProperty("historyPage", static_cast(page)); - loadEarlier->setFixedHeight(UpcomingControlHeight); - loadEarlier->setToolTip(QStringLiteral("%1 earlier activities are retained") - .arg(static_cast(first))); - connect(loadEarlier, &QPushButton::clicked, this, [this, loadEarlier] { - const std::size_t page = static_cast( - loadEarlier->property("historyPage").toULongLong()); - conversationItemLimit += page; - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - }); - conversationLayout->addWidget(loadEarlier, 0, Qt::AlignHCenter); - } - for (std::size_t index = first; index < items.size(); ++index) { - if (transitioningMaterializedItems.contains(items[index].key)) - continue; - std::optional outputScrollState; - if (const auto retained = commandOutputScrollStates.find(items[index].key); - retained != commandOutputScrollStates.end()) - outputScrollState = retained->second; - QWidget *card = itemFrame(*items[index].item, outputScrollState); - const auto mappedAnchor = promptAnchorKeys.find(items[index].key); - card->setProperty(ConversationAnchorProperty, - mappedAnchor == promptAnchorKeys.end() - ? text(items[index].key) - : mappedAnchor->second); - conversationCards[items[index].key] = card; - conversationCardFingerprints[items[index].key] = - conversationItemFingerprint(*items[index].item); - conversationLayout->addWidget(card); - } - const auto pending = pendingPrompts.find(selectedThreadId); - if (items.empty() && - (pending == pendingPrompts.end() || pending->second.empty())) - conversationLayout->addWidget( - makeLabel(QStringLiteral("No materialized activity."), "muted")); - if (pending != pendingPrompts.end()) { - for (const PendingPrompt &submission : pending->second) { - const bool acknowledged = - submission.status == PendingPromptStatus::Acknowledged && - QDateTime::currentMSecsSinceEpoch() - - submission.acknowledgedAtMilliseconds < - AcknowledgementTransitionMilliseconds; - auto *card = new PendingPromptCard( - submission.prompt, static_cast(submission.attachments.size()), - submission.status == PendingPromptStatus::Awaiting, acknowledged, - submission.acknowledgedAtMilliseconds, - submission.status == PendingPromptStatus::Failed, submission.error); - card->setProperty( - ConversationAnchorProperty, - pendingPromptAnchorKey(selectedThreadId, submission.id)); - conversationLayout->addWidget(card); - } - } - addConversationTrailingSpace(); - conversationLayout->addStretch(); - settleConversationScroll(followLatest, anchor, smoothly); -} - -void ShellWidget::refreshConversationItems() { - if (dirtyConversationItems.empty()) { - conversationSmoothFollowRequested = false; - return; - } - const ThreadPresentation *thread = model.thread(selectedThreadId); - bool requiresRebuild = !thread; - for (const auto &[key, identity] : dirtyConversationItems) { - if (requiresRebuild) - break; - const auto turn = thread->turns.find(identity.first); - requiresRebuild = - turn == thread->turns.end() || - turn->second.items.find(identity.second) == turn->second.items.end(); - } - if (requiresRebuild) { - refreshConversation(); - return; - } - - const bool followLatest = conversationFollowsLatest; - const ConversationScrollAnchor anchor = captureConversationScrollAnchor(); - stopConversationScrollAnimation(); - conversationScrollRebuilding = true; - conversationScroll->viewport()->setUpdatesEnabled(false); - bool changed = false; - for (const auto &[key, identity] : dirtyConversationItems) { - bool itemChanged = false; - if (!refreshConversationItem(key, identity.first, identity.second, - itemChanged)) { - conversationScroll->viewport()->setUpdatesEnabled(true); - conversationScrollRebuilding = false; - refreshConversation(); - return; - } - changed = changed || itemChanged; - } - conversationSmoothFollowRequested = false; - if (!changed) { - conversationScrollRebuilding = false; - conversationScroll->viewport()->setUpdatesEnabled(true); - return; - } - conversationLayout->activate(); - conversationContent->updateGeometry(); - settleConversationScroll(followLatest, anchor, followLatest); -} - -bool ShellWidget::refreshConversationItem(const std::string &key, - const std::string &turnId, - const std::string &itemId, - bool &changed) { - changed = false; - const auto existing = conversationCards.find(key); - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) - return false; - const auto turn = thread->turns.find(turnId); - if (turn == thread->turns.end()) - return false; - const auto item = turn->second.items.find(itemId); - if (item == turn->second.items.end()) - return false; - const std::string fingerprint = conversationItemFingerprint(item->second); - if (existing == conversationCards.end()) { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (const auto prompts = pendingPrompts.find(selectedThreadId); - prompts != pendingPrompts.end()) { - const bool representedByTransition = std::any_of( - prompts->second.begin(), prompts->second.end(), - [&key, now](const PendingPrompt &submission) { - return submission.materializedIdentity == key && - submission.status == PendingPromptStatus::Acknowledged && - now - submission.acknowledgedAtMilliseconds < - AcknowledgementTransitionMilliseconds; - }); - if (representedByTransition) - return true; - } - - bool evictedCard = false; - if (conversationFollowsLatest && - conversationCards.size() >= conversationItemLimit) { - for (const std::string &orderedTurnId : thread->turnOrder) { - const auto orderedTurn = thread->turns.find(orderedTurnId); - if (orderedTurn == thread->turns.end()) - continue; - for (const std::string &orderedItemId : orderedTurn->second.itemOrder) { - const std::string oldestKey = orderedTurnId + '\x1f' + orderedItemId; - const auto oldest = conversationCards.find(oldestKey); - if (oldest == conversationCards.end()) - continue; - if (const auto state = commandOutputScrollState(oldest->second)) - commandOutputScrollStates[oldestKey] = *state; - conversationLayout->removeWidget(oldest->second); - oldest->second->hide(); - oldest->second->deleteLater(); - conversationCards.erase(oldest); - conversationCardFingerprints.erase(oldestKey); - evictedCard = true; - break; - } - if (evictedCard) - break; - } - } - - QWidget *replacement = itemFrame(item->second, std::nullopt); - const auto mappedAnchor = promptAnchorKeys.find(key); - const QString anchorKey = mappedAnchor == promptAnchorKeys.end() - ? text(key) - : mappedAnchor->second; - replacement->setProperty(ConversationAnchorProperty, anchorKey); - - bool replacedPendingCard = false; - for (int index = 0; index < conversationLayout->count(); ++index) { - QWidget *candidate = conversationLayout->itemAt(index)->widget(); - if (!candidate || - candidate->property(ConversationAnchorProperty).toString() != - anchorKey) - continue; - QLayoutItem *replaced = - conversationLayout->replaceWidget(candidate, replacement); - if (!replaced) { - replacement->deleteLater(); - return false; - } - delete replaced; - candidate->hide(); - candidate->deleteLater(); - replacedPendingCard = true; - break; - } - if (!replacedPendingCard) { - int insertionIndex = -1; - bool afterTarget = false; - for (const std::string &orderedTurnId : thread->turnOrder) { - const auto orderedTurn = thread->turns.find(orderedTurnId); - if (orderedTurn == thread->turns.end()) - continue; - for (const std::string &orderedItemId : orderedTurn->second.itemOrder) { - const std::string orderedKey = orderedTurnId + '\x1f' + orderedItemId; - if (orderedKey == key) { - afterTarget = true; - continue; - } - if (!afterTarget) - continue; - const auto following = conversationCards.find(orderedKey); - if (following == conversationCards.end()) - continue; - insertionIndex = conversationLayout->indexOf(following->second); - break; - } - if (insertionIndex >= 0) - break; - } - if (insertionIndex < 0) { - insertionIndex = conversationLayout->count(); - for (int index = 0; index < conversationLayout->count(); ++index) { - QWidget *candidate = conversationLayout->itemAt(index)->widget(); - if (!candidate) - continue; - if (candidate == conversationTrailingSpace) { - insertionIndex = index; - break; - } - } - } - conversationLayout->insertWidget(insertionIndex, replacement); - } - conversationCards[key] = replacement; - conversationCardFingerprints[key] = fingerprint; - - if (evictedCard) { - QPushButton *historyButton = nullptr; - for (int index = 0; index < conversationLayout->count(); ++index) { - auto *candidate = qobject_cast( - conversationLayout->itemAt(index)->widget()); - if (candidate && candidate->property("kind").toString() == - QStringLiteral("history")) { - historyButton = candidate; - break; - } - } - std::size_t totalItems = 0; - for (const auto &[orderedTurnId, orderedTurn] : thread->turns) { - static_cast(orderedTurnId); - totalItems += orderedTurn.itemOrder.size(); - } - const std::size_t hiddenItems = - totalItems > conversationCards.size() - ? totalItems - conversationCards.size() - : 0; - const std::size_t page = - std::min(conversationItemLimit, hiddenItems); - if (!historyButton) { - historyButton = new QPushButton; - historyButton->setProperty("kind", "history"); - historyButton->setFixedHeight(UpcomingControlHeight); - connect(historyButton, &QPushButton::clicked, this, - [this, historyButton] { - const std::size_t page = static_cast( - historyButton->property("historyPage").toULongLong()); - conversationItemLimit += page; - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - }); - conversationLayout->insertWidget(0, historyButton, 0, Qt::AlignHCenter); - } - historyButton->setText(QStringLiteral("Load %1 more activities") - .arg(static_cast(page))); - historyButton->setProperty("historyPage", static_cast(page)); - historyButton->setToolTip( - QStringLiteral("%1 earlier activities are retained") - .arg(static_cast(hiddenItems))); - } - changed = true; - return true; - } - const auto previousFingerprint = conversationCardFingerprints.find(key); - if (previousFingerprint != conversationCardFingerprints.end() && - previousFingerprint->second == fingerprint) - return true; - if (stringValue(item->second.raw, "type") == "commandExecution" && - updateCommandExecutionFrame(existing->second, item->second)) { - conversationCardFingerprints[key] = fingerprint; - changed = true; - return true; - } - std::optional outputScrollState = - commandOutputScrollState(existing->second); - if (outputScrollState) - commandOutputScrollStates[key] = *outputScrollState; - QWidget *replacement = itemFrame(item->second, outputScrollState); - const auto mappedAnchor = promptAnchorKeys.find(key); - replacement->setProperty(ConversationAnchorProperty, - mappedAnchor == promptAnchorKeys.end() - ? text(key) - : mappedAnchor->second); - QLayoutItem *replaced = - conversationLayout->replaceWidget(existing->second, replacement); - if (!replaced) { - replacement->deleteLater(); - return false; - } - delete replaced; - existing->second->hide(); - existing->second->deleteLater(); - existing->second = replacement; - conversationCardFingerprints[key] = fingerprint; - changed = true; - return true; -} - -void ShellWidget::refreshInspector() { - const int activeTab = inspectorTabs->currentIndex(); - QVBoxLayout *activeLayout = nullptr; - if (activeTab == 0) - activeLayout = planLayout; - else if (activeTab == 1) - activeLayout = agentsLayout; - else if (activeTab == 3) - activeLayout = requestsLayout; - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (activeTab == 2) { - QStringList commandCwds; - QStringList changedPaths; - if (thread) { - for (const std::string &value : thread->commandCwds) - commandCwds.push_back(text(value)); - for (const std::string &value : thread->changedPaths) - changedPaths.push_back(text(value)); - } - diffViewer->setRepositoryContext( - text(selectedThreadId), thread ? text(thread->cwd) : QString{}, - std::move(commandCwds), std::move(changedPaths)); - diffViewer->refreshRepository(); - return; - } - if (!activeLayout) - return; - - clearLayout(activeLayout); - if (!thread && activeTab != 3) { - activeLayout->addWidget( - makeLabel(QStringLiteral("No selected thread."), "muted")); - activeLayout->addStretch(); - return; + showNotice(QStringLiteral("Protocol diagnostic: %1").arg(text(message))); + } else if (kind == "event" && type == "connection.lifecycle" && + (stringValue(data, "state") == "failure" || + stringValue(data, "state") == "disconnected")) { + const std::string detail = stringValue(data, "detail"); + if (!detail.starts_with("local-")) + showNotice(detail.empty() ? QStringLiteral("Codex bridge disconnected") + : text(detail)); } - if (activeTab == 0) { - const TurnPresentation *planTurn = nullptr; - const ItemPresentation *planItem = nullptr; - for (auto turnId = thread->turnOrder.rbegin(); - turnId != thread->turnOrder.rend(); ++turnId) { - const auto turn = thread->turns.find(*turnId); - if (turn == thread->turns.end()) - continue; - if (turn->second.plan.is_object() && - turn->second.plan.contains("steps")) { - planTurn = &turn->second; - break; - } - for (auto itemId = turn->second.itemOrder.rbegin(); - itemId != turn->second.itemOrder.rend(); ++itemId) { - const auto item = turn->second.items.find(*itemId); - if (item != turn->second.items.end() && - stringValue(item->second.raw, "type") == "plan") { - planItem = &item->second; - break; - } - } - if (planItem) - break; - } - if (planTurn) { - const QString explanation = - text(stringValue(planTurn->plan, "explanation")); - if (!explanation.isEmpty()) - planLayout->addWidget(makeMarkdownLabel(explanation)); - const nlohmann::json steps = - planTurn->plan.value("steps", nlohmann::json::array()); - for (const auto &step : steps) { - auto *row = new QFrame; - row->setProperty("kind", "summary"); - auto *rowLayout = new QVBoxLayout(row); - rowLayout->setContentsMargins(9, 7, 9, 7); - rowLayout->addWidget(makeLabel(text(stringValue(step, "step")))); - rowLayout->addWidget( - makeLabel(displayStatus(stringValue(step, "status")), "meta")); - planLayout->addWidget(row); + if (kind == "event" && type == "connection.bridge" && + stringValue(data, "state") == "opened") { + session.listThreads(); + session.listModels(); + ensureThreadHydrated(selectedThreadId); + for (const std::string &threadId : prompts.queuedThreadIds()) { + if (threadId == DraftThreadId) { + if (newThreadIntent) + startThreadForDraft(); + } else { + dispatchNextPrompt(threadId); } - } else if (planItem) { - const QString planText = text(stringValue(planItem->raw, "text")); - if (planText.isEmpty()) - planLayout->addWidget( - makeLabel(QStringLiteral("Plan is being prepared."), "muted")); - else - planLayout->addWidget(makeMarkdownLabel(planText)); - } else { - planLayout->addWidget( - makeLabel(QStringLiteral("No plan for this thread."), "muted")); } - planLayout->addStretch(); - return; + session.listPermissionProfiles( + {{"cwd", QDir::currentPath().toStdString()}}); } - if (activeTab == 1) { - std::size_t agentCount = 0; - for (const std::string &agentId : thread->agentOrder) { - const auto agent = thread->agents.find(agentId); - if (agent == thread->agents.end()) - continue; - agentsLayout->addWidget(agentFrame(agent->second)); - ++agentCount; + if (type == "thread.removed" && !eventThreadId.empty()) { + prompts.clearThread(eventThreadId); + hydration.erase(eventThreadId); + readRevisions.erase(eventThreadId); + operationReadyThreads.erase(eventThreadId); + resumeInFlightThreads.erase(eventThreadId); + dispatchScheduledThreads.erase(eventThreadId); + historyWindows.erase(eventThreadId); + if (selectedThreadId == eventThreadId) { + selectedThreadId.clear(); + middleRegion->composer().clearDraft(); } - if (agentCount == 0) - agentsLayout->addWidget(makeLabel( - QStringLiteral("No agent activity for this thread."), "muted")); - agentsLayout->addStretch(); - return; - } - - std::size_t requestCount = 0; - for (const auto &[id, request] : model.pendingRequestPresentations()) { - auto *frame = new QFrame; - frame->setProperty("kind", "summary"); - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(9, 7, 9, 7); - layout->setSpacing(5); - layout->addWidget(makeLabel(text(request.kind), "title")); - QString threadContext = text(request.threadId); - if (const ThreadPresentation *requestThread = - model.thread(request.threadId); - requestThread && !requestThread->title.empty()) - threadContext = text(requestThread->title); - layout->addWidget( - makeLabel(QStringLiteral("thread %1 | generation %2 | request %3") - .arg(threadContext) - .arg(static_cast(request.generation)) - .arg(text(id)), - "meta")); - const std::string command = stringValue(request.raw, "command"); - const std::string reason = stringValue(request.raw, "reason"); - const std::string message = stringValue(request.raw, "message"); - if (!command.empty()) - layout->addWidget( - makeLabel(QStringLiteral("Command: %1").arg(text(command)), "meta")); - if (!reason.empty()) - layout->addWidget( - makeLabel(QStringLiteral("Reason: %1").arg(text(reason)), "meta")); - if (!message.empty()) - layout->addWidget(makeLabel(text(message), "meta")); - if (request.raw.contains("questions") && - request.raw["questions"].is_array()) - layout->addWidget(makeLabel( - QStringLiteral("%1 questions") - .arg(static_cast(request.raw["questions"].size())), - "meta")); - auto *actions = new QHBoxLayout; - actions->setContentsMargins(0, 2, 0, 0); - auto *deny = new QPushButton(QStringLiteral("Deny")); - auto *review = new QPushButton(QStringLiteral("Review")); - review->setProperty("kind", "primary"); - deny->setFixedHeight(28); - review->setFixedHeight(28); - connect(deny, &QPushButton::clicked, this, - [this, id] { rejectPending(id); }); - connect(review, &QPushButton::clicked, this, - [this, id] { reviewPending(id); }); - actions->addStretch(); - actions->addWidget(deny); - actions->addWidget(review); - layout->addLayout(actions); - requestsLayout->addWidget(frame); - ++requestCount; - } - if (requestCount == 0) - requestsLayout->addWidget( - makeLabel(QStringLiteral("No pending requests."), "muted")); - requestsLayout->addStretch(); -} - -void ShellWidget::resetComposer() { - promptEditor->clear(); - attachmentDrafts.clear(); - ++attachmentRevision; - refreshAttachments(); - refreshComposerEnabledState(); -} - -void ShellWidget::refreshComposerEnabledState() { - if (!promptEditor || !sendButton || !attachmentButton) - return; - const ConnectionPresentation &connection = model.connection(); - const bool canSubmit = - connection.connected && connection.role == "controller"; - promptEditor->setEnabled(true); - sendButton->setEnabled(canSubmit); - attachmentButton->setEnabled(canSubmit); - for (QPushButton *button : attachmentPanel->findChildren()) - button->setEnabled(true); -} - -void ShellWidget::completePromptSubmission(const std::string &threadId, - std::uint64_t submissionId, - const nlohmann::json &result) { - const auto prompts = pendingPrompts.find(threadId); - if (prompts == pendingPrompts.end()) - return; - const auto submission = - std::find_if(prompts->second.begin(), prompts->second.end(), - [submissionId](const PendingPrompt &candidate) { - return candidate.id == submissionId; - }); - if (submission == prompts->second.end()) - return; - if (result.value("ok", false)) { - operationReadyThreads.insert(threadId); - submission->status = PendingPromptStatus::Acknowledged; - submission->acknowledgedAtMilliseconds = - QDateTime::currentMSecsSinceEpoch(); - scheduleAcknowledgementCompletion(threadId, *submission); - } else { - submission->status = PendingPromptStatus::Failed; - const nlohmann::json error = - result.value("error", nlohmann::json::object()); - const std::string message = safeMessage(error); - submission->error = - text(message.empty() ? std::string("Submission failed") : message); - showNotice(text(message.empty() ? std::string("Turn submission failed") - : message)); + } else if (!eventThreadId.empty()) { + if (const ThreadPresentation *thread = model.thread(eventThreadId)) { + prompts.reconcile(eventThreadId, *thread); + prompts.compactResolved(eventThreadId, + QDateTime::currentMSecsSinceEpoch()); + } + } else if (kind == "event" && type == "connection.provider" && + stringValue(data, "state") == "ready") { + session.listThreads(); + session.listModels(); + readThread(selectedThreadId, true); } - if (threadId == selectedThreadId) - conversationSmoothFollowRequested = true; - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation | RefreshStatus); - QTimer::singleShot(0, this, - [this, threadId] { dispatchNextPrompt(threadId); }); -} -QString ShellWidget::pendingPromptAnchorKey(const std::string &threadId, - std::uint64_t submissionId) const { - return QStringLiteral("prompt:%1:%2") - .arg(text(threadId.empty() ? std::string("new") : threadId)) - .arg(submissionId); + hydrateHistoricalAgents(); + scheduleRender(); } -void ShellWidget::scheduleAcknowledgementCompletion(const std::string &threadId, - PendingPrompt &submission) { - if (submission.completionRefreshScheduled || - submission.status != PendingPromptStatus::Acknowledged) +void ShellWidget::Impl::scheduleRender() { + if (renderScheduled) return; - const qint64 elapsed = QDateTime::currentMSecsSinceEpoch() - - submission.acknowledgedAtMilliseconds; - const int remaining = static_cast( - std::max(0, AcknowledgementTransitionMilliseconds - elapsed)); - submission.completionRefreshScheduled = true; - const std::uint64_t submissionId = submission.id; - QTimer::singleShot(remaining, this, [this, threadId, submissionId] { - const auto prompts = pendingPrompts.find(threadId); - if (prompts == pendingPrompts.end()) - return; - const auto pending = - std::find_if(prompts->second.begin(), prompts->second.end(), - [submissionId](const PendingPrompt &candidate) { - return candidate.id == submissionId; - }); - if (pending == prompts->second.end()) + renderScheduled = true; + const auto token = alive; + // A streamed response may deliver many deltas in one display interval. + // Reconcile once per frame instead of rebuilding rich text and layout for + // every transport chunk. + QTimer::singleShot(16, Qt::PreciseTimer, owner, [this, token] { + if (!*token) return; - pending->completionRefreshScheduled = false; - if (threadId == selectedThreadId) { - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - } + renderScheduled = false; + render(); }); } -std::unordered_set -ShellWidget::materializedUserMessageIds(const std::string &threadId) const { - std::unordered_set result; - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - return result; - for (const std::string &turnId : thread->turnOrder) { - const auto turn = thread->turns.find(turnId); - if (turn == thread->turns.end()) - continue; - for (const std::string &itemId : turn->second.itemOrder) { - const auto item = turn->second.items.find(itemId); - if (item != turn->second.items.end() && - stringValue(item->second.raw, "type") == "userMessage") - result.insert(turnId + '\x1f' + itemId); - } - } - return result; +void ShellWidget::Impl::render() { + middleRegion->threads().refresh(model, selectedThreadId); + renderConversation(); + middleRegion->inspector().refresh(model, selectedThreadId); + refreshSettings(); + refreshStatus(); } -void ShellWidget::reconcileAcknowledgedPrompts(const std::string &threadId) { - const auto prompts = pendingPrompts.find(threadId); - const ThreadPresentation *thread = model.thread(threadId); - if (prompts == pendingPrompts.end() || !thread) - return; - auto &claimed = materializedPromptItemIds[threadId]; +void ShellWidget::Impl::renderConversation() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); - for (auto submission = prompts->second.begin(); - submission != prompts->second.end();) { - if (submission->status != PendingPromptStatus::Acknowledged) { - ++submission; - continue; - } - std::string matchedId = submission->materializedIdentity; + const ThreadPresentation *thread = model.thread(selectedThreadId); + const std::string projectionId = selectedThreadId.empty() && newThreadIntent + ? std::string(DraftThreadId) + : selectedThreadId; + if (thread) + prompts.reconcile(selectedThreadId, *thread); + if (!projectionId.empty()) + prompts.compactResolved(projectionId, now); + const auto submissions = prompts.submissions(projectionId); + std::size_t authoritativeCount = 0; + if (thread) { for (const std::string &turnId : thread->turnOrder) { - if (!matchedId.empty()) - break; const auto turn = thread->turns.find(turnId); - if (turn == thread->turns.end()) - continue; - for (const std::string &itemId : turn->second.itemOrder) { - const auto item = turn->second.items.find(itemId); - if (item == turn->second.items.end() || - stringValue(item->second.raw, "type") != "userMessage") - continue; - const std::string identity = turnId + '\x1f' + itemId; - if (claimed.contains(identity) || - submission->knownUserMessageIds.contains(identity)) - continue; - if (messageText(item->second.raw).trimmed() == - submission->prompt.trimmed()) { - matchedId = identity; - break; - } - } - if (!matchedId.empty()) - break; - } - if (matchedId.empty()) { - ++submission; - continue; + if (turn != thread->turns.end()) + authoritativeCount += turn->second.itemOrder.size(); } - claimed.insert(matchedId); - submission->materializedIdentity = matchedId; - promptAnchorKeys[matchedId] = - pendingPromptAnchorKey(threadId, submission->id); - if (now - submission->acknowledgedAtMilliseconds < - AcknowledgementTransitionMilliseconds) { - scheduleAcknowledgementCompletion(threadId, *submission); - ++submission; - continue; + } + HistoryWindow &history = historyWindows[projectionId]; + const middle::ConversationView::Mode viewportMode = + middleRegion->conversation().modeForThread(projectionId); + if (viewportMode == middle::ConversationView::Mode::Paused && + authoritativeCount > history.lastAuthoritativeCount) { + // Do not evict the paused visual anchor merely because newer items were + // appended. The hidden prefix stays constant until following resumes. + history.effective += authoritativeCount - history.lastAuthoritativeCount; + } else if (viewportMode == middle::ConversationView::Mode::Following) { + history.effective = history.requested; + } + history.lastAuthoritativeCount = authoritativeCount; + const middle::ConversationSnapshot snapshot = + middle::ConversationProjection::project(projectionId, thread, submissions, + history.effective, now); + if (!thread && newThreadIntent) + middleRegion->conversation().setEmptyMessage( + QStringLiteral("Send a message to create this thread.")); + else if (thread) + middleRegion->conversation().setEmptyMessage( + QStringLiteral("No materialized activity.")); + else + middleRegion->conversation().setEmptyMessage( + QStringLiteral("Conversation activity appears here.")); + middleRegion->conversation().reconcile(snapshot); + + if (thread) { + middleRegion->setThreadHeading(text(thread->title), + text(thread->cwd) + QStringLiteral(" | ") + + displayStatus(thread->status)); + } else if (newThreadIntent) { + middleRegion->setThreadHeading(QStringLiteral("New thread"), + newThreadWorkspace.isEmpty() + ? QDir::currentPath() + : newThreadWorkspace); + } else { + middleRegion->setThreadHeading(QStringLiteral("Select a thread"), {}); + } +} + +void ShellWidget::Impl::refreshSettings() { + nlohmann::json canonical = nlohmann::json::object(); + std::string identity = "no-thread"; + if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { + identity = thread->id; + canonical = thread->raw; + const auto settings = thread->domains.find("thread.settings.changed"); + if (settings != thread->domains.end() && settings->second.is_object()) { + nlohmann::json update = settings->second; + if (update.contains("threadSettings") && + update["threadSettings"].is_object()) + update = update["threadSettings"]; + canonical.merge_patch(update); } - submission = prompts->second.erase(submission); + } else if (newThreadIntent) { + identity = DraftThreadId; + canonical["cwd"] = (newThreadWorkspace.isEmpty() ? QDir::currentPath() + : newThreadWorkspace) + .toStdString(); + } else { + canonical["cwd"] = QDir::currentPath().toStdString(); } - if (prompts->second.empty()) - pendingPrompts.erase(prompts); + nlohmann::json profiles = nlohmann::json::array(); + const auto found = + model.globalDomains().find("operation.permission-profiles.list"); + if (found != model.globalDomains().end()) + profiles = found->second; + const std::string serialized = nlohmann::json{ + {"identity", identity}, + {"canonical", canonical}, + {"models", model.modelCatalog()}, + {"profiles", profiles}}.dump(); + const QByteArray next(serialized.data(), + static_cast(serialized.size())); + if (next == settingsSnapshot) + return; + settingsSnapshot = next; + middleRegion->composer().turnSettings()->setContext( + identity, canonical, model.modelCatalog(), profiles); } -void ShellWidget::refreshStatus() { +void ShellWidget::Impl::refreshStatus() { const ConnectionPresentation &connection = model.connection(); + const ThreadPresentation *thread = model.thread(selectedThreadId); + std::size_t runningAgents = 0; + if (thread) { + for (const auto &[id, agent] : thread->agents) { + static_cast(id); + if (agent.status == "inProgress" || agent.status == "running" || + agent.status == "started") + ++runningAgents; + } + } + const bool active = model.activeTurnId(selectedThreadId).has_value(); + const std::string serialized = nlohmann::json{ + {"connected", connection.connected}, + {"retrying", connection.retrying}, + {"role", connection.role}, + {"settings", connection.settings}, + {"selectedThreadId", selectedThreadId}, + {"newThreadIntent", newThreadIntent}, + {"newThreadWorkspace", newThreadWorkspace.toStdString()}, + {"threadTitle", thread ? thread->title : std::string{}}, + {"threadCwd", thread ? thread->cwd : std::string{}}, + {"threadStatus", thread ? thread->status : std::string{}}, + {"agentCount", thread ? thread->agents.size() : 0U}, + {"runningAgents", runningAgents}, + {"active", active}, + {"selectedPending", model.pendingRequestCount(selectedThreadId)}, + {"totalPending", + model.pendingRequestCount()}}.dump(); + const QByteArray next(serialized.data(), + static_cast(serialized.size())); + if (next == statusSnapshot) + return; + statusSnapshot = next; QString dotStyle; - QString dotToolTip; + QString dotTip; if (connection.connected) { dotStyle = QStringLiteral("background:#18865e;border-radius:5px;"); - dotToolTip = QStringLiteral("Connected"); + dotTip = QStringLiteral("Connected"); } else if (connection.retrying) { dotStyle = QStringLiteral("background:#a85d0c;border-radius:5px;"); - dotToolTip = QStringLiteral("Disconnected, retrying"); + dotTip = QStringLiteral("Disconnected, retrying"); } else { dotStyle = QStringLiteral("background:#c43d4d;border-radius:5px;"); - dotToolTip = QStringLiteral("Disconnected"); + dotTip = QStringLiteral("Disconnected"); } connectionStatusDot->setStyleSheet(dotStyle); - connectionStatusDot->setToolTip(dotToolTip); + connectionStatusDot->setToolTip(dotTip); QString selectedTransport; const std::string selectedKey = stringValue(connection.settings, "selected"); const nlohmann::json available = @@ -2953,29 +750,21 @@ void ShellWidget::refreshStatus() { ? QStringLiteral("Release control") : QStringLiteral("Claim control")); controllerButton->setEnabled(connection.connected); - const std::size_t pending = model.pendingRequestCount(selectedThreadId); + + const std::size_t selectedPending = + model.pendingRequestCount(selectedThreadId); const std::size_t totalPending = model.pendingRequestCount(); requestButton->setText(QStringLiteral("Requests (%1)") .arg(static_cast(totalPending))); requestButton->setVisible(totalPending != 0); - approveButton->parentWidget()->setVisible(pending != 0); + middleRegion->composer().setAttentionVisible(selectedPending != 0); - const ThreadPresentation *thread = model.thread(selectedThreadId); + QString workspace = QStringLiteral("No workspace"); if (thread) { - const QString workspace = text(thread->cwd); - workspaceBreadcrumb->setToolTip(workspace); - workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( - workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); + workspace = text(thread->cwd); threadContextStatus->setText( QStringLiteral("%1 | %2") .arg(text(thread->title), displayStatus(thread->status))); - std::size_t runningAgents = 0; - for (const auto &[agentId, agent] : thread->agents) { - static_cast(agentId); - if (agent.status == "inProgress" || agent.status == "running" || - agent.status == "started") - ++runningAgents; - } agentActivityStatus->setText( thread->agents.empty() ? QStringLiteral("No agent activity") @@ -2983,158 +772,167 @@ void ShellWidget::refreshStatus() { .arg(static_cast(thread->agents.size())) .arg(static_cast(runningAgents))); } else { - const QString workspace = - localNewThreadIntent - ? text(turnSettings->workspace(QDir::currentPath().toStdString())) - : QStringLiteral("No workspace"); - workspaceBreadcrumb->setToolTip(workspace); - workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( - workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); - threadContextStatus->setText(localNewThreadIntent + if (newThreadIntent) + workspace = text(middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString())); + threadContextStatus->setText(newThreadIntent ? QStringLiteral("New thread") : QStringLiteral("No thread context")); agentActivityStatus->setText(QStringLiteral("No agent activity")); } - const bool active = model.activeTurnId(selectedThreadId).has_value(); - interruptButton->setVisible(active); - sendButton->setText(active ? QStringLiteral("Steer") - : QStringLiteral("Send")); - const QString actionKind = - active ? QStringLiteral("steer") : QStringLiteral("primary"); - if (sendButton->property("kind").toString() != actionKind) { - sendButton->setProperty("kind", actionKind); - sendButton->style()->unpolish(sendButton); - sendButton->style()->polish(sendButton); - } - scheduleComposerLayout(); - refreshComposerEnabledState(); - turnSettings->setControlsEnabled(connection.connected && - connection.role == "controller" && !active); + workspaceBreadcrumb->setToolTip(workspace); + workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( + workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); + + const bool canSubmit = + connection.connected && connection.role == "controller"; + middleRegion->composer().setActiveTurn(active); + middleRegion->composer().setCanSubmit(canSubmit); + middleRegion->composer().setSettingsEnabled(canSubmit && !active); } -void ShellWidget::refreshStateInspector() { - if (!stateView || !infoTabs || inspectorTabs->currentIndex() != 4 || - infoTabs->currentIndex() != 0) +void ShellWidget::Impl::hydrateHistoricalAgents() { + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) return; - nlohmann::json domains = nlohmann::json::object(); - for (const auto &[name, value] : model.globalDomains()) - domains[name] = value; - - nlohmann::json pending = nlohmann::json::object(); - for (const auto &[id, request] : model.pendingRequestPresentations()) { - pending[id] = {{"category", request.kind}, - {"threadId", request.threadId}, - {"generation", request.generation}}; - } - - const nlohmann::json state{{"models", model.modelCatalog()}, - {"pendingRequests", std::move(pending)}, - {"domains", std::move(domains)}}; - std::string rendered = state.dump(2); - constexpr std::size_t MaximumRenderedStateBytes = 32U * 1024U; - if (rendered.size() > MaximumRenderedStateBytes) { - const std::size_t totalBytes = rendered.size(); - rendered.resize(MaximumRenderedStateBytes); - rendered += "\n\n[State display truncated at 32 KiB; retained bytes: " + - std::to_string(totalBytes) + "]"; + for (const std::string &id : thread->agentOrder) { + const auto agent = thread->agents.find(id); + if (agent == thread->agents.end() || agent->second.childThreadId.empty() || + agent->second.status != "started") + continue; + // Historical child hydration shares the same monotonic read boundary as + // user-selected threads, so a pre-reconnect result cannot replace newer + // child/agent presentation state. + readThread(agent->second.childThreadId); } - stateView->setPlainText(text(rendered)); } -void ShellWidget::selectThread(std::string threadId) { - stopConversationScrollAnimation(); - conversationSmoothScrollFloor = 0; - conversationPausedAnchorValid = false; - ++conversationScrollSettlementRevision; - conversationSmoothFollowRequested = false; - if (threadId != selectedThreadId) - conversationItemLimit = 80; +void ShellWidget::Impl::selectThread(std::string threadId) { + if (threadId.empty()) + return; + if (threadId == selectedThreadId) { + ensureThreadHydrated(threadId); + return; + } selectedThreadId = std::move(threadId); - localNewThreadIntent = false; - newThreadDraftOptions = nlohmann::json::object(); - newThreadDraftName.clear(); - newThreadDraftWorkspace.clear(); - conversationRebuildPending = true; - resetComposer(); + newThreadIntent = false; + newThreadOptions = nlohmann::json::object(); + newThreadName.clear(); + newThreadWorkspace.clear(); + historyWindows.try_emplace(selectedThreadId); ensureThreadHydrated(selectedThreadId); - scheduleRefresh(); + render(); } -void ShellWidget::beginNewThread() { +void ShellWidget::Impl::beginNewThread() { if (newThreadCreationInFlight) { showNotice(QStringLiteral("The current new thread is still being created."), false); return; } - NewThreadDialog dialog( - text(turnSettings->workspace(QDir::currentPath().toStdString())), this); + const QString initial = + text(middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString())); + NewThreadDialog dialog(initial, owner); if (dialog.exec() != QDialog::Accepted) return; const NewThreadDraft draft = dialog.draft(); - newThreadPendingPrompts.clear(); + prompts.clearThread(DraftThreadId); selectedThreadId.clear(); - localNewThreadIntent = true; - newThreadDraftName = draft.name; - newThreadDraftWorkspace = draft.workspace; - newThreadDraftOptions = nlohmann::json::object(); + newThreadIntent = true; + newThreadName = draft.name; + newThreadWorkspace = draft.workspace; + newThreadOptions = nlohmann::json::object(); if (!draft.baseInstructions.isEmpty()) - newThreadDraftOptions["baseInstructions"] = - draft.baseInstructions.toStdString(); + newThreadOptions["baseInstructions"] = draft.baseInstructions.toStdString(); if (!draft.developerInstructions.isEmpty()) - newThreadDraftOptions["developerInstructions"] = + newThreadOptions["developerInstructions"] = draft.developerInstructions.toStdString(); if (draft.ephemeral) - newThreadDraftOptions["ephemeral"] = true; - turnSettings->setWorkspace(draft.workspace); - conversationItemLimit = 80; - conversationRebuildPending = true; - threadList->clearSelection(); - resetComposer(); - promptEditor->setFocus(); - scheduleRefresh(); + newThreadOptions["ephemeral"] = true; + settingsSnapshot.clear(); + middleRegion->composer().clearDraft(); + middleRegion->composer().turnSettings()->setWorkspace(draft.workspace); + middleRegion->composer().promptEditor()->setFocus(); + render(); } -void ShellWidget::requestThreads() { session.listThreads(); } - -void ShellWidget::requestModels() { session.listModels(); } - -void ShellWidget::readThread(const std::string &threadId) { - if (threadId.empty()) +void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { + if (threadId.empty() || resumeInFlightThreads.contains(threadId)) return; - threadHydration[threadId] = ThreadHydrationState::ReadInFlight; - session.readThread(threadId); + if (!forced) { + const auto existing = hydration.find(threadId); + if (existing != hydration.end() && + (existing->second == Hydration::InFlight || + existing->second == Hydration::Hydrated || + existing->second == Hydration::Failed)) + return; + } + hydration[threadId] = Hydration::InFlight; + const auto token = alive; + const std::uint64_t revision = nextReadRevision++; + readRevisions[threadId] = revision; + session.readThread(threadId, [this, token, threadId, + revision](const nlohmann::json &result) { + if (!*token) + return; + const auto current = readRevisions.find(threadId); + if (current == readRevisions.end() || current->second != revision) { + const std::string correlationId = stringValue(result, "correlationId"); + if (!correlationId.empty()) + staleReadResultCorrelations.insert(correlationId); + return; + } + if (result.value("ok", false)) { + hydration[threadId] = Hydration::Hydrated; + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); + return; + } + // A non-forced hydration is attempted once per connection generation. + // Explicit Reload bypasses this terminal state, while a new generation + // clears it together with the other hydration bookkeeping. + hydration[threadId] = Hydration::Failed; + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const QString displayed = + text(message.empty() ? std::string("Thread loading failed") : message); + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + render(); + }); } -void ShellWidget::ensureThreadHydrated(const std::string &threadId) { - if (threadId.empty() || threadIsHydrated(threadId)) +void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { + if (threadId.empty() || threadIsHydrated(threadId) || + !model.connection().connected) return; - const auto state = threadHydration.find(threadId); - if (state != threadHydration.end() && - state->second == ThreadHydrationState::ReadInFlight) + const auto found = hydration.find(threadId); + if (found != hydration.end() && found->second == Hydration::InFlight) return; readThread(threadId); } -bool ShellWidget::threadIsHydrated(const std::string &threadId) const { - const auto state = threadHydration.find(threadId); - return state != threadHydration.end() && - state->second == ThreadHydrationState::Hydrated; +bool ShellWidget::Impl::threadIsHydrated(const std::string &threadId) const { + const auto found = hydration.find(threadId); + return found != hydration.end() && found->second == Hydration::Hydrated; } -bool ShellWidget::threadRequiresResume(const std::string &threadId) const { +bool ShellWidget::Impl::threadRequiresResume( + const std::string &threadId) const { if (operationReadyThreads.contains(threadId)) return false; const ThreadPresentation *thread = model.thread(threadId); return thread && thread->status == "notLoaded"; } -void ShellWidget::renameThread(const std::string &threadId) { +void ShellWidget::Impl::renameThread(const std::string &threadId) { const ThreadPresentation *thread = model.thread(threadId); if (!thread) return; bool accepted = false; const QString name = - QInputDialog::getText(this, QStringLiteral("Rename thread"), + QInputDialog::getText(owner, QStringLiteral("Rename thread"), QStringLiteral("Name"), QLineEdit::Normal, text(thread->title), &accepted) .trimmed(); @@ -3142,23 +940,24 @@ void ShellWidget::renameThread(const std::string &threadId) { session.renameThread(threadId, name.toStdString()); } -void ShellWidget::forkThread(const std::string &threadId) { +void ShellWidget::Impl::forkThread(const std::string &threadId) { if (threadId.empty()) return; - session.forkThread( - threadId, nlohmann::json::object(), [this](const nlohmann::json &result) { - if (!result.value("ok", false)) - return; - const std::string threadId = - stringValue(result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); - if (!threadId.empty()) - selectThread(threadId); - }); + const auto token = alive; + session.forkThread(threadId, nlohmann::json::object(), + [this, token](const nlohmann::json &result) { + if (!*token || !result.value("ok", false)) + return; + const std::string id = stringValue( + result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (!id.empty()) + selectThread(id); + }); } -void ShellWidget::toggleThreadArchive(const std::string &threadId) { +void ShellWidget::Impl::toggleThreadArchive(const std::string &threadId) { const ThreadPresentation *thread = model.thread(threadId); if (!thread) return; @@ -3168,368 +967,401 @@ void ShellWidget::toggleThreadArchive(const std::string &threadId) { session.archiveThread(threadId); } -void ShellWidget::deleteThread(const std::string &threadId) { +void ShellWidget::Impl::deleteThread(const std::string &threadId) { if (threadId.empty()) return; - if (QMessageBox::question(this, QStringLiteral("Delete thread"), + if (QMessageBox::question(owner, QStringLiteral("Delete thread"), QStringLiteral("Delete the selected thread?"), QMessageBox::Yes | QMessageBox::Cancel, - QMessageBox::Cancel) == QMessageBox::Yes) { + QMessageBox::Cancel) == QMessageBox::Yes) session.deleteThread(threadId); - } } -void ShellWidget::submitPrompt() { - const QString promptValue = promptEditor->toPlainText().trimmed(); - if (promptValue.isEmpty()) - return; - const std::string visibleThreadId = visiblySelectedThreadId(); - if (!visibleThreadId.empty() && visibleThreadId != selectedThreadId) { - if (!model.thread(visibleThreadId)) { +bool ShellWidget::Impl::submitPrompt(QString prompt, + std::vector attachments) { + prompt = prompt.trimmed(); + if (prompt.isEmpty()) + return false; + const std::string visiblySelected = + middleRegion->threads().visiblySelectedThreadId(); + if (!visiblySelected.empty() && visiblySelected != selectedThreadId) { + if (!model.thread(visiblySelected)) { showNotice(QStringLiteral("The visibly selected thread is no longer " "available. Your message was not sent.")); - return; + return false; } - selectThread(visibleThreadId); + selectThread(visiblySelected); } - PendingPrompt submission; - submission.id = nextPendingPromptId++; - submission.admittedAtMilliseconds = QDateTime::currentMSecsSinceEpoch(); - submission.prompt = promptValue; - submission.attachments = attachmentDrafts; - submission.turnOptions = turnSettings->turnStartOptions(); - - if (!selectedThreadId.empty()) { - submission.knownUserMessageIds = - materializedUserMessageIds(selectedThreadId); - const std::string destination = selectedThreadId; - pendingPrompts[destination].push_back(std::move(submission)); - resetComposer(); - conversationSmoothFollowRequested = true; - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - dispatchNextPrompt(destination); - return; + std::string destination = selectedThreadId; + const ThreadPresentation *thread = model.thread(destination); + if (destination.empty()) { + if (!newThreadIntent) { + showNotice(QStringLiteral("No destination thread is selected. Your " + "message was not sent; select a thread or use " + "New thread.")); + middleRegion->composer().promptEditor()->setFocus(); + return false; + } + destination = DraftThreadId; + thread = nullptr; } - if (!localNewThreadIntent) { - showNotice(QStringLiteral("No destination thread is selected. Your " - "message was not sent; select a thread or use " - "New thread.")); - promptEditor->setFocus(); - return; + + if (destination != DraftThreadId) { + const auto state = hydration.find(destination); + if (state != hydration.end() && state->second == Hydration::Failed) { + showNotice(QStringLiteral("Thread loading failed. Reload the thread " + "before sending; your message was not sent.")); + middleRegion->composer().promptEditor()->setFocus(); + return false; + } } - newThreadPendingPrompts.push_back(std::move(submission)); - resetComposer(); - conversationSmoothFollowRequested = true; - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - startThreadForPendingPrompts(); + + const auto activeTurn = destination == DraftThreadId + ? std::optional{} + : model.activeTurnId(destination); + const std::uint64_t submissionId = + prompts.admit(destination, prompt, std::move(attachments), + middleRegion->composer().turnSettings()->turnStartOptions(), + thread, activeTurn, QDateTime::currentMSecsSinceEpoch()); + static_cast(submissionId); + + // Admission is a synchronous UI fact. Transport dispatch is queued below so + // this awaiting projection is committed without forcing paint reentrancy. + middleRegion->conversation().prepareForLocalPromptAdmission(); + renderConversation(); + + if (destination == DraftThreadId) + startThreadForDraft(); + else + dispatchNextPrompt(destination); + return true; } -void ShellWidget::startThreadForPendingPrompts() { - if (newThreadCreationInFlight || newThreadPendingPrompts.empty()) +void ShellWidget::Impl::startThreadForDraft() { + if (newThreadCreationInFlight || prompts.submissions(DraftThreadId).empty()) return; newThreadCreationInFlight = true; - nlohmann::json threadOptions = turnSettings->threadStartOptions(); - threadOptions.update(newThreadDraftOptions); - threadOptions["cwd"] = - turnSettings->workspace(QDir::currentPath().toStdString()); - const QString requestedName = newThreadDraftName; - session.createThread( - std::move(threadOptions), - [this, requestedName](const nlohmann::json &result) { - newThreadCreationInFlight = false; - if (!result.value("ok", false)) { - const nlohmann::json error = - result.value("error", nlohmann::json::object()); - const std::string message = safeMessage(error); - const QString displayedError = - text(message.empty() ? std::string("Thread creation failed") - : message); - for (PendingPrompt &pending : newThreadPendingPrompts) { - if (pending.status == PendingPromptStatus::Awaiting) { - pending.status = PendingPromptStatus::Failed; - pending.error = displayedError; - } - } - showNotice(text(message.empty() - ? std::string("Thread creation failed") - : message)); - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - return; - } - const std::string threadId = - stringValue(result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); - if (threadId.empty()) { - for (PendingPrompt &pending : newThreadPendingPrompts) { - if (pending.status == PendingPromptStatus::Awaiting) { - pending.status = PendingPromptStatus::Failed; - pending.error = QStringLiteral( - "Thread creation returned no thread identifier"); - } - } - showNotice(QStringLiteral("Thread creation returned no thread.")); - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - return; - } - auto &threadPrompts = pendingPrompts[threadId]; - threadPrompts.insert( - threadPrompts.end(), - std::make_move_iterator(newThreadPendingPrompts.begin()), - std::make_move_iterator(newThreadPendingPrompts.end())); - newThreadPendingPrompts.clear(); - threadHydration[threadId] = ThreadHydrationState::Hydrated; - operationReadyThreads.insert(threadId); - const bool viewingNewThreadDraft = - selectedThreadId.empty() && localNewThreadIntent; - if (viewingNewThreadDraft) { - selectedThreadId = threadId; - localNewThreadIntent = false; - } - newThreadDraftOptions = nlohmann::json::object(); - newThreadDraftName.clear(); - newThreadDraftWorkspace.clear(); - conversationItemLimit = 80; - conversationRebuildPending = true; - if (!requestedName.isEmpty()) - session.renameThread(threadId, requestedName.toStdString()); - scheduleRefresh(); - QTimer::singleShot(0, this, - [this, threadId] { dispatchNextPrompt(threadId); }); - }); + nlohmann::json options = + middleRegion->composer().turnSettings()->threadStartOptions(); + options.update(newThreadOptions); + options["cwd"] = middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString()); + const QString requestedName = newThreadName; + const auto token = alive; + session.createThread(std::move(options), [this, token, requestedName]( + const nlohmann::json &result) { + if (!*token) + return; + newThreadCreationInFlight = false; + if (!result.value("ok", false)) { + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const QString error = text( + message.empty() ? std::string("Thread creation failed") : message); + const auto pending = prompts.submissions(DraftThreadId); + std::vector ids; + for (const auto &submission : pending) + ids.push_back(submission.id); + for (const std::uint64_t id : ids) + static_cast(prompts.fail(DraftThreadId, id, error)); + showNotice(error); + render(); + return; + } + const std::string threadId = + stringValue(result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (threadId.empty()) { + const QString error = + QStringLiteral("Thread creation returned no thread identifier"); + const auto pending = prompts.submissions(DraftThreadId); + std::vector ids; + for (const auto &submission : pending) + ids.push_back(submission.id); + for (const std::uint64_t id : ids) + static_cast(prompts.fail(DraftThreadId, id, error)); + showNotice(error); + render(); + return; + } + + if (!prompts.reassignThread(DraftThreadId, threadId)) { + showNotice(QStringLiteral("Could not attach the draft prompts to " + "the created thread.")); + render(); + return; + } + hydration[threadId] = Hydration::Hydrated; + operationReadyThreads.insert(threadId); + const bool viewingDraft = selectedThreadId.empty() && newThreadIntent; + if (viewingDraft) { + selectedThreadId = threadId; + newThreadIntent = false; + } + newThreadOptions = nlohmann::json::object(); + newThreadName.clear(); + newThreadWorkspace.clear(); + settingsSnapshot.clear(); + if (!requestedName.isEmpty()) + session.renameThread(threadId, requestedName.toStdString()); + render(); + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); + }); } -void ShellWidget::dispatchNextPrompt(const std::string &threadId) { - const auto prompts = pendingPrompts.find(threadId); - if (prompts == pendingPrompts.end()) +void ShellWidget::Impl::dispatchNextPrompt(const std::string &threadId) { + if (threadId.empty() || !model.connection().connected) + return; + const auto submissions = prompts.submissions(threadId); + if (std::ranges::none_of( + submissions, [](const middle::PromptSubmission &submission) { + return submission.state == middle::PromptState::Queued; + })) + return; + if (resumeInFlightThreads.contains(threadId)) return; if (!threadIsHydrated(threadId)) { ensureThreadHydrated(threadId); return; } - if (std::any_of(prompts->second.begin(), prompts->second.end(), - [](const PendingPrompt &candidate) { - return candidate.status == PendingPromptStatus::Awaiting && - candidate.dispatched; - })) + if (prompts.hasInFlight(threadId)) + return; + if (threadRequiresResume(threadId)) { + resumePromptQueue(threadId); return; - const auto next = - std::find_if(prompts->second.begin(), prompts->second.end(), - [](const PendingPrompt &candidate) { - return candidate.status == PendingPromptStatus::Awaiting && - !candidate.dispatched; - }); - if (next == prompts->second.end()) + } + if (!dispatchScheduledThreads.insert(threadId).second) return; - next->dispatched = true; - submitPromptToThread(threadId, next->id, next->prompt.toStdString(), - next->turnOptions, next->attachments); + + // The admitted card already presents the awaiting state. Queueing transport + // gives Qt one normal paint turn, then samples start-versus-steer at the + // actual send boundary without a forced repaint or reentrant event drain. + const std::uint64_t generation = observedConnectionGeneration; + QTimer::singleShot(0, owner, [this, threadId, generation] { + dispatchScheduledThreads.erase(threadId); + if (observedConnectionGeneration != generation) + return; + if (!model.connection().connected || + resumeInFlightThreads.contains(threadId)) + return; + if (!threadIsHydrated(threadId) || threadRequiresResume(threadId)) { + dispatchNextPrompt(threadId); + return; + } + const auto dispatch = + prompts.beginNext(threadId, model.activeTurnId(threadId)); + if (dispatch) + dispatchPrompt(*dispatch); + }); } -void ShellWidget::submitPromptToThread( - std::string threadId, std::uint64_t submissionId, std::string prompt, - nlohmann::json options, std::vector attachments) { +void ShellWidget::Impl::dispatchPrompt(middle::PromptDispatch dispatch) { nlohmann::json input = nlohmann::json::array({{{"type", "text"}, - {"text", std::move(prompt)}, + {"text", dispatch.prompt.toStdString()}, {"text_elements", nlohmann::json::array()}}}); - for (const AttachmentDraft &attachment : attachments) { - if (attachment.mimeType.startsWith(QStringLiteral("image/"))) { + for (const AttachmentDraft &attachment : dispatch.attachments) { + if (attachment.mimeType.startsWith(QStringLiteral("image/"))) input.push_back( {{"type", "localImage"}, {"path", attachment.path.toStdString()}}); - } else if (attachment.mimeType.startsWith(QStringLiteral("audio/"))) { + else if (attachment.mimeType.startsWith(QStringLiteral("audio/"))) input.push_back( {{"type", "localAudio"}, {"path", attachment.path.toStdString()}}); - } else { + else input.push_back({{"type", "mention"}, {"name", attachment.name.toStdString()}, {"path", attachment.path.toStdString()}}); - } } - const auto completed = [this, threadId, - submissionId](const nlohmann::json &result) { - if (attemptPromptThreadRecovery(threadId, submissionId, result)) - return; - completePromptSubmission(threadId, submissionId, result); - }; - auto sendPrompt = std::make_shared>(); - *sendPrompt = [this, threadId, input = std::move(input), - options = std::move(options), completed]() mutable { - const auto activeTurn = model.activeTurnId(threadId); - if (activeTurn) { - session.steerTurn(threadId, *activeTurn, std::move(input), completed); - } else { - session.startTurn(threadId, std::move(input), std::move(options), - completed); - } + + const std::string threadId = dispatch.threadId; + const std::uint64_t submissionId = dispatch.id; + const auto token = alive; + auto completed = [this, token, threadId, + submissionId](const nlohmann::json &result) { + if (*token) + completePrompt(threadId, submissionId, result); }; - if (threadRequiresResume(threadId)) { - session.resumeThread( - threadId, nlohmann::json::object(), - [this, threadId, sendPrompt, - completed](const nlohmann::json &result) mutable { - if (!result.value("ok", false)) { - completed(result); - return; - } - QTimer::singleShot(0, this, [this, threadId, sendPrompt] { - threadHydration[threadId] = ThreadHydrationState::Hydrated; - operationReadyThreads.insert(threadId); - (*sendPrompt)(); - }); - }); + if (dispatch.expectedTurnId) { + session.request("turn.steer", + {{"threadId", dispatch.threadId}, + {"expectedTurnId", *dispatch.expectedTurnId}, + {"clientUserMessageId", dispatch.clientUserMessageId}, + {"input", std::move(input)}}, + std::move(completed)); + } else { + dispatch.turnOptions["clientUserMessageId"] = dispatch.clientUserMessageId; + session.startTurn(dispatch.threadId, std::move(input), + std::move(dispatch.turnOptions), std::move(completed)); + } +} + +void ShellWidget::Impl::resumePromptQueue(const std::string &threadId) { + if (!resumeInFlightThreads.insert(threadId).second) + return; + const auto token = alive; + session.resumeThread( + threadId, nlohmann::json::object(), + [this, token, threadId](const nlohmann::json &result) { + if (!*token) + return; + resumeInFlightThreads.erase(threadId); + if (!result.value("ok", false)) { + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const QString displayed = text( + message.empty() ? std::string("Thread resume failed") : message); + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + render(); + return; + } + hydration[threadId] = Hydration::Hydrated; + operationReadyThreads.insert(threadId); + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); + }); +} + +void ShellWidget::Impl::completePrompt(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result) { + if (attemptThreadRecovery(threadId, submissionId, result)) + return; + promptRecoveryAttempted.erase(recoveryKey(threadId, submissionId)); + if (result.value("ok", false)) { + operationReadyThreads.insert(threadId); + static_cast(prompts.acknowledge(threadId, submissionId, + resultTurnId(result), + QDateTime::currentMSecsSinceEpoch())); + if (const ThreadPresentation *thread = model.thread(threadId)) + prompts.reconcile(threadId, *thread); + scheduleAcceptedTransition(threadId, submissionId); } else { - (*sendPrompt)(); + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const QString displayed = + text(message.empty() ? std::string("Submission failed") : message); + static_cast(prompts.fail(threadId, submissionId, displayed)); + showNotice(text(message.empty() ? std::string("Turn submission failed") + : message)); } + render(); + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); } -bool ShellWidget::attemptPromptThreadRecovery(const std::string &threadId, +bool ShellWidget::Impl::attemptThreadRecovery(const std::string &threadId, std::uint64_t submissionId, const nlohmann::json &result) { if (!isThreadNotFoundResult(result)) return false; - const auto prompts = pendingPrompts.find(threadId); - if (prompts == pendingPrompts.end()) + const std::string key = recoveryKey(threadId, submissionId); + if (!promptRecoveryAttempted.insert(key).second) return false; - const auto submission = - std::find_if(prompts->second.begin(), prompts->second.end(), - [submissionId](const PendingPrompt &candidate) { - return candidate.id == submissionId; - }); - if (submission == prompts->second.end() || - submission->readinessRetryAttempted) + if (!prompts.requeue(threadId, submissionId)) return false; - - submission->readinessRetryAttempted = true; - threadHydration[threadId] = ThreadHydrationState::NotHydrated; + hydration[threadId] = Hydration::NotHydrated; operationReadyThreads.erase(threadId); + render(); + resumeInFlightThreads.insert(threadId); + const auto token = alive; session.resumeThread( threadId, nlohmann::json::object(), - [this, threadId, submissionId](const nlohmann::json &resumeResult) { + [this, token, threadId](const nlohmann::json &resumeResult) { + if (!*token) + return; + resumeInFlightThreads.erase(threadId); if (!resumeResult.value("ok", false)) { - completePromptSubmission(threadId, submissionId, resumeResult); + const std::string message = safeMessage( + resumeResult.value("error", nlohmann::json::object())); + const QString displayed = + text(message.empty() ? std::string("Thread recovery failed") + : message); + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + render(); return; } - QTimer::singleShot(0, this, [this, threadId, submissionId] { - threadHydration[threadId] = ThreadHydrationState::Hydrated; - operationReadyThreads.insert(threadId); - const auto prompts = pendingPrompts.find(threadId); - if (prompts == pendingPrompts.end()) - return; - const auto submission = - std::find_if(prompts->second.begin(), prompts->second.end(), - [submissionId](const PendingPrompt &candidate) { - return candidate.id == submissionId; - }); - if (submission == prompts->second.end()) - return; - submission->dispatched = false; - dispatchNextPrompt(threadId); - }); + hydration[threadId] = Hydration::Hydrated; + operationReadyThreads.insert(threadId); + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); }); return true; } -void ShellWidget::chooseAttachments() { - const QString initialDirectory = - text(turnSettings->workspace(QDir::currentPath().toStdString())); - FileSelectionDialog dialog(FileSelectionDialog::Mode::Attachments, - initialDirectory, attachmentDrafts, this); - if (dialog.exec() != QDialog::Accepted) +void ShellWidget::Impl::scheduleAcceptedTransition(const std::string &threadId, + std::uint64_t submissionId) { + const middle::PromptSubmission *submission = + prompts.submission(threadId, submissionId); + if (!submission || submission->state != middle::PromptState::Accepted) return; - attachmentDrafts = dialog.selectedAttachments(); - ++attachmentRevision; - refreshAttachments(); + const qint64 elapsed = + QDateTime::currentMSecsSinceEpoch() - submission->acceptedAtMilliseconds; + const int remaining = static_cast(std::max( + 1, middle::AcknowledgementTransitionMilliseconds - elapsed)); + QTimer::singleShot( + remaining, Qt::PreciseTimer, owner, [this, threadId, submissionId] { + const middle::PromptSubmission *current = + prompts.submission(threadId, submissionId); + if (!current || current->state != middle::PromptState::Accepted) + return; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (current->acceptedTransitionActive(now)) { + scheduleAcceptedTransition(threadId, submissionId); + return; + } + if (const ThreadPresentation *thread = model.thread(threadId)) + prompts.reconcile(threadId, *thread); + prompts.compactResolved(threadId, now); + render(); + }); } -void ShellWidget::refreshAttachments() { - const bool hasAttachments = !attachmentDrafts.empty(); - attachmentPanel->setVisible(hasAttachments); - clearLayout(attachmentListLayout); - if (!hasAttachments) { - attachmentListScroll->setFixedHeight(0); - return; - } - constexpr int AttachmentRowHeight = 28; - constexpr int MaximumVisibleAttachments = 4; - for (std::size_t index = 0; index < attachmentDrafts.size(); ++index) { - const AttachmentDraft &attachment = attachmentDrafts[index]; - auto *row = new QWidget; - row->setFixedHeight(AttachmentRowHeight); - auto *rowLayout = new QHBoxLayout(row); - rowLayout->setContentsMargins(0, 2, 0, 2); - rowLayout->setSpacing(5); - auto *remove = new QPushButton(QStringLiteral("X")); - remove->setAccessibleName(QStringLiteral("Remove %1").arg(attachment.name)); - remove->setToolTip(QStringLiteral("Remove attachment")); - remove->setFixedSize(18, 18); - remove->setProperty("kind", "destructiveCompact"); - connect(remove, &QPushButton::clicked, this, [this, index] { - attachmentDrafts.erase(attachmentDrafts.begin() + - static_cast(index)); - ++attachmentRevision; - refreshAttachments(); - }); - auto *fileBox = new QFrame; - fileBox->setObjectName(QStringLiteral("attachmentFileBox")); - fileBox->setStyleSheet( - QStringLiteral("QFrame#attachmentFileBox{background:#ffffff;" - "border:1px solid #d7dee8;border-radius:6px;}")); - auto *fileLayout = new QHBoxLayout(fileBox); - fileLayout->setContentsMargins(8, 1, 8, 1); - auto *name = makeLabel(attachment.name, "meta"); - name->setToolTip(QDir::toNativeSeparators(attachment.path)); - name->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - fileLayout->addWidget(name); - rowLayout->addWidget(fileBox, 1); - rowLayout->addWidget(remove, 0, Qt::AlignVCenter); - attachmentListLayout->addWidget(row); - } - const int visibleRows = std::min( - static_cast(attachmentDrafts.size()), MaximumVisibleAttachments); - attachmentListScroll->setFixedHeight(visibleRows * AttachmentRowHeight + - (visibleRows - 1) * 4); +void ShellWidget::Impl::chooseAttachments() { + const QString initial = + text(middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString())); + FileSelectionDialog dialog(FileSelectionDialog::Mode::Attachments, initial, + middleRegion->composer().attachments(), owner); + if (dialog.exec() == QDialog::Accepted) + middleRegion->composer().setAttachments(dialog.selectedAttachments()); } -void ShellWidget::interruptActiveTurn() { - const auto turnId = model.activeTurnId(selectedThreadId); - if (!turnId) - return; - session.interruptTurn(selectedThreadId, *turnId); +void ShellWidget::Impl::interruptTurn() { + const auto turn = model.activeTurnId(selectedThreadId); + if (turn) + session.interruptTurn(selectedThreadId, *turn); } -void ShellWidget::respondToFirstPending(bool approve) { +void ShellWidget::Impl::respondToFirstPending(bool approve) { const auto &pending = model.pendingRequestPresentations(); - const auto request = - std::find_if(pending.begin(), pending.end(), [this](const auto &entry) { - return entry.second.threadId == selectedThreadId; - }); + const auto request = std::ranges::find_if(pending, [this](const auto &entry) { + return entry.second.threadId == selectedThreadId; + }); if (request == pending.end()) return; - if (approve) reviewPending(request->first); else rejectPending(request->first); } -void ShellWidget::reviewPending(const std::string &requestKey) { +void ShellWidget::Impl::reviewPending(const std::string &requestKey) { const auto request = model.pendingRequestPresentations().find(requestKey); if (request == model.pendingRequestPresentations().end()) return; - const auto response = PendingRequestDialog::present(request->second, this); + const auto response = PendingRequestDialog::present(request->second, owner); if (!response) return; session.respondToServerRequest(nlohmann::json::parse(requestKey), response->result, response->error); } -void ShellWidget::rejectPending(const std::string &requestKey) { +void ShellWidget::Impl::rejectPending(const std::string &requestKey) { const auto request = model.pendingRequestPresentations().find(requestKey); if (request == model.pendingRequestPresentations().end()) return; @@ -3540,4 +1372,20 @@ void ShellWidget::rejectPending(const std::string &requestKey) { std::move(response.error)); } +ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) + : QWidget(parent), impl(nullptr) { + // Impl installs this widget as the application event filter. Keep the + // member in a defined null state while Impl builds child widgets: their + // construction can synchronously pass events through that filter. + impl = std::make_unique(this, session); +} + +ShellWidget::~ShellWidget() = default; + +bool ShellWidget::eventFilter(QObject *watched, QEvent *event) { + if (impl && impl->middleRegion->routeScrollEvent(watched, event)) + return true; + return QWidget::eventFilter(watched, event); +} + } // namespace codexui::codex diff --git a/src/codex/ShellWidget.h b/src/codex/ShellWidget.h index f111677..be9cdf1 100644 --- a/src/codex/ShellWidget.h +++ b/src/codex/ShellWidget.h @@ -3,277 +3,33 @@ #ifndef CODEXUI_CODEX_SHELLWIDGET_H #define CODEXUI_CODEX_SHELLWIDGET_H -#include "codex/FileSelectionDialog.h" -#include "codex/PresentationModel.h" - #include -#include -#include -#include -#include -#include -#include - -class QFrame; -class QAction; -class QEvent; -class QGridLayout; -class QLabel; -class QListWidget; -class QPlainTextEdit; -class QPushButton; -class QScrollArea; -class QSplitter; -class QTabWidget; -class QTimer; -class QToolButton; -class QVariantAnimation; -class QVBoxLayout; - -namespace codexui { -class ExpandingPromptEditor; -} +#include namespace codexui::codex { class FrontendSession; -class DiffViewer; -class ShellWidgetScrollTest; -class TurnSettingsWidget; +// Production shell backed by the middle-region implementation. +// The private implementation keeps protocol/application coordination out of +// the visual component interfaces. class ShellWidget final : public QWidget { public: explicit ShellWidget(FrontendSession &session, QWidget *parent = nullptr); + ~ShellWidget() override; + + ShellWidget(const ShellWidget &) = delete; + ShellWidget &operator=(const ShellWidget &) = delete; protected: bool eventFilter(QObject *watched, QEvent *event) override; private: - friend class ShellWidgetScrollTest; - - enum class PendingPromptStatus { Awaiting, Acknowledged, Failed }; - enum class ThreadHydrationState { NotHydrated, ReadInFlight, Hydrated }; - - struct PendingPrompt { - std::uint64_t id = 0; - QString prompt; - std::vector attachments; - nlohmann::json turnOptions = nlohmann::json::object(); - PendingPromptStatus status = PendingPromptStatus::Awaiting; - bool dispatched = false; - bool readinessRetryAttempted = false; - qint64 admittedAtMilliseconds = 0; - qint64 acknowledgedAtMilliseconds = 0; - bool completionRefreshScheduled = false; - std::string materializedIdentity; - QString error; - std::unordered_set knownUserMessageIds; - }; - - struct ConversationScrollAnchor { - QString key; - int viewportOffset = 0; - int absoluteValue = 0; - }; - - enum RefreshArea : std::uint32_t { - RefreshNone = 0, - RefreshThreads = 1U << 0U, - RefreshConversation = 1U << 1U, - RefreshInspector = 1U << 2U, - RefreshState = 1U << 3U, - RefreshProtocolStats = 1U << 4U, - RefreshTurnSettings = 1U << 5U, - RefreshStatus = 1U << 6U, - RefreshAll = (1U << 7U) - 1U, - }; - - void handleEvent(const nlohmann::json &event); - void scheduleRefresh(std::uint32_t areas = RefreshAll); - void refresh(); - void refreshThreads(); - void refreshConversation(); - void refreshConversationItems(); - [[nodiscard]] bool refreshConversationItem(const std::string &key, - const std::string &turnId, - const std::string &itemId, - bool &changed); - void refreshInspector(); - void refreshStateInspector(); - void refreshProtocolStats(); - void showProtocolTail(); - void refreshStatus(); - void refreshTurnSettings(); - [[nodiscard]] std::string visiblySelectedThreadId() const; - void addConversationTrailingSpace(); - void updateComposerDockHeight(int height); - void scheduleConversationFollowLatest(); - void scrollConversationToLatest(bool smoothly = true); - void stopConversationScrollAnimation(); - [[nodiscard]] ConversationScrollAnchor - captureConversationScrollAnchor() const; - void restoreConversationScrollAnchor(const ConversationScrollAnchor &anchor); - void scheduleConversationPausedAnchorRestore(); - void settleConversationScroll(bool followLatest, - ConversationScrollAnchor anchor, bool smoothly); - void appendProtocolFrame(const nlohmann::json &frame); - void hydrateHistoricalAgents(); - void showNotice(QString message, bool error = true); - [[nodiscard]] std::uint32_t - refreshAreasForEvent(const nlohmann::json &event) const; - [[nodiscard]] std::string - conversationItemFingerprint(const ItemPresentation &item) const; - [[nodiscard]] QString - pendingPromptAnchorKey(const std::string &threadId, - std::uint64_t submissionId) const; - void scheduleAcknowledgementCompletion(const std::string &threadId, - PendingPrompt &submission); - - void selectThread(std::string threadId); - void beginNewThread(); - void requestThreads(); - void requestModels(); - void readThread(const std::string &threadId); - void ensureThreadHydrated(const std::string &threadId); - [[nodiscard]] bool threadIsHydrated(const std::string &threadId) const; - [[nodiscard]] bool threadRequiresResume(const std::string &threadId) const; - void renameThread(const std::string &threadId); - void forkThread(const std::string &threadId); - void toggleThreadArchive(const std::string &threadId); - void deleteThread(const std::string &threadId); - void submitPrompt(); - void submitPromptToThread(std::string threadId, std::uint64_t submissionId, - std::string prompt, nlohmann::json options, - std::vector attachments); - void dispatchNextPrompt(const std::string &threadId); - void startThreadForPendingPrompts(); - void resetComposer(); - void refreshComposerEnabledState(); - void completePromptSubmission(const std::string &threadId, - std::uint64_t submissionId, - const nlohmann::json &result); - [[nodiscard]] bool attemptPromptThreadRecovery(const std::string &threadId, - std::uint64_t submissionId, - const nlohmann::json &result); - void reconcileAcknowledgedPrompts(const std::string &threadId); - [[nodiscard]] std::unordered_set - materializedUserMessageIds(const std::string &threadId) const; - void chooseAttachments(); - void refreshAttachments(); - void scheduleComposerLayout(); - void refreshComposerLayout(); - void interruptActiveTurn(); - void respondToFirstPending(bool approve); - void reviewPending(const std::string &requestKey); - void rejectPending(const std::string &requestKey); - - FrontendSession &session; - PresentationModel model; - std::string selectedThreadId; - bool localNewThreadIntent = false; - nlohmann::json newThreadDraftOptions = nlohmann::json::object(); - QString newThreadDraftName; - QString newThreadDraftWorkspace; - std::vector attachmentDrafts; - std::uint64_t attachmentRevision = 0; - std::unordered_map> pendingPrompts; - std::deque newThreadPendingPrompts; - std::unordered_map> - materializedPromptItemIds; - std::unordered_map promptAnchorKeys; - std::unordered_map threadHydration; - std::unordered_set operationReadyThreads; - std::uint64_t nextPendingPromptId = 1; - bool newThreadCreationInFlight = false; - - QLabel *controllerLabel = nullptr; - QLabel *workspaceBreadcrumb = nullptr; - QLabel *threadContextStatus = nullptr; - QLabel *agentActivityStatus = nullptr; - QLabel *conversationTitle = nullptr; - QLabel *conversationMeta = nullptr; - QLabel *emptyConversation = nullptr; - QLabel *noticeLabel = nullptr; - QFrame *connectionStatusDot = nullptr; - QFrame *noticeBar = nullptr; - QFrame *sidebar = nullptr; - QFrame *conversationRegion = nullptr; - QFrame *inspector = nullptr; - QSplitter *splitter = nullptr; - QListWidget *threadList = nullptr; - QWidget *conversationContent = nullptr; - QWidget *conversationTrailingSpace = nullptr; - QWidget *composerReserve = nullptr; - QVBoxLayout *conversationLayout = nullptr; - QScrollArea *conversationScroll = nullptr; - QTabWidget *inspectorTabs = nullptr; - QTabWidget *infoTabs = nullptr; - QWidget *planContent = nullptr; - QVBoxLayout *planLayout = nullptr; - QWidget *agentsContent = nullptr; - QVBoxLayout *agentsLayout = nullptr; - DiffViewer *diffViewer = nullptr; - QWidget *requestsContent = nullptr; - QVBoxLayout *requestsLayout = nullptr; - QLabel *protocolStats = nullptr; - QPlainTextEdit *protocolLog = nullptr; - QPlainTextEdit *stateView = nullptr; - codexui::ExpandingPromptEditor *promptEditor = nullptr; - TurnSettingsWidget *turnSettings = nullptr; - QWidget *composerBody = nullptr; - QGridLayout *composerGrid = nullptr; - QPushButton *sendButton = nullptr; - QToolButton *attachmentButton = nullptr; - QFrame *attachmentPanel = nullptr; - QScrollArea *attachmentListScroll = nullptr; - QVBoxLayout *attachmentListLayout = nullptr; - QPushButton *interruptButton = nullptr; - QPushButton *controllerButton = nullptr; - QPushButton *requestButton = nullptr; - QToolButton *connectionButton = nullptr; - QAction *connectAction = nullptr; - QAction *disconnectAction = nullptr; - QAction *reconnectAction = nullptr; - QPushButton *restoreSidebarButton = nullptr; - QPushButton *restoreInspectorButton = nullptr; - QPushButton *approveButton = nullptr; - QPushButton *denyButton = nullptr; - QTimer *refreshTimer = nullptr; - QVariantAnimation *conversationScrollAnimation = nullptr; - std::uint64_t observedPresentationSequence = 0; - std::uint32_t pendingRefreshAreas = RefreshAll; - bool composerExpanded = false; - bool composerActive = false; - bool composerLayoutRefreshPending = false; - bool conversationFollowsLatest = true; - bool conversationScrollRebuilding = false; - bool conversationScrollProgrammatic = false; - bool conversationSpacerAdjusting = false; - bool conversationFollowScrollPending = false; - bool conversationUserScrollPending = false; - bool conversationUserScrollInteraction = false; - bool conversationSmoothFollowRequested = false; - int conversationSmoothScrollFloor = 0; - ConversationScrollAnchor conversationPausedAnchor; - bool conversationPausedAnchorValid = false; - bool conversationPausedAnchorRestorePending = false; - int composerCanonicalHeight = 0; - int conversationTrailingSpaceHeight = 0; - std::uint64_t conversationSpacerRevision = 0; - std::uint64_t conversationScrollSettlementRevision = 0; - std::size_t conversationItemLimit = 80; - bool conversationRebuildPending = true; - std::unordered_map conversationCards; - std::unordered_map conversationCardFingerprints; - std::unordered_map> - commandOutputScrollStates; - std::unordered_map> - dirtyConversationItems; - std::deque protocolLines; - std::unordered_set requestedAgentThreads; + struct Impl; + std::unique_ptr impl; }; } // namespace codexui::codex -#endif // CODEXUI_CODEX_SHELLWIDGET_H +#endif diff --git a/src/codex/WorkbenchWidget.cpp b/src/codex/WorkbenchWidget.cpp deleted file mode 100644 index 45d2b5e..0000000 --- a/src/codex/WorkbenchWidget.cpp +++ /dev/null @@ -1,1001 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/WorkbenchWidget.h" - -#include "codex/FrontendSession.h" -#include "codex/ui/BrandMark.h" -#include "codex/ui/ExpandingPromptEditor.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace codexui::codex { -namespace { - -QString text(const std::string &value) { - return QString::fromUtf8(value.data(), static_cast(value.size())); -} - -std::string stringValue(const nlohmann::json &object, const char *key) { - if (!object.is_object()) - return {}; - const auto iterator = object.find(key); - return iterator != object.end() && iterator->is_string() - ? iterator->get() - : std::string{}; -} - -QString displayStatus(const std::string &status) { - if (status == "inProgress" || status == "active") - return QStringLiteral("Running"); - if (status == "completed" || status == "idle") - return QStringLiteral("Completed"); - if (status == "failed" || status == "systemError") - return QStringLiteral("Failed"); - if (status.empty()) - return QStringLiteral("Unknown"); - return text(status); -} - -QLabel *makeLabel(QString value, const char *kind = "body") { - auto *label = new QLabel(std::move(value)); - label->setProperty("kind", kind); - label->setWordWrap(true); - label->setMinimumWidth(0); - label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - label->setTextInteractionFlags(Qt::TextSelectableByMouse); - return label; -} - -void clearLayout(QLayout *layout) { - while (QLayoutItem *item = layout->takeAt(0)) { - if (QWidget *widget = item->widget()) - widget->deleteLater(); - if (QLayout *child = item->layout()) { - clearLayout(child); - delete child; - } - delete item; - } -} - -QString joinedStrings(const nlohmann::json &value) { - if (!value.is_array()) - return {}; - QStringList result; - for (const auto &item : value) { - if (item.is_string()) - result.push_back(text(item.get())); - } - return result.join(QStringLiteral(", ")); -} - -QString messageText(const nlohmann::json &item) { - const std::string type = stringValue(item, "type"); - if (type == "agentMessage" || type == "plan") - return text(stringValue(item, "text")); - if (type == "userMessage") { - QStringList parts; - const nlohmann::json content = - item.value("content", nlohmann::json::array()); - if (content.is_array()) { - for (const auto &entry : content) { - const std::string value = stringValue(entry, "text"); - if (!value.empty()) - parts.push_back(text(value)); - } - } - return parts.join(QStringLiteral("\n")); - } - return {}; -} - -QFrame *itemFrame(const ItemPresentation &presentation) { - const nlohmann::json &item = presentation.raw; - const std::string typeName = stringValue(item, "type"); - auto *frame = new QFrame; - frame->setProperty("kind", "raised"); - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(6); - - QString title; - if (typeName == "userMessage") - title = QStringLiteral("You"); - else if (typeName == "agentMessage") - title = stringValue(item, "phase") == "final_answer" - ? QStringLiteral("Codex") - : QStringLiteral("Codex activity"); - else if (typeName == "commandExecution") - title = QStringLiteral("Command execution"); - else if (typeName == "collabAgentToolCall" || typeName == "subAgentActivity") - title = QStringLiteral("Agent activity"); - else if (typeName == "reasoning") - title = QStringLiteral("Reasoning"); - else if (typeName == "fileChange") - title = QStringLiteral("File changes"); - else - title = text(typeName.empty() ? std::string("Activity") : typeName); - layout->addWidget(makeLabel(title, "title")); - - const QString body = messageText(item); - if (!body.isEmpty()) - layout->addWidget(makeLabel(body)); - - if (typeName == "commandExecution") { - const QString command = text(stringValue(item, "command")); - if (!command.isEmpty()) { - auto *commandView = new QPlainTextEdit(command); - commandView->setReadOnly(true); - commandView->setMaximumHeight(90); - commandView->setLineWrapMode(QPlainTextEdit::WidgetWidth); - commandView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - commandView->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - commandView->setProperty("kind", "command"); - layout->addWidget(commandView); - } - const QString output = text(stringValue(item, "aggregatedOutput")); - if (!output.trimmed().isEmpty()) { - auto *outputView = new QPlainTextEdit(output); - outputView->setReadOnly(true); - outputView->setMaximumHeight(220); - outputView->setLineWrapMode(QPlainTextEdit::WidgetWidth); - outputView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - outputView->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - layout->addWidget(outputView); - } - QStringList metadata; - metadata << displayStatus(stringValue(item, "status")); - if (item.contains("exitCode") && item["exitCode"].is_number_integer()) - metadata << QStringLiteral("exit %1").arg(item["exitCode"].get()); - const QString cwd = text(stringValue(item, "cwd")); - if (!cwd.isEmpty()) - metadata << cwd; - layout->addWidget( - makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); - } else if (typeName == "collabAgentToolCall") { - QStringList metadata; - metadata << text(stringValue(item, "tool")); - metadata << displayStatus(stringValue(item, "status")); - const QString receivers = - joinedStrings(item.value("receiverThreadIds", nlohmann::json::array())); - if (!receivers.isEmpty()) - metadata << receivers; - layout->addWidget( - makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); - const QString prompt = text(stringValue(item, "prompt")); - if (!prompt.isEmpty()) - layout->addWidget(makeLabel(prompt)); - } else if (typeName == "reasoning") { - const QString summaries = - joinedStrings(item.value("summary", nlohmann::json::array())); - if (!summaries.isEmpty()) - layout->addWidget(makeLabel(summaries)); - } else if (body.isEmpty()) { - layout->addWidget(makeLabel(text(item.dump(2)), "meta")); - } - return frame; -} - -QFrame *agentFrame(const AgentPresentation &agent) { - const nlohmann::json &activity = agent.raw; - auto *frame = new QFrame; - frame->setProperty("kind", "raised"); - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(6); - - const std::string tool = stringValue(activity, "tool"); - const bool childAgent = !agent.childThreadId.empty(); - const QString title = childAgent ? QStringLiteral("Subagent") - : tool.empty() - ? QStringLiteral("Agent activity") - : QStringLiteral("Agent %1").arg(text(tool)); - layout->addWidget(makeLabel(title, "title")); - - QStringList metadata; - metadata << displayStatus(agent.status); - const QString path = text(stringValue(activity, "agentPath")); - if (!path.isEmpty()) - metadata << path; - if (!tool.empty()) - metadata << text(tool); - const QString model = text(stringValue(activity, "model")); - if (!model.isEmpty()) - metadata << model; - const QString effort = text(stringValue(activity, "reasoningEffort")); - if (!effort.isEmpty()) - metadata << effort; - layout->addWidget(makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); - - const QString prompt = text(stringValue(activity, "prompt")); - if (!prompt.isEmpty()) - layout->addWidget(makeLabel(prompt)); - - const QString result = text(stringValue(activity, "resultText")); - if (!result.isEmpty()) - layout->addWidget(makeLabel(result)); - - QStringList identities; - if (!agent.childThreadId.empty()) - identities << QStringLiteral("thread %1").arg(text(agent.childThreadId)); - const QString sender = text(stringValue(activity, "senderThreadId")); - if (!sender.isEmpty()) - identities << QStringLiteral("sender %1").arg(sender); - const QString receivers = joinedStrings( - activity.value("receiverThreadIds", nlohmann::json::array())); - if (!receivers.isEmpty()) - identities << QStringLiteral("receivers %1").arg(receivers); - if (!identities.isEmpty()) - layout->addWidget( - makeLabel(identities.join(QStringLiteral(" | ")), "meta")); - return frame; -} - -} // namespace - -WorkbenchWidget::WorkbenchWidget(FrontendSession &session, QWidget *parent) - : QWidget(parent), session(session) { - setObjectName(QStringLiteral("workbench")); - auto *root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(0); - - auto *top = new QFrame; - top->setProperty("kind", "panel"); - top->setFixedHeight(64); - auto *topLayout = new QHBoxLayout(top); - topLayout->setContentsMargins(18, 0, 18, 0); - topLayout->addWidget(codexui::BrandMark::createLockup()); - topLayout->addStretch(); - attentionLabel = makeLabel({}, "attentionSection"); - connectionLabel = makeLabel(QStringLiteral("Disconnected"), "meta"); - controllerLabel = makeLabel(QStringLiteral("No role"), "meta"); - controllerButton = new QPushButton(QStringLiteral("Claim control")); - auto *reconnectButton = new QPushButton(QStringLiteral("Reconnect")); - connect(controllerButton, &QPushButton::clicked, this, [this] { - if (model.connection().role == "controller") - this->session.releaseController(); - else - this->session.claimController(); - }); - connect(reconnectButton, &QPushButton::clicked, this, - [this] { this->session.reconnect(); }); - topLayout->addWidget(attentionLabel); - topLayout->addWidget(connectionLabel); - topLayout->addWidget(controllerLabel); - topLayout->addWidget(controllerButton); - topLayout->addWidget(reconnectButton); - root->addWidget(top); - - auto *splitter = new QSplitter; - splitter->setChildrenCollapsible(false); - - auto *sidebar = new QFrame; - sidebar->setProperty("kind", "panel"); - sidebar->setMinimumWidth(230); - sidebar->setMaximumWidth(390); - auto *sidebarLayout = new QVBoxLayout(sidebar); - sidebarLayout->setContentsMargins(10, 10, 10, 10); - auto *sidebarHeader = new QHBoxLayout; - sidebarHeader->addWidget(makeLabel(QStringLiteral("Threads"), "section")); - sidebarHeader->addStretch(); - auto *refreshButton = new QPushButton(QStringLiteral("Refresh")); - auto *newButton = new QPushButton(QStringLiteral("New")); - threadActionsButton = new QToolButton; - threadActionsButton->setText(QStringLiteral("More")); - threadActionsButton->setPopupMode(QToolButton::InstantPopup); - auto *threadActions = new QMenu(threadActionsButton); - threadActions->addAction(QStringLiteral("Reload"), this, - [this] { readSelectedThread(); }); - threadActions->addAction(QStringLiteral("Rename"), this, - [this] { renameSelectedThread(); }); - threadActions->addAction(QStringLiteral("Fork"), this, - [this] { forkSelectedThread(); }); - threadActions->addAction(QStringLiteral("Archive / unarchive"), this, - [this] { toggleSelectedThreadArchive(); }); - threadActions->addSeparator(); - threadActions->addAction(QStringLiteral("Delete"), this, - [this] { deleteSelectedThread(); }); - threadActionsButton->setMenu(threadActions); - sidebarHeader->addWidget(refreshButton); - sidebarHeader->addWidget(newButton); - sidebarHeader->addWidget(threadActionsButton); - sidebarLayout->addLayout(sidebarHeader); - threadList = new QListWidget; - threadList->setSelectionMode(QAbstractItemView::SingleSelection); - threadList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - threadList->setTextElideMode(Qt::ElideRight); - sidebarLayout->addWidget(threadList); - connect(refreshButton, &QPushButton::clicked, this, - [this] { requestThreads(); }); - connect(newButton, &QPushButton::clicked, this, [this] { beginNewThread(); }); - connect(threadList, &QListWidget::itemClicked, this, - [this](QListWidgetItem *item) { - selectThread(item->data(Qt::UserRole).toString().toStdString()); - }); - splitter->addWidget(sidebar); - - auto *center = new QFrame; - center->setProperty("kind", "panel"); - auto *centerLayout = new QVBoxLayout(center); - centerLayout->setContentsMargins(16, 12, 16, 12); - centerLayout->setSpacing(8); - conversationTitle = makeLabel(QStringLiteral("Select a thread"), "heading"); - conversationMeta = makeLabel({}, "meta"); - centerLayout->addWidget(conversationTitle); - centerLayout->addWidget(conversationMeta); - - conversationScroll = new QScrollArea; - conversationScroll->setWidgetResizable(true); - conversationScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - conversationContent = new QWidget; - conversationContent->setMinimumWidth(0); - conversationContent->setSizePolicy(QSizePolicy::Ignored, - QSizePolicy::Preferred); - conversationLayout = new QVBoxLayout(conversationContent); - conversationLayout->setContentsMargins(4, 6, 4, 6); - conversationLayout->setSpacing(8); - emptyConversation = - makeLabel(QStringLiteral("Conversation activity appears here."), "muted"); - conversationLayout->addWidget(emptyConversation); - conversationLayout->addStretch(); - conversationScroll->setWidget(conversationContent); - centerLayout->addWidget(conversationScroll, 1); - - auto *attention = new QFrame; - attention->setProperty("kind", "orangeBadge"); - auto *attentionLayout = new QHBoxLayout(attention); - attentionLayout->setContentsMargins(10, 6, 10, 6); - attentionLayout->addWidget(makeLabel( - QStringLiteral("A Codex request needs attention"), "attentionSection")); - attentionLayout->addStretch(); - approveButton = new QPushButton(QStringLiteral("Approve")); - denyButton = new QPushButton(QStringLiteral("Deny")); - attentionLayout->addWidget(denyButton); - attentionLayout->addWidget(approveButton); - connect(approveButton, &QPushButton::clicked, this, - [this] { respondToFirstPending(true); }); - connect(denyButton, &QPushButton::clicked, this, - [this] { respondToFirstPending(false); }); - centerLayout->addWidget(attention); - - auto *composer = new QFrame; - composer->setProperty("kind", "composer"); - auto *composerLayout = new QHBoxLayout(composer); - composerLayout->setContentsMargins(10, 8, 8, 8); - promptEditor = new codexui::ExpandingPromptEditor; - sendButton = new QPushButton(QStringLiteral("Send")); - sendButton->setProperty("kind", "primary"); - interruptButton = new QPushButton(QStringLiteral("Stop")); - interruptButton->setProperty("kind", "stop"); - composerLayout->addWidget(promptEditor, 1); - composerLayout->addWidget(interruptButton); - composerLayout->addWidget(sendButton); - centerLayout->addWidget(composer); - connect(sendButton, &QPushButton::clicked, this, [this] { submitPrompt(); }); - connect(promptEditor, &codexui::ExpandingPromptEditor::submitRequested, this, - [this] { submitPrompt(); }); - connect(interruptButton, &QPushButton::clicked, this, - [this] { interruptActiveTurn(); }); - splitter->addWidget(center); - - inspectorTabs = new QTabWidget; - inspectorTabs->setMinimumWidth(260); - inspectorTabs->setMaximumWidth(430); - planContent = new QWidget; - planLayout = new QVBoxLayout(planContent); - planLayout->setContentsMargins(12, 12, 12, 12); - planLayout->setSpacing(8); - agentsContent = new QWidget; - agentsLayout = new QVBoxLayout(agentsContent); - agentsLayout->setContentsMargins(12, 12, 12, 12); - agentsLayout->setSpacing(8); - requestsContent = new QWidget; - requestsLayout = new QVBoxLayout(requestsContent); - requestsLayout->setContentsMargins(12, 12, 12, 12); - requestsLayout->setSpacing(8); - auto *planScroll = new QScrollArea; - planScroll->setWidgetResizable(true); - planScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - planScroll->setWidget(planContent); - auto *agentsScroll = new QScrollArea; - agentsScroll->setWidgetResizable(true); - agentsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - agentsScroll->setWidget(agentsContent); - auto *requestsScroll = new QScrollArea; - requestsScroll->setWidgetResizable(true); - requestsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - requestsScroll->setWidget(requestsContent); - auto *protocolContent = new QWidget; - auto *protocolLayout = new QVBoxLayout(protocolContent); - protocolLayout->setContentsMargins(8, 8, 8, 8); - protocolLayout->setSpacing(6); - protocolStats = makeLabel({}, "meta"); - protocolLog = new QPlainTextEdit; - protocolLog->setProperty("kind", "code"); - protocolLog->setReadOnly(true); - protocolLog->setLineWrapMode(QPlainTextEdit::WidgetWidth); - protocolLog->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - protocolLog->document()->setMaximumBlockCount(2000); - protocolLayout->addWidget(protocolStats); - protocolLayout->addWidget(protocolLog, 1); - auto *stateContent = new QWidget; - auto *stateLayout = new QVBoxLayout(stateContent); - stateLayout->setContentsMargins(8, 8, 8, 8); - stateView = new QPlainTextEdit; - stateView->setProperty("kind", "code"); - stateView->setReadOnly(true); - stateView->setLineWrapMode(QPlainTextEdit::WidgetWidth); - stateView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - stateLayout->addWidget(stateView); - inspectorTabs->addTab(planScroll, QStringLiteral("Plan")); - inspectorTabs->addTab(agentsScroll, QStringLiteral("Agents")); - inspectorTabs->addTab(requestsScroll, QStringLiteral("Requests")); - inspectorTabs->addTab(stateContent, QStringLiteral("State")); - inspectorTabs->addTab(protocolContent, QStringLiteral("Protocol")); - connect(inspectorTabs, &QTabWidget::currentChanged, this, [this](int index) { - if (index == 3) - requestEnvironment(); - }); - splitter->addWidget(inspectorTabs); - splitter->setSizes({270, 900, 320}); - root->addWidget(splitter, 1); - - refreshTimer = new QTimer(this); - refreshTimer->setSingleShot(true); - refreshTimer->setInterval(16); - connect(refreshTimer, &QTimer::timeout, this, [this] { refresh(); }); - - session.setEventHandler( - [this](const nlohmann::json &event) { handleEvent(event); }); - refresh(); -} - -void WorkbenchWidget::handleEvent(const nlohmann::json &event) { - appendProtocolFrame(event); - model.applyEvent(event); - if (event.value("kind", std::string{}) == "event" && - event.value("type", std::string{}) == "connection.bridge" && - event.value("data", nlohmann::json::object()) - .value("state", std::string{}) == "opened") { - environmentRequested = false; - requestThreads(); - if (inspectorTabs->currentIndex() == 3) - requestEnvironment(); - } - - hydrateHistoricalAgents(); - - if (!selectedThreadId.empty() && !model.thread(selectedThreadId)) - selectedThreadId.clear(); - scheduleRefresh(); -} - -void WorkbenchWidget::hydrateHistoricalAgents() { - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) - return; - for (const std::string &agentId : thread->agentOrder) { - const auto agent = thread->agents.find(agentId); - if (agent == thread->agents.end() || agent->second.childThreadId.empty() || - agent->second.status != "started") - continue; - if (!requestedAgentThreads.insert(agent->second.childThreadId).second) - continue; - session.readThread(agent->second.childThreadId); - } -} - -void WorkbenchWidget::scheduleRefresh() { - if (!refreshTimer->isActive()) - refreshTimer->start(); -} - -void WorkbenchWidget::refresh() { - refreshThreads(); - refreshConversation(); - refreshInspector(); - refreshStateInspector(); - refreshProtocolStats(); - refreshStatus(); -} - -void WorkbenchWidget::refreshProtocolStats() { - std::size_t turns = 0; - std::size_t items = 0; - if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { - turns = thread->turnOrder.size(); - for (const auto &[turnId, turn] : thread->turns) { - static_cast(turnId); - items += turn.itemOrder.size(); - } - } - protocolStats->setText( - QStringLiteral("seq %1 | threads %2 | models %3 | turns %4 | " - "items %5 | pending %6 | telemetry %7") - .arg(static_cast(observedPresentationSequence)) - .arg(static_cast(model.threadOrder().size())) - .arg(static_cast(model.modelCatalog().size())) - .arg(static_cast(turns)) - .arg(static_cast(items)) - .arg(static_cast(model.pendingRequestCount())) - .arg(static_cast(model.telemetry().size()))); -} - -void WorkbenchWidget::appendProtocolFrame(const nlohmann::json &frame) { - if (!protocolLog) - return; - - const std::uint64_t sequence = frame.value("sequence", 0ULL); - if (sequence != 0) { - if (observedPresentationSequence != 0 && - sequence != observedPresentationSequence + 1) { - const QString relation = sequence <= observedPresentationSequence - ? QStringLiteral("NON-MONOTONIC") - : QStringLiteral("SEQUENCE GAP"); - protocolLog->appendPlainText( - QStringLiteral("[%1] %2 expected=%3 received=%4") - .arg(QDateTime::currentDateTime().toString( - QStringLiteral("HH:mm:ss.zzz")), - relation) - .arg(static_cast(observedPresentationSequence + 1)) - .arg(static_cast(sequence))); - } - observedPresentationSequence = - std::max(observedPresentationSequence, sequence); - } - - const std::string kind = stringValue(frame, "kind"); - const std::string subject = kind == "result" ? stringValue(frame, "action") - : stringValue(frame, "type"); - const nlohmann::json scope = frame.value("scope", nlohmann::json::object()); - QStringList parts; - parts << QStringLiteral("[%1]").arg( - QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz"))); - if (sequence != 0) - parts << QStringLiteral("#%1").arg(static_cast(sequence)); - parts << QStringLiteral("g%1").arg( - static_cast(frame.value("generation", 0ULL))); - parts << text(kind); - parts << text(subject); - parts << text(stringValue(frame, "authority")); - if (kind == "result") - parts << (frame.value("ok", false) ? QStringLiteral("ok") - : QStringLiteral("ERROR")); - for (const char *key : - {"threadId", "turnId", "itemId", "requestId", "processId"}) { - const std::string value = stringValue(scope, key); - if (!value.empty()) - parts << QStringLiteral("%1=%2").arg(QString::fromLatin1(key), - text(value)); - } - const std::string correlationId = stringValue(frame, "correlationId"); - if (!correlationId.empty()) - parts << QStringLiteral("correlation=%1").arg(text(correlationId)); - if (kind == "result" && !frame.value("ok", false)) { - const nlohmann::json error = frame.value("error", nlohmann::json::object()); - const std::string message = stringValue(error, "message"); - if (!message.empty()) - parts << text(message); - } - protocolLog->appendPlainText(parts.join(QStringLiteral(" "))); -} - -void WorkbenchWidget::refreshThreads() { - threadList->blockSignals(true); - threadList->clear(); - for (const std::string &threadId : model.threadOrder()) { - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - continue; - QString title = text(thread->title); - if (title.isEmpty()) - title = text(threadId.substr(0, 12)); - if (model.pendingRequestCount(threadId) != 0) - title.prepend(QStringLiteral("! ")); - auto *item = new QListWidgetItem(title, threadList); - if (model.pendingRequestCount(threadId) != 0) - item->setForeground(QColor(QStringLiteral("#8a5208"))); - item->setData(Qt::UserRole, text(threadId)); - item->setToolTip(text(thread->cwd)); - if (threadId == selectedThreadId) - threadList->setCurrentItem(item); - } - threadList->blockSignals(false); -} - -void WorkbenchWidget::refreshConversation() { - const int previousMaximum = - conversationScroll->verticalScrollBar()->maximum(); - const bool followLatest = - conversationScroll->verticalScrollBar()->value() >= previousMaximum - 12; - clearLayout(conversationLayout); - - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) { - conversationTitle->setText(localNewThreadIntent - ? QStringLiteral("New thread") - : QStringLiteral("Select a thread")); - conversationMeta->setText(localNewThreadIntent ? QDir::currentPath() - : QString{}); - emptyConversation = - makeLabel(localNewThreadIntent - ? QStringLiteral("Send a message to create this thread.") - : QStringLiteral("Conversation activity appears here."), - "muted"); - conversationLayout->addWidget(emptyConversation); - conversationLayout->addStretch(); - return; - } - - conversationTitle->setText(text(thread->title)); - conversationMeta->setText(text(thread->cwd) + QStringLiteral(" | ") + - displayStatus(thread->status)); - std::size_t count = 0; - for (const std::string &turnId : thread->turnOrder) { - const auto turn = thread->turns.find(turnId); - if (turn == thread->turns.end()) - continue; - for (const std::string &itemId : turn->second.itemOrder) { - const auto item = turn->second.items.find(itemId); - if (item == turn->second.items.end()) - continue; - conversationLayout->addWidget(itemFrame(item->second)); - ++count; - } - } - if (count == 0) - conversationLayout->addWidget( - makeLabel(QStringLiteral("No materialized activity."), "muted")); - conversationLayout->addStretch(); - if (followLatest) - QTimer::singleShot(0, conversationScroll, [scroll = conversationScroll] { - scroll->verticalScrollBar()->setValue( - scroll->verticalScrollBar()->maximum()); - }); -} - -void WorkbenchWidget::refreshInspector() { - clearLayout(planLayout); - clearLayout(agentsLayout); - clearLayout(requestsLayout); - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) { - planLayout->addWidget( - makeLabel(QStringLiteral("No selected thread."), "muted")); - planLayout->addStretch(); - agentsLayout->addWidget( - makeLabel(QStringLiteral("No selected thread."), "muted")); - agentsLayout->addStretch(); - requestsLayout->addWidget( - makeLabel(QStringLiteral("No selected thread."), "muted")); - requestsLayout->addStretch(); - return; - } - - const TurnPresentation *planTurn = nullptr; - for (auto turnId = thread->turnOrder.rbegin(); - turnId != thread->turnOrder.rend(); ++turnId) { - const auto turn = thread->turns.find(*turnId); - if (turn != thread->turns.end() && turn->second.plan.is_object() && - turn->second.plan.contains("steps")) { - planTurn = &turn->second; - break; - } - } - if (planTurn) { - const QString explanation = - text(stringValue(planTurn->plan, "explanation")); - if (!explanation.isEmpty()) - planLayout->addWidget(makeLabel(explanation)); - const nlohmann::json steps = - planTurn->plan.value("steps", nlohmann::json::array()); - for (const auto &step : steps) { - auto *row = new QFrame; - row->setProperty("kind", "summary"); - auto *rowLayout = new QVBoxLayout(row); - rowLayout->setContentsMargins(9, 7, 9, 7); - rowLayout->addWidget(makeLabel(text(stringValue(step, "step")))); - rowLayout->addWidget( - makeLabel(displayStatus(stringValue(step, "status")), "meta")); - planLayout->addWidget(row); - } - } else { - planLayout->addWidget( - makeLabel(QStringLiteral("No plan for this thread."), "muted")); - } - planLayout->addStretch(); - - std::size_t agentCount = 0; - for (const std::string &agentId : thread->agentOrder) { - const auto agent = thread->agents.find(agentId); - if (agent == thread->agents.end()) - continue; - agentsLayout->addWidget(agentFrame(agent->second)); - ++agentCount; - } - if (agentCount == 0) - agentsLayout->addWidget(makeLabel( - QStringLiteral("No agent activity for this thread."), "muted")); - agentsLayout->addStretch(); - - std::size_t requestCount = 0; - for (const auto &[id, request] : model.pendingRequestPresentations()) { - if (request.threadId != selectedThreadId) - continue; - auto *frame = new QFrame; - frame->setProperty("kind", "summary"); - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(9, 7, 9, 7); - layout->setSpacing(5); - layout->addWidget(makeLabel(text(request.kind), "title")); - layout->addWidget( - makeLabel(QStringLiteral("generation %1 | request %2") - .arg(static_cast(request.generation)) - .arg(text(id)), - "meta")); - layout->addWidget(makeLabel( - QStringLiteral("Review and answer this request using the action bar."), - "meta")); - requestsLayout->addWidget(frame); - ++requestCount; - } - if (requestCount == 0) - requestsLayout->addWidget(makeLabel( - QStringLiteral("No pending requests for this thread."), "muted")); - requestsLayout->addStretch(); -} - -void WorkbenchWidget::refreshStatus() { - const ConnectionPresentation &connection = model.connection(); - connectionLabel->setText(connection.connected - ? QStringLiteral("Connected") - : QStringLiteral("Disconnected")); - controllerLabel->setText(connection.role.empty() ? QStringLiteral("No role") - : text(connection.role)); - controllerButton->setText(connection.role == "controller" - ? QStringLiteral("Release control") - : QStringLiteral("Claim control")); - controllerButton->setEnabled(connection.connected); - const std::size_t pending = model.pendingRequestCount(selectedThreadId); - attentionLabel->setText( - pending == 0 - ? QString{} - : QStringLiteral("%1 pending").arg(static_cast(pending))); - approveButton->parentWidget()->setVisible(pending != 0); - const bool active = model.activeTurnId(selectedThreadId).has_value(); - interruptButton->setVisible(active); - sendButton->setText(active ? QStringLiteral("Steer") - : QStringLiteral("Send")); - sendButton->setEnabled(connection.connected && - connection.role == "controller"); - threadActionsButton->setEnabled(!selectedThreadId.empty() && - connection.connected && - connection.role == "controller"); -} - -void WorkbenchWidget::refreshStateInspector() { - if (!stateView) - return; - nlohmann::json domains = nlohmann::json::object(); - for (const auto &[name, value] : model.globalDomains()) - domains[name] = value; - - nlohmann::json pending = nlohmann::json::object(); - for (const auto &[id, request] : model.pendingRequestPresentations()) { - pending[id] = {{"category", request.kind}, - {"threadId", request.threadId}, - {"generation", request.generation}}; - } - - const nlohmann::json state{{"models", model.modelCatalog()}, - {"pendingRequests", std::move(pending)}, - {"domains", std::move(domains)}}; - stateView->setPlainText(text(state.dump(2))); -} - -void WorkbenchWidget::selectThread(std::string threadId) { - selectedThreadId = std::move(threadId); - localNewThreadIntent = false; - readSelectedThread(); - refresh(); -} - -void WorkbenchWidget::beginNewThread() { - selectedThreadId.clear(); - localNewThreadIntent = true; - threadList->clearSelection(); - promptEditor->setFocus(); - refresh(); -} - -void WorkbenchWidget::requestThreads() { session.listThreads(); } - -void WorkbenchWidget::requestModels() { session.listModels(); } - -void WorkbenchWidget::requestEnvironment() { - if (environmentRequested) - return; - environmentRequested = true; - const std::string cwd = QDir::currentPath().toStdString(); - requestModels(); - session.readModelProviderCapabilities(); - session.readAccount({{"refreshToken", false}}); - session.readAccountRateLimits(); - session.readAccountTokenUsage(); - session.readConfig({{"cwd", cwd}, {"includeLayers", true}}); - session.listPermissionProfiles({{"cwd", cwd}}); - session.listExperimentalFeatures(); - session.listSkills( - {{"cwds", nlohmann::json::array({cwd})}, {"forceReload", false}}); - session.listHooks({{"cwds", nlohmann::json::array({cwd})}}); - session.listPlugins( - {{"cwds", nlohmann::json::array({cwd})}, {"forceRefetch", false}}); - session.listApps(); - session.listMcpServers(); -} - -void WorkbenchWidget::readSelectedThread() { - if (selectedThreadId.empty()) - return; - session.readThread(selectedThreadId); -} - -void WorkbenchWidget::renameSelectedThread() { - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) - return; - bool accepted = false; - const QString name = - QInputDialog::getText(this, QStringLiteral("Rename thread"), - QStringLiteral("Name"), QLineEdit::Normal, - text(thread->title), &accepted) - .trimmed(); - if (accepted && !name.isEmpty()) - session.renameThread(selectedThreadId, name.toStdString()); -} - -void WorkbenchWidget::forkSelectedThread() { - if (selectedThreadId.empty()) - return; - session.forkThread(selectedThreadId, nlohmann::json::object(), - [this](const nlohmann::json &result) { - if (!result.value("ok", false)) - return; - const std::string threadId = stringValue( - result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); - if (!threadId.empty()) - selectThread(threadId); - }); -} - -void WorkbenchWidget::toggleSelectedThreadArchive() { - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) - return; - if (thread->archived) - session.unarchiveThread(selectedThreadId); - else - session.archiveThread(selectedThreadId); -} - -void WorkbenchWidget::deleteSelectedThread() { - if (selectedThreadId.empty()) - return; - if (QMessageBox::question(this, QStringLiteral("Delete thread"), - QStringLiteral("Delete the selected thread?"), - QMessageBox::Yes | QMessageBox::Cancel, - QMessageBox::Cancel) == QMessageBox::Yes) { - session.deleteThread(selectedThreadId); - } -} - -void WorkbenchWidget::submitPrompt() { - const QString promptValue = promptEditor->toPlainText().trimmed(); - if (promptValue.isEmpty()) - return; - const std::string prompt = promptValue.toStdString(); - promptEditor->clear(); - - if (!selectedThreadId.empty()) { - submitPromptToThread(selectedThreadId, prompt); - return; - } - localNewThreadIntent = true; - session.createThread({{"cwd", QDir::currentPath().toStdString()}}, - [this, prompt](const nlohmann::json &result) { - if (!result.value("ok", false)) - return; - const std::string threadId = stringValue( - result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); - if (threadId.empty()) - return; - selectedThreadId = threadId; - localNewThreadIntent = false; - submitPromptToThread(threadId, prompt); - scheduleRefresh(); - }); -} - -void WorkbenchWidget::submitPromptToThread(std::string threadId, - std::string prompt) { - const nlohmann::json input = - nlohmann::json::array({{{"type", "text"}, - {"text", std::move(prompt)}, - {"text_elements", nlohmann::json::array()}}}); - const auto activeTurn = model.activeTurnId(threadId); - if (activeTurn) { - session.steerTurn(threadId, *activeTurn, input); - } else { - session.startTurn(threadId, input); - } -} - -void WorkbenchWidget::interruptActiveTurn() { - const auto turnId = model.activeTurnId(selectedThreadId); - if (!turnId) - return; - session.interruptTurn(selectedThreadId, *turnId); -} - -void WorkbenchWidget::respondToFirstPending(bool approve) { - const auto &pending = model.pendingRequestPresentations(); - const auto request = - std::find_if(pending.begin(), pending.end(), [this](const auto &entry) { - return entry.second.threadId == selectedThreadId; - }); - if (request == pending.end()) - return; - - nlohmann::json result; - const std::string &type = request->second.kind; - if (type == "command-approval" || type == "file-change-approval") { - result = {{"decision", approve ? "accept" : "decline"}}; - } else if (type == "legacy-patch-approval" || - type == "legacy-command-approval") { - result = {{"decision", - approve ? nlohmann::json("approved") - : nlohmann::json{ - {"denied", {{"rejection", "Denied by user"}}}}}}; - } else if (type == "mcp-elicitation") { - result = {{"action", approve ? "accept" : "decline"}, - {"content", nullptr}, - {"_meta", nullptr}}; - } else { - return; - } - session.respondToServerRequest(nlohmann::json::parse(request->first), - std::move(result)); -} - -} // namespace codexui::codex diff --git a/src/codex/WorkbenchWidget.h b/src/codex/WorkbenchWidget.h deleted file mode 100644 index d6c1e48..0000000 --- a/src/codex/WorkbenchWidget.h +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_WORKBENCHWIDGET_H -#define CODEXUI_CODEX_WORKBENCHWIDGET_H - -#include "codex/PresentationModel.h" - -#include - -#include -#include -#include - -class QLabel; -class QListWidget; -class QPlainTextEdit; -class QPushButton; -class QScrollArea; -class QTabWidget; -class QTimer; -class QToolButton; -class QVBoxLayout; - -namespace codexui { -class ExpandingPromptEditor; -} - -namespace codexui::codex { - -class FrontendSession; - -class WorkbenchWidget final : public QWidget { -public: - explicit WorkbenchWidget(FrontendSession &session, QWidget *parent = nullptr); - -private: - void handleEvent(const nlohmann::json &event); - void scheduleRefresh(); - void refresh(); - void refreshThreads(); - void refreshConversation(); - void refreshInspector(); - void refreshStateInspector(); - void refreshProtocolStats(); - void refreshStatus(); - void appendProtocolFrame(const nlohmann::json &frame); - void hydrateHistoricalAgents(); - - void selectThread(std::string threadId); - void beginNewThread(); - void requestThreads(); - void requestModels(); - void requestEnvironment(); - void readSelectedThread(); - void renameSelectedThread(); - void forkSelectedThread(); - void toggleSelectedThreadArchive(); - void deleteSelectedThread(); - void submitPrompt(); - void submitPromptToThread(std::string threadId, std::string prompt); - void interruptActiveTurn(); - void respondToFirstPending(bool approve); - - FrontendSession &session; - PresentationModel model; - std::string selectedThreadId; - bool localNewThreadIntent = false; - bool environmentRequested = false; - - QLabel *connectionLabel = nullptr; - QLabel *controllerLabel = nullptr; - QLabel *attentionLabel = nullptr; - QLabel *conversationTitle = nullptr; - QLabel *conversationMeta = nullptr; - QLabel *emptyConversation = nullptr; - QListWidget *threadList = nullptr; - QWidget *conversationContent = nullptr; - QVBoxLayout *conversationLayout = nullptr; - QScrollArea *conversationScroll = nullptr; - QTabWidget *inspectorTabs = nullptr; - QWidget *planContent = nullptr; - QVBoxLayout *planLayout = nullptr; - QWidget *agentsContent = nullptr; - QVBoxLayout *agentsLayout = nullptr; - QWidget *requestsContent = nullptr; - QVBoxLayout *requestsLayout = nullptr; - QLabel *protocolStats = nullptr; - QPlainTextEdit *protocolLog = nullptr; - QPlainTextEdit *stateView = nullptr; - codexui::ExpandingPromptEditor *promptEditor = nullptr; - QPushButton *sendButton = nullptr; - QPushButton *interruptButton = nullptr; - QPushButton *controllerButton = nullptr; - QToolButton *threadActionsButton = nullptr; - QPushButton *approveButton = nullptr; - QPushButton *denyButton = nullptr; - QTimer *refreshTimer = nullptr; - std::uint64_t observedPresentationSequence = 0; - std::unordered_set requestedAgentThreads; -}; - -} // namespace codexui::codex - -#endif // CODEXUI_CODEX_WORKBENCHWIDGET_H diff --git a/src/greenfield/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp similarity index 100% rename from src/greenfield/codex/middle/ComposerPane.cpp rename to src/codex/middle/ComposerPane.cpp diff --git a/src/greenfield/codex/middle/ComposerPane.h b/src/codex/middle/ComposerPane.h similarity index 94% rename from src/greenfield/codex/middle/ComposerPane.h rename to src/codex/middle/ComposerPane.h index 6f31952..d6ab838 100644 --- a/src/greenfield/codex/middle/ComposerPane.h +++ b/src/codex/middle/ComposerPane.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H +#ifndef CODEXUI_CODEX_MIDDLE_COMPOSERPANE_H +#define CODEXUI_CODEX_MIDDLE_COMPOSERPANE_H #include "codex/FileSelectionDialog.h" @@ -106,4 +106,4 @@ class ComposerPane final : public QWidget { } // namespace middle } // namespace codexui::codex -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H +#endif // CODEXUI_CODEX_MIDDLE_COMPOSERPANE_H diff --git a/src/greenfield/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp similarity index 100% rename from src/greenfield/codex/middle/ConversationCards.cpp rename to src/codex/middle/ConversationCards.cpp diff --git a/src/greenfield/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h similarity index 93% rename from src/greenfield/codex/middle/ConversationCards.h rename to src/codex/middle/ConversationCards.h index 6eb5021..a2e542e 100644 --- a/src/greenfield/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONCARDS_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONCARDS_H #include "codex/middle/MiddleTypes.h" @@ -101,4 +101,4 @@ createConversationCard(const VisibleCardData &data, QWidget *parent = nullptr); } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONCARDS_H diff --git a/src/greenfield/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp similarity index 100% rename from src/greenfield/codex/middle/ConversationProjection.cpp rename to src/codex/middle/ConversationProjection.cpp diff --git a/src/greenfield/codex/middle/ConversationProjection.h b/src/codex/middle/ConversationProjection.h similarity index 86% rename from src/greenfield/codex/middle/ConversationProjection.h rename to src/codex/middle/ConversationProjection.h index 9cfe024..e42f663 100644 --- a/src/greenfield/codex/middle/ConversationProjection.h +++ b/src/codex/middle/ConversationProjection.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H #include "codex/PresentationModel.h" #include "codex/middle/MiddleTypes.h" @@ -39,4 +39,4 @@ class ConversationProjection final { } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H diff --git a/src/greenfield/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp similarity index 100% rename from src/greenfield/codex/middle/ConversationView.cpp rename to src/codex/middle/ConversationView.cpp diff --git a/src/greenfield/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h similarity index 95% rename from src/greenfield/codex/middle/ConversationView.h rename to src/codex/middle/ConversationView.h index 8c01ad5..b72daa5 100644 --- a/src/greenfield/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONVIEW_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONVIEW_H #include "codex/middle/ConversationCards.h" @@ -126,4 +126,4 @@ class ConversationView final : public QAbstractScrollArea { } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONVIEW_H diff --git a/src/greenfield/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp similarity index 100% rename from src/greenfield/codex/middle/InspectorPane.cpp rename to src/codex/middle/InspectorPane.cpp diff --git a/src/greenfield/codex/middle/InspectorPane.h b/src/codex/middle/InspectorPane.h similarity index 95% rename from src/greenfield/codex/middle/InspectorPane.h rename to src/codex/middle/InspectorPane.h index fcd2688..34757ea 100644 --- a/src/greenfield/codex/middle/InspectorPane.h +++ b/src/codex/middle/InspectorPane.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_INSPECTORPANE_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_INSPECTORPANE_H +#ifndef CODEXUI_CODEX_MIDDLE_INSPECTORPANE_H +#define CODEXUI_CODEX_MIDDLE_INSPECTORPANE_H #include #include diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp similarity index 100% rename from src/greenfield/codex/middle/MiddleRegionWidget.cpp rename to src/codex/middle/MiddleRegionWidget.cpp diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.h b/src/codex/middle/MiddleRegionWidget.h similarity index 94% rename from src/greenfield/codex/middle/MiddleRegionWidget.h rename to src/codex/middle/MiddleRegionWidget.h index 391ea7f..1648573 100644 --- a/src/greenfield/codex/middle/MiddleRegionWidget.h +++ b/src/codex/middle/MiddleRegionWidget.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H +#ifndef CODEXUI_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H +#define CODEXUI_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H #include diff --git a/src/greenfield/codex/middle/MiddleTypes.cpp b/src/codex/middle/MiddleTypes.cpp similarity index 100% rename from src/greenfield/codex/middle/MiddleTypes.cpp rename to src/codex/middle/MiddleTypes.cpp diff --git a/src/greenfield/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h similarity index 96% rename from src/greenfield/codex/middle/MiddleTypes.h rename to src/codex/middle/MiddleTypes.h index 2eec985..5faf0f9 100644 --- a/src/greenfield/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H +#ifndef CODEXUI_CODEX_MIDDLE_MIDDLETYPES_H +#define CODEXUI_CODEX_MIDDLE_MIDDLETYPES_H #include @@ -185,4 +185,4 @@ struct ConversationSnapshot { } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H +#endif // CODEXUI_CODEX_MIDDLE_MIDDLETYPES_H diff --git a/src/greenfield/codex/middle/PromptCoordinator.cpp b/src/codex/middle/PromptCoordinator.cpp similarity index 100% rename from src/greenfield/codex/middle/PromptCoordinator.cpp rename to src/codex/middle/PromptCoordinator.cpp diff --git a/src/greenfield/codex/middle/PromptCoordinator.h b/src/codex/middle/PromptCoordinator.h similarity index 96% rename from src/greenfield/codex/middle/PromptCoordinator.h rename to src/codex/middle/PromptCoordinator.h index 3c52802..59e6837 100644 --- a/src/greenfield/codex/middle/PromptCoordinator.h +++ b/src/codex/middle/PromptCoordinator.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H +#ifndef CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H +#define CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H #include "codex/FileSelectionDialog.h" #include "codex/PresentationModel.h" @@ -116,4 +116,4 @@ class PromptCoordinator final { } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H +#endif // CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H diff --git a/src/greenfield/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp similarity index 100% rename from src/greenfield/codex/middle/ThreadPane.cpp rename to src/codex/middle/ThreadPane.cpp diff --git a/src/greenfield/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h similarity index 95% rename from src/greenfield/codex/middle/ThreadPane.h rename to src/codex/middle/ThreadPane.h index 1ad6cfb..9e56d36 100644 --- a/src/greenfield/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_THREADPANE_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_THREADPANE_H +#ifndef CODEXUI_CODEX_MIDDLE_THREADPANE_H +#define CODEXUI_CODEX_MIDDLE_THREADPANE_H #include #include diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index e104140..fc2264a 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -56,7 +56,7 @@ QString applicationStyleSheet() { color: #1d2633; font-size: %1pt; } - QMainWindow, QWidget#workbench { background: #f6f8fb; } + QMainWindow, QWidget#applicationShell { background: #f6f8fb; } QLabel { background: transparent; font-weight: 400; } QLabel[kind="muted"] { color: #667085; font-size: %1pt; } QLabel[kind="section"] { diff --git a/src/greenfield/codex/ShellWidget.cpp b/src/greenfield/codex/ShellWidget.cpp deleted file mode 100644 index 83a0de5..0000000 --- a/src/greenfield/codex/ShellWidget.cpp +++ /dev/null @@ -1,1391 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/ShellWidget.h" - -#include "codex/ConnectionDialog.h" -#include "codex/FileSelectionDialog.h" -#include "codex/FrontendSession.h" -#include "codex/NewThreadDialog.h" -#include "codex/PendingRequestDialog.h" -#include "codex/PresentationModel.h" -#include "codex/TurnSettingsWidget.h" -#include "codex/middle/ComposerPane.h" -#include "codex/middle/ConversationProjection.h" -#include "codex/middle/ConversationView.h" -#include "codex/middle/InspectorPane.h" -#include "codex/middle/MiddleRegionWidget.h" -#include "codex/middle/PromptCoordinator.h" -#include "codex/middle/ThreadPane.h" -#include "codex/ui/BrandMark.h" -#include "codex/ui/ExpandingPromptEditor.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace codexui::codex { -namespace { - -constexpr auto DraftThreadId = "draft:new-thread"; - -QString text(const std::string &value) { - return QString::fromUtf8(value.data(), static_cast(value.size())); -} - -std::string stringValue(const nlohmann::json &object, const char *key) { - if (!object.is_object()) - return {}; - const auto found = object.find(key); - return found != object.end() && found->is_string() ? found->get() - : std::string{}; -} - -QString displayStatus(const std::string &status) { - if (status == "inProgress" || status == "active") - return QStringLiteral("Running"); - if (status == "completed" || status == "idle") - return QStringLiteral("Completed"); - if (status == "failed" || status == "systemError") - return QStringLiteral("Failed"); - return status.empty() ? QStringLiteral("Unknown") : text(status); -} - -std::string safeMessage(const nlohmann::json &value) { - std::string message = stringValue(value, "message"); - if (message.empty()) - message = stringValue(value, "detail"); - if (!message.empty()) - return message; - const auto error = value.find("error"); - return error != value.end() && error->is_object() - ? stringValue(*error, "message") - : std::string{}; -} - -bool isThreadNotFoundResult(const nlohmann::json &result) { - if (result.value("ok", false)) - return false; - const QString message = - text(safeMessage(result.value("error", nlohmann::json::object()))) - .toLower(); - return message.contains(QStringLiteral("thread")) && - message.contains(QStringLiteral("not found")); -} - -std::optional resultTurnId(const nlohmann::json &result) { - const nlohmann::json scope = result.value("scope", nlohmann::json::object()); - std::string id = stringValue(scope, "turnId"); - if (!id.empty()) - return id; - const nlohmann::json data = result.value("data", nlohmann::json::object()); - id = stringValue(data, "turnId"); - if (!id.empty()) - return id; - const nlohmann::json turn = data.value("turn", nlohmann::json::object()); - id = stringValue(turn, "id"); - return id.empty() ? std::nullopt : std::optional(std::move(id)); -} - -QLabel *makeLabel(QString value, const char *kind = "body") { - auto *label = new QLabel(std::move(value)); - label->setProperty("kind", kind); - label->setTextFormat(Qt::PlainText); - label->setWordWrap(true); - label->setMinimumWidth(0); - label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - label->setTextInteractionFlags(Qt::TextSelectableByMouse); - return label; -} - -QFrame *statusDot() { - auto *dot = new QFrame; - dot->setFixedSize(10, 10); - dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:5px;")); - return dot; -} - -std::string recoveryKey(const std::string &threadId, - std::uint64_t submissionId) { - return threadId + ':' + std::to_string(submissionId); -} - -} // namespace - -struct ShellWidget::Impl final { - enum class Hydration { NotHydrated, InFlight, Hydrated, Failed }; - struct HistoryWindow { - std::size_t requested = - middle::ConversationProjection::DefaultAuthoritativeItemLimit; - std::size_t effective = - middle::ConversationProjection::DefaultAuthoritativeItemLimit; - std::size_t lastAuthoritativeCount = 0; - }; - - Impl(ShellWidget *owner, FrontendSession &session) - : owner(owner), session(session), alive(std::make_shared(true)) { - buildUi(); - connectUi(); - const auto token = alive; - session.setEventHandler([this, token](const nlohmann::json &event) { - if (*token) - handleEvent(event); - }); - render(); - } - - ~Impl() { - *alive = false; - session.setEventHandler({}); - qApp->removeEventFilter(owner); - } - - void buildUi(); - void connectUi(); - void handleEvent(const nlohmann::json &event); - void scheduleRender(); - void render(); - void renderConversation(); - void refreshSettings(); - void refreshStatus(); - void hydrateHistoricalAgents(); - void showNotice(QString message, bool error = true); - - void selectThread(std::string threadId); - void beginNewThread(); - void readThread(const std::string &threadId, bool forced = false); - void ensureThreadHydrated(const std::string &threadId); - [[nodiscard]] bool threadIsHydrated(const std::string &threadId) const; - [[nodiscard]] bool threadRequiresResume(const std::string &threadId) const; - void renameThread(const std::string &threadId); - void forkThread(const std::string &threadId); - void toggleThreadArchive(const std::string &threadId); - void deleteThread(const std::string &threadId); - - [[nodiscard]] bool submitPrompt(QString prompt, - std::vector attachments); - void startThreadForDraft(); - void dispatchNextPrompt(const std::string &threadId); - void dispatchPrompt(middle::PromptDispatch dispatch); - void resumePromptQueue(const std::string &threadId); - void completePrompt(const std::string &threadId, std::uint64_t submissionId, - const nlohmann::json &result); - [[nodiscard]] bool attemptThreadRecovery(const std::string &threadId, - std::uint64_t submissionId, - const nlohmann::json &result); - void scheduleAcceptedTransition(const std::string &threadId, - std::uint64_t submissionId); - - void chooseAttachments(); - void interruptTurn(); - void reviewPending(const std::string &requestKey); - void rejectPending(const std::string &requestKey); - void respondToFirstPending(bool approve); - - ShellWidget *owner = nullptr; - FrontendSession &session; - PresentationModel model; - middle::PromptCoordinator prompts; - std::shared_ptr alive; - - std::string selectedThreadId; - bool newThreadIntent = false; - bool newThreadCreationInFlight = false; - nlohmann::json newThreadOptions = nlohmann::json::object(); - QString newThreadName; - QString newThreadWorkspace; - - std::unordered_map hydration; - std::unordered_map readRevisions; - std::unordered_set staleReadResultCorrelations; - std::uint64_t nextReadRevision = 1; - std::unordered_set operationReadyThreads; - std::unordered_set resumeInFlightThreads; - std::unordered_set dispatchScheduledThreads; - std::unordered_set promptRecoveryAttempted; - std::unordered_map historyWindows; - std::uint64_t observedConnectionGeneration = 0; - std::uint64_t observedProviderGeneration = 0; - QByteArray settingsSnapshot; - QByteArray statusSnapshot; - bool renderScheduled = false; - - middle::MiddleRegionWidget *middleRegion = nullptr; - QPushButton *restoreSidebarButton = nullptr; - QPushButton *restoreInspectorButton = nullptr; - QLabel *workspaceBreadcrumb = nullptr; - QPushButton *requestButton = nullptr; - QFrame *connectionStatusDot = nullptr; - QToolButton *connectionButton = nullptr; - QAction *connectAction = nullptr; - QAction *disconnectAction = nullptr; - QAction *reconnectAction = nullptr; - QPushButton *controllerButton = nullptr; - QLabel *threadContextStatus = nullptr; - QLabel *agentActivityStatus = nullptr; - QLabel *controllerLabel = nullptr; -}; - -void ShellWidget::Impl::buildUi() { - owner->setObjectName(QStringLiteral("workbench")); - auto *root = new QVBoxLayout(owner); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(0); - - auto *top = new QFrame; - top->setObjectName(QStringLiteral("topBar")); - top->setStyleSheet(QStringLiteral( - "QFrame#topBar{background:#ffffff;border-bottom:1px solid #d7dee8;}")); - top->setFixedHeight(64); - auto *topLayout = new QHBoxLayout(top); - topLayout->setContentsMargins(18, 0, 18, 0); - topLayout->setSpacing(12); - topLayout->addWidget(codexui::BrandMark::createLockup()); - - restoreSidebarButton = new QPushButton(QStringLiteral("Show threads")); - restoreSidebarButton->setProperty("kind", "subtle"); - restoreSidebarButton->setFixedHeight(32); - restoreSidebarButton->hide(); - topLayout->addSpacing(12); - topLayout->addWidget(restoreSidebarButton); - topLayout->addSpacing(18); - workspaceBreadcrumb = makeLabel(QStringLiteral("No workspace"), "muted"); - workspaceBreadcrumb->setWordWrap(false); - workspaceBreadcrumb->setMaximumWidth(280); - workspaceBreadcrumb->setStyleSheet( - QStringLiteral("color:#667085;font-weight:500;")); - topLayout->addWidget(workspaceBreadcrumb); - topLayout->addStretch(); - - restoreInspectorButton = new QPushButton(QStringLiteral("Show inspector")); - restoreInspectorButton->setProperty("kind", "subtle"); - restoreInspectorButton->setFixedHeight(32); - restoreInspectorButton->hide(); - requestButton = new QPushButton; - requestButton->setProperty("kind", "request"); - requestButton->setFixedHeight(32); - requestButton->hide(); - controllerButton = new QPushButton(QStringLiteral("Claim control")); - controllerButton->setFixedHeight(32); - topLayout->addWidget(restoreInspectorButton); - topLayout->addWidget(requestButton); - topLayout->addWidget(controllerButton); - - connectionStatusDot = statusDot(); - connectionStatusDot->setToolTip(QStringLiteral("Not connected")); - connectionButton = new QToolButton; - connectionButton->setText(QStringLiteral("Connection")); - connectionButton->setProperty("kind", "subtle"); - connectionButton->setPopupMode(QToolButton::InstantPopup); - connectionButton->setFixedHeight(32); - auto *connectionMenu = new QMenu(connectionButton); - connectionMenu->addAction(QStringLiteral("Configure..."), owner, [this] { - if (!model.connection().settings.is_object() || - model.connection().settings.empty()) { - showNotice(QStringLiteral("Connection settings are not available yet.")); - return; - } - ConnectionDialog dialog(model.connection().settings, owner); - if (dialog.exec() != QDialog::Accepted) - return; - const auto token = alive; - session.configureConnection( - dialog.selection(), [this, token](const nlohmann::json &result) { - if (!*token || result.value("ok", false)) - return; - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - showNotice(text(message.empty() - ? std::string("Connection configuration failed") - : message)); - }); - }); - connectionMenu->addSeparator(); - connectAction = connectionMenu->addAction( - QStringLiteral("Connect"), owner, [this] { session.connectTransport(); }); - disconnectAction = - connectionMenu->addAction(QStringLiteral("Disconnect"), owner, - [this] { session.disconnectTransport(); }); - reconnectAction = connectionMenu->addAction( - QStringLiteral("Reconnect"), owner, [this] { session.reconnect(); }); - connectionButton->setMenu(connectionMenu); - auto *connectionControl = new QWidget; - auto *connectionLayout = new QHBoxLayout(connectionControl); - connectionLayout->setContentsMargins(0, 0, 0, 0); - connectionLayout->setSpacing(6); - connectionLayout->addWidget(connectionButton); - connectionLayout->addWidget(connectionStatusDot); - topLayout->addWidget(connectionControl); - root->addWidget(top); - - middleRegion = new middle::MiddleRegionWidget; - root->addWidget(middleRegion, 1); - - auto *statusBar = new QFrame; - statusBar->setObjectName(QStringLiteral("customStatusBar")); - statusBar->setStyleSheet(QStringLiteral( - "QFrame#customStatusBar{background:#f8fafc;border-top:1px solid " - "#d7dee8;}")); - statusBar->setFixedHeight(40); - auto *statusLayout = new QHBoxLayout(statusBar); - statusLayout->setContentsMargins(18, 0, 24, 0); - statusLayout->setSpacing(8); - threadContextStatus = makeLabel(QStringLiteral("No thread context"), "meta"); - statusLayout->addWidget(threadContextStatus); - statusLayout->addSpacing(42); - agentActivityStatus = makeLabel(QStringLiteral("No agent activity"), "meta"); - statusLayout->addWidget(agentActivityStatus); - statusLayout->addStretch(); - controllerLabel = makeLabel(QStringLiteral("Observer"), "meta"); - statusLayout->addWidget(controllerLabel); - root->addWidget(statusBar); -} - -void ShellWidget::Impl::connectUi() { - middle::ThreadPane::Actions threadActions; - threadActions.newThread = [this] { beginNewThread(); }; - threadActions.refresh = [this] { session.listThreads(); }; - threadActions.hide = [this] { middleRegion->showSidebar(false); }; - threadActions.select = [this](const std::string &id) { - if (id != selectedThreadId) - selectThread(id); - }; - threadActions.reload = [this](const std::string &id) { - readThread(id, true); - }; - threadActions.rename = [this](const std::string &id) { renameThread(id); }; - threadActions.fork = [this](const std::string &id) { forkThread(id); }; - threadActions.toggleArchive = [this](const std::string &id) { - toggleThreadArchive(id); - }; - threadActions.remove = [this](const std::string &id) { deleteThread(id); }; - middleRegion->threads().setActions(std::move(threadActions)); - - middle::ComposerPane::Actions composerActions; - composerActions.submit = [this](QString prompt, - std::vector attachments) { - return submitPrompt(std::move(prompt), std::move(attachments)); - }; - composerActions.stop = [this] { interruptTurn(); }; - composerActions.attach = [this] { chooseAttachments(); }; - composerActions.review = [this] { respondToFirstPending(true); }; - composerActions.deny = [this] { respondToFirstPending(false); }; - middleRegion->composer().setActions(std::move(composerActions)); - - middleRegion->conversation().setLoadMoreAction([this] { - const std::string key = selectedThreadId.empty() - ? std::string(DraftThreadId) - : selectedThreadId; - HistoryWindow &history = historyWindows[key]; - history.requested += - middle::ConversationProjection::DefaultAuthoritativeItemLimit; - history.effective += - middle::ConversationProjection::DefaultAuthoritativeItemLimit; - renderConversation(); - }); - middleRegion->inspector().setRequestActions( - [this](const std::string &id) { reviewPending(id); }, - [this](const std::string &id) { rejectPending(id); }); - middleRegion->setPaneVisibilityAction( - [this](bool sidebarVisible, bool inspectorVisible) { - restoreSidebarButton->setVisible(!sidebarVisible); - restoreInspectorButton->setVisible(!inspectorVisible); - }); - - connect(restoreSidebarButton, &QPushButton::clicked, owner, - [this] { middleRegion->showSidebar(true); }); - connect(restoreInspectorButton, &QPushButton::clicked, owner, - [this] { middleRegion->showInspector(true); }); - connect(requestButton, &QPushButton::clicked, owner, [this] { - middleRegion->showInspector(true); - middleRegion->inspector().tabs()->setCurrentIndex(3); - }); - connect(controllerButton, &QPushButton::clicked, owner, [this] { - if (model.connection().role == "controller") - session.releaseController(); - else - session.claimController(); - }); - qApp->installEventFilter(owner); -} - -void ShellWidget::Impl::showNotice(QString message, bool error) { - middleRegion->showNotice(std::move(message), error); -} - -void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { - middleRegion->inspector().appendProtocolFrame(event); - - const std::string kind = stringValue(event, "kind"); - const std::string action = stringValue(event, "action"); - const std::string correlationId = stringValue(event, "correlationId"); - const bool staleReadResult = - kind == "result" && action == "thread.read" && !correlationId.empty() && - staleReadResultCorrelations.erase(correlationId) > 0; - if (!staleReadResult) - model.applyEvent(event); - - const ConnectionPresentation &connection = model.connection(); - if (connection.generation != observedConnectionGeneration) { - observedConnectionGeneration = connection.generation; - hydration.clear(); - readRevisions.clear(); - operationReadyThreads.clear(); - dispatchScheduledThreads.clear(); - } - if (connection.providerGeneration != observedProviderGeneration) { - observedProviderGeneration = connection.providerGeneration; - hydration.clear(); - readRevisions.clear(); - operationReadyThreads.clear(); - dispatchScheduledThreads.clear(); - } - - const std::string type = stringValue(event, "type"); - const nlohmann::json data = event.value("data", nlohmann::json::object()); - const nlohmann::json scope = event.value("scope", nlohmann::json::object()); - const std::string eventThreadId = stringValue(scope, "threadId"); - if (kind == "event" && type == "connection.provider" && - stringValue(data, "state") == "disconnected") { - hydration.clear(); - readRevisions.clear(); - operationReadyThreads.clear(); - dispatchScheduledThreads.clear(); - } - - if (kind == "result" && !event.value("ok", false) && action != "turn.start" && - action != "turn.steer" && action != "thread.read" && - action != "thread.resume") { - const std::string message = - safeMessage(event.value("error", nlohmann::json::object())); - showNotice(text(message.empty() ? std::string("Codex operation failed") - : message)); - } else if (kind == "event" && type == "notice.added") { - const nlohmann::json notice = - data.value("notice", nlohmann::json::object()); - const std::string message = safeMessage(notice); - if (!message.empty()) - showNotice(text(message), stringValue(data, "severity") == "error"); - } else if (kind == "event" && type == "system.diagnostic") { - const std::string message = safeMessage(data); - if (!message.empty()) - showNotice(QStringLiteral("Protocol diagnostic: %1").arg(text(message))); - } else if (kind == "event" && type == "connection.lifecycle" && - (stringValue(data, "state") == "failure" || - stringValue(data, "state") == "disconnected")) { - const std::string detail = stringValue(data, "detail"); - if (!detail.starts_with("local-")) - showNotice(detail.empty() ? QStringLiteral("Codex bridge disconnected") - : text(detail)); - } - - if (kind == "event" && type == "connection.bridge" && - stringValue(data, "state") == "opened") { - session.listThreads(); - session.listModels(); - ensureThreadHydrated(selectedThreadId); - for (const std::string &threadId : prompts.queuedThreadIds()) { - if (threadId == DraftThreadId) { - if (newThreadIntent) - startThreadForDraft(); - } else { - dispatchNextPrompt(threadId); - } - } - session.listPermissionProfiles( - {{"cwd", QDir::currentPath().toStdString()}}); - } - - if (type == "thread.removed" && !eventThreadId.empty()) { - prompts.clearThread(eventThreadId); - hydration.erase(eventThreadId); - readRevisions.erase(eventThreadId); - operationReadyThreads.erase(eventThreadId); - resumeInFlightThreads.erase(eventThreadId); - dispatchScheduledThreads.erase(eventThreadId); - historyWindows.erase(eventThreadId); - if (selectedThreadId == eventThreadId) { - selectedThreadId.clear(); - middleRegion->composer().clearDraft(); - } - } else if (!eventThreadId.empty()) { - if (const ThreadPresentation *thread = model.thread(eventThreadId)) { - prompts.reconcile(eventThreadId, *thread); - prompts.compactResolved(eventThreadId, - QDateTime::currentMSecsSinceEpoch()); - } - } else if (kind == "event" && type == "connection.provider" && - stringValue(data, "state") == "ready") { - session.listThreads(); - session.listModels(); - readThread(selectedThreadId, true); - } - - hydrateHistoricalAgents(); - scheduleRender(); -} - -void ShellWidget::Impl::scheduleRender() { - if (renderScheduled) - return; - renderScheduled = true; - const auto token = alive; - // A streamed response may deliver many deltas in one display interval. - // Reconcile once per frame instead of rebuilding rich text and layout for - // every transport chunk. - QTimer::singleShot(16, Qt::PreciseTimer, owner, [this, token] { - if (!*token) - return; - renderScheduled = false; - render(); - }); -} - -void ShellWidget::Impl::render() { - middleRegion->threads().refresh(model, selectedThreadId); - renderConversation(); - middleRegion->inspector().refresh(model, selectedThreadId); - refreshSettings(); - refreshStatus(); -} - -void ShellWidget::Impl::renderConversation() { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const ThreadPresentation *thread = model.thread(selectedThreadId); - const std::string projectionId = selectedThreadId.empty() && newThreadIntent - ? std::string(DraftThreadId) - : selectedThreadId; - if (thread) - prompts.reconcile(selectedThreadId, *thread); - if (!projectionId.empty()) - prompts.compactResolved(projectionId, now); - const auto submissions = prompts.submissions(projectionId); - std::size_t authoritativeCount = 0; - if (thread) { - for (const std::string &turnId : thread->turnOrder) { - const auto turn = thread->turns.find(turnId); - if (turn != thread->turns.end()) - authoritativeCount += turn->second.itemOrder.size(); - } - } - HistoryWindow &history = historyWindows[projectionId]; - const middle::ConversationView::Mode viewportMode = - middleRegion->conversation().modeForThread(projectionId); - if (viewportMode == middle::ConversationView::Mode::Paused && - authoritativeCount > history.lastAuthoritativeCount) { - // Do not evict the paused visual anchor merely because newer items were - // appended. The hidden prefix stays constant until following resumes. - history.effective += authoritativeCount - history.lastAuthoritativeCount; - } else if (viewportMode == middle::ConversationView::Mode::Following) { - history.effective = history.requested; - } - history.lastAuthoritativeCount = authoritativeCount; - const middle::ConversationSnapshot snapshot = - middle::ConversationProjection::project(projectionId, thread, submissions, - history.effective, now); - if (!thread && newThreadIntent) - middleRegion->conversation().setEmptyMessage( - QStringLiteral("Send a message to create this thread.")); - else if (thread) - middleRegion->conversation().setEmptyMessage( - QStringLiteral("No materialized activity.")); - else - middleRegion->conversation().setEmptyMessage( - QStringLiteral("Conversation activity appears here.")); - middleRegion->conversation().reconcile(snapshot); - - if (thread) { - middleRegion->setThreadHeading(text(thread->title), - text(thread->cwd) + QStringLiteral(" | ") + - displayStatus(thread->status)); - } else if (newThreadIntent) { - middleRegion->setThreadHeading(QStringLiteral("New thread"), - newThreadWorkspace.isEmpty() - ? QDir::currentPath() - : newThreadWorkspace); - } else { - middleRegion->setThreadHeading(QStringLiteral("Select a thread"), {}); - } -} - -void ShellWidget::Impl::refreshSettings() { - nlohmann::json canonical = nlohmann::json::object(); - std::string identity = "no-thread"; - if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { - identity = thread->id; - canonical = thread->raw; - const auto settings = thread->domains.find("thread.settings.changed"); - if (settings != thread->domains.end() && settings->second.is_object()) { - nlohmann::json update = settings->second; - if (update.contains("threadSettings") && - update["threadSettings"].is_object()) - update = update["threadSettings"]; - canonical.merge_patch(update); - } - } else if (newThreadIntent) { - identity = DraftThreadId; - canonical["cwd"] = (newThreadWorkspace.isEmpty() ? QDir::currentPath() - : newThreadWorkspace) - .toStdString(); - } else { - canonical["cwd"] = QDir::currentPath().toStdString(); - } - nlohmann::json profiles = nlohmann::json::array(); - const auto found = - model.globalDomains().find("operation.permission-profiles.list"); - if (found != model.globalDomains().end()) - profiles = found->second; - const std::string serialized = nlohmann::json{ - {"identity", identity}, - {"canonical", canonical}, - {"models", model.modelCatalog()}, - {"profiles", profiles}}.dump(); - const QByteArray next(serialized.data(), - static_cast(serialized.size())); - if (next == settingsSnapshot) - return; - settingsSnapshot = next; - middleRegion->composer().turnSettings()->setContext( - identity, canonical, model.modelCatalog(), profiles); -} - -void ShellWidget::Impl::refreshStatus() { - const ConnectionPresentation &connection = model.connection(); - const ThreadPresentation *thread = model.thread(selectedThreadId); - std::size_t runningAgents = 0; - if (thread) { - for (const auto &[id, agent] : thread->agents) { - static_cast(id); - if (agent.status == "inProgress" || agent.status == "running" || - agent.status == "started") - ++runningAgents; - } - } - const bool active = model.activeTurnId(selectedThreadId).has_value(); - const std::string serialized = nlohmann::json{ - {"connected", connection.connected}, - {"retrying", connection.retrying}, - {"role", connection.role}, - {"settings", connection.settings}, - {"selectedThreadId", selectedThreadId}, - {"newThreadIntent", newThreadIntent}, - {"newThreadWorkspace", newThreadWorkspace.toStdString()}, - {"threadTitle", thread ? thread->title : std::string{}}, - {"threadCwd", thread ? thread->cwd : std::string{}}, - {"threadStatus", thread ? thread->status : std::string{}}, - {"agentCount", thread ? thread->agents.size() : 0U}, - {"runningAgents", runningAgents}, - {"active", active}, - {"selectedPending", model.pendingRequestCount(selectedThreadId)}, - {"totalPending", - model.pendingRequestCount()}}.dump(); - const QByteArray next(serialized.data(), - static_cast(serialized.size())); - if (next == statusSnapshot) - return; - statusSnapshot = next; - QString dotStyle; - QString dotTip; - if (connection.connected) { - dotStyle = QStringLiteral("background:#18865e;border-radius:5px;"); - dotTip = QStringLiteral("Connected"); - } else if (connection.retrying) { - dotStyle = QStringLiteral("background:#a85d0c;border-radius:5px;"); - dotTip = QStringLiteral("Disconnected, retrying"); - } else { - dotStyle = QStringLiteral("background:#c43d4d;border-radius:5px;"); - dotTip = QStringLiteral("Disconnected"); - } - connectionStatusDot->setStyleSheet(dotStyle); - connectionStatusDot->setToolTip(dotTip); - QString selectedTransport; - const std::string selectedKey = stringValue(connection.settings, "selected"); - const nlohmann::json available = - connection.settings.value("available", nlohmann::json::array()); - if (available.is_array()) { - for (const auto &entry : available) { - if (stringValue(entry, "key") == selectedKey) { - selectedTransport = text(stringValue(entry, "label")); - break; - } - } - } - connectionButton->setText(selectedTransport.isEmpty() - ? QStringLiteral("Connection") - : selectedTransport); - connectionButton->setToolTip( - connection.connected ? QStringLiteral("Connected bridge transport") - : QStringLiteral("Disconnected bridge transport")); - connectAction->setEnabled(!connection.connected); - disconnectAction->setEnabled(connection.connected); - reconnectAction->setEnabled(connection.connected); - controllerLabel->setText(connection.role.empty() ? QStringLiteral("No role") - : text(connection.role)); - controllerButton->setText(connection.role == "controller" - ? QStringLiteral("Release control") - : QStringLiteral("Claim control")); - controllerButton->setEnabled(connection.connected); - - const std::size_t selectedPending = - model.pendingRequestCount(selectedThreadId); - const std::size_t totalPending = model.pendingRequestCount(); - requestButton->setText(QStringLiteral("Requests (%1)") - .arg(static_cast(totalPending))); - requestButton->setVisible(totalPending != 0); - middleRegion->composer().setAttentionVisible(selectedPending != 0); - - QString workspace = QStringLiteral("No workspace"); - if (thread) { - workspace = text(thread->cwd); - threadContextStatus->setText( - QStringLiteral("%1 | %2") - .arg(text(thread->title), displayStatus(thread->status))); - agentActivityStatus->setText( - thread->agents.empty() - ? QStringLiteral("No agent activity") - : QStringLiteral("%1 agents | %2 active") - .arg(static_cast(thread->agents.size())) - .arg(static_cast(runningAgents))); - } else { - if (newThreadIntent) - workspace = text(middleRegion->composer().turnSettings()->workspace( - QDir::currentPath().toStdString())); - threadContextStatus->setText(newThreadIntent - ? QStringLiteral("New thread") - : QStringLiteral("No thread context")); - agentActivityStatus->setText(QStringLiteral("No agent activity")); - } - workspaceBreadcrumb->setToolTip(workspace); - workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( - workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); - - const bool canSubmit = - connection.connected && connection.role == "controller"; - middleRegion->composer().setActiveTurn(active); - middleRegion->composer().setCanSubmit(canSubmit); - middleRegion->composer().setSettingsEnabled(canSubmit && !active); -} - -void ShellWidget::Impl::hydrateHistoricalAgents() { - const ThreadPresentation *thread = model.thread(selectedThreadId); - if (!thread) - return; - for (const std::string &id : thread->agentOrder) { - const auto agent = thread->agents.find(id); - if (agent == thread->agents.end() || agent->second.childThreadId.empty() || - agent->second.status != "started") - continue; - // Historical child hydration shares the same monotonic read boundary as - // user-selected threads, so a pre-reconnect result cannot replace newer - // child/agent presentation state. - readThread(agent->second.childThreadId); - } -} - -void ShellWidget::Impl::selectThread(std::string threadId) { - if (threadId.empty()) - return; - if (threadId == selectedThreadId) { - ensureThreadHydrated(threadId); - return; - } - selectedThreadId = std::move(threadId); - newThreadIntent = false; - newThreadOptions = nlohmann::json::object(); - newThreadName.clear(); - newThreadWorkspace.clear(); - historyWindows.try_emplace(selectedThreadId); - ensureThreadHydrated(selectedThreadId); - render(); -} - -void ShellWidget::Impl::beginNewThread() { - if (newThreadCreationInFlight) { - showNotice(QStringLiteral("The current new thread is still being created."), - false); - return; - } - const QString initial = - text(middleRegion->composer().turnSettings()->workspace( - QDir::currentPath().toStdString())); - NewThreadDialog dialog(initial, owner); - if (dialog.exec() != QDialog::Accepted) - return; - const NewThreadDraft draft = dialog.draft(); - prompts.clearThread(DraftThreadId); - selectedThreadId.clear(); - newThreadIntent = true; - newThreadName = draft.name; - newThreadWorkspace = draft.workspace; - newThreadOptions = nlohmann::json::object(); - if (!draft.baseInstructions.isEmpty()) - newThreadOptions["baseInstructions"] = draft.baseInstructions.toStdString(); - if (!draft.developerInstructions.isEmpty()) - newThreadOptions["developerInstructions"] = - draft.developerInstructions.toStdString(); - if (draft.ephemeral) - newThreadOptions["ephemeral"] = true; - settingsSnapshot.clear(); - middleRegion->composer().clearDraft(); - middleRegion->composer().turnSettings()->setWorkspace(draft.workspace); - middleRegion->composer().promptEditor()->setFocus(); - render(); -} - -void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { - if (threadId.empty() || resumeInFlightThreads.contains(threadId)) - return; - if (!forced) { - const auto existing = hydration.find(threadId); - if (existing != hydration.end() && - (existing->second == Hydration::InFlight || - existing->second == Hydration::Hydrated || - existing->second == Hydration::Failed)) - return; - } - hydration[threadId] = Hydration::InFlight; - const auto token = alive; - const std::uint64_t revision = nextReadRevision++; - readRevisions[threadId] = revision; - session.readThread(threadId, [this, token, threadId, - revision](const nlohmann::json &result) { - if (!*token) - return; - const auto current = readRevisions.find(threadId); - if (current == readRevisions.end() || current->second != revision) { - const std::string correlationId = stringValue(result, "correlationId"); - if (!correlationId.empty()) - staleReadResultCorrelations.insert(correlationId); - return; - } - if (result.value("ok", false)) { - hydration[threadId] = Hydration::Hydrated; - QTimer::singleShot(0, owner, - [this, threadId] { dispatchNextPrompt(threadId); }); - return; - } - // A non-forced hydration is attempted once per connection generation. - // Explicit Reload bypasses this terminal state, while a new generation - // clears it together with the other hydration bookkeeping. - hydration[threadId] = Hydration::Failed; - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - const QString displayed = - text(message.empty() ? std::string("Thread loading failed") : message); - static_cast(prompts.failQueued(threadId, displayed)); - showNotice(displayed); - render(); - }); -} - -void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { - if (threadId.empty() || threadIsHydrated(threadId) || - !model.connection().connected) - return; - const auto found = hydration.find(threadId); - if (found != hydration.end() && found->second == Hydration::InFlight) - return; - readThread(threadId); -} - -bool ShellWidget::Impl::threadIsHydrated(const std::string &threadId) const { - const auto found = hydration.find(threadId); - return found != hydration.end() && found->second == Hydration::Hydrated; -} - -bool ShellWidget::Impl::threadRequiresResume( - const std::string &threadId) const { - if (operationReadyThreads.contains(threadId)) - return false; - const ThreadPresentation *thread = model.thread(threadId); - return thread && thread->status == "notLoaded"; -} - -void ShellWidget::Impl::renameThread(const std::string &threadId) { - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - return; - bool accepted = false; - const QString name = - QInputDialog::getText(owner, QStringLiteral("Rename thread"), - QStringLiteral("Name"), QLineEdit::Normal, - text(thread->title), &accepted) - .trimmed(); - if (accepted && !name.isEmpty()) - session.renameThread(threadId, name.toStdString()); -} - -void ShellWidget::Impl::forkThread(const std::string &threadId) { - if (threadId.empty()) - return; - const auto token = alive; - session.forkThread(threadId, nlohmann::json::object(), - [this, token](const nlohmann::json &result) { - if (!*token || !result.value("ok", false)) - return; - const std::string id = stringValue( - result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); - if (!id.empty()) - selectThread(id); - }); -} - -void ShellWidget::Impl::toggleThreadArchive(const std::string &threadId) { - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - return; - if (thread->archived) - session.unarchiveThread(threadId); - else - session.archiveThread(threadId); -} - -void ShellWidget::Impl::deleteThread(const std::string &threadId) { - if (threadId.empty()) - return; - if (QMessageBox::question(owner, QStringLiteral("Delete thread"), - QStringLiteral("Delete the selected thread?"), - QMessageBox::Yes | QMessageBox::Cancel, - QMessageBox::Cancel) == QMessageBox::Yes) - session.deleteThread(threadId); -} - -bool ShellWidget::Impl::submitPrompt(QString prompt, - std::vector attachments) { - prompt = prompt.trimmed(); - if (prompt.isEmpty()) - return false; - const std::string visiblySelected = - middleRegion->threads().visiblySelectedThreadId(); - if (!visiblySelected.empty() && visiblySelected != selectedThreadId) { - if (!model.thread(visiblySelected)) { - showNotice(QStringLiteral("The visibly selected thread is no longer " - "available. Your message was not sent.")); - return false; - } - selectThread(visiblySelected); - } - - std::string destination = selectedThreadId; - const ThreadPresentation *thread = model.thread(destination); - if (destination.empty()) { - if (!newThreadIntent) { - showNotice(QStringLiteral("No destination thread is selected. Your " - "message was not sent; select a thread or use " - "New thread.")); - middleRegion->composer().promptEditor()->setFocus(); - return false; - } - destination = DraftThreadId; - thread = nullptr; - } - - if (destination != DraftThreadId) { - const auto state = hydration.find(destination); - if (state != hydration.end() && state->second == Hydration::Failed) { - showNotice(QStringLiteral("Thread loading failed. Reload the thread " - "before sending; your message was not sent.")); - middleRegion->composer().promptEditor()->setFocus(); - return false; - } - } - - const auto activeTurn = destination == DraftThreadId - ? std::optional{} - : model.activeTurnId(destination); - const std::uint64_t submissionId = - prompts.admit(destination, prompt, std::move(attachments), - middleRegion->composer().turnSettings()->turnStartOptions(), - thread, activeTurn, QDateTime::currentMSecsSinceEpoch()); - static_cast(submissionId); - - // Admission is a synchronous UI fact. Transport dispatch is queued below so - // this awaiting projection is committed without forcing paint reentrancy. - middleRegion->conversation().prepareForLocalPromptAdmission(); - renderConversation(); - - if (destination == DraftThreadId) - startThreadForDraft(); - else - dispatchNextPrompt(destination); - return true; -} - -void ShellWidget::Impl::startThreadForDraft() { - if (newThreadCreationInFlight || prompts.submissions(DraftThreadId).empty()) - return; - newThreadCreationInFlight = true; - nlohmann::json options = - middleRegion->composer().turnSettings()->threadStartOptions(); - options.update(newThreadOptions); - options["cwd"] = middleRegion->composer().turnSettings()->workspace( - QDir::currentPath().toStdString()); - const QString requestedName = newThreadName; - const auto token = alive; - session.createThread(std::move(options), [this, token, requestedName]( - const nlohmann::json &result) { - if (!*token) - return; - newThreadCreationInFlight = false; - if (!result.value("ok", false)) { - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - const QString error = text( - message.empty() ? std::string("Thread creation failed") : message); - const auto pending = prompts.submissions(DraftThreadId); - std::vector ids; - for (const auto &submission : pending) - ids.push_back(submission.id); - for (const std::uint64_t id : ids) - static_cast(prompts.fail(DraftThreadId, id, error)); - showNotice(error); - render(); - return; - } - const std::string threadId = - stringValue(result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); - if (threadId.empty()) { - const QString error = - QStringLiteral("Thread creation returned no thread identifier"); - const auto pending = prompts.submissions(DraftThreadId); - std::vector ids; - for (const auto &submission : pending) - ids.push_back(submission.id); - for (const std::uint64_t id : ids) - static_cast(prompts.fail(DraftThreadId, id, error)); - showNotice(error); - render(); - return; - } - - if (!prompts.reassignThread(DraftThreadId, threadId)) { - showNotice(QStringLiteral("Could not attach the draft prompts to " - "the created thread.")); - render(); - return; - } - hydration[threadId] = Hydration::Hydrated; - operationReadyThreads.insert(threadId); - const bool viewingDraft = selectedThreadId.empty() && newThreadIntent; - if (viewingDraft) { - selectedThreadId = threadId; - newThreadIntent = false; - } - newThreadOptions = nlohmann::json::object(); - newThreadName.clear(); - newThreadWorkspace.clear(); - settingsSnapshot.clear(); - if (!requestedName.isEmpty()) - session.renameThread(threadId, requestedName.toStdString()); - render(); - QTimer::singleShot(0, owner, - [this, threadId] { dispatchNextPrompt(threadId); }); - }); -} - -void ShellWidget::Impl::dispatchNextPrompt(const std::string &threadId) { - if (threadId.empty() || !model.connection().connected) - return; - const auto submissions = prompts.submissions(threadId); - if (std::ranges::none_of( - submissions, [](const middle::PromptSubmission &submission) { - return submission.state == middle::PromptState::Queued; - })) - return; - if (resumeInFlightThreads.contains(threadId)) - return; - if (!threadIsHydrated(threadId)) { - ensureThreadHydrated(threadId); - return; - } - if (prompts.hasInFlight(threadId)) - return; - if (threadRequiresResume(threadId)) { - resumePromptQueue(threadId); - return; - } - if (!dispatchScheduledThreads.insert(threadId).second) - return; - - // The admitted card already presents the awaiting state. Queueing transport - // gives Qt one normal paint turn, then samples start-versus-steer at the - // actual send boundary without a forced repaint or reentrant event drain. - const std::uint64_t generation = observedConnectionGeneration; - QTimer::singleShot(0, owner, [this, threadId, generation] { - dispatchScheduledThreads.erase(threadId); - if (observedConnectionGeneration != generation) - return; - if (!model.connection().connected || - resumeInFlightThreads.contains(threadId)) - return; - if (!threadIsHydrated(threadId) || threadRequiresResume(threadId)) { - dispatchNextPrompt(threadId); - return; - } - const auto dispatch = - prompts.beginNext(threadId, model.activeTurnId(threadId)); - if (dispatch) - dispatchPrompt(*dispatch); - }); -} - -void ShellWidget::Impl::dispatchPrompt(middle::PromptDispatch dispatch) { - nlohmann::json input = - nlohmann::json::array({{{"type", "text"}, - {"text", dispatch.prompt.toStdString()}, - {"text_elements", nlohmann::json::array()}}}); - for (const AttachmentDraft &attachment : dispatch.attachments) { - if (attachment.mimeType.startsWith(QStringLiteral("image/"))) - input.push_back( - {{"type", "localImage"}, {"path", attachment.path.toStdString()}}); - else if (attachment.mimeType.startsWith(QStringLiteral("audio/"))) - input.push_back( - {{"type", "localAudio"}, {"path", attachment.path.toStdString()}}); - else - input.push_back({{"type", "mention"}, - {"name", attachment.name.toStdString()}, - {"path", attachment.path.toStdString()}}); - } - - const std::string threadId = dispatch.threadId; - const std::uint64_t submissionId = dispatch.id; - const auto token = alive; - auto completed = [this, token, threadId, - submissionId](const nlohmann::json &result) { - if (*token) - completePrompt(threadId, submissionId, result); - }; - if (dispatch.expectedTurnId) { - session.request("turn.steer", - {{"threadId", dispatch.threadId}, - {"expectedTurnId", *dispatch.expectedTurnId}, - {"clientUserMessageId", dispatch.clientUserMessageId}, - {"input", std::move(input)}}, - std::move(completed)); - } else { - dispatch.turnOptions["clientUserMessageId"] = dispatch.clientUserMessageId; - session.startTurn(dispatch.threadId, std::move(input), - std::move(dispatch.turnOptions), std::move(completed)); - } -} - -void ShellWidget::Impl::resumePromptQueue(const std::string &threadId) { - if (!resumeInFlightThreads.insert(threadId).second) - return; - const auto token = alive; - session.resumeThread( - threadId, nlohmann::json::object(), - [this, token, threadId](const nlohmann::json &result) { - if (!*token) - return; - resumeInFlightThreads.erase(threadId); - if (!result.value("ok", false)) { - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - const QString displayed = text( - message.empty() ? std::string("Thread resume failed") : message); - static_cast(prompts.failQueued(threadId, displayed)); - showNotice(displayed); - render(); - return; - } - hydration[threadId] = Hydration::Hydrated; - operationReadyThreads.insert(threadId); - QTimer::singleShot(0, owner, - [this, threadId] { dispatchNextPrompt(threadId); }); - }); -} - -void ShellWidget::Impl::completePrompt(const std::string &threadId, - std::uint64_t submissionId, - const nlohmann::json &result) { - if (attemptThreadRecovery(threadId, submissionId, result)) - return; - promptRecoveryAttempted.erase(recoveryKey(threadId, submissionId)); - if (result.value("ok", false)) { - operationReadyThreads.insert(threadId); - static_cast(prompts.acknowledge(threadId, submissionId, - resultTurnId(result), - QDateTime::currentMSecsSinceEpoch())); - if (const ThreadPresentation *thread = model.thread(threadId)) - prompts.reconcile(threadId, *thread); - scheduleAcceptedTransition(threadId, submissionId); - } else { - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - const QString displayed = - text(message.empty() ? std::string("Submission failed") : message); - static_cast(prompts.fail(threadId, submissionId, displayed)); - showNotice(text(message.empty() ? std::string("Turn submission failed") - : message)); - } - render(); - QTimer::singleShot(0, owner, - [this, threadId] { dispatchNextPrompt(threadId); }); -} - -bool ShellWidget::Impl::attemptThreadRecovery(const std::string &threadId, - std::uint64_t submissionId, - const nlohmann::json &result) { - if (!isThreadNotFoundResult(result)) - return false; - const std::string key = recoveryKey(threadId, submissionId); - if (!promptRecoveryAttempted.insert(key).second) - return false; - if (!prompts.requeue(threadId, submissionId)) - return false; - hydration[threadId] = Hydration::NotHydrated; - operationReadyThreads.erase(threadId); - render(); - resumeInFlightThreads.insert(threadId); - const auto token = alive; - session.resumeThread( - threadId, nlohmann::json::object(), - [this, token, threadId](const nlohmann::json &resumeResult) { - if (!*token) - return; - resumeInFlightThreads.erase(threadId); - if (!resumeResult.value("ok", false)) { - const std::string message = safeMessage( - resumeResult.value("error", nlohmann::json::object())); - const QString displayed = - text(message.empty() ? std::string("Thread recovery failed") - : message); - static_cast(prompts.failQueued(threadId, displayed)); - showNotice(displayed); - render(); - return; - } - hydration[threadId] = Hydration::Hydrated; - operationReadyThreads.insert(threadId); - QTimer::singleShot(0, owner, - [this, threadId] { dispatchNextPrompt(threadId); }); - }); - return true; -} - -void ShellWidget::Impl::scheduleAcceptedTransition(const std::string &threadId, - std::uint64_t submissionId) { - const middle::PromptSubmission *submission = - prompts.submission(threadId, submissionId); - if (!submission || submission->state != middle::PromptState::Accepted) - return; - const qint64 elapsed = - QDateTime::currentMSecsSinceEpoch() - submission->acceptedAtMilliseconds; - const int remaining = static_cast(std::max( - 1, middle::AcknowledgementTransitionMilliseconds - elapsed)); - QTimer::singleShot( - remaining, Qt::PreciseTimer, owner, [this, threadId, submissionId] { - const middle::PromptSubmission *current = - prompts.submission(threadId, submissionId); - if (!current || current->state != middle::PromptState::Accepted) - return; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (current->acceptedTransitionActive(now)) { - scheduleAcceptedTransition(threadId, submissionId); - return; - } - if (const ThreadPresentation *thread = model.thread(threadId)) - prompts.reconcile(threadId, *thread); - prompts.compactResolved(threadId, now); - render(); - }); -} - -void ShellWidget::Impl::chooseAttachments() { - const QString initial = - text(middleRegion->composer().turnSettings()->workspace( - QDir::currentPath().toStdString())); - FileSelectionDialog dialog(FileSelectionDialog::Mode::Attachments, initial, - middleRegion->composer().attachments(), owner); - if (dialog.exec() == QDialog::Accepted) - middleRegion->composer().setAttachments(dialog.selectedAttachments()); -} - -void ShellWidget::Impl::interruptTurn() { - const auto turn = model.activeTurnId(selectedThreadId); - if (turn) - session.interruptTurn(selectedThreadId, *turn); -} - -void ShellWidget::Impl::respondToFirstPending(bool approve) { - const auto &pending = model.pendingRequestPresentations(); - const auto request = std::ranges::find_if(pending, [this](const auto &entry) { - return entry.second.threadId == selectedThreadId; - }); - if (request == pending.end()) - return; - if (approve) - reviewPending(request->first); - else - rejectPending(request->first); -} - -void ShellWidget::Impl::reviewPending(const std::string &requestKey) { - const auto request = model.pendingRequestPresentations().find(requestKey); - if (request == model.pendingRequestPresentations().end()) - return; - const auto response = PendingRequestDialog::present(request->second, owner); - if (!response) - return; - session.respondToServerRequest(nlohmann::json::parse(requestKey), - response->result, response->error); -} - -void ShellWidget::Impl::rejectPending(const std::string &requestKey) { - const auto request = model.pendingRequestPresentations().find(requestKey); - if (request == model.pendingRequestPresentations().end()) - return; - PendingRequestResponse response = - PendingRequestDialog::negativeResponse(request->second); - session.respondToServerRequest(nlohmann::json::parse(requestKey), - std::move(response.result), - std::move(response.error)); -} - -ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) - : QWidget(parent), impl(nullptr) { - // Impl installs this widget as the application event filter. Keep the - // member in a defined null state while Impl builds child widgets: their - // construction can synchronously pass events through that filter. - impl = std::make_unique(this, session); -} - -ShellWidget::~ShellWidget() = default; - -bool ShellWidget::eventFilter(QObject *watched, QEvent *event) { - if (impl && impl->middleRegion->routeScrollEvent(watched, event)) - return true; - return QWidget::eventFilter(watched, event); -} - -} // namespace codexui::codex diff --git a/src/greenfield/codex/ShellWidget.h b/src/greenfield/codex/ShellWidget.h deleted file mode 100644 index b400a45..0000000 --- a/src/greenfield/codex/ShellWidget.h +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_GREENFIELD_CODEX_SHELLWIDGET_H -#define CODEXUI_GREENFIELD_CODEX_SHELLWIDGET_H - -#include - -#include - -namespace codexui::codex { - -class FrontendSession; - -// Production shell backed by the green-field middle-region implementation. -// The private implementation keeps protocol/application coordination out of -// the visual component interfaces. -class ShellWidget final : public QWidget { -public: - explicit ShellWidget(FrontendSession &session, QWidget *parent = nullptr); - ~ShellWidget() override; - - ShellWidget(const ShellWidget &) = delete; - ShellWidget &operator=(const ShellWidget &) = delete; - -protected: - bool eventFilter(QObject *watched, QEvent *event) override; - -private: - struct Impl; - std::unique_ptr impl; -}; - -} // namespace codexui::codex - -#endif diff --git a/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp similarity index 99% rename from tests/codex/GreenfieldLayoutTest.cpp rename to tests/codex/ApplicationLayoutTest.cpp index 981f563..0a33e59 100644 --- a/tests/codex/GreenfieldLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -919,6 +919,6 @@ int main(int argc, char **argv) { result &= testInspectorDetailParity(); result &= testGitDiffScopes(); if (result) - std::cout << "Greenfield layout tests passed\n"; + std::cout << "Application layout tests passed\n"; return result ? 0 : 1; } diff --git a/tests/codex/GreenfieldMiddleTest.cpp b/tests/codex/ConversationCardsTest.cpp similarity index 99% rename from tests/codex/GreenfieldMiddleTest.cpp rename to tests/codex/ConversationCardsTest.cpp index 08ce960..e55da45 100644 --- a/tests/codex/GreenfieldMiddleTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -757,6 +757,6 @@ int main(int argc, char **argv) { result &= testCommandOutputStateAcrossNavigation(); result &= testPendingPromptAnimation(); if (result) - std::cout << "Greenfield middle-region tests passed\n"; + std::cout << "Conversation card tests passed\n"; return result ? 0 : 1; } diff --git a/tests/codex/GreenfieldProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp similarity index 99% rename from tests/codex/GreenfieldProjectionTest.cpp rename to tests/codex/ConversationProjectionTest.cpp index 156f2bc..f75f1f8 100644 --- a/tests/codex/GreenfieldProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -374,6 +374,6 @@ int main() { result &= testAnchoredDuplicatePrompts(); result &= testCommandOutputVisibility(); if (result) - std::cout << "Greenfield projection tests passed\n"; + std::cout << "Conversation projection tests passed\n"; return result ? 0 : 1; } diff --git a/tests/codex/ConversationScrollTest.cpp b/tests/codex/ConversationScrollTest.cpp deleted file mode 100644 index 5eb92c5..0000000 --- a/tests/codex/ConversationScrollTest.cpp +++ /dev/null @@ -1,518 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include - -#include "codex/Configuration.h" -#include "codex/FrontendSession.h" -#include "codex/PresentationProtocol.h" -#include "codex/ProtocolNormalizer.h" -#include "codex/ShellWidget.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace codexui::codex { - -class ShellWidgetScrollTest { -public: - static bool run(FrontendSession &session) { - ShellWidget shell(session); - shell.resize(1280, 820); - shell.show(); - spinEvents(60); - - populate(shell, 14, 84); - QScrollBar *scrollBar = shell.conversationScroll->verticalScrollBar(); - scrollBar->setValue(std::min(330, scrollBar->maximum() - 40)); - shell.conversationFollowsLatest = false; - spinEvents(20); - - const ShellWidget::ConversationScrollAnchor pausedAnchor = - shell.captureConversationScrollAnchor(); - const int pausedOffset = anchorOffset(shell, pausedAnchor.key); - const int pausedValue = scrollBar->value(); - - const bool appendKeptViewportHeight = observeViewportHeight( - shell, [&shell] { insertCard(shell, 14, 84); }, 50); - bool result = expect(scrollBar->value() == pausedValue, - "paused bottom append preserves scrollbar value"); - result &= expect(appendKeptViewportHeight, - "card insertion never changes viewport height"); - result &= expect(anchorOffset(shell, pausedAnchor.key) == pausedOffset, - "paused bottom append preserves the visible card"); - result &= expect(!shell.conversationFollowsLatest, - "paused bottom append does not enable following"); - - const ShellWidget::ConversationScrollAnchor reflowAnchor = - shell.captureConversationScrollAnchor(); - const int reflowOffset = anchorOffset(shell, reflowAnchor.key); - QWidget *first = card(shell, QStringLiteral("card:0")); - first->setFixedHeight(first->height() + 73); - shell.conversationScrollRebuilding = true; - shell.settleConversationScroll(false, reflowAnchor, false); - spinEvents(60); - result &= expect(anchorOffset(shell, reflowAnchor.key) == reflowOffset, - "paused reflow above preserves the visible card offset"); - result &= expect(!shell.conversationFollowsLatest, - "paused reflow above remains paused"); - - const ShellWidget::ConversationScrollAnchor rebuildAnchor = - shell.captureConversationScrollAnchor(); - const int rebuildOffset = anchorOffset(shell, rebuildAnchor.key); - shell.stopConversationScrollAnimation(); - shell.conversationScrollRebuilding = true; - populate(shell, 17, 84, 73); - shell.settleConversationScroll(false, rebuildAnchor, false); - spinEvents(70); - result &= expect(anchorOffset(shell, rebuildAnchor.key) == rebuildOffset, - "paused reconstruction preserves the visible card offset"); - result &= expect(!shell.conversationFollowsLatest, - "paused reconstruction remains paused"); - - const ShellWidget::ConversationScrollAnchor lateReflowAnchor = - shell.captureConversationScrollAnchor(); - const int lateReflowOffset = anchorOffset(shell, lateReflowAnchor.key); - first = card(shell, QStringLiteral("card:0")); - first->setFixedHeight(first->height() + 41); - shell.conversationLayout->invalidate(); - shell.conversationContent->updateGeometry(); - spinEvents(70); - result &= - expect(anchorOffset(shell, lateReflowAnchor.key) == lateReflowOffset, - "paused late range change restores the retained visual anchor"); - result &= expect(!shell.conversationFollowsLatest, - "paused late range change remains paused"); - - shell.conversationPausedAnchor = shell.captureConversationScrollAnchor(); - shell.conversationPausedAnchorValid = true; - for (int index = 16; index >= 7; --index) { - QWidget *removed = card(shell, QStringLiteral("card:%1").arg(index)); - shell.conversationLayout->removeWidget(removed); - delete removed; - } - shell.conversationLayout->invalidate(); - shell.conversationContent->updateGeometry(); - spinEvents(80); - result &= expect(!shell.conversationFollowsLatest, - "a layout range clamp cannot re-enable following"); - - populate(shell, 17, 84, 73); - - scrollBar->setValue(scrollBar->maximum()); - shell.conversationFollowsLatest = true; - const int formerMaximum = scrollBar->maximum(); - const ShellWidget::ConversationScrollAnchor followAnchor = - shell.captureConversationScrollAnchor(); - shell.conversationScrollRebuilding = true; - insertCard(shell, 17, 180); - shell.settleConversationScroll(true, followAnchor, true); - spinEvents(70); - const int animatedValue = scrollBar->value(); - result &= expect(animatedValue > formerMaximum && - animatedValue < scrollBar->maximum(), - "bottom following advances through an intermediate value"); - spinEvents(300); - result &= expect(scrollBar->value() == scrollBar->maximum(), - "smooth following reaches the latest content"); - - const int secondFormerMaximum = scrollBar->maximum(); - insertCard(shell, 18, 220); - spinEvents(45); - result &= expect(scrollBar->value() > secondFormerMaximum, - "a later append starts another smooth follow"); - scrollBar->triggerAction(QAbstractSlider::SliderSingleStepSub); - spinEvents(20); - const int interruptedValue = scrollBar->value(); - result &= expect(!shell.conversationFollowsLatest, - "user scroll immediately pauses smooth following"); - spinEvents(300); - result &= expect(scrollBar->value() == interruptedValue, - "interrupted following does not resume or jump"); - - ItemPresentation emptyOutput; - emptyOutput.raw = {{"type", "commandExecution"}, - {"command", "true"}, - {"status", "inProgress"}, - {"cwd", "/workspace"}, - {"aggregatedOutput", ""}}; - ItemPresentation whitespaceOutput = emptyOutput; - whitespaceOutput.raw["aggregatedOutput"] = " \n\t"; - whitespaceOutput.raw["nonVisualProtocolMetadata"] = 7; - result &= expect(shell.conversationItemFingerprint(emptyOutput) == - shell.conversationItemFingerprint(whitespaceOutput), - "nonvisual command updates do not invalidate a card"); - whitespaceOutput.raw["aggregatedOutput"] = "visible\n"; - result &= expect(shell.conversationItemFingerprint(emptyOutput) != - shell.conversationItemFingerprint(whitespaceOutput), - "visible command output invalidates its card"); - - const nlohmann::json commandItems = - nlohmann::json::array({{{"id", "empty"}, - {"type", "commandExecution"}, - {"command", "true"}, - {"status", "completed"}, - {"aggregatedOutput", ""}}, - {{"id", "whitespace"}, - {"type", "commandExecution"}, - {"command", "printf whitespace"}, - {"status", "completed"}, - {"aggregatedOutput", " \n\t"}}, - {{"id", "control"}, - {"type", "commandExecution"}, - {"command", "printf control"}, - {"status", "completed"}, - {"aggregatedOutput", "\x1b[0m\x1b]0;\x07"}}, - {{"id", "visible"}, - {"type", "commandExecution"}, - {"command", "printf visible"}, - {"status", "completed"}, - {"aggregatedOutput", "visible\n"}}}); - const nlohmann::json hydratedThread = { - {"id", "output-visibility"}, - {"status", {{"type", "idle"}}}, - {"turns", nlohmann::json::array({{{"id", "output-turn"}, - {"status", "completed"}, - {"items", commandItems}}})}}; - shell.model.applyEvent(presentation::result( - 1, 1, "thread.read", "read-output-visibility", true, - {{"thread", hydratedThread}}, presentation::Authority::Merge, - {{"threadId", "output-visibility"}})); - shell.selectedThreadId = "output-visibility"; - shell.refreshConversation(); - spinEvents(80); - const auto outputSurfaceCount = [&shell](const std::string &itemId) { - const std::string key = std::string("output-turn\x1f") + itemId; - const auto card = shell.conversationCards.find(key); - if (card == shell.conversationCards.end()) - return 0; - const QList views = card->second->findChildren(); - return static_cast( - std::count_if(views.begin(), views.end(), [](QTextEdit *view) { - return view->property("kind").toString() == QStringLiteral("code"); - })); - }; - result &= expect(outputSurfaceCount("empty") == 0 && - outputSurfaceCount("whitespace") == 0 && - outputSurfaceCount("control") == 0, - "non-presentable command output creates no black box"); - result &= expect(outputSurfaceCount("visible") == 1, - "presentable command output creates one black box"); - - const std::string emptyCommandKey = - std::string("output-turn") + '\x1f' + "empty"; - const std::string insertedCommandKey = - std::string("output-turn") + '\x1f' + "inserted"; - QWidget *retainedEmptyCard = shell.conversationCards.at(emptyCommandKey); - const nlohmann::json insertedCommand = {{"id", "inserted"}, - {"type", "commandExecution"}, - {"command", "printf inserted"}, - {"status", "inProgress"}, - {"aggregatedOutput", "inserted\n"}}; - shell.model.applyEvent(presentation::event( - 2, 1, "conversation.item.upsert", {{"item", insertedCommand}}, - presentation::Authority::Merge, - {{"threadId", "output-visibility"}, - {"turnId", "output-turn"}, - {"itemId", "inserted"}})); - shell.dirtyConversationItems[insertedCommandKey] = {"output-turn", - "inserted"}; - const bool incrementalInsertKeptViewportHeight = observeViewportHeight( - shell, [&shell] { shell.refreshConversationItems(); }, 80); - result &= expect( - shell.conversationCards.contains(insertedCommandKey) && - shell.conversationCards.at(emptyCommandKey) == retainedEmptyCard, - "new command card inserts without reconstructing retained cards"); - result &= expect(incrementalInsertKeptViewportHeight, - "new command insertion keeps viewport geometry fixed"); - - nlohmann::json updatedCommand = insertedCommand; - updatedCommand["aggregatedOutput"] = - "inserted\nwith a second visible line\n"; - shell.model.applyEvent(presentation::event( - 3, 1, "conversation.item.upsert", {{"item", updatedCommand}}, - presentation::Authority::Merge, - {{"threadId", "output-visibility"}, - {"turnId", "output-turn"}, - {"itemId", "inserted"}})); - shell.dirtyConversationItems[insertedCommandKey] = {"output-turn", - "inserted"}; - QWidget *insertedCard = shell.conversationCards.at(insertedCommandKey); - const bool commandUpdateKeptViewportHeight = observeViewportHeight( - shell, [&shell] { shell.refreshConversationItems(); }, 80); - result &= expect(commandUpdateKeptViewportHeight && - shell.conversationCards.at(insertedCommandKey) == - insertedCard, - "command update mutates in place with fixed viewport"); - - nlohmann::json completedCommand = updatedCommand; - completedCommand["status"] = "completed"; - completedCommand["exitCode"] = 0; - shell.model.applyEvent(presentation::event( - 4, 1, "conversation.item.upsert", {{"item", completedCommand}}, - presentation::Authority::Merge, - {{"threadId", "output-visibility"}, - {"turnId", "output-turn"}, - {"itemId", "inserted"}})); - shell.dirtyConversationItems[insertedCommandKey] = {"output-turn", - "inserted"}; - const bool completionKeptViewportHeight = observeViewportHeight( - shell, [&shell] { shell.refreshConversationItems(); }, 80); - result &= expect(completionKeptViewportHeight && - shell.conversationCards.at(insertedCommandKey) == - insertedCard, - "command completion mutates the retained card in place"); - - nlohmann::json nonvisualCommand = completedCommand; - nonvisualCommand["nonVisualProtocolMetadata"] = "ignored"; - shell.model.applyEvent(presentation::event( - 5, 1, "conversation.item.upsert", {{"item", nonvisualCommand}}, - presentation::Authority::Merge, - {{"threadId", "output-visibility"}, - {"turnId", "output-turn"}, - {"itemId", "inserted"}})); - shell.dirtyConversationItems[insertedCommandKey] = {"output-turn", - "inserted"}; - const int valueBeforeNonvisualUpdate = - shell.conversationScroll->verticalScrollBar()->value(); - shell.refreshConversationItems(); - spinEvents(80); - result &= expect( - shell.conversationCards.at(insertedCommandKey) == insertedCard && - shell.conversationScroll->verticalScrollBar()->value() == - valueBeforeNonvisualUpdate, - "nonvisual command completion data does not touch card or scroll"); - - const std::string streamedAgentKey = - std::string("output-turn") + '\x1f' + "streamed-agent"; - const nlohmann::json startedAgent = {{"id", "streamed-agent"}, - {"type", "agentMessage"}, - {"phase", "commentary"}, - {"text", ""}}; - nlohmann::json normalizedStart; - ProtocolNormalizer normalizer( - [&normalizedStart](const nlohmann::json &frame) { - normalizedStart = frame; - return true; - }); - normalizer.serverNotification("item/started", - {{"threadId", "output-visibility"}, - {"turnId", "output-turn"}, - {"item", startedAgent}}); - result &= expect( - normalizedStart.value("scope", nlohmann::json::object()) - .value("itemId", std::string{}) == "streamed-agent", - "item start derives stable scope identity from the item payload"); - normalizedStart["sequence"] = 6; - normalizedStart["generation"] = 1; - shell.handleEvent(normalizedStart); - shell.handleEvent( - presentation::event(7, 1, "conversation.item.append", - {{"field", "text"}, {"text", "streamed reply"}}, - presentation::Authority::Merge, - {{"threadId", "output-visibility"}, - {"turnId", "output-turn"}, - {"itemId", "streamed-agent"}})); - spinEvents(100); - bool streamedTextVisible = false; - const auto streamedCard = shell.conversationCards.find(streamedAgentKey); - if (streamedCard != shell.conversationCards.end()) { - for (QLabel *label : streamedCard->second->findChildren()) - streamedTextVisible |= - label->text().contains(QStringLiteral("streamed reply")); - } - result &= expect(streamedCard != shell.conversationCards.end() && - streamedTextVisible, - "streamed agent card appears without another prompt"); - - ShellWidget::PendingPrompt awaiting; - awaiting.id = 90; - awaiting.prompt = QStringLiteral("Prompt before streamed output"); - shell.pendingPrompts["output-visibility"].push_back(std::move(awaiting)); - shell.refreshConversation(); - spinEvents(80); - const QString pendingAnchor = - shell.pendingPromptAnchorKey("output-visibility", 90); - QWidget *pendingBeforeOutput = nullptr; - for (int index = 0; index < shell.conversationLayout->count(); ++index) { - QWidget *candidate = shell.conversationLayout->itemAt(index)->widget(); - if (candidate && - candidate->property("conversationAnchorKey").toString() == - pendingAnchor) { - pendingBeforeOutput = candidate; - break; - } - } - const std::string laterAgentKey = - std::string("output-turn") + '\x1f' + "later-agent"; - const nlohmann::json laterAgent = {{"id", "later-agent"}, - {"type", "agentMessage"}, - {"phase", "commentary"}, - {"text", "later reply"}}; - shell.model.applyEvent(presentation::event( - 8, 1, "conversation.item.upsert", {{"item", laterAgent}}, - presentation::Authority::Merge, - {{"threadId", "output-visibility"}, - {"turnId", "output-turn"}, - {"itemId", "later-agent"}})); - shell.dirtyConversationItems[laterAgentKey] = {"output-turn", - "later-agent"}; - const bool orderedInsertKeptViewportHeight = observeViewportHeight( - shell, [&shell] { shell.refreshConversationItems(); }, 80); - QWidget *laterAgentCard = shell.conversationCards.at(laterAgentKey); - result &= - expect(pendingBeforeOutput && - shell.conversationLayout->indexOf(pendingBeforeOutput) < - shell.conversationLayout->indexOf(laterAgentCard), - "new streamed cards remain after the locally admitted prompt"); - result &= expect(orderedInsertKeptViewportHeight, - "post-prompt insertion keeps viewport geometry fixed"); - - shell.localNewThreadIntent = true; - shell.selectedThreadId.clear(); - ShellWidget::PendingPrompt acknowledged; - acknowledged.id = 91; - acknowledged.prompt = QStringLiteral("Fast acknowledgment"); - acknowledged.status = ShellWidget::PendingPromptStatus::Acknowledged; - acknowledged.acknowledgedAtMilliseconds = - QDateTime::currentMSecsSinceEpoch(); - shell.newThreadPendingPrompts.push_back(std::move(acknowledged)); - shell.refreshConversation(); - spinEvents(60); - QFrame *pendingCard = nullptr; - for (int index = 0; index < shell.conversationLayout->count(); ++index) { - QWidget *candidate = shell.conversationLayout->itemAt(index)->widget(); - if (candidate && - candidate->property("conversationAnchorKey").toString() == - QStringLiteral("pending:new:91")) { - pendingCard = qobject_cast(candidate); - break; - } - } - bool acceptedLabelFound = false; - if (pendingCard) { - for (QLabel *label : pendingCard->findChildren()) - acceptedLabelFound |= - label->text().contains(QStringLiteral("Accepted by app-server")); - } - const QImage firstFrame = - pendingCard ? pendingCard->grab().toImage() : QImage{}; - spinEvents(120); - const QImage secondFrame = - pendingCard ? pendingCard->grab().toImage() : QImage{}; - result &= expect(pendingCard && acceptedLabelFound, - "fast acknowledgment retains a visible transition card"); - result &= expect(!firstFrame.isNull() && firstFrame != secondFrame, - "acknowledgment transition visibly animates"); - - return result; - } - -private: - static void spinEvents(int milliseconds) { - for (int elapsed = 0; elapsed < milliseconds; elapsed += 2) { - QApplication::processEvents(QEventLoop::AllEvents, 2); - QThread::msleep(2); - } - QApplication::processEvents(QEventLoop::AllEvents); - } - - static bool observeViewportHeight(ShellWidget &shell, - const std::function &operation, - int milliseconds) { - const int expectedHeight = shell.conversationScroll->viewport()->height(); - bool stable = true; - operation(); - for (int elapsed = 0; elapsed < milliseconds; elapsed += 2) { - QApplication::processEvents(QEventLoop::AllEvents, 2); - stable &= - shell.conversationScroll->viewport()->height() == expectedHeight; - QThread::msleep(2); - } - QApplication::processEvents(QEventLoop::AllEvents); - return stable && - shell.conversationScroll->viewport()->height() == expectedHeight; - } - - static bool expect(bool condition, const char *message) { - std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; - return condition; - } - - static QWidget *newCard(int index, int height) { - auto *result = new QWidget; - result->setFixedHeight(height); - result->setProperty("conversationAnchorKey", - QStringLiteral("card:%1").arg(index)); - return result; - } - - static void populate(ShellWidget &shell, int count, int height, - int firstExtraHeight = 0) { - while (QLayoutItem *item = shell.conversationLayout->takeAt(0)) { - delete item->widget(); - delete item; - } - shell.conversationCards.clear(); - shell.conversationTrailingSpace = nullptr; - for (int index = 0; index < count; ++index) - shell.conversationLayout->addWidget( - newCard(index, height + (index == 0 ? firstExtraHeight : 0))); - shell.addConversationTrailingSpace(); - shell.conversationLayout->addStretch(); - shell.conversationLayout->invalidate(); - shell.conversationContent->updateGeometry(); - spinEvents(50); - } - - static void insertCard(ShellWidget &shell, int index, int height) { - shell.conversationLayout->insertWidget( - std::max(0, shell.conversationLayout->count() - 2), - newCard(index, height)); - shell.conversationLayout->invalidate(); - shell.conversationContent->updateGeometry(); - } - - static QWidget *card(ShellWidget &shell, const QString &key) { - for (int index = 0; index < shell.conversationLayout->count(); ++index) { - QWidget *candidate = shell.conversationLayout->itemAt(index)->widget(); - if (candidate && - candidate->property("conversationAnchorKey").toString() == key) - return candidate; - } - return nullptr; - } - - static int anchorOffset(ShellWidget &shell, const QString &key) { - QWidget *anchored = card(shell, key); - return anchored - ? anchored - ->mapTo(shell.conversationScroll->viewport(), QPoint(0, 0)) - .y() - : -100000; - } -}; - -} // namespace codexui::codex - -int main(int argc, char **argv) { - QApplication application(argc, argv); - auto *configuration = - utils::Config::configRoot.newSubCommand(); - codexui::codex::FrontendSession session(*configuration); - return codexui::codex::ShellWidgetScrollTest::run(session) ? 0 : 1; -} diff --git a/tests/codex/GreenfieldShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp similarity index 99% rename from tests/codex/GreenfieldShellIntegrationTest.cpp rename to tests/codex/ShellIntegrationTest.cpp index 3ca700f..bf7c98e 100644 --- a/tests/codex/GreenfieldShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -725,6 +725,6 @@ int main(int argc, char **argv) { codexui::codex::FrontendSessionTestPeer::takeClientDescriptor(session)); const bool result = codexui::codex::runShellFlow(session, peer); if (result) - std::cout << "Greenfield shell integration test passed\n"; + std::cout << "Shell integration test passed\n"; return result ? 0 : 1; } From c07843806e195631b6e646c6272940c784c2083f Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 13:12:46 +0200 Subject: [PATCH 14/36] Install libgit2 in CI --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe09bb6..ba4f449 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: cmake \ iproute2 \ libbluetooth-dev \ + libgit2-dev \ libmagic-dev \ libssl-dev \ ninja-build \ From 985f76ca628a3809c1c54ab630633b78ac90a641 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 13:38:30 +0200 Subject: [PATCH 15/36] Align transport and sort chevrons --- docs/ui-behavior.md | 5 +++-- src/codex/ShellWidget.cpp | 5 ++++- src/codex/middle/ThreadPane.cpp | 23 +---------------------- src/codex/ui/UiStyle.cpp | 23 +++++++++++++++++++++-- src/codex/ui/UiStyle.h | 10 ++++++++++ tests/codex/ApplicationLayoutTest.cpp | 2 ++ tests/codex/ShellIntegrationTest.cpp | 10 ++++++++++ 7 files changed, 51 insertions(+), 27 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index e8189a6..ed6dcca 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -47,8 +47,9 @@ bottom or is owned by the user. does not provide a separate ascending/descending control. - Each visible thread is presented as a compact card. Its status indicator is part of that card, and hover and selection strengthen the same card surface - instead of introducing a separate row treatment. The Sort control uses the - same centered chevron treatment as the prompt settings. + instead of introducing a separate row treatment. The Sort and Transport + controls use the same centered chevron and compact text-to-indicator spacing + as the prompt settings. - A left click selects a thread and changes the displayed conversation. A right click opens actions for the pointed-to card without changing the selected thread or displayed conversation. That card retains its hover diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 16c831d..218550e 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -18,6 +18,7 @@ #include "codex/middle/ThreadPane.h" #include "codex/ui/BrandMark.h" #include "codex/ui/ExpandingPromptEditor.h" +#include "codex/ui/UiStyle.h" #include #include @@ -298,9 +299,11 @@ void ShellWidget::Impl::buildUi() { connectionStatusDot = statusDot(); connectionStatusDot->setToolTip(QStringLiteral("Not connected")); - connectionButton = new QToolButton; + connectionButton = new UiStyle::ChevronToolButton; + connectionButton->setObjectName(QStringLiteral("transportButton")); connectionButton->setText(QStringLiteral("Connection")); connectionButton->setProperty("kind", "subtle"); + connectionButton->setProperty("codexChevron", true); connectionButton->setPopupMode(QToolButton::InstantPopup); connectionButton->setFixedHeight(32); auto *connectionMenu = new QMenu(connectionButton); diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index fa827e8..b23beb3 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -14,10 +14,8 @@ #include #include #include -#include #include #include -#include #include #include @@ -58,25 +56,6 @@ class ThreadItemDelegate final : public QStyledItemDelegate { } }; -class ChevronToolButton final : public QToolButton { -protected: - void paintEvent(QPaintEvent *event) override { - QToolButton::paintEvent(event); - QStyleOptionToolButton option; - initStyleOption(&option); - const QRect contents = - style()->subElementRect(QStyle::SE_ToolButtonLayoutItem, &option, this); - const int indicatorWidth = - style()->pixelMetric(QStyle::PM_MenuButtonIndicator, &option, this); - const QRect indicator(contents.right() - std::max(12, indicatorWidth), - contents.top(), std::max(12, indicatorWidth), - contents.height()); - UiStyle::drawChevron( - this, indicator, option.state & QStyle::State_Enabled, - option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)); - } -}; - QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -222,7 +201,7 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { }); toolbar->addWidget(refresh); toolbar->addStretch(); - sortButton = new ChevronToolButton; + sortButton = new UiStyle::ChevronToolButton; sortButton->setObjectName(QStringLiteral("threadSortButton")); sortButton->setProperty("kind", "subtle"); sortButton->setProperty("codexChevron", true); diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index fc2264a..4f6867e 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -4,8 +4,11 @@ #include #include +#include #include #include +#include +#include #include #include @@ -41,6 +44,22 @@ void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, painter.drawPath(chevron); } +void ChevronToolButton::paintEvent(QPaintEvent *event) { + QToolButton::paintEvent(event); + QStyleOptionToolButton option; + initStyleOption(&option); + const QRect contents = + style()->subElementRect(QStyle::SE_ToolButtonLayoutItem, &option, this); + const int indicatorWidth = + style()->pixelMetric(QStyle::PM_MenuButtonIndicator, &option, this); + const QRect indicator(contents.right() - std::max(12, indicatorWidth), + contents.top(), std::max(12, indicatorWidth), + contents.height()); + drawChevron(this, indicator, option.state & QStyle::State_Enabled, + option.state & + (QStyle::State_MouseOver | QStyle::State_HasFocus)); +} + QString applicationStyleSheet() { const qreal configuredSize = QFontInfo(QApplication::font()).pointSizeF(); const qreal baseSize = configuredSize > 0.0 ? configuredSize : 10.0; @@ -149,9 +168,9 @@ QString applicationStyleSheet() { QPushButton[kind="destructiveCompact"] { background: #c43d4d; border: 0; color: white; border-radius: 4px; padding: 0; font-weight: 700; } QPushButton[kind="destructiveCompact"]:hover { background: #aa3342; } QPushButton[kind="destructiveCompact"]:pressed { background: #8f2b38; } - QPushButton[codexChevron="true"] { padding-right: 26px; } + QPushButton[codexChevron="true"] { padding-right: 20px; } QPushButton[codexChevron="true"]::menu-indicator { image: none; width: 0; } - QToolButton[codexChevron="true"] { padding-right: 26px; } + QToolButton[codexChevron="true"] { padding-right: 20px; } QToolButton[codexChevron="true"]::menu-indicator { image: none; width: 0; } QPushButton[changed="true"] { background: #e5eeff; diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index fa5d066..81eea51 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -4,7 +4,9 @@ #define CODEXUI_UI_UISTYLE_H #include +#include +class QPaintEvent; class QRect; class QWidget; @@ -53,6 +55,14 @@ void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, bool highlighted, ChevronDirection direction = ChevronDirection::Down); +class ChevronToolButton final : public QToolButton { +public: + using QToolButton::QToolButton; + +protected: + void paintEvent(QPaintEvent *event) override; +}; + } // namespace codexui::UiStyle #endif // CODEXUI_UI_UISTYLE_H diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 0a33e59..2847191 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -10,6 +10,7 @@ #include "codex/middle/MiddleRegionWidget.h" #include "codex/middle/ThreadPane.h" #include "codex/ui/ExpandingPromptEditor.h" +#include "codex/ui/UiStyle.h" #include #include @@ -289,6 +290,7 @@ bool testThreadSelectionProjection() { rowLayout->spacing() == 8 && title && status && dot && dot->size() == QSize(10, 10) && rowLayout->indexOf(dot) >= 0 && sortButton && + dynamic_cast(sortButton) && sortButton->property("codexChevron").toBool() && title->property("kind").toString() == QStringLiteral("title") && status->property("kind").toString() == QStringLiteral("meta") && diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index bf7c98e..1199d86 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -10,6 +10,7 @@ #include "codex/middle/ConversationCards.h" #include "codex/middle/ConversationView.h" #include "codex/ui/ExpandingPromptEditor.h" +#include "codex/ui/UiStyle.h" #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include @@ -273,6 +275,14 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { spin(10); bool result = true; + auto *transportButton = + shell.findChild(QStringLiteral("transportButton")); + result &= expect( + transportButton && + dynamic_cast(transportButton) && + transportButton->property("codexChevron").toBool(), + "transport and thread sorting use the canonical compact chevron button"); + auto *conversation = dynamic_cast( shell.findChild(QStringLiteral("conversationScroll"))); result &= expect(conversation, "the shell owns the conversation viewport"); From 5dbb3470530e8c79dcd54df8ca851947b7607735 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 14:32:43 +0200 Subject: [PATCH 16/36] Honor retained thread settings --- src/codex/PresentationModel.cpp | 26 +++ src/codex/PresentationModel.h | 2 + src/codex/ProtocolNormalizer.cpp | 13 +- src/codex/ShellWidget.cpp | 12 +- src/codex/TurnSettingsWidget.cpp | 202 +++++++++++++++-------- src/codex/TurnSettingsWidget.h | 11 +- tests/codex/ApplicationLayoutTest.cpp | 59 +++++++ tests/codex/PresentationPipelineTest.cpp | 40 ++++- 8 files changed, 294 insertions(+), 71 deletions(-) diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 9d47421..36d8dd4 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -135,6 +135,21 @@ void mergePreservingCompleteness(nlohmann::json &target, } } +void mergeExplicitMembers(nlohmann::json &target, + const nlohmann::json &update) { + if (!target.is_object() || !update.is_object()) { + target = update; + return; + } + for (const auto &[key, value] : update.items()) { + auto current = target.find(key); + if (current != target.end() && current->is_object() && value.is_object()) + mergeExplicitMembers(*current, value); + else + target[key] = value; + } +} + void appendText(nlohmann::json &item, const char *field, const nlohmann::json ¶ms) { const std::string delta = stringValue(params, "delta"); @@ -183,6 +198,12 @@ void applyDomainAuthority( domains[type] = data; return; } + if (type == "thread.settings.changed") { + // A null setting explicitly restores the app-server default. Preserve it + // instead of treating it as an incomplete presentation update. + mergeExplicitMembers(domains[type], data); + return; + } mergePreservingCompleteness(domains[type], data); } @@ -415,6 +436,11 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { retainDomainEvent(type, data, scope, presentation::stringMember(event, "authority")); + if (type == "thread.settings.changed" && data.is_object()) { + thread.latestSettingsUpdate = data.value("threadSettings", data); + ++thread.settingsRevision; + } + if (type == "turn.upsert") { upsertTurn(thread, memberValue(data, "turn", nlohmann::json::object()), false); diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index bfa0bf9..42af052 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -53,6 +53,8 @@ struct ThreadPresentation { std::unordered_map turns; nlohmann::json raw = nlohmann::json::object(); std::unordered_map domains; + nlohmann::json latestSettingsUpdate = nlohmann::json::object(); + std::uint64_t settingsRevision = 0; std::vector agentOrder; std::unordered_map agents; bool archived = false; diff --git a/src/codex/ProtocolNormalizer.cpp b/src/codex/ProtocolNormalizer.cpp index cb88773..9e827bc 100644 --- a/src/codex/ProtocolNormalizer.cpp +++ b/src/codex/ProtocolNormalizer.cpp @@ -433,8 +433,17 @@ void ProtocolNormalizer::operationResult(std::string action, scope["threadId"] = threadId; } else if (action == "thread.create" || action == "thread.resume" || action == "thread.fork") { - data = {{"thread", presentation::member( - value, "thread", nlohmann::json::object())}}; + nlohmann::json thread = + presentation::member(value, "thread", nlohmann::json::object()); + for (const char *field : {"activePermissionProfile", "approvalPolicy", + "approvalsReviewer", "cwd", "model", + "modelProvider", "reasoningEffort", "sandbox", + "serviceTier"}) { + const auto setting = value.find(field); + if (setting != value.end()) + thread[field] = *setting; + } + data = {{"thread", std::move(thread)}}; authority = Authority::Merge; } else if (action == "models.list") { data = {{"models", presentation::member(value, "data", diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 218550e..db5429c 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -638,9 +638,13 @@ void ShellWidget::Impl::renderConversation() { void ShellWidget::Impl::refreshSettings() { nlohmann::json canonical = nlohmann::json::object(); + nlohmann::json settingsUpdate = nlohmann::json::object(); + std::uint64_t settingsRevision = 0; std::string identity = "no-thread"; if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { identity = thread->id; + settingsUpdate = thread->latestSettingsUpdate; + settingsRevision = thread->settingsRevision; canonical = thread->raw; const auto settings = thread->domains.find("thread.settings.changed"); if (settings != thread->domains.end() && settings->second.is_object()) { @@ -648,6 +652,10 @@ void ShellWidget::Impl::refreshSettings() { if (update.contains("threadSettings") && update["threadSettings"].is_object()) update = update["threadSettings"]; + if (update.contains("effort")) + canonical.erase("reasoningEffort"); + if (update.contains("sandboxPolicy")) + canonical.erase("sandbox"); canonical.merge_patch(update); } } else if (newThreadIntent) { @@ -666,6 +674,7 @@ void ShellWidget::Impl::refreshSettings() { const std::string serialized = nlohmann::json{ {"identity", identity}, {"canonical", canonical}, + {"settingsRevision", settingsRevision}, {"models", model.modelCatalog()}, {"profiles", profiles}}.dump(); const QByteArray next(serialized.data(), @@ -674,7 +683,8 @@ void ShellWidget::Impl::refreshSettings() { return; settingsSnapshot = next; middleRegion->composer().turnSettings()->setContext( - identity, canonical, model.modelCatalog(), profiles); + identity, canonical, model.modelCatalog(), profiles, settingsRevision, + settingsUpdate); } void ShellWidget::Impl::refreshStatus() { diff --git a/src/codex/TurnSettingsWidget.cpp b/src/codex/TurnSettingsWidget.cpp index f57bc05..d366845 100644 --- a/src/codex/TurnSettingsWidget.cpp +++ b/src/codex/TurnSettingsWidget.cpp @@ -206,7 +206,7 @@ TurnSettingsWidget::TurnSettingsWidget(QWidget *parent) : QWidget(parent) { cwd->setFixedHeight(SettingControlHeight); cwd->setStyleSheet( QStringLiteral("QLineEdit#codexWorkspace{min-height:30px;}")); - cwd->setPlaceholderText(QStringLiteral("Codex default workspace")); + cwd->setPlaceholderText(QStringLiteral("Thread default workspace")); auto *workspacePicker = new QWidget; workspacePicker->setFixedHeight(SettingControlHeight); auto *workspaceLayout = new QHBoxLayout(workspacePicker); @@ -266,48 +266,48 @@ TurnSettingsWidget::TurnSettingsWidget(QWidget *parent) : QWidget(parent) { moreMenu->addAction(moreAction); more->setMenu(moreMenu); - addChoice(effort, QStringLiteral("Codex default"), DefaultValue); + addChoice(effort, QStringLiteral("Thread default"), DefaultValue); for (const char *value : {"minimal", "low", "medium", "high", "xhigh", "ultra"}) addChoice(effort, friendly(QString::fromLatin1(value)), QString::fromLatin1(value)); - addChoice(sandbox, QStringLiteral("Codex default"), DefaultValue); + addChoice(sandbox, QStringLiteral("Thread default"), DefaultValue); addChoice(sandbox, QStringLiteral("Workspace"), QStringLiteral("workspace-write")); addChoice(sandbox, QStringLiteral("Read only"), QStringLiteral("read-only")); addChoice(sandbox, QStringLiteral("Full access"), QStringLiteral("danger-full-access")); addChoice(sandbox, QStringLiteral("External"), QStringLiteral("external")); - addChoice(network, QStringLiteral("Codex default"), DefaultValue); + addChoice(network, QStringLiteral("Thread default"), DefaultValue); addChoice(network, QStringLiteral("Restricted"), QStringLiteral("restricted")); addChoice(network, QStringLiteral("Enabled"), QStringLiteral("enabled")); - addChoice(approval, QStringLiteral("Codex default"), DefaultValue); + addChoice(approval, QStringLiteral("Thread default"), DefaultValue); addChoice(approval, QStringLiteral("On request"), QStringLiteral("on-request")); addChoice(approval, QStringLiteral("Untrusted"), QStringLiteral("untrusted")); addChoice(approval, QStringLiteral("Never"), QStringLiteral("never")); - addChoice(personality, QStringLiteral("Codex default"), DefaultValue); + addChoice(personality, QStringLiteral("Thread default"), DefaultValue); addChoice(personality, QStringLiteral("None"), QStringLiteral("none")); addChoice(personality, QStringLiteral("Friendly"), QStringLiteral("friendly")); addChoice(personality, QStringLiteral("Pragmatic"), QStringLiteral("pragmatic")); - addChoice(reviewer, QStringLiteral("Codex default"), DefaultValue); + addChoice(reviewer, QStringLiteral("Thread default"), DefaultValue); addChoice(reviewer, QStringLiteral("User"), QStringLiteral("user")); addChoice(reviewer, QStringLiteral("Auto review"), QStringLiteral("auto_review")); addChoice(reviewer, QStringLiteral("Guardian"), QStringLiteral("guardian_subagent")); - addChoice(serviceTier, QStringLiteral("Codex default"), DefaultValue); - addChoice(summary, QStringLiteral("Codex default"), DefaultValue); + addChoice(serviceTier, QStringLiteral("Thread default"), DefaultValue); + addChoice(summary, QStringLiteral("Thread default"), DefaultValue); addChoice(summary, QStringLiteral("Auto"), QStringLiteral("auto")); addChoice(summary, QStringLiteral("Concise"), QStringLiteral("concise")); addChoice(summary, QStringLiteral("Detailed"), QStringLiteral("detailed")); addChoice(summary, QStringLiteral("None"), QStringLiteral("none")); addChoice(collaboration, QStringLiteral("Code"), QStringLiteral("default")); addChoice(collaboration, QStringLiteral("Plan"), QStringLiteral("plan")); - addChoice(permissionProfile, QStringLiteral("Codex default"), DefaultValue); + addChoice(permissionProfile, QStringLiteral("Thread default"), DefaultValue); const auto connectCombo = [this](QComboBox *combo, Field field) { connect(combo, &QComboBox::currentIndexChanged, this, @@ -351,12 +351,61 @@ TurnSettingsWidget::TurnSettingsWidget(QWidget *parent) : QWidget(parent) { void TurnSettingsWidget::setContext(std::string identity, const nlohmann::json &canonical, const nlohmann::json &models, - const nlohmann::json &permissionProfiles) { + const nlohmann::json &permissionProfiles, + std::uint64_t settingsRevision, + const nlohmann::json &settingsUpdate) { const bool changed = contextIdentity != identity; contextIdentity = std::move(identity); modelCatalog = models.is_array() ? models : nlohmann::json::array(); - if (changed) - resetFromCanonical(canonical); + std::array(Field::Count)> fields{}; + if (changed) { + fields.fill(true); + } else { + const auto differs = [this, &canonical](const char *name) { + return canonicalContext.value(name, nlohmann::json(nullptr)) != + canonical.value(name, nlohmann::json(nullptr)); + }; + const auto received = [&settingsUpdate, + authoritative = canonicalSettingsRevision != + settingsRevision](const char *name) { + return authoritative && settingsUpdate.is_object() && + settingsUpdate.contains(name); + }; + fields[static_cast(Field::Model)] = + differs("model") || received("model"); + fields[static_cast(Field::Effort)] = + differs("effort") || differs("reasoningEffort") || + received("effort") || received("reasoningEffort"); + fields[static_cast(Field::Personality)] = + differs("personality") || received("personality"); + const bool sandboxChanged = differs("sandbox") || + differs("sandboxPolicy") || + received("sandbox") || + received("sandboxPolicy"); + fields[static_cast(Field::Sandbox)] = sandboxChanged; + fields[static_cast(Field::Network)] = sandboxChanged; + fields[static_cast(Field::Approval)] = + differs("approvalPolicy") || received("approvalPolicy"); + fields[static_cast(Field::Reviewer)] = + differs("approvalsReviewer") || received("approvalsReviewer"); + fields[static_cast(Field::Workspace)] = + differs("cwd") || received("cwd"); + fields[static_cast(Field::PermissionProfile)] = + differs("activePermissionProfile") || + received("activePermissionProfile"); + fields[static_cast(Field::ServiceTier)] = + differs("serviceTier") || received("serviceTier"); + fields[static_cast(Field::Summary)] = + differs("summary") || received("summary"); + fields[static_cast(Field::Collaboration)] = + differs("collaborationMode") || received("collaborationMode"); + } + for (std::size_t index = 0; index < fields.size(); ++index) + if (fields[index]) + touchedFields[index] = false; + refreshFromCanonical(canonical, fields); + canonicalContext = canonical; + canonicalSettingsRevision = settingsRevision; refreshModels(modelCatalog); refreshPermissionProfiles(permissionProfiles); refreshModelOptions(); @@ -445,9 +494,11 @@ nlohmann::json TurnSettingsWidget::turnStartOptions() const { (touched(Field::Sandbox) || touched(Field::Network))) { result["sandboxPolicy"] = sandboxPolicy(); } - const nlohmann::json mode = collaborationMode(); - if (!mode.is_null()) - result["collaborationMode"] = mode; + if (touched(Field::Collaboration)) { + const nlohmann::json mode = collaborationMode(); + if (!mode.is_null()) + result["collaborationMode"] = mode; + } return result; } @@ -456,8 +507,12 @@ void TurnSettingsWidget::markTouched(Field field) { refreshMoreIndicator(); } -void TurnSettingsWidget::resetFromCanonical(const nlohmann::json &canonical) { - touchedFields.fill(false); +void TurnSettingsWidget::refreshFromCanonical( + const nlohmann::json &canonical, + const std::array(Field::Count)> &fields) { + const auto refresh = [&fields](Field field) { + return fields[static_cast(field)]; + }; const QSignalBlocker modelBlocker(model); const QSignalBlocker effortBlocker(effort); const QSignalBlocker personalityBlocker(personality); @@ -471,49 +526,66 @@ void TurnSettingsWidget::resetFromCanonical(const nlohmann::json &canonical) { const QSignalBlocker summaryBlocker(summary); const QSignalBlocker collaborationBlocker(collaboration); - selectValue(model, optionalString(canonical, "model"), - QStringLiteral("Codex default")); - QString canonicalEffort = optionalString(canonical, "reasoningEffort"); - if (canonicalEffort == DefaultValue) - canonicalEffort = optionalString(canonical, "effort"); - selectValue(effort, canonicalEffort, QStringLiteral("Codex default")); - selectValue(personality, optionalString(canonical, "personality"), - QStringLiteral("Codex default")); - const nlohmann::json nativeSandbox = canonicalSandbox(canonical); - selectValue(sandbox, sandboxKey(nativeSandbox), - QStringLiteral("Codex default")); - selectValue(network, sandboxKey(nativeSandbox) == DefaultValue - ? QString::fromLatin1(DefaultValue) - : sandboxNetworkEnabled(nativeSandbox) - ? QStringLiteral("enabled") - : QStringLiteral("restricted")); - std::string nativeApproval = stringValue(canonical, "approvalPolicy"); - selectValue(approval, - nativeApproval.empty() ? QString::fromLatin1(DefaultValue) - : text(nativeApproval), - nativeApproval.empty() ? QStringLiteral("Codex default") - : QStringLiteral("Current policy")); - selectValue(reviewer, optionalString(canonical, "approvalsReviewer"), - QStringLiteral("Codex default")); - cwd->setText(text(stringValue(canonical, "cwd"))); - QString activeProfile = QString::fromLatin1(DefaultValue); - const nlohmann::json profile = - canonical.value("activePermissionProfile", nlohmann::json::object()); - if (profile.is_object() && profile.contains("id") && - profile["id"].is_string()) - activeProfile = text(profile["id"].get()); - selectValue(permissionProfile, activeProfile, - QStringLiteral("Current permission profile")); - selectValue(serviceTier, optionalString(canonical, "serviceTier"), - QStringLiteral("Codex default")); - selectValue(summary, optionalString(canonical, "summary"), - QStringLiteral("Codex default")); - const nlohmann::json mode = - canonical.value("collaborationMode", nlohmann::json::object()); - selectValue(collaboration, mode.is_object() && mode.contains("mode") && - mode["mode"].is_string() - ? text(mode["mode"].get()) - : QStringLiteral("default")); + if (refresh(Field::Model)) + selectValue(model, optionalString(canonical, "model"), + QStringLiteral("Thread default")); + if (refresh(Field::Effort)) { + QString canonicalEffort = optionalString(canonical, "reasoningEffort"); + if (canonicalEffort == DefaultValue) + canonicalEffort = optionalString(canonical, "effort"); + selectValue(effort, canonicalEffort, QStringLiteral("Thread default")); + } + if (refresh(Field::Personality)) + selectValue(personality, optionalString(canonical, "personality"), + QStringLiteral("Thread default")); + if (refresh(Field::Sandbox)) + selectValue(sandbox, sandboxKey(canonicalSandbox(canonical)), + QStringLiteral("Thread default")); + if (refresh(Field::Network)) { + const nlohmann::json nativeSandbox = canonicalSandbox(canonical); + selectValue(network, sandboxKey(nativeSandbox) == DefaultValue + ? QString::fromLatin1(DefaultValue) + : sandboxNetworkEnabled(nativeSandbox) + ? QStringLiteral("enabled") + : QStringLiteral("restricted")); + } + if (refresh(Field::Approval)) { + const std::string nativeApproval = stringValue(canonical, "approvalPolicy"); + selectValue(approval, + nativeApproval.empty() ? QString::fromLatin1(DefaultValue) + : text(nativeApproval), + nativeApproval.empty() ? QStringLiteral("Thread default") + : QStringLiteral("Current policy")); + } + if (refresh(Field::Reviewer)) + selectValue(reviewer, optionalString(canonical, "approvalsReviewer"), + QStringLiteral("Thread default")); + if (refresh(Field::Workspace)) + cwd->setText(text(stringValue(canonical, "cwd"))); + if (refresh(Field::PermissionProfile)) { + QString activeProfile = QString::fromLatin1(DefaultValue); + const nlohmann::json profile = canonical.value( + "activePermissionProfile", nlohmann::json::object()); + if (profile.is_object() && profile.contains("id") && + profile["id"].is_string()) + activeProfile = text(profile["id"].get()); + selectValue(permissionProfile, activeProfile, + QStringLiteral("Current permission profile")); + } + if (refresh(Field::ServiceTier)) + selectValue(serviceTier, optionalString(canonical, "serviceTier"), + QStringLiteral("Thread default")); + if (refresh(Field::Summary)) + selectValue(summary, optionalString(canonical, "summary"), + QStringLiteral("Thread default")); + if (refresh(Field::Collaboration)) { + const nlohmann::json mode = + canonical.value("collaborationMode", nlohmann::json::object()); + selectValue(collaboration, mode.is_object() && mode.contains("mode") && + mode["mode"].is_string() + ? text(mode["mode"].get()) + : QStringLiteral("default")); + } } void TurnSettingsWidget::refreshModels(const nlohmann::json &models) { @@ -525,7 +597,7 @@ void TurnSettingsWidget::refreshModels(const nlohmann::json &models) { model->currentText() != model->itemText(model->currentIndex())); const QSignalBlocker blocker(model); model->clear(); - addChoice(model, QStringLiteral("Codex default"), DefaultValue); + addChoice(model, QStringLiteral("Thread default"), DefaultValue); if (models.is_array()) { for (const auto &entry : models) { if (!entry.is_object() || entry.value("hidden", false)) @@ -563,7 +635,7 @@ void TurnSettingsWidget::refreshModelOptions() { } const QSignalBlocker blocker(effort); effort->clear(); - QString defaultLabel = QStringLiteral("Codex default"); + QString defaultLabel = QStringLiteral("Thread default"); if (definition) { const std::string defaultEffort = stringValue(*definition, "defaultReasoningEffort"); @@ -598,7 +670,7 @@ void TurnSettingsWidget::refreshModelOptions() { const QString selectedTier = value(serviceTier); const QSignalBlocker tierBlocker(serviceTier); serviceTier->clear(); - QString defaultTierLabel = QStringLiteral("Codex default"); + QString defaultTierLabel = QStringLiteral("Thread default"); if (definition) { const std::string defaultTier = stringValue(*definition, "defaultServiceTier"); @@ -658,7 +730,7 @@ void TurnSettingsWidget::refreshPermissionProfiles( const QString selected = value(permissionProfile); const QSignalBlocker blocker(permissionProfile); permissionProfile->clear(); - addChoice(permissionProfile, QStringLiteral("Codex default"), DefaultValue); + addChoice(permissionProfile, QStringLiteral("Thread default"), DefaultValue); nlohmann::json entries = profiles; if (profiles.is_object()) entries = profiles.value("data", nlohmann::json::array()); diff --git a/src/codex/TurnSettingsWidget.h b/src/codex/TurnSettingsWidget.h index b4b65ba..3b5f71f 100644 --- a/src/codex/TurnSettingsWidget.h +++ b/src/codex/TurnSettingsWidget.h @@ -23,7 +23,10 @@ class TurnSettingsWidget final : public QWidget { void setContext(std::string identity, const nlohmann::json &canonical, const nlohmann::json &models, - const nlohmann::json &permissionProfiles); + const nlohmann::json &permissionProfiles, + std::uint64_t settingsRevision = 0, + const nlohmann::json &settingsUpdate = + nlohmann::json::object()); void setControlsEnabled(bool enabled); void setWorkspace(QString path); @@ -49,7 +52,9 @@ class TurnSettingsWidget final : public QWidget { }; void markTouched(Field field); - void resetFromCanonical(const nlohmann::json &canonical); + void refreshFromCanonical( + const nlohmann::json &canonical, + const std::array(Field::Count)> &fields); void refreshModels(const nlohmann::json &models); void refreshModelOptions(); void refreshPermissionProfiles(const nlohmann::json &profiles); @@ -61,6 +66,8 @@ class TurnSettingsWidget final : public QWidget { [[nodiscard]] nlohmann::json collaborationMode() const; std::string contextIdentity; + nlohmann::json canonicalContext = nlohmann::json::object(); + std::uint64_t canonicalSettingsRevision = 0; nlohmann::json modelCatalog = nlohmann::json::array(); std::array(Field::Count)> touchedFields{}; diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 2847191..7557983 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -3,6 +3,7 @@ #include "codex/GitDiffProvider.h" #include "codex/PresentationModel.h" #include "codex/PresentationProtocol.h" +#include "codex/TurnSettingsWidget.h" #include "codex/middle/ComposerPane.h" #include "codex/middle/ConversationCards.h" #include "codex/middle/ConversationView.h" @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -326,6 +328,62 @@ bool testThreadSelectionProjection() { return result; } +bool testIncrementalThreadSettings() { + TurnSettingsWidget settings; + const nlohmann::json models = + nlohmann::json::array({{{"model", "gpt-a"}, {"displayName", "A"}}, + {{"model", "gpt-b"}, {"displayName", "B"}}}); + settings.setContext("thread-a", + {{"model", "gpt-a"}, {"approvalPolicy", "never"}}, + models, nlohmann::json::array()); + auto *model = settings.findChild(QStringLiteral("codexModel")); + auto *approval = + settings.findChild(QStringLiteral("codexApproval")); + if (!model || !approval) + return expect(false, "thread settings controls are discoverable"); + + model->setCurrentIndex(model->findData(QStringLiteral("gpt-b"))); + settings.setContext( + "thread-a", + {{"model", "gpt-a"}, {"approvalPolicy", "on-request"}}, models, + nlohmann::json::array(), 1, {{"approvalPolicy", "on-request"}}); + bool result = expect( + model->currentData().toString() == QStringLiteral("gpt-b") && + approval->currentData().toString() == QStringLiteral("on-request"), + "a partial authoritative update preserves unrelated pending settings"); + + settings.setContext( + "thread-a", + {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}, models, + nlohmann::json::array(), 2, + {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}); + result &= expect(!settings.turnStartOptions().contains("model") && + !settings.turnStartOptions().contains("approvalPolicy"), + "authoritative settings clear their pending overrides"); + + settings.setContext( + "thread-b", + {{"model", "gpt-a"}, + {"reasoningEffort", "medium"}, + {"personality", "friendly"}, + {"sandboxPolicy", + {{"type", "workspaceWrite"}, {"networkAccess", false}}}, + {"approvalPolicy", "never"}, + {"approvalsReviewer", "user"}, + {"cwd", "/workspace"}, + {"activePermissionProfile", {{"id", "managed"}}}, + {"serviceTier", "priority"}, + {"summary", "concise"}, + {"collaborationMode", {{"mode", "default"}}}}, + models, nlohmann::json::array()); + result &= expect(model->currentData().toString() == QStringLiteral("gpt-a"), + "thread selection restores that thread's retained value"); + result &= expect(settings.turnStartOptions().empty() && + settings.threadStartOptions().empty(), + "all untouched thread settings produce no overrides"); + return result; +} + bool testThreadAlphanumericSort() { PresentationModel model; model.applyEvent(presentation::result( @@ -911,6 +969,7 @@ int main(int argc, char **argv) { using namespace codexui::codex::middle; bool result = testOverlayGeometryAndRegionRouting(); result &= testThreadSelectionProjection(); + result &= testIncrementalThreadSettings(); result &= testThreadAlphanumericSort(); result &= testThreadCreatedSort(); result &= testThreadLastChangedSort(); diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index 50393b6..c5c8df3 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -148,6 +148,29 @@ int main() { {"status", {{"type", "idle"}}}, {"turns", nlohmann::json::array()}}}}}}); + normalizer.operationResult( + "thread.resume", "resume-1", {"threadId", "thread-1"}, + {{"id", "resume-1"}, + {"result", + {{"thread", {{"id", "thread-1"}}}, + {"model", "gpt-current"}, + {"reasoningEffort", "high"}, + {"approvalPolicy", "never"}, + {"sandbox", "workspaceWrite"}}}}); + normalizer.serverNotification( + "thread/settings/updated", + {{"threadId", "thread-1"}, + {"threadSettings", + {{"model", "gpt-current"}, {"personality", "friendly"}}}}); + normalizer.serverNotification( + "thread/settings/updated", + {{"threadId", "thread-1"}, + {"threadSettings", {{"personality", nullptr}}}}); + normalizer.serverNotification( + "thread/settings/updated", + {{"threadId", "thread-2"}, + {"threadSettings", {{"model", "gpt-background"}}}}); + bool validFrames = !frames.empty(); std::uint64_t expectedSequence = 1; for (const nlohmann::json &frame : frames) { @@ -180,8 +203,23 @@ int main() { stringMember(model.connection().settings, "selected") == "ipv6", "connection, controller, and transport settings form coherent state"); passed &= expect(thread != nullptr && thread->turnOrder.size() == 2 && - thread->cwd == "/workspace", + thread->cwd == "/workspace" && + stringMember(thread->raw, "model") == "gpt-current" && + stringMember(thread->raw, "reasoningEffort") == "high", "list, full read, and live events retain one stable thread"); + const auto settings = + thread == nullptr ? nullptr + : &thread->domains.at("thread.settings.changed") + .at("threadSettings"); + const auto *background = model.thread("thread-2"); + passed &= expect( + settings != nullptr && settings->contains("personality") && + settings->at("personality").is_null() && + thread->settingsRevision == 2 && background != nullptr && + background->settingsRevision == 1 && + stringMember(background->latestSettingsUpdate, "model") == + "gpt-background", + "settings updates retain explicit defaults and remain thread scoped"); passed &= expect(turn != nullptr && turn->status == "completed" && turn->itemOrder.size() == 1, "live turn lifecycle resolves one stable turn"); From 091db530ea0041b4a0cd4f165ae2632d9cbe3df1 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 14:32:49 +0200 Subject: [PATCH 17/36] Simplify pending prompt presentation --- src/codex/middle/ConversationCards.cpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 7b9cf40..ace3192 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -574,12 +574,7 @@ class ConversationCard::Impl final { } QString status; - if (prompt->state == PromptState::Queued || - prompt->state == PromptState::InFlight) - status = QStringLiteral("Waiting for app-server acknowledgment"); - else if (transitioning) - status = QStringLiteral("Accepted by app-server"); - else if (failed) + if (failed) status = prompt->error.isEmpty() ? QStringLiteral("Not sent") : QStringLiteral("Not sent: %1").arg(prompt->error); From b7685c18363450e7a810823c0e62a64980f84d68 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 15:32:32 +0200 Subject: [PATCH 18/36] Hydrate retained thread settings across navigation --- src/codex/ShellWidget.cpp | 85 +++++++++++++++++++++++++ tests/codex/ShellIntegrationTest.cpp | 95 +++++++++++++++++++++++++--- 2 files changed, 170 insertions(+), 10 deletions(-) diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index db5429c..5523aaf 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -140,6 +140,13 @@ std::string recoveryKey(const std::string &threadId, struct ShellWidget::Impl final { enum class Hydration { NotHydrated, InFlight, Hydrated, Failed }; + enum class SettingsHydration { + Unknown, + WaitingForRead, + InFlight, + Hydrated, + Failed + }; struct HistoryWindow { std::size_t requested = middle::ConversationProjection::DefaultAuthoritativeItemLimit; @@ -181,6 +188,8 @@ struct ShellWidget::Impl final { void beginNewThread(); void readThread(const std::string &threadId, bool forced = false); void ensureThreadHydrated(const std::string &threadId); + void ensureThreadSettingsHydrated(const std::string &threadId); + void resumeThreadForSettings(const std::string &threadId); [[nodiscard]] bool threadIsHydrated(const std::string &threadId) const; [[nodiscard]] bool threadRequiresResume(const std::string &threadId) const; void renameThread(const std::string &threadId); @@ -222,6 +231,7 @@ struct ShellWidget::Impl final { QString newThreadWorkspace; std::unordered_map hydration; + std::unordered_map settingsHydration; std::unordered_map readRevisions; std::unordered_set staleReadResultCorrelations; std::uint64_t nextReadRevision = 1; @@ -379,7 +389,9 @@ void ShellWidget::Impl::connectUi() { selectThread(id); }; threadActions.reload = [this](const std::string &id) { + settingsHydration.erase(id); readThread(id, true); + ensureThreadSettingsHydrated(id); }; threadActions.rename = [this](const std::string &id) { renameThread(id); }; threadActions.fork = [this](const std::string &id) { forkThread(id); }; @@ -457,6 +469,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { if (connection.generation != observedConnectionGeneration) { observedConnectionGeneration = connection.generation; hydration.clear(); + settingsHydration.clear(); readRevisions.clear(); operationReadyThreads.clear(); dispatchScheduledThreads.clear(); @@ -464,6 +477,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { if (connection.providerGeneration != observedProviderGeneration) { observedProviderGeneration = connection.providerGeneration; hydration.clear(); + settingsHydration.clear(); readRevisions.clear(); operationReadyThreads.clear(); dispatchScheduledThreads.clear(); @@ -476,6 +490,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { if (kind == "event" && type == "connection.provider" && stringValue(data, "state") == "disconnected") { hydration.clear(); + settingsHydration.clear(); readRevisions.clear(); operationReadyThreads.clear(); dispatchScheduledThreads.clear(); @@ -512,6 +527,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { session.listThreads(); session.listModels(); ensureThreadHydrated(selectedThreadId); + ensureThreadSettingsHydrated(selectedThreadId); for (const std::string &threadId : prompts.queuedThreadIds()) { if (threadId == DraftThreadId) { if (newThreadIntent) @@ -527,6 +543,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { if (type == "thread.removed" && !eventThreadId.empty()) { prompts.clearThread(eventThreadId); hydration.erase(eventThreadId); + settingsHydration.erase(eventThreadId); readRevisions.erase(eventThreadId); operationReadyThreads.erase(eventThreadId); resumeInFlightThreads.erase(eventThreadId); @@ -547,6 +564,7 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { session.listThreads(); session.listModels(); readThread(selectedThreadId, true); + ensureThreadSettingsHydrated(selectedThreadId); } hydrateHistoricalAgents(); @@ -825,6 +843,7 @@ void ShellWidget::Impl::selectThread(std::string threadId) { return; if (threadId == selectedThreadId) { ensureThreadHydrated(threadId); + ensureThreadSettingsHydrated(threadId); return; } selectedThreadId = std::move(threadId); @@ -834,6 +853,7 @@ void ShellWidget::Impl::selectThread(std::string threadId) { newThreadWorkspace.clear(); historyWindows.try_emplace(selectedThreadId); ensureThreadHydrated(selectedThreadId); + ensureThreadSettingsHydrated(selectedThreadId); render(); } @@ -898,6 +918,10 @@ void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { } if (result.value("ok", false)) { hydration[threadId] = Hydration::Hydrated; + const auto settings = settingsHydration.find(threadId); + if (settings != settingsHydration.end() && + settings->second == SettingsHydration::WaitingForRead) + resumeThreadForSettings(threadId); QTimer::singleShot(0, owner, [this, threadId] { dispatchNextPrompt(threadId); }); return; @@ -906,6 +930,10 @@ void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { // Explicit Reload bypasses this terminal state, while a new generation // clears it together with the other hydration bookkeeping. hydration[threadId] = Hydration::Failed; + const auto settings = settingsHydration.find(threadId); + if (settings != settingsHydration.end() && + settings->second == SettingsHydration::WaitingForRead) + settings->second = SettingsHydration::Failed; const std::string message = safeMessage(result.value("error", nlohmann::json::object())); const QString displayed = @@ -916,6 +944,54 @@ void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { }); } +void ShellWidget::Impl::ensureThreadSettingsHydrated( + const std::string &threadId) { + if (threadId.empty() || !model.connection().connected || + model.connection().role != "controller") + return; + SettingsHydration &state = settingsHydration[threadId]; + if (state == SettingsHydration::WaitingForRead || + state == SettingsHydration::InFlight || + state == SettingsHydration::Hydrated) + return; + if (!threadIsHydrated(threadId)) { + state = SettingsHydration::WaitingForRead; + ensureThreadHydrated(threadId); + return; + } + resumeThreadForSettings(threadId); +} + +void ShellWidget::Impl::resumeThreadForSettings( + const std::string &threadId) { + if (resumeInFlightThreads.contains(threadId)) + return; + settingsHydration[threadId] = SettingsHydration::InFlight; + const auto token = alive; + session.resumeThread( + threadId, {{"excludeTurns", true}}, + [this, token, threadId](const nlohmann::json &result) { + if (!*token) + return; + if (!result.value("ok", false)) { + settingsHydration[threadId] = SettingsHydration::Failed; + if (selectedThreadId == threadId) { + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + showNotice(text(message.empty() + ? std::string("Thread settings refresh failed") + : message)); + } + } else { + settingsHydration[threadId] = SettingsHydration::Hydrated; + hydration[threadId] = Hydration::Hydrated; + operationReadyThreads.insert(threadId); + } + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); + }); +} + void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { if (threadId.empty() || threadIsHydrated(threadId) || !model.connection().connected) @@ -1107,6 +1183,7 @@ void ShellWidget::Impl::startThreadForDraft() { return; } hydration[threadId] = Hydration::Hydrated; + settingsHydration[threadId] = SettingsHydration::Hydrated; operationReadyThreads.insert(threadId); const bool viewingDraft = selectedThreadId.empty() && newThreadIntent; if (viewingDraft) { @@ -1128,6 +1205,10 @@ void ShellWidget::Impl::startThreadForDraft() { void ShellWidget::Impl::dispatchNextPrompt(const std::string &threadId) { if (threadId.empty() || !model.connection().connected) return; + const auto settings = settingsHydration.find(threadId); + if (settings != settingsHydration.end() && + settings->second == SettingsHydration::InFlight) + return; const auto submissions = prompts.submissions(threadId); if (std::ranges::none_of( submissions, [](const middle::PromptSubmission &submission) { @@ -1222,6 +1303,7 @@ void ShellWidget::Impl::resumePromptQueue(const std::string &threadId) { return; resumeInFlightThreads.erase(threadId); if (!result.value("ok", false)) { + settingsHydration[threadId] = SettingsHydration::Failed; const std::string message = safeMessage(result.value("error", nlohmann::json::object())); const QString displayed = text( @@ -1232,6 +1314,7 @@ void ShellWidget::Impl::resumePromptQueue(const std::string &threadId) { return; } hydration[threadId] = Hydration::Hydrated; + settingsHydration[threadId] = SettingsHydration::Hydrated; operationReadyThreads.insert(threadId); QTimer::singleShot(0, owner, [this, threadId] { dispatchNextPrompt(threadId); }); @@ -1288,6 +1371,7 @@ bool ShellWidget::Impl::attemptThreadRecovery(const std::string &threadId, return; resumeInFlightThreads.erase(threadId); if (!resumeResult.value("ok", false)) { + settingsHydration[threadId] = SettingsHydration::Failed; const std::string message = safeMessage( resumeResult.value("error", nlohmann::json::object())); const QString displayed = @@ -1299,6 +1383,7 @@ bool ShellWidget::Impl::attemptThreadRecovery(const std::string &threadId, return; } hydration[threadId] = Hydration::Hydrated; + settingsHydration[threadId] = SettingsHydration::Hydrated; operationReadyThreads.insert(threadId); QTimer::singleShot(0, owner, [this, threadId] { dispatchNextPrompt(threadId); }); diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 1199d86..04a9d0b 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -324,12 +325,91 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { result &= expect(readA.has_value(), "selecting A requests hydration"); if (!readA) return false; + result &= expect(selectThread(list, "thread-b"), + "B can be selected while A is still hydrating"); + const auto readB = peer.waitFor("thread.read", "thread-b"); + result &= expect(readB.has_value(), "selecting B requests its own hydration"); + if (!readB) + return false; + result &= peer.send(presentation::event( + sequence++, 1, "thread.upsert", + {{"thread", thread("thread-a", "A", "notLoaded")}}, Authority::Merge, + {{"threadId", "thread-a"}})); result &= peer.send(presentation::result( sequence++, 1, "thread.read", readA->value("correlationId", std::string{}), true, {{"thread", threadWithPlanAndAgent("thread-a", "A")}}, Authority::Replace, {{"threadId", "thread-a"}})); spin(10); + const auto rejoinA = peer.waitFor("thread.resume", "thread-a"); + result &= expect( + rejoinA.has_value() && + rejoinA->value("data", nlohmann::json::object()) + .value("excludeTurns", false), + "a thread still refreshes settings after selection moves away"); + if (!rejoinA) + return false; + result &= peer.send(presentation::result( + sequence++, 1, "thread.resume", + rejoinA->value("correlationId", std::string{}), true, + {{"thread", + {{"id", "thread-a"}, + {"model", "gpt-resumed"}, + {"reasoningEffort", "medium"}, + {"sandbox", "workspaceWrite"}}}}, + Authority::Merge, {{"threadId", "thread-a"}})); + spin(10); + auto *settingsModel = + shell.findChild(QStringLiteral("codexModel")); + auto *settingsEffort = + shell.findChild(QStringLiteral("codexEffort")); + result &= expect( + settingsModel && settingsEffort && + settingsModel->currentData().toString() != + QStringLiteral("gpt-resumed"), + "a background settings response does not alter visible B controls"); + result &= peer.send( + presentation::result(sequence++, 1, "thread.read", + readB->value("correlationId", std::string{}), true, + {{"thread", thread("thread-b", "B")}}, + Authority::Replace, {{"threadId", "thread-b"}})); + const auto rejoinB = peer.waitFor("thread.resume", "thread-b"); + result &= expect(rejoinB.has_value(), + "B receives its independent settings refresh"); + if (!rejoinB) + return false; + result &= peer.send(presentation::result( + sequence++, 1, "thread.resume", + rejoinB->value("correlationId", std::string{}), true, + {{"thread", + {{"id", "thread-b"}, + {"model", "gpt-b"}, + {"reasoningEffort", "low"}}}}, + Authority::Merge, {{"threadId", "thread-b"}})); + spin(10); + result &= expect(selectThread(list, "thread-a"), + "A can be selected again after background hydration"); + spin(10); + result &= expect( + settingsModel && settingsEffort && + settingsModel->currentData().toString() == + QStringLiteral("gpt-resumed") && + settingsEffort->currentData().toString() == QStringLiteral("medium"), + "A retains its background-refreshed effective settings"); + + const auto completeSettingsRefresh = [&](const std::string &threadId) { + const auto request = peer.waitFor("thread.resume", threadId); + const std::string message = + threadId + " receives its metadata-only settings refresh"; + if (!expect(request.has_value(), message.c_str()) || + !request) + return false; + return peer.send(presentation::result( + sequence++, 2, "thread.resume", + request->value("correlationId", std::string{}), true, + {{"thread", {{"id", threadId}}}}, Authority::Merge, + {{"threadId", threadId}})); + }; result &= expect(submit(editor, QStringLiteral("prompt A1")), "A1 is admitted through the real composer"); @@ -373,16 +453,8 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { result &= expect(editor->toPlainText() == QStringLiteral("unsent shared draft"), "thread navigation retains the shared composer draft"); - const auto readB = peer.waitFor("thread.read", "thread-b"); - result &= expect(readB.has_value(), "selecting B requests its own hydration"); - if (!readB) - return false; - result &= peer.send( - presentation::result(sequence++, 1, "thread.read", - readB->value("correlationId", std::string{}), true, - {{"thread", thread("thread-b", "B")}}, - Authority::Replace, {{"threadId", "thread-b"}})); - spin(10); + result &= expect(!peer.waitFor("thread.read", "thread-b", 100).has_value(), + "returning to hydrated B does not reread its history"); result &= expect(submit(editor, QStringLiteral("prompt B1")), "B1 is admitted while A1 is in flight"); const auto startB = peer.waitFor("turn.start", "thread-b"); @@ -469,6 +541,7 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { readC1->value("correlationId", std::string{}), true, {{"thread", thread("thread-c", "stale C")}}, Authority::Replace, {{"threadId", "thread-c"}})); + result &= completeSettingsRefresh("thread-c"); spin(10); result &= expect(hasAgentMessage(shell, QStringLiteral("current C marker")), "a late successful stale read cannot replace newer cards"); @@ -535,6 +608,7 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { readD->value("correlationId", std::string{}), true, {{"thread", thread("thread-d", "D")}}, Authority::Replace, {{"threadId", "thread-d"}})); + result &= completeSettingsRefresh("thread-d"); spin(5); result &= expect(submit(editor, QStringLiteral("prompt D1")), "D1 is admitted before recovery"); @@ -685,6 +759,7 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { readF->value("correlationId", std::string{}), true, {{"thread", thread("thread-f", "F")}}, Authority::Replace, {{"threadId", "thread-f"}})); + result &= completeSettingsRefresh("thread-f"); spin(5); result &= expect(submit(editor, QStringLiteral("prompt F1")), "F1 is admitted while connected"); From c225032902a2b67a274bf3c302bbd37d7a608660 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 17:22:49 +0200 Subject: [PATCH 19/36] Stabilize composer overlay spacing --- src/codex/middle/ComposerPane.cpp | 10 +++- src/codex/ui/UiStyle.cpp | 1 + tests/codex/ApplicationLayoutTest.cpp | 68 ++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp index e4f8d2d..4d53326 100644 --- a/src/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -60,7 +60,7 @@ ComposerPane::ComposerPane(QWidget *anchor) : QWidget(anchor), anchor_(anchor), reserve_(new QWidget(anchor)) { Q_ASSERT(anchor_); setObjectName(QStringLiteral("composerOverlay")); - setAttribute(Qt::WA_StyledBackground, false); + setAttribute(Qt::WA_StyledBackground, true); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); anchor_->installEventFilter(this); @@ -70,7 +70,7 @@ ComposerPane::ComposerPane(QWidget *anchor) auto *root = new QVBoxLayout(this); root->setContentsMargins(0, 8, 0, 0); - root->setSpacing(0); + root->setSpacing(8); attention_ = new QFrame(this); attention_->setProperty("kind", "orangeBadge"); @@ -273,6 +273,12 @@ void ComposerPane::synchronizeGeometry() { : 0; setGeometry(HorizontalInset, availableHeight - wantedHeight, width, wantedHeight); + // The natural height was calculated after the prompt layout changed, while + // the child layout still had the previous overlay geometry. Lay out the + // children once against the final rectangle so fixed surfaces cannot remain + // compressed and leave a false gap during upward growth. + layout()->invalidate(); + layout()->activate(); raise(); if (wantedExtra != extraHeight_) { diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 4f6867e..4207125 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -186,6 +186,7 @@ QString applicationStyleSheet() { QFrame[kind="greenBadge"] { background: #e9f7f0; border: 1px solid #a9d8c1; border-radius: 6px; } QFrame[kind="blueBadge"] { background: #e5eeff; border-radius: 5px; } QFrame[kind="orangeBadge"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } + QWidget#composerOverlay { background: #f6f8fb; } QFrame[kind="composer"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } QFrame[kind="composer"][focused="true"] { border: 2px solid #2f6feb; } QPlainTextEdit, QTextEdit { diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 7557983..de17d45 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -183,6 +184,44 @@ bool testOverlayGeometryAndRegionRouting() { region.composer().canonicalReserve()->height() == canonical, "composer establishes one compact canonical reserve"); + QFrame *composerSurface = nullptr; + for (QFrame *frame : + region.composer().findChildren(Qt::FindDirectChildrenOnly)) { + if (frame->property("kind").toString() == QStringLiteral("composer")) { + composerSurface = frame; + break; + } + } + TurnSettingsWidget *settings = region.composer().turnSettings(); + const auto settingsToComposerGap = [&] { + return composerSurface ? composerSurface->geometry().top() - + settings->geometry().bottom() - 1 + : -1; + }; + const auto settingsToEditorGap = [&] { + return region.composer().promptEditor()->mapTo(®ion.composer(), QPoint()) + .y() - + settings->geometry().bottom() - 1; + }; + const auto stableComposerGeometry = [&] { + return settings->geometry().top() == 8 && + settings->height() == settings->sizeHint().height() && + settingsToComposerGap() == 8; + }; + result &= expect( + composerSurface && + region.composer().testAttribute(Qt::WA_StyledBackground) && + UiStyle::applicationStyleSheet().contains( + QStringLiteral("QWidget#composerOverlay")) && + stableComposerGeometry(), + "compact composer has an opaque surface and canonical section gaps"); + const int compactEditorGap = settingsToEditorGap(); + region.composer().setActiveTurn(true); + spin(20); + result &= expect(stableComposerGeometry() && + settingsToEditorGap() == compactEditorGap, + "active-turn controls retain the compact composer gaps"); + QString longPrompt; for (int line = 0; line < 14; ++line) longPrompt += QStringLiteral("A deliberately long prompt line %1 that " @@ -193,18 +232,33 @@ bool testOverlayGeometryAndRegionRouting() { const int extra = region.composer().extraOverlayHeight(); result &= expect(extra > 0 && view.trailingSpaceHeight() == extra, "prompt growth is mirrored by exact trailing scroll space"); - result &= - expect(view.geometry() == viewGeometry && - view.viewport()->geometry() == viewportGeometry && - region.composer().canonicalReserve()->height() == canonical, - "prompt growth overlays without shifting the message viewport"); + result &= expect( + stableComposerGeometry() && + settingsToEditorGap() == compactEditorGap && + view.geometry() == viewGeometry && + view.viewport()->geometry() == viewportGeometry && + region.composer().canonicalReserve()->height() == canonical, + "prompt growth keeps gaps fixed without shifting the message viewport"); + region.composer().setAttachments( + {{QStringLiteral("/tmp/layout-diagnostic.png"), + QStringLiteral("layout-diagnostic.png"), QStringLiteral("image/png")}}); + spin(30); + result &= expect( + stableComposerGeometry() && + region.composer().extraOverlayHeight() > extra && + view.trailingSpaceHeight() == region.composer().extraOverlayHeight(), + "attachments retain the canonical settings-to-composer gap"); region.composer().clearDraft(); spin(30); result &= expect( region.composer().extraOverlayHeight() == 0 && view.trailingSpaceHeight() == 0 && view.geometry() == viewGeometry && - view.viewport()->geometry() == viewportGeometry, - "prompt contraction restores canonical layout and removes space"); + view.viewport()->geometry() == viewportGeometry && + stableComposerGeometry() && + settingsToEditorGap() == compactEditorGap, + "prompt contraction restores canonical layout, gaps, and trailing space"); + region.composer().setActiveTurn(false); + spin(20); ComposerPane::Actions rejected; rejected.submit = [](QString, std::vector) { return false; }; From 042dd68d4159ffce770132d4c86c8415637c7fee Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 17:58:17 +0200 Subject: [PATCH 20/36] Align conversation and composer boundary --- docs/codex-architecture.md | 22 ++++---- docs/ui-behavior.md | 16 +++--- src/codex/middle/ComposerPane.cpp | 29 ++++++++--- src/codex/middle/ConversationView.cpp | 17 ++----- src/codex/middle/ConversationView.h | 1 - tests/codex/ApplicationLayoutTest.cpp | 72 +++++++++++++++++++++++---- ui-review/STATE-MATRIX.md | 2 +- ui-review/UX-DESIGN-DECISIONS.md | 13 +++-- 8 files changed, 117 insertions(+), 55 deletions(-) diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 15735b4..26ba555 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -616,16 +616,18 @@ following resumes. The bottom composer overlay has a canonical in-layout reservation. As multiline input, attachments, settings, or attention controls grow beyond that height, the conversation viewport keeps its geometry and the composer overlays its -lower portion. A content-owned trailing spacer grows by the same extra height, -extending the natural `QScrollArea` range so the final card can be scrolled -above the overlay with the normal gap. The scrollbar maximum is never assigned -manually. - -Spacer growth temporarily suppresses range-driven bottom following and restores -the previous scrollbar value, so existing messages do not move. Reaching the -new maximum re-enables following. Composer contraction removes the spacer; Qt -may clamp the value to the reduced range, and being at that maximum re-enables -following for later content. +lower portion. A content-owned logical trailing extent grows by the same extra +height, extending the natural `QScrollArea` range so the final card can be +scrolled to the overlay boundary. Permanent scroll-owned bottom padding is not +used; the moving composer owns the standard divider with the canonical 8 px +vertical spacing on both sides and 10 px horizontal outset beyond its adjacent +content. The scrollbar maximum is never assigned manually. + +Trailing-extent growth temporarily suppresses range-driven bottom following +and restores the previous scrollbar value, so existing messages do not move. +Reaching the new maximum re-enables following. Composer contraction removes the +extent; Qt may clamp the value to the reduced range, and being at that maximum +re-enables following for later content. ### 7.6 Command Execution Output and Info Viewers diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index ed6dcca..af110ef 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -173,13 +173,17 @@ its configured maximum height, after which it scrolls internally. The message-view layout reserves only the composer's canonical height. When prompt text, attachments, settings, or attention controls increase that height, the composer grows upward as an overlay: the viewport keeps its normal geometry -and may be partly covered. An equal-height trailing spacer is added to the -scrollable conversation content so the final card can still be moved into the -visible region with the normal gap above the composer. - -Growing this spacer preserves the current scrollbar value and does not move the +and may be partly covered. An equal logical trailing extent is added to the +scrollable conversation content so the final card can still be moved to the +overlay boundary. The conversation owns no permanent bottom padding; the moving +composer uses the canonical Changes-tab treatment of 8 px space, a standard +divider extending 10 px beyond the adjacent content on each side, and another +8 px space. This boundary remains identical whether the conversation is at its +bottom or paused higher in history. + +Growing this extent preserves the current scrollbar value and does not move the messages automatically. Reaching its new maximum re-enables bottom-follow for -subsequent content. When the composer returns to canonical height, the spacer +subsequent content. When the composer returns to canonical height, the extent is removed; Qt may clamp a former bottom position to the reduced range, after which the normal viewport state and bottom-follow policy apply again. diff --git a/src/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp index 4d53326..b654c8f 100644 --- a/src/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -27,6 +27,7 @@ namespace { constexpr int ControlHeight = 32; constexpr int HorizontalInset = 24; +constexpr int DividerOutset = 10; constexpr int BottomInset = 12; constexpr int AttachmentRowHeight = 28; constexpr int MaximumVisibleAttachments = 4; @@ -72,7 +73,18 @@ ComposerPane::ComposerPane(QWidget *anchor) root->setContentsMargins(0, 8, 0, 0); root->setSpacing(8); - attention_ = new QFrame(this); + auto *boundary = new QFrame(this); + boundary->setProperty("kind", "standardDivider"); + boundary->setFixedHeight(1); + root->addWidget(boundary); + + auto *surfaces = new QWidget(this); + auto *surfacesLayout = new QVBoxLayout(surfaces); + surfacesLayout->setContentsMargins(DividerOutset, 0, DividerOutset, 0); + surfacesLayout->setSpacing(8); + root->addWidget(surfaces); + + attention_ = new QFrame(surfaces); attention_->setProperty("kind", "orangeBadge"); auto *attentionLayout = new QHBoxLayout(attention_); attentionLayout->setContentsMargins(10, 6, 10, 6); @@ -92,12 +104,12 @@ ComposerPane::ComposerPane(QWidget *anchor) actions_.review(); }); attention_->hide(); - root->addWidget(attention_); + surfacesLayout->addWidget(attention_); - turnSettings_ = new TurnSettingsWidget(this); - root->addWidget(turnSettings_); + turnSettings_ = new TurnSettingsWidget(surfaces); + surfacesLayout->addWidget(turnSettings_); - composer_ = new QFrame(this); + composer_ = new QFrame(surfaces); composer_->setProperty("kind", "composer"); auto *composerLayout = new QVBoxLayout(composer_); composerLayout->setContentsMargins(10, 8, 8, 8); @@ -151,7 +163,7 @@ ComposerPane::ComposerPane(QWidget *anchor) composerGrid_->addWidget(promptEditor_, 0, 1); composerGrid_->addWidget(sendButton_, 0, 2); composerLayout->addWidget(composerBody_); - root->addWidget(composer_); + surfacesLayout->addWidget(composer_); connect(sendButton_, &QPushButton::clicked, this, [this] { submitDraft(); }); connect(promptEditor_, &codexui::ExpandingPromptEditor::submitRequested, this, @@ -252,7 +264,8 @@ void ComposerPane::synchronizeGeometry() { return; synchronizing_ = true; - const int width = std::max(0, anchor_->width() - 2 * HorizontalInset); + const int overlayInset = HorizontalInset - DividerOutset; + const int width = std::max(0, anchor_->width() - 2 * overlayInset); if (this->width() != width) resize(width, std::max(0, height())); layout()->activate(); @@ -271,7 +284,7 @@ void ComposerPane::synchronizeGeometry() { const int wantedExtra = canonicalCaptureEnabled_ && canonicalHeight_ > 0 ? std::max(0, wantedHeight - canonicalHeight_) : 0; - setGeometry(HorizontalInset, availableHeight - wantedHeight, width, + setGeometry(overlayInset, availableHeight - wantedHeight, width, wantedHeight); // The natural height was calculated after the prompt layout changed, while // the child layout still had the previous overlay geometry. Lay out the diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 06cc602..ec0de3e 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -27,7 +27,6 @@ namespace codexui::codex::middle { namespace { -constexpr int BottomMargin = 16; constexpr int CardSpacing = 8; constexpr int NativeScrollLineStep = 20; @@ -73,8 +72,9 @@ ConversationView::ConversationView(QWidget *parent) content_->installEventFilter(this); contentLayout_ = new QVBoxLayout(content_); - contentLayout_->setContentsMargins(0, 0, 0, BottomMargin); + contentLayout_->setContentsMargins(0, 0, 0, 0); contentLayout_->setSpacing(CardSpacing); + contentLayout_->setAlignment(Qt::AlignTop); loadMore_ = new QPushButton(QStringLiteral("Load more activities"), content_); loadMore_->setProperty("kind", "history"); @@ -91,17 +91,6 @@ ConversationView::ConversationView(QWidget *parent) empty_->setParent(content_); contentLayout_->addWidget(empty_); - trailingSpace_ = new QWidget(content_); - trailingSpace_->setObjectName(QStringLiteral("conversationTrailingSpace")); - trailingSpace_->setFixedHeight(0); - trailingSpace_->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - contentLayout_->addWidget(trailingSpace_); - - auto *tailStretch = new QWidget(content_); - tailStretch->setMinimumHeight(0); - tailStretch->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); - contentLayout_->addWidget(tailStretch, 1); - followAnimation_ = new QVariantAnimation(this); followAnimation_->setEasingCurve(QEasingCurve::OutCubic); connect(followAnimation_, &QVariantAnimation::valueChanged, this, @@ -421,7 +410,6 @@ void ConversationView::setTrailingSpaceHeight(int height) { mode_ = Mode::Paused; } trailingSpaceHeight_ = height; - trailingSpace_->setFixedHeight(height); recomputeGeometry(); if (mode_ == Mode::Following) setScrollValue(verticalScrollBar()->maximum()); @@ -606,6 +594,7 @@ void ConversationView::recomputeGeometry() { ? contentLayout_->heightForWidth(width) : contentLayout_->sizeHint().height(); wanted = std::max(wanted, contentLayout_->minimumSize().height()); + wanted += trailingSpaceHeight_; contentHeight_ = std::max(viewport()->height(), wanted); content_->resize(width, contentHeight_); contentLayout_->setGeometry(QRect(0, 0, width, contentHeight_)); diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index b72daa5..e566ce1 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -99,7 +99,6 @@ class ConversationView final : public QAbstractScrollArea { QVBoxLayout *contentLayout_ = nullptr; QPushButton *loadMore_ = nullptr; QLabel *empty_ = nullptr; - QWidget *trailingSpace_ = nullptr; QVariantAnimation *followAnimation_ = nullptr; std::function loadMoreAction_; diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index de17d45..0b09820 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -184,37 +184,77 @@ bool testOverlayGeometryAndRegionRouting() { region.composer().canonicalReserve()->height() == canonical, "composer establishes one compact canonical reserve"); + QFrame *boundary = nullptr; QFrame *composerSurface = nullptr; - for (QFrame *frame : - region.composer().findChildren(Qt::FindDirectChildrenOnly)) { - if (frame->property("kind").toString() == QStringLiteral("composer")) { + for (QFrame *frame : region.composer().findChildren()) { + const QString kind = frame->property("kind").toString(); + if (kind == QStringLiteral("standardDivider")) + boundary = frame; + else if (kind == QStringLiteral("composer")) composerSurface = frame; - break; - } } TurnSettingsWidget *settings = region.composer().turnSettings(); + const auto overlayRect = [&](QWidget *widget) { + return QRect(widget->mapTo(®ion.composer(), QPoint()), widget->size()); + }; const auto settingsToComposerGap = [&] { - return composerSurface ? composerSurface->geometry().top() - - settings->geometry().bottom() - 1 + return composerSurface ? overlayRect(composerSurface).top() - + overlayRect(settings).bottom() - 1 : -1; }; const auto settingsToEditorGap = [&] { return region.composer().promptEditor()->mapTo(®ion.composer(), QPoint()) .y() - - settings->geometry().bottom() - 1; + overlayRect(settings).bottom() - 1; }; const auto stableComposerGeometry = [&] { - return settings->geometry().top() == 8 && + if (!boundary || !composerSurface) + return false; + const QRect boundaryRect = overlayRect(boundary); + const QRect settingsRect = overlayRect(settings); + const QRect composerRect = overlayRect(composerSurface); + return boundaryRect.top() == 8 && boundaryRect.height() == 1 && + settingsRect.top() - boundaryRect.bottom() - 1 == 8 && settings->height() == settings->sizeHint().height() && - settingsToComposerGap() == 8; + settingsToComposerGap() == 8 && boundaryRect.left() == 0 && + boundaryRect.right() == region.composer().width() - 1 && + settingsRect.left() == 10 && composerRect.left() == 10 && + settingsRect.right() == region.composer().width() - 11 && + composerRect.right() == region.composer().width() - 11 && + boundaryRect.width() == composerRect.width() + 20; + }; + const auto finalCardBottom = [&] { + int bottom = -1; + for (QFrame *frame : view.findChildren()) { + if (!frame->property("conversationAnchorKey").toString().isEmpty() && + frame->isVisible()) + bottom = std::max( + bottom, + frame->mapTo(view.viewport(), QPoint(0, frame->height())).y()); + } + return bottom; }; result &= expect( - composerSurface && + boundary && composerSurface && region.composer().testAttribute(Qt::WA_StyledBackground) && UiStyle::applicationStyleSheet().contains( QStringLiteral("QWidget#composerOverlay")) && stableComposerGeometry(), "compact composer has an opaque surface and canonical section gaps"); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); + spin(10); + result &= expect(finalCardBottom() == view.viewport()->height(), + "compact bottom has no scroll-owned trailing gap"); + const QRect compactOverlayGeometry = region.composer().geometry(); + const QRect compactBoundaryGeometry = boundary->geometry(); + view.verticalScrollBar()->setValue( + std::max(0, view.verticalScrollBar()->maximum() - 80)); + spin(10); + result &= expect(region.composer().geometry() == compactOverlayGeometry && + boundary->geometry() == compactBoundaryGeometry, + "history scrolling leaves the composer boundary fixed"); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); + spin(10); const int compactEditorGap = settingsToEditorGap(); region.composer().setActiveTurn(true); spin(20); @@ -232,9 +272,12 @@ bool testOverlayGeometryAndRegionRouting() { const int extra = region.composer().extraOverlayHeight(); result &= expect(extra > 0 && view.trailingSpaceHeight() == extra, "prompt growth is mirrored by exact trailing scroll space"); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); + spin(10); result &= expect( stableComposerGeometry() && settingsToEditorGap() == compactEditorGap && + view.viewport()->height() - finalCardBottom() == extra && view.geometry() == viewGeometry && view.viewport()->geometry() == viewportGeometry && region.composer().canonicalReserve()->height() == canonical, @@ -243,17 +286,24 @@ bool testOverlayGeometryAndRegionRouting() { {{QStringLiteral("/tmp/layout-diagnostic.png"), QStringLiteral("layout-diagnostic.png"), QStringLiteral("image/png")}}); spin(30); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); + spin(10); result &= expect( stableComposerGeometry() && region.composer().extraOverlayHeight() > extra && + view.viewport()->height() - finalCardBottom() == + region.composer().extraOverlayHeight() && view.trailingSpaceHeight() == region.composer().extraOverlayHeight(), "attachments retain the canonical settings-to-composer gap"); region.composer().clearDraft(); spin(30); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); + spin(10); result &= expect( region.composer().extraOverlayHeight() == 0 && view.trailingSpaceHeight() == 0 && view.geometry() == viewGeometry && view.viewport()->geometry() == viewportGeometry && + finalCardBottom() == view.viewport()->height() && stableComposerGeometry() && settingsToEditorGap() == compactEditorGap, "prompt contraction restores canonical layout, gaps, and trailing space"); diff --git a/ui-review/STATE-MATRIX.md b/ui-review/STATE-MATRIX.md index dedb1a0..f1f4b56 100644 --- a/ui-review/STATE-MATRIX.md +++ b/ui-review/STATE-MATRIX.md @@ -30,7 +30,7 @@ | Conversation | User returns to bottom | Automatic following resumes. | | Composer | Short prompt | One-line compact height. | | Composer | Multiline prompt | Editor overlays the unchanged message viewport; matching trailing scroll space is added without moving existing messages. | -| Composer | User reaches extended bottom | The final card sits above the composer with the normal gap and bottom-follow resumes. | +| Composer | User reaches extended bottom | The final card reaches the overlay boundary; canonical 8 px spacing surrounds the moving divider and bottom-follow resumes. | | Composer | Prompt shrinks | Trailing space is removed; Qt may clamp the scroll position to the reduced range. | | Composer | Shrink clamps to conversation bottom | Bottom-follow is reactivated for subsequent incoming content. | | Composer | Maximum prompt height | Editor stops growing and scrolls internally. | diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 3d05167..8281efb 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -101,10 +101,15 @@ follow mode and anchor are retained independently for each thread. The upcoming-turn settings and composer remain anchored to the bottom. The prompt editor starts at one line, grows upward to its maximum, and then scrolls internally. The message view reserves the canonical composer height. Additional -growth overlays, but does not resize, the viewport. A trailing content spacer -grows by the overlap so the user can scroll the final card above the composer. -Spacer growth does not move the existing reading position. Shrinking the -composer removes the spacer and restores the canonical geometry. +growth overlays, but does not resize, the viewport. The trailing allowance is +represented as a logical extent equal to the overlap so the user can scroll +the final card to the composer boundary. The scroll content has no permanent +bottom padding. Matching the Changes-tab separator, the moving composer uses +8 px space, a standard divider extending 10 px beyond the adjacent content on +each side, and another 8 px space. This provides the same boundary at the bottom +and while reading higher in history. Extent growth does not move the existing +reading position. Shrinking the composer removes the extent and restores the +canonical geometry. ## Pending prompt presentation From 1a7c59e69e83a60c2705f3a69f2721d4f30f7261 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 18:56:46 +0200 Subject: [PATCH 21/36] Keep preset access controls editable --- src/codex/TurnSettingsWidget.cpp | 19 ++++++++++--------- tests/codex/ApplicationLayoutTest.cpp | 22 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/codex/TurnSettingsWidget.cpp b/src/codex/TurnSettingsWidget.cpp index d366845..f03e7bc 100644 --- a/src/codex/TurnSettingsWidget.cpp +++ b/src/codex/TurnSettingsWidget.cpp @@ -752,9 +752,8 @@ void TurnSettingsWidget::refreshPermissionProfiles( } void TurnSettingsWidget::refreshAccessCompatibility() { - const bool namedProfile = value(permissionProfile) != DefaultValue; - sandbox->setEnabled(!namedProfile); - network->setEnabled(!namedProfile && value(sandbox) != "danger-full-access" && + sandbox->setEnabled(true); + network->setEnabled(value(sandbox) != "danger-full-access" && value(sandbox) != DefaultValue); if (value(sandbox) == "danger-full-access") { const QSignalBlocker blocker(network); @@ -763,12 +762,14 @@ void TurnSettingsWidget::refreshAccessCompatibility() { const QSignalBlocker blocker(network); selectValue(network, QString::fromLatin1(DefaultValue)); } - const QString reason = - namedProfile - ? QStringLiteral("The selected permission profile owns access policy") - : QString{}; - sandbox->setToolTip(reason); - network->setToolTip(reason); + sandbox->setToolTip({}); + network->setToolTip(value(sandbox) == "danger-full-access" + ? QStringLiteral( + "Full access already includes network access") + : value(sandbox) == DefaultValue + ? QStringLiteral( + "Select an access mode before network access") + : QString{}); } void TurnSettingsWidget::refreshMoreIndicator() { diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 0b09820..93f0e40 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -443,7 +443,11 @@ bool testIncrementalThreadSettings() { auto *model = settings.findChild(QStringLiteral("codexModel")); auto *approval = settings.findChild(QStringLiteral("codexApproval")); - if (!model || !approval) + auto *access = + settings.findChild(QStringLiteral("codexSandbox")); + auto *network = + settings.findChild(QStringLiteral("codexNetwork")); + if (!model || !approval || !access || !network) return expect(false, "thread settings controls are discoverable"); model->setCurrentIndex(model->findData(QStringLiteral("gpt-b"))); @@ -485,6 +489,22 @@ bool testIncrementalThreadSettings() { result &= expect(settings.turnStartOptions().empty() && settings.threadStartOptions().empty(), "all untouched thread settings produce no overrides"); + result &= expect(access->isEnabled() && network->isEnabled(), + "a permission preset does not lock its effective access " + "controls"); + + settings.setContext( + "full-access-thread", + {{"sandboxPolicy", {{"type", "dangerFullAccess"}}}, + {"activePermissionProfile", {{"id", ":full-access"}}}}, + nlohmann::json::array(), + {{"data", nlohmann::json::array( + {{{"id", ":full-access"}, {"allowed", true}}})}}); + result &= expect(access->isEnabled() && !network->isEnabled() && + network->currentData().toString() == + QStringLiteral("enabled"), + "only logically redundant network selection is disabled"); + return result; } From ffd4fda45eb0d70b99a566a70f6912ac15bf7c9e Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 19:03:33 +0200 Subject: [PATCH 22/36] Render GitHub Markdown tables --- src/codex/middle/ConversationCards.cpp | 5 ++++- src/codex/middle/InspectorPane.cpp | 5 ++++- tests/codex/ConversationCardsTest.cpp | 11 +++++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index ace3192..02f87af 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -45,7 +45,10 @@ QLabel *makeLabel(const QString &value, const char *kind = "body", QString markdownHtml(const QString &markdown) { QTextDocument document; - document.setMarkdown(markdown, QTextDocument::MarkdownNoHTML); + document.setMarkdown( + markdown, + QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub) | + QTextDocument::MarkdownNoHTML); return document.toHtml(); } diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index 27da816..dd1c918 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -87,7 +87,10 @@ QLabel *makeLabel(QString value, const char *kind = "body") { QLabel *makeMarkdownLabel(const QString &value) { QTextDocument document; - document.setMarkdown(value, QTextDocument::MarkdownNoHTML); + document.setMarkdown( + value, + QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub) | + QTextDocument::MarkdownNoHTML); auto *label = new QLabel(document.toHtml()); label->setProperty("kind", "body"); label->setTextFormat(Qt::RichText); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index e55da45..7c5249a 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -349,7 +349,8 @@ bool testMutableCardsAndCommandOutput() { section.cards = { {AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, thread, "turn", "user", - UserMessageData{QStringLiteral("hello **Markdown**")}}, + UserMessageData{QStringLiteral( + "hello **Markdown**\n\n| Value | Rating |\n|---|---|\n| State | 10 |")}}, {AuthoritativeItemKey{thread, "turn", "agent"}, CardKind::AgentMessage, thread, "turn", "agent", AgentMessageData{QStringLiteral("answer"), false}}, @@ -414,10 +415,12 @@ bool testMutableCardsAndCommandOutput() { result &= expect( std::ranges::any_of(userLabels, [](QLabel *label) { return label->property("markdownSource").toString() == - QStringLiteral("hello **Markdown**") && - label->textFormat() == Qt::RichText; + QStringLiteral("hello **Markdown**\n\n| Value | Rating |\n" + "|---|---|\n| State | 10 |") && + label->textFormat() == Qt::RichText && + label->text().contains(QStringLiteral("toPlainText() == QStringLiteral("printf test") && From c08c18cbe9da172f851e6f208ce056c12f1e9d0d Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 19:46:13 +0200 Subject: [PATCH 23/36] Document code polish roadmap --- ui-review/CODE-POLISH-ROADMAP.md | 283 +++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 ui-review/CODE-POLISH-ROADMAP.md diff --git a/ui-review/CODE-POLISH-ROADMAP.md b/ui-review/CODE-POLISH-ROADMAP.md new file mode 100644 index 0000000..1cba0ef --- /dev/null +++ b/ui-review/CODE-POLISH-ROADMAP.md @@ -0,0 +1,283 @@ +# CodexUI code-polish roadmap + +This roadmap collects the identified code-simplification and performance work +for CodexUI. Its objective is a smaller, clearer application with predictable +runtime behavior—not a larger framework around the UI. + +## Rating conventions + +Effort describes implementation and verification together: + +| Rating | Meaning | +|---|---| +| Low | Narrow, local change with a limited regression surface | +| Medium | Several connected files and focused new tests | +| High | Lifecycle- or layout-sensitive work requiring broad regression testing | +| Ongoing | A review constraint applied to every relevant change | + +Complexity notation used below: + +| Symbol | Meaning | +|---|---| +| `T` | Threads | +| `C` | Visible conversation cards | +| `P` | Retained prompt submissions | +| `I` | Authoritative conversation items | +| `A` | Agents | +| `R` | Repositories | +| `F` | Changed files or watched paths | +| `D` | Candidate directories | +| `Q` | Pending requests | + +## High priority + +### Centralize thread and turn status classification + +**Effort: Medium** + +Define active, completed, failed and idle once, then use that definition for +thread dots, composer controls, conversation cards and the Inspector. This +prevents contradictory UI such as a completed gray thread still presenting +Steer and Stop. + +### Build an authoritative-item index + +**Effort: Medium** + +Index conversation items by stable key, client ID and position once per +projection. Prompt matching and anchor lookup can then use direct access +instead of repeatedly scanning the complete history. + +### Make prompt reconciliation linear + +**Effort: Medium–High · Target: `O(P × I)` → approximately `O(P + I)`** + +Every unresolved prompt currently searches authoritative history. Reuse the +projection index for exact identity, anchor and fallback matching while +preserving acknowledgement semantics and claimed-item ownership. + +### Compact fully resolved prompt submissions + +**Effort: Medium** + +Resolved submissions currently remain in future reconciliation work. Remove +them after their transition completes, or retain only the compact mapping +needed to preserve stable visual identity across reconstruction and navigation. + +### Remove repeated Qt layout searches + +**Effort: High · Target: `O(C²)` → approximately `O(C)` for stable order** + +`ConversationView` calls the linear `QLayout::indexOf()` operation for each +section and card during reconciliation. Retain known positions and avoid asking +the layout to rediscover an order CodexUI already owns. + +### Retain explicit section and card order + +**Effort: High** + +Compare desired order against retained order vectors and move widgets only at +changed positions. Content-only streaming updates should not traverse and +rearrange the entire layout; scroll anchors and command-output state must remain +stable. + +### Split shell integration scenarios + +**Effort: Medium** + +Separate start, steer, completion, hydration, recovery and navigation into +clearly named scenario functions. Smaller scenarios reduce accidental coupling +and make lifecycle failures attributable to one protocol sequence. + +### Provide canonical protocol fixtures + +**Effort: Low–Medium** + +Fixture builders must guarantee unique turn IDs and valid combinations of +thread and turn status. This prevents impossible mock states from hiding real +defects or rejecting correct invariants. + +## Medium priority + +### Consolidate per-thread runtime bookkeeping + +**Effort: Medium–High** + +Hydration, settings hydration, read revision, resume, dispatch and recovery are +currently represented by parallel maps and sets. Store them in one small +`ThreadRuntimeState` per thread to reduce synchronization mistakes and repeated +cleanup code without introducing a new subsystem. + +### Replace serialized JSON UI snapshots + +**Effort: Medium** + +Several render paths build and serialize JSON solely to detect visual changes. +Use small typed snapshot structures with equality instead; this removes +allocation, parsing-shaped code and untyped comparison logic. + +### Rebuild thread ordering in one pass + +**Effort: Low · Target: `O(T²)` → `O(T)`** + +`mergeThreadList()` repeatedly erases IDs from a vector. Use one membership set +and construct the resulting order once while preserving provider order and the +required retained tail. + +### Precompute thread-panel positions + +**Effort: Medium · Target: `O(T²)` → approximately `O(T)`** + +Repeated `QListWidget::row()` calls linearly rediscover current positions. +Retain or calculate row indices once per refresh, then perform only the moves +required by the desired order. + +### Aggregate request counts once + +**Effort: Low · Target: `O(T × Q)` → `O(T + Q)`** + +The thread panel repeatedly scans pending requests for individual threads. +Build one per-thread count map and reuse it for serialization and row updates. + +### Consolidate presentation helpers + +**Effort: Low–Medium** + +Status formatting, JSON string extraction and related classification are +repeated across source files. Move only genuinely shared semantics into the +existing presentation or UI support code. + +### Consolidate repeated styling + +**Effort: Low–Medium** + +Move repeated canonical colors, borders and semantic states into `UiStyle`. +Keep widget-specific geometry and genuinely exceptional presentation local to +the owning widget. + +## Thread-hierarchy follow-up + +These tasks belong with the planned structural parent/child thread work rather +than the current flat thread-panel polish. + +### Add a child-thread ownership index + +**Effort: Medium** + +Map each child thread directly to its owning agent and parent thread. Model +updates, removals and hydration must maintain this relationship consistently. + +### Use indexed agent correlation + +**Effort: Low–Medium · Target: repeated global agent scans → direct lookup** + +Child status and result updates currently scan child history and agents across +threads, potentially approaching `O(A²)` across many updates. Once the ownership +index exists, update the owning presentation directly. + +### Present structural thread hierarchy + +**Effort: High** + +Render child threads beneath their parent and support expansion, arbitrary +depth and navigation in both directions. This requires recursive presentation +state and dedicated hierarchy tests. + +## Lower priority and profiling + +### Profile repository resolution + +**Effort: Low · Current bound: `O(R × I × D)`** + +Repository selection combines roots, changed paths and candidate directories, +including filesystem and libgit2 work. It already runs asynchronously; measure +representative multi-repository workloads before changing the algorithm. + +### Use sets for repository/path membership + +**Effort: Low–Medium** + +Replace repeated list membership and deduplication searches where profiling +shows value. Path normalization must remain identical before values enter the +sets. + +### Use sets for filesystem-watch reconciliation + +**Effort: Low · Target: `O(F²)` → approximately `O(F)`** + +Compare desired and existing watch paths with `QSet` differences +instead of nested `QStringList::contains()` calls. Preserve handling of deleted +files and watched parent directories. + +### Profile Inspector reconstruction + +**Effort: Low for measurement** + +Some tabs destroy and recreate child widgets when their snapshots change. +Expected lists are currently small, so incremental reconciliation should be +introduced only if measurement shows visible cost. + +### Profile settings-catalog construction + +**Effort: Low for measurement** + +Combo-box population performs repeated linear option lookup and can be +quadratic in catalog size. Provider catalogs are normally small, making this a +measurement-led optimization. + +### Retain bounded repository hints + +**Effort: None beyond optional documentation or tests** + +Command directories and changed paths use linear uniqueness checks but are +explicitly capped at 64 and 512 entries. These bounds keep the operation from +growing indefinitely and are currently appropriate. + +## Guardrails + +### Prefer removal over abstraction + +**Effort: Ongoing** + +Each refactoring should remove duplicated state, repeated searches or repeated +interpretation. A lower line count is valuable only when responsibilities and +invariants also become clearer. + +### Avoid speculative architecture + +**Effort: Ongoing** + +Do not introduce generic controllers, repositories, event buses or framework +layers without a demonstrated CodexUI responsibility. Typed data and direct +algorithms cover the identified problems. + +### Add representative performance tests + +**Effort: Medium–High** + +Exercise long histories, many visible cards, large thread lists and pending +requests. Prefer deterministic operation-count or benchmark evidence over +fragile wall-clock assertions where possible. + +### Verify every refactoring step + +**Effort: Low per change** + +Run focused tests for the affected invariant and the complete test suite after +each step. Investigate every failure rather than classifying it as unrelated or +flaky without evidence. + +## Recommended sequence + +1. Centralize status semantics and canonical fixtures. +2. Rebuild thread ordering and aggregate pending-request counts. +3. Index authoritative conversation items and simplify prompt reconciliation. +4. Compact resolved submissions. +5. Retain conversation order and remove quadratic Qt layout searches. +6. Consolidate per-thread runtime state and typed UI snapshots. +7. Address agent correlation with the future structural hierarchy. +8. Apply profiling-led Git, filesystem and Inspector improvements. + +This sequence starts with narrow correctness and low-risk algorithmic wins, +then approaches the scroll- and lifecycle-sensitive conversation work with +stronger fixtures and measurements already in place. From 398969b1a06281cb257fed0dc320e48a0f6b8ebf Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 19:54:30 +0200 Subject: [PATCH 24/36] Align panel headers and thread context --- src/codex/middle/MiddleRegionWidget.cpp | 44 +++++++++++++++++-------- src/codex/middle/ThreadPane.cpp | 1 + tests/codex/ApplicationLayoutTest.cpp | 39 ++++++++++++++++++++++ ui-review/UX-DESIGN-DECISIONS.md | 4 +++ 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp index 814dd8b..c73a9ba 100644 --- a/src/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -35,10 +35,12 @@ QLabel *makeLabel(QString value, const char *kind = "body") { return label; } -QFrame *divider() { +QFrame *divider(const char *name = nullptr) { auto *line = new QFrame; + if (name) + line->setObjectName(QString::fromLatin1(name)); + line->setProperty("kind", "standardDivider"); line->setFixedHeight(1); - line->setStyleSheet(QStringLiteral("background:#d7dee8;")); return line; } @@ -81,9 +83,10 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { QStringLiteral("QFrame#conversation{background:#f6f8fb;}")); conversationRegion->setMinimumWidth(480); auto *center = new QVBoxLayout(conversationRegion); - center->setContentsMargins(24, 14, 24, 12); + center->setContentsMargins(10, 14, 10, 12); center->setSpacing(0); auto *context = new QHBoxLayout; + context->setContentsMargins(14, 0, 14, 0); context->addStrut(24); auto *sectionTitle = makeLabel(QStringLiteral("CONVERSATION"), "panelHeader"); @@ -92,17 +95,31 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { context->addWidget(sectionTitle); context->addStretch(); center->addLayout(context); - center->addWidget(divider()); + center->addWidget(divider("conversationHeaderDivider")); center->addSpacing(8); + + auto *content = new QWidget; + auto *contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(14, 0, 14, 0); + contentLayout->setSpacing(0); + auto *threadHeading = new QHBoxLayout; + threadHeading->setSpacing(10); conversationTitle = makeLabel(QStringLiteral("No synchronized thread"), "heading"); + conversationTitle->setObjectName(QStringLiteral("conversationTitle")); + conversationTitle->setWordWrap(false); + conversationTitle->setSizePolicy(QSizePolicy::Minimum, + QSizePolicy::Preferred); conversationMetadata = makeLabel({}, "meta"); - center->addWidget(conversationTitle); - center->addSpacing(2); - center->addWidget(conversationMetadata); - center->addSpacing(7); - center->addWidget(divider()); - center->addSpacing(7); + conversationMetadata->setObjectName( + QStringLiteral("conversationMetadata")); + conversationMetadata->setWordWrap(false); + threadHeading->addWidget(conversationTitle, 0, Qt::AlignBaseline); + threadHeading->addWidget(conversationMetadata, 1, Qt::AlignBaseline); + contentLayout->addLayout(threadHeading); + contentLayout->addSpacing(7); + contentLayout->addWidget(divider()); + contentLayout->addSpacing(7); noticeBar = new QFrame; noticeBar->setStyleSheet(QStringLiteral( @@ -118,14 +135,15 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { noticeLayout->addWidget(dismiss); noticeBar->hide(); connect(dismiss, &QPushButton::clicked, noticeBar, &QWidget::hide); - center->addWidget(noticeBar); + contentLayout->addWidget(noticeBar); conversationView = new ConversationView; - center->addWidget(conversationView, 1); + contentLayout->addWidget(conversationView, 1); composerPane = new ComposerPane(conversationRegion); composerPane->setExtraOverlayHeightAction( [this](int height) { conversationView->setTrailingSpaceHeight(height); }); - center->addWidget(composerPane->canonicalReserve()); + contentLayout->addWidget(composerPane->canonicalReserve()); + center->addWidget(content, 1); splitter->addWidget(conversationRegion); inspectorPane = new InspectorPane; diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index b23beb3..40ffdac 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -170,6 +170,7 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { header->addWidget(hide); layout->addLayout(header); auto *headerDivider = new QFrame; + headerDivider->setObjectName(QStringLiteral("threadHeaderDivider")); headerDivider->setProperty("kind", "standardDivider"); headerDivider->setFixedHeight(1); layout->addWidget(headerDivider); diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 93f0e40..dc7d640 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -173,6 +173,45 @@ bool testOverlayGeometryAndRegionRouting() { splitter->widget(2)->maximumWidth() == 520, "pane width constraints match the visual contract"); + auto *threadHeaderDivider = + splitter->widget(0)->findChild( + QStringLiteral("threadHeaderDivider")); + auto *conversationHeaderDivider = + splitter->widget(1)->findChild( + QStringLiteral("conversationHeaderDivider")); + auto *conversationTitle = + splitter->widget(1)->findChild( + QStringLiteral("conversationTitle")); + auto *conversationMetadata = + splitter->widget(1)->findChild( + QStringLiteral("conversationMetadata")); + const auto paneRect = [](QWidget *widget, QWidget *pane) { + return QRect(widget->mapTo(pane, QPoint()), widget->size()); + }; + const QRect threadDividerRect = + threadHeaderDivider + ? paneRect(threadHeaderDivider, splitter->widget(0)) + : QRect{}; + const QRect conversationDividerRect = + conversationHeaderDivider + ? paneRect(conversationHeaderDivider, splitter->widget(1)) + : QRect{}; + result &= expect( + threadHeaderDivider && conversationHeaderDivider && + threadDividerRect.left() == 10 && + threadDividerRect.right() == splitter->widget(0)->width() - 11 && + conversationDividerRect.left() == 10 && + conversationDividerRect.right() == + splitter->widget(1)->width() - 11, + "Threads and Conversation header dividers share the 10 px inset"); + result &= expect( + conversationTitle && conversationMetadata && + conversationMetadata->geometry().left() > + conversationTitle->geometry().right() && + std::abs(conversationMetadata->geometry().bottom() - + conversationTitle->geometry().bottom()) <= 1, + "thread title and metadata form one baseline-aligned lockup"); + ConversationView &view = region.conversation(); view.reconcile(longConversation("layout-thread")); spin(20); diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 8281efb..d1d2707 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -87,6 +87,10 @@ with cards in server order. Stable turn/item and local-submission keys drive a single reconcile path for both first display and updates. Retained cards mutate in place, and identical visible projections do not trigger layout work. +The active thread name and its smaller `workspace | state` metadata form one +baseline-aligned lockup, following the application brand/titlebar pattern +without sharing its font size. + ## Conversation following The message view smoothly follows appended or streamed content only while From 01d03eb96fd4af20e5e47e1c5bd072275112e5ee Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 19:54:39 +0200 Subject: [PATCH 25/36] Reconcile completed turn activity --- src/codex/PresentationModel.cpp | 70 ++++++++++++++++++++++-- tests/codex/PresentationPipelineTest.cpp | 41 ++++++++++++++ tests/codex/ShellIntegrationTest.cpp | 12 ++-- 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 36d8dd4..16d026b 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -56,6 +56,15 @@ std::string statusValue(const nlohmann::json &value) { return {}; } +bool isActiveTurnStatus(const std::string &status) { + return status == "inProgress" || status == "active"; +} + +bool isTerminalTurnStatus(const std::string &status) { + return status == "completed" || status == "interrupted" || + status == "failed"; +} + std::string requestKey(const nlohmann::json &value) { return value.is_null() ? std::string{} : value.dump(); } @@ -271,9 +280,13 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { } else if (action == "turn.start") { const std::string threadId = stringValue(scope, "threadId"); const auto thread = threads.find(threadId); - if (thread != threads.end()) - upsertTurn(thread->second, - memberValue(data, "turn", nlohmann::json::object()), false); + if (thread != threads.end()) { + TurnPresentation &turn = upsertTurn( + thread->second, + memberValue(data, "turn", nlohmann::json::object()), false); + if (isActiveTurnStatus(turn.status)) + thread->second.status = "active"; + } } else if (action == "models.list") { const nlohmann::json listedModels = memberValue(data, "models", nlohmann::json::array()); @@ -442,8 +455,19 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { } if (type == "turn.upsert") { - upsertTurn(thread, memberValue(data, "turn", nlohmann::json::object()), - false); + nlohmann::json turn = + memberValue(data, "turn", nlohmann::json::object()); + const std::string lifecycle = stringValue(data, "lifecycle"); + const std::string embeddedStatus = + statusValue(memberValue(turn, "status")); + if (lifecycle == "completed" && + !isTerminalTurnStatus(embeddedStatus)) + turn["status"] = "completed"; + else if (lifecycle == "started" && embeddedStatus.empty()) + turn["status"] = "inProgress"; + TurnPresentation &updated = upsertTurn(thread, turn, false); + if (lifecycle == "started" && isActiveTurnStatus(updated.status)) + thread.status = "active"; correlateAgentThread(threadId); return; } @@ -545,6 +569,9 @@ PresentationModel::activeTurnId(const std::string &threadId) const { const ThreadPresentation *value = thread(threadId); if (!value) return std::nullopt; + if (!value->status.empty() && value->status != "active" && + value->status != "inProgress" && value->status != "running") + return std::nullopt; for (auto iterator = value->turnOrder.rbegin(); iterator != value->turnOrder.rend(); ++iterator) { const auto turn = value->turns.find(*iterator); @@ -624,6 +651,14 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, result.id = id; orderedThreads.insert(orderedThreads.begin(), id); } + const std::string previousThreadStatus = result.status; + std::unordered_map terminalTurnStatuses; + if (replaceTurns) { + for (const auto &[turnId, turn] : result.turns) { + if (isTerminalTurnStatus(turn.status)) + terminalTurnStatuses.emplace(turnId, turn.status); + } + } nlohmann::json threadFields = raw; threadFields.erase("turns"); if (replaceTurns) @@ -663,6 +698,26 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, } for (const auto &turn : *turns) upsertTurn(result, turn, replaceTurns); + if (replaceTurns) { + for (const auto &[turnId, terminalStatus] : terminalTurnStatuses) { + const auto turn = result.turns.find(turnId); + if (turn != result.turns.end() && + isActiveTurnStatus(turn->second.status)) { + turn->second.status = terminalStatus; + turn->second.raw["status"] = terminalStatus; + } + } + } + const bool containsActiveTurn = + std::ranges::any_of(result.turns, [](const auto &entry) { + return isActiveTurnStatus(entry.second.status); + }); + if (!containsActiveTurn && isActiveTurnStatus(result.status) && + (previousThreadStatus == "idle" || + previousThreadStatus == "completed")) { + result.status = previousThreadStatus; + result.raw["status"] = previousThreadStatus; + } } return result; } @@ -688,8 +743,11 @@ TurnPresentation &PresentationModel::upsertTurn(ThreadPresentation &thread, else mergePreservingCompleteness(result.raw, turnFields); const std::string status = statusValue(memberValue(raw, "status")); - if (!status.empty()) + if (!status.empty() && + !(isTerminalTurnStatus(result.status) && isActiveTurnStatus(status))) result.status = status; + if (isTerminalTurnStatus(result.status) && isActiveTurnStatus(status)) + result.raw["status"] = result.status; const auto items = raw.find("items"); if (items != raw.end() && items->is_array()) { if (replaceItems) { diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index c5c8df3..908743b 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -243,6 +243,47 @@ int main() { "incomplete thread reads preserve live plan and inspector state"); passed &= expect(!model.activeTurnId("thread-1").has_value(), "completed stream leaves no active turn"); + passed &= expect(model.thread("thread-1") != nullptr && + model.thread("thread-1")->status == "idle", + "completed stream retains idle thread status"); + + normalizer.operationResult( + "thread.read", "stale-active-read", {{"threadId", "thread-1"}}, + {{"id", "stale-active-read"}, + {"result", + {{"thread", + {{"id", "thread-1"}, + {"status", {{"type", "active"}}}, + {"turns", + nlohmann::json::array( + {{{"id", "turn-1"}, {"status", "completed"}}, + {{"id", "turn-2"}, {"status", "inProgress"}}})}}}}}}); + thread = model.thread("thread-1"); + turn = thread == nullptr ? nullptr : &thread->turns.at("turn-2"); + passed &= expect(turn != nullptr && turn->status == "completed" && + !model.activeTurnId("thread-1").has_value(), + "a stale authoritative read cannot reactivate a completed " + "turn"); + passed &= expect(thread != nullptr && thread->status == "idle", + "a stale authoritative read cannot restore running thread " + "chrome"); + + normalizer.serverNotification( + "turn/started", + {{"threadId", "thread-1"}, {"turn", {{"id", "turn-3"}}}}); + passed &= expect(model.activeTurnId("thread-1") == "turn-3" && + model.thread("thread-1")->status == "active", + "started lifecycle supplies a missing active status"); + normalizer.serverNotification( + "thread/status/changed", + {{"threadId", "thread-1"}, {"status", {{"type", "completed"}}}}); + passed &= expect(!model.activeTurnId("thread-1").has_value(), + "completed thread chrome vetoes a stale active turn"); + normalizer.serverNotification( + "turn/completed", + {{"threadId", "thread-1"}, {"turn", {{"id", "turn-3"}}}}); + passed &= expect(!model.activeTurnId("thread-1").has_value(), + "completed lifecycle clears activity without turn status"); PresentationModel hydratedModel; ProtocolNormalizer hydratedNormalizer( diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 04a9d0b..3a51eb8 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -439,7 +439,9 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { {"clientId", clientId}, {"content", {{{"type", "text"}, {"text", "prompt A1"}}}}}}}, Authority::Merge, - {{"threadId", "thread-a"}, {"turnId", "turn-a"}, {"itemId", "user-a1"}})); + {{"threadId", "thread-a"}, + {"turnId", "turn-a-live"}, + {"itemId", "user-a1"}})); spin(10); const middle::LocalPromptData *beforeAck = localPrompt(shell, QStringLiteral("prompt A1")); @@ -464,8 +466,9 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { result &= peer.send(presentation::result( sequence++, 1, "turn.start", startA->value("correlationId", std::string{}), true, - {{"turn", {{"id", "turn-a"}, {"status", "inProgress"}}}}, - Authority::Merge, {{"threadId", "thread-a"}, {"turnId", "turn-a"}})); + {{"turn", {{"id", "turn-a-live"}, {"status", "inProgress"}}}}, + Authority::Merge, + {{"threadId", "thread-a"}, {"turnId", "turn-a-live"}})); const auto steerA = peer.waitFor("turn.steer", "thread-a"); result &= expect(steerA.has_value(), "A1's real background ack releases queued A2"); @@ -682,7 +685,8 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { result &= peer.send( presentation::result(sequence++, 2, "thread.resume", secondResumeD->value("correlationId", std::string{}), - true, {{"thread", thread("thread-d", "D")}}, + true, + {{"thread", thread("thread-d", "D", "active")}}, Authority::Merge, {{"threadId", "thread-d"}})); const auto retriedSteerD = peer.waitFor("turn.steer", "thread-d"); result &= expect(retriedSteerD.has_value() && From ac29f9a0851725ca222c77125ea373b193e95842 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 21:28:50 +0200 Subject: [PATCH 26/36] Render attached user images --- docs/codex-architecture.md | 6 + src/codex/middle/ConversationCards.cpp | 160 +++++++++++++++++++- src/codex/middle/ConversationProjection.cpp | 29 +++- src/codex/middle/MiddleTypes.h | 2 + src/codex/ui/UiStyle.cpp | 6 + tests/codex/ConversationCardsTest.cpp | 90 +++++++++++ tests/codex/ConversationProjectionTest.cpp | 96 ++++++++++++ 7 files changed, 386 insertions(+), 3 deletions(-) diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 26ba555..37478e6 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -501,6 +501,12 @@ correct for a local CodexUI/app-server workspace and remains explicit for a remote bridge topology; adding remote file transfer would require a separate bounded protocol and security design. +Image paths are retained in pending and authoritative user-message +presentation. The conversation shows bounded thumbnails below the Markdown +prompt; selecting one opens a non-modal, fit-to-window viewer. CodexUI never +fetches remote image URLs implicitly, and missing local images remain visible +as unavailable placeholders. + ### 7.4 Changes and Diff Presentation The Changes inspector is authoritative over the local Git worktrees associated diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 02f87af..b031d14 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -4,13 +4,21 @@ #include #include +#include +#include +#include +#include #include #include +#include #include #include #include +#include #include +#include #include +#include #include #include #include @@ -30,6 +38,123 @@ constexpr int MaximumCommandTextHeight = 90; constexpr int CommandTextPadding = 7; constexpr int PendingAnimationIntervalMilliseconds = 32; constexpr qint64 PendingHalfCycleMilliseconds = 850; +constexpr int ThumbnailMaximumWidth = 280; +constexpr int ThumbnailMaximumHeight = 180; +constexpr int ViewerMaximumImageExtent = 4096; + +void openImageViewer(const QString &path); + +class ImageThumbnail final : public QLabel { +public: + ImageThumbnail(QString path, QWidget *parent) + : QLabel(parent), path_(std::move(path)) { + setObjectName(QStringLiteral("messageImageThumbnail")); + setProperty("kind", "imageThumbnail"); + setCursor(Qt::PointingHandCursor); + setToolTip(QDir::toNativeSeparators(path_)); + setAlignment(Qt::AlignCenter); + setMinimumSize(72, 48); + setMaximumSize(ThumbnailMaximumWidth, ThumbnailMaximumHeight); + + QImageReader reader(path_); + reader.setAutoTransform(true); + const QSize source = reader.size(); + if (source.isValid()) + reader.setScaledSize(source.scaled(ThumbnailMaximumWidth - 8, + ThumbnailMaximumHeight - 8, + Qt::KeepAspectRatio)); + const QImage image = reader.read(); + if (image.isNull()) { + setText(QStringLiteral("Image unavailable\n%1") + .arg(QFileInfo(path_).fileName())); + setProperty("imageAvailable", false); + unsetCursor(); + path_.clear(); + return; + } + setProperty("imageAvailable", true); + setPixmap(QPixmap::fromImage(image)); + setFixedSize(image.size() + QSize(8, 8)); + } + +protected: + void mousePressEvent(QMouseEvent *event) override { + if (event->button() == Qt::LeftButton && !path_.isEmpty()) { + openImageViewer(path_); + event->accept(); + return; + } + QLabel::mousePressEvent(event); + } + +private: + QString path_; +}; + +class ImageViewer final : public QDialog { +public: + explicit ImageViewer(const QString &path) : QDialog(nullptr, Qt::Window) { + setObjectName(QStringLiteral("messageImageViewer")); + setAttribute(Qt::WA_DeleteOnClose); + setWindowModality(Qt::NonModal); + setWindowTitle(QFileInfo(path).fileName()); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(8, 8, 8, 8); + scroll_ = new QScrollArea(this); + scroll_->setWidgetResizable(true); + imageLabel_ = new QLabel(scroll_); + imageLabel_->setObjectName(QStringLiteral("messageImageViewerImage")); + imageLabel_->setAlignment(Qt::AlignCenter); + + QImageReader reader(path); + reader.setAutoTransform(true); + const QSize source = reader.size(); + if (source.isValid() && (source.width() > ViewerMaximumImageExtent || + source.height() > ViewerMaximumImageExtent)) + reader.setScaledSize(source.scaled(ViewerMaximumImageExtent, + ViewerMaximumImageExtent, + Qt::KeepAspectRatio)); + image_ = reader.read(); + if (image_.isNull()) + imageLabel_->setText(QStringLiteral("Image unavailable")); + scroll_->setWidget(imageLabel_); + layout->addWidget(scroll_); + resize(900, 650); + updatePixmap(); + } + +protected: + void showEvent(QShowEvent *event) override { + QDialog::showEvent(event); + updatePixmap(); + } + + void resizeEvent(QResizeEvent *event) override { + QDialog::resizeEvent(event); + updatePixmap(); + } + +private: + void updatePixmap() { + if (image_.isNull() || !scroll_) + return; + const QSize available = scroll_->viewport()->size() - QSize(8, 8); + if (available.isEmpty()) + return; + imageLabel_->setPixmap(QPixmap::fromImage( + image_.scaled(available, Qt::KeepAspectRatio, + Qt::SmoothTransformation))); + } + + QImage image_; + QScrollArea *scroll_ = nullptr; + QLabel *imageLabel_ = nullptr; +}; + +void openImageViewer(const QString &path) { + auto *viewer = new ImageViewer(path); + viewer->show(); +} QLabel *makeLabel(const QString &value, const char *kind = "body", QWidget *parent = nullptr) { @@ -170,6 +295,7 @@ bool presentationEquals(const VisibleCardData &left, const auto &second = std::get(right.payload); return first.prompt == second.prompt && first.attachmentCount == second.attachmentCount && + first.imagePaths == second.imagePaths && first.state == second.state && first.acceptedAtMilliseconds == second.acceptedAtMilliseconds && first.error == second.error; @@ -255,7 +381,10 @@ CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) followsLatest_ = isAtBottom(); }); connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, - [this](int, int) { settleScroll(); }); + [this](int, int) { + if (!programmaticScroll_) + settleScroll(); + }); setOutput(output); measureAtCurrentWidth(false); @@ -391,6 +520,7 @@ class ConversationCard::Impl final { body = makeMarkdownLabel({}, owner); layout->addWidget(title); layout->addWidget(body); + createImageContainer(); break; case CardKind::AgentMessage: owner->setProperty("messageRole", "agent"); @@ -460,6 +590,7 @@ class ConversationCard::Impl final { layout->addWidget(title); layout->addWidget(body); layout->addWidget(metadata); + createImageContainer(); animationTimer = new QTimer(owner); animationTimer->setInterval(PendingAnimationIntervalMilliseconds); QObject::connect(animationTimer, &QTimer::timeout, owner, [this] { @@ -471,11 +602,35 @@ class ConversationCard::Impl final { } } + void createImageContainer() { + images = new QWidget(owner); + images->setObjectName(QStringLiteral("messageImages")); + imageLayout = new QVBoxLayout(images); + imageLayout->setContentsMargins(0, 0, 0, 0); + imageLayout->setSpacing(8); + imageLayout->setAlignment(Qt::AlignLeft); + images->hide(); + layout->addWidget(images); + } + + void setImages(const QStringList &paths) { + while (QLayoutItem *item = imageLayout->takeAt(0)) { + delete item->widget(); + delete item; + } + for (const QString &path : paths) { + auto *thumbnail = new ImageThumbnail(path, images); + imageLayout->addWidget(thumbnail, 0, Qt::AlignLeft); + } + images->setVisible(!paths.isEmpty()); + } + void applyPayload(const VisibleCardData &data) { switch (data.kind) { case CardKind::UserMessage: { const auto &message = std::get(data.payload); setVisibleMarkdown(body, message.text); + setImages(message.imagePaths); break; } case CardKind::AgentMessage: { @@ -547,6 +702,7 @@ class ConversationCard::Impl final { const auto &prompt = std::get(data.payload); body->setText(prompt.prompt); body->show(); + setImages(prompt.imagePaths); refreshPendingPresentation(); break; } @@ -613,6 +769,8 @@ class ConversationCard::Impl final { ContentSizedTextView *command = nullptr; CommandOutputView *output = nullptr; QTimer *animationTimer = nullptr; + QWidget *images = nullptr; + QVBoxLayout *imageLayout = nullptr; }; ConversationCard::ConversationCard(const VisibleCardData &data, QWidget *parent) diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp index d5757cb..4d649e8 100644 --- a/src/codex/middle/ConversationProjection.cpp +++ b/src/codex/middle/ConversationProjection.cpp @@ -48,6 +48,29 @@ QString messageText(const nlohmann::json &item) { return parts.join(QStringLiteral("\n")); } +QStringList messageImagePaths(const nlohmann::json &item) { + QStringList result; + const auto content = item.find("content"); + if (content == item.end() || !content->is_array()) + return result; + for (const nlohmann::json &entry : *content) { + if (stringValue(entry, "type") != "localImage") + continue; + const std::string path = stringValue(entry, "path"); + if (!path.empty()) + result.push_back(text(path)); + } + return result; +} + +QStringList localImagePaths(const PromptSubmission &submission) { + QStringList result; + for (const AttachmentDraft &attachment : submission.attachments) + if (attachment.mimeType.startsWith(QStringLiteral("image/"))) + result.push_back(attachment.path); + return result; +} + QString joinedStrings(const nlohmann::json &value) { if (!value.is_array()) return {}; @@ -92,7 +115,8 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, if (type == "userMessage") { result.kind = CardKind::UserMessage; - result.payload = UserMessageData{messageText(item)}; + result.payload = + UserMessageData{messageText(item), messageImagePaths(item)}; } else if (type == "agentMessage") { result.kind = CardKind::AgentMessage; result.payload = AgentMessageData{ @@ -279,7 +303,8 @@ ConversationSnapshot ConversationProjection::project( submission.state == PromptState::Queued ? PromptState::InFlight : submission.state, - submission.acceptedAtMilliseconds, submission.error}}; + submission.acceptedAtMilliseconds, submission.error, + localImagePaths(submission)}}; nodes.push_back({position, submission.admissionOrdinal, sectionKey, turnId, std::move(card)}); } diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 5faf0f9..9df2fe2 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -61,6 +61,7 @@ enum class CardKind { struct UserMessageData { QString text; + QStringList imagePaths; bool operator==(const UserMessageData &) const = default; }; @@ -132,6 +133,7 @@ struct LocalPromptData { PromptState state = PromptState::Queued; qint64 acceptedAtMilliseconds = 0; QString error; + QStringList imagePaths; [[nodiscard]] bool acceptedTransitionActive(qint64 nowMilliseconds) const noexcept { diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 4207125..232103e 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -100,6 +100,12 @@ QString applicationStyleSheet() { QLabel[kind="body"] { font-size: %2pt; } QLabel[kind="meta"] { color: #667085; font-size: %1pt; } QLabel[kind="small"] { color: #667085; font-size: %1pt; } + QLabel[kind="imageThumbnail"] { + background: #f8fafc; + border: 1px solid #d7dee8; + border-radius: 6px; + padding: 3px; + } QLabel[kind="diffAdditionMeta"] { color: #176b45; font-size: %1pt; font-weight: 600; } QLabel[kind="diffDeletionMeta"] { color: #982f3d; font-size: %1pt; font-weight: 600; } QPushButton, QToolButton { diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 7c5249a..88af893 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -745,6 +746,94 @@ bool testPendingPromptAnimation() { return result; } +bool testMessageImagePresentation() { + QTemporaryDir directory; + const QString path = directory.filePath(QStringLiteral("sample.png")); + QImage source(640, 360, QImage::Format_ARGB32_Premultiplied); + source.fill(QColor(QStringLiteral("#2f6feb"))); + bool result = expect(directory.isValid() && source.save(path), + "image test fixture is a real readable image"); + + VisibleCardData message{ + AuthoritativeItemKey{"images", "turn", "message"}, + CardKind::UserMessage, + "images", + "turn", + "message", + UserMessageData{QStringLiteral("attached image"), {path}}}; + auto *card = new ConversationCard(message); + card->show(); + spin(); + auto *thumbnail = + card->findChild(QStringLiteral("messageImageThumbnail")); + const QPixmap thumbnailPixmap = thumbnail ? thumbnail->pixmap() : QPixmap{}; + result &= expect(thumbnail && thumbnail->property("imageAvailable").toBool() && + !thumbnailPixmap.isNull() && + thumbnailPixmap.width() <= 280 && + thumbnailPixmap.height() <= 180, + "a local image is decoded directly to a bounded thumbnail"); + if (thumbnail) { + const QPointF local(thumbnail->rect().center()); + QMouseEvent click(QEvent::MouseButtonPress, + local, local, + thumbnail->mapToGlobal(local.toPoint()), Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(thumbnail, &click); + spin(); + } + QWidget *viewer = nullptr; + for (QWidget *candidate : QApplication::topLevelWidgets()) + if (candidate->objectName() == QStringLiteral("messageImageViewer")) + viewer = candidate; + result &= expect(viewer && viewer->isVisible(), + "clicking a thumbnail opens the non-modal image viewer"); + const auto *viewerImage = + viewer ? viewer->findChild( + QStringLiteral("messageImageViewerImage")) + : nullptr; + result &= expect(viewerImage && !viewerImage->pixmap().isNull(), + "the shown viewer contains a fitted image pixmap"); + if (viewer) + viewer->close(); + spin(); + + auto &payload = std::get(message.payload); + payload.imagePaths = {directory.filePath(QStringLiteral("missing.png"))}; + result &= expect(card->apply(message), + "changing the image list invalidates card presentation"); + thumbnail = + card->findChild(QStringLiteral("messageImageThumbnail")); + result &= expect(thumbnail && + !thumbnail->property("imageAvailable").toBool() && + thumbnail->text().contains(QStringLiteral("unavailable")), + "an unreadable image has a stable restrained placeholder"); + + payload.imagePaths = {path}; + card->apply(message); + thumbnail = + card->findChild(QStringLiteral("messageImageThumbnail")); + if (thumbnail) { + const QPointF local(thumbnail->rect().center()); + QMouseEvent click(QEvent::MouseButtonPress, local, local, + thumbnail->mapToGlobal(local.toPoint()), Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(thumbnail, &click); + spin(); + } + viewer = nullptr; + for (QWidget *candidate : QApplication::topLevelWidgets()) + if (candidate->objectName() == QStringLiteral("messageImageViewer")) + viewer = candidate; + delete card; + spin(); + result &= expect(viewer && viewer->isVisible(), + "an open viewer is independent of its originating card"); + if (viewer) + viewer->close(); + spin(); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -759,6 +848,7 @@ int main(int argc, char **argv) { result &= testBottomAnchoredCommandOutputGrowth(); result &= testCommandOutputStateAcrossNavigation(); result &= testPendingPromptAnimation(); + result &= testMessageImagePresentation(); if (result) std::cout << "Conversation card tests passed\n"; return result ? 0 : 1; diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp index f75f1f8..51d4644 100644 --- a/tests/codex/ConversationProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -362,6 +362,101 @@ bool testCommandOutputVisibility() { return result; } +bool testUserMessageImages() { + ThreadPresentation thread = baseThread("image-thread"); + appendItem( + thread, "turn-1", + item("user-images", + {{"type", "userMessage"}, + {"content", + {{{"type", "text"}, {"text", "image prompt"}}, + {{"type", "localImage"}, {"path", "/tmp/first.png"}}, + {{"type", "localImage"}, {"path", "/tmp/second.jpg"}}}}})); + appendItem(thread, "turn-1", + item("user-image-only", + {{"type", "userMessage"}, + {"content", {{{"type", "localImage"}, + {"path", "/tmp/only.png"}}}}})); + + const ConversationSnapshot authoritative = ConversationProjection::project( + thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); + const auto &cards = authoritative.sections.front().cards; + const auto *mixed = std::get_if(&cards[2].payload); + const auto *imageOnly = std::get_if(&cards[3].payload); + bool result = expect( + mixed && mixed->text == QStringLiteral("image prompt") && + mixed->imagePaths == + QStringList{QStringLiteral("/tmp/first.png"), + QStringLiteral("/tmp/second.jpg")}, + "authoritative user messages retain text and local image paths"); + result &= expect(imageOnly && imageOnly->text.isEmpty() && + imageOnly->imagePaths == + QStringList{QStringLiteral("/tmp/only.png")}, + "an image-only user message remains presentable"); + + PromptSubmission pending; + pending.id = 41; + pending.threadId = thread.id; + pending.prompt = QStringLiteral("pending image"); + pending.state = PromptState::InFlight; + pending.attachments = { + {QStringLiteral("/tmp/pending.png"), QStringLiteral("pending.png"), + QStringLiteral("image/png"), 10}, + {QStringLiteral("/tmp/note.txt"), QStringLiteral("note.txt"), + QStringLiteral("text/plain"), 10}}; + const std::array submissions{pending}; + const ConversationSnapshot local = ConversationProjection::project( + thread, submissions, ConversationProjection::DefaultAuthoritativeItemLimit, + 10); + const auto *localCard = local.find(LocalPromptKey{41}); + const auto *localPrompt = + localCard ? std::get_if(&localCard->payload) : nullptr; + result &= expect(localPrompt && localPrompt->attachmentCount == 2 && + localPrompt->imagePaths == + QStringList{QStringLiteral("/tmp/pending.png")}, + "temporary prompts expose only their image attachment paths"); + + ThreadPresentation replacement = baseThread("replacement-thread"); + addTurn(replacement, "turn-image"); + PromptCoordinator prompts; + const auto submissionId = prompts.admit( + replacement.id, QStringLiteral("replacement image"), + {{QStringLiteral("/tmp/replacement.png"), + QStringLiteral("replacement.png"), QStringLiteral("image/png"), 10}}, + nlohmann::json::object(), &replacement, std::nullopt, 100); + const auto dispatch = prompts.beginNext(replacement.id); + result &= expect(dispatch && prompts.acknowledge( + replacement.id, submissionId, + std::string("turn-image"), 200), + "image prompt receives a real acknowledgement"); + appendItem( + replacement, "turn-image", + item("authoritative-image", + {{"type", "userMessage"}, + {"clientId", dispatch ? dispatch->clientUserMessageId : ""}, + {"content", + {{{"type", "text"}, {"text", "replacement image"}}, + {{"type", "localImage"}, + {"path", "/tmp/replacement.png"}}}}})); + prompts.reconcile(replacement.id, replacement); + prompts.compactResolved(replacement.id, 800); + const ConversationSnapshot replaced = ConversationProjection::project( + replacement, prompts.submissions(replacement.id), 80, 800); + const VisibleCardData *replacedCard = + replaced.find(LocalPromptKey{submissionId}); + const auto *replacedMessage = + replacedCard ? std::get_if(&replacedCard->payload) + : nullptr; + result &= expect( + replacedCard && replacedCard->kind == CardKind::UserMessage && + replacedMessage && + replacedMessage->imagePaths == + QStringList{QStringLiteral("/tmp/replacement.png")} && + prompts.submission(replacement.id, submissionId)->attachments.empty(), + "authoritative image presentation survives local payload compaction"); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -373,6 +468,7 @@ int main() { result &= testClientIdentityBindsBeforeAcknowledgement(); result &= testAnchoredDuplicatePrompts(); result &= testCommandOutputVisibility(); + result &= testUserMessageImages(); if (result) std::cout << "Conversation projection tests passed\n"; return result ? 0 : 1; From efb6b4d938faa7209facf8fa11b88b547585eb5d Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 21:28:57 +0200 Subject: [PATCH 27/36] Scope conversation notice styling --- src/codex/middle/MiddleRegionWidget.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp index c73a9ba..72e537e 100644 --- a/src/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -122,8 +122,10 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { contentLayout->addSpacing(7); noticeBar = new QFrame; + noticeBar->setObjectName(QStringLiteral("conversationNoticeBar")); noticeBar->setStyleSheet(QStringLiteral( - "background:#fff0f2;border:1px solid #efb8c0;border-radius:6px;")); + "QFrame#conversationNoticeBar{background:#fff0f2;" + "border:1px solid #efb8c0;border-radius:7px;}")); auto *noticeLayout = new QHBoxLayout(noticeBar); noticeLayout->setContentsMargins(10, 6, 8, 6); noticeLabel = makeLabel({}, "meta"); @@ -187,10 +189,10 @@ void MiddleRegionWidget::showNotice(QString message, bool error) { return; noticeLabel->setText(std::move(message)); noticeBar->setStyleSheet( - error ? QStringLiteral("background:#fff0f2;border:1px solid #efb8c0;" - "border-radius:6px;") - : QStringLiteral("background:#fff6df;border:1px solid #e5c77d;" - "border-radius:6px;")); + error ? QStringLiteral("QFrame#conversationNoticeBar{background:#fff0f2;" + "border:1px solid #efb8c0;border-radius:7px;}") + : QStringLiteral("QFrame#conversationNoticeBar{background:#fff6df;" + "border:1px solid #e5c77d;border-radius:7px;}")); noticeLabel->setStyleSheet(error ? QStringLiteral("color:#982f3d;") : QStringLiteral("color:#8a5208;")); noticeBar->show(); From d84931da67b1d6bda3d2b2269c6c68807ca329f0 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 23:11:12 +0200 Subject: [PATCH 28/36] Stop perpetual composer layout requests --- src/codex/middle/ComposerPane.cpp | 13 +++++++--- tests/codex/ApplicationLayoutTest.cpp | 35 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp index b654c8f..9488e85 100644 --- a/src/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -284,14 +284,19 @@ void ComposerPane::synchronizeGeometry() { const int wantedExtra = canonicalCaptureEnabled_ && canonicalHeight_ > 0 ? std::max(0, wantedHeight - canonicalHeight_) : 0; - setGeometry(overlayInset, availableHeight - wantedHeight, width, - wantedHeight); + const QRect wantedGeometry(overlayInset, availableHeight - wantedHeight, + width, wantedHeight); + const bool geometryChanged = geometry() != wantedGeometry; + if (geometryChanged) + setGeometry(wantedGeometry); // The natural height was calculated after the prompt layout changed, while // the child layout still had the previous overlay geometry. Lay out the // children once against the final rectangle so fixed surfaces cannot remain // compressed and leave a false gap during upward growth. - layout()->invalidate(); - layout()->activate(); + if (geometryChanged) { + layout()->invalidate(); + layout()->activate(); + } raise(); if (wantedExtra != extraHeight_) { diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index dc7d640..6286190 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,19 @@ namespace codexui::codex::middle { namespace { +class LayoutRequestCounter final : public QObject { +public: + int count = 0; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override { + static_cast(watched); + if (event->type() == QEvent::LayoutRequest) + ++count; + return false; + } +}; + bool expect(bool condition, const char *message) { if (condition) return true; @@ -382,6 +396,26 @@ bool testOverlayGeometryAndRegionRouting() { return result; } +bool testStableComposerLayoutRequests() { + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); + bool result = true; + { + MiddleRegionWidget region; + region.resize(1500, 820); + region.show(); + spin(20); + + LayoutRequestCounter composerLayoutRequests; + region.composer().installEventFilter(&composerLayoutRequests); + spin(80); + result = expect( + composerLayoutRequests.count <= 1, + "stable composer geometry does not perpetually request layout"); + } + qApp->setStyleSheet(QString{}); + return result; +} + bool testThreadSelectionProjection() { PresentationModel model; model.applyEvent(presentation::event( @@ -1142,6 +1176,7 @@ int main(int argc, char **argv) { result &= testInfoViewerLayout(); result &= testInspectorDetailParity(); result &= testGitDiffScopes(); + result &= testStableComposerLayoutRequests(); if (result) std::cout << "Application layout tests passed\n"; return result ? 0 : 1; From 2e796e529ba06a29f344dba462642f9690853321 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 23:12:38 +0200 Subject: [PATCH 29/36] Preserve attached file links in prompt cards --- docs/codex-architecture.md | 5 ++-- docs/ui-behavior.md | 4 +++ src/codex/ShellWidget.cpp | 5 +--- src/codex/middle/ConversationCards.cpp | 16 ++--------- src/codex/middle/ConversationProjection.cpp | 1 - src/codex/middle/MiddleTypes.h | 1 - src/codex/middle/PromptCoordinator.cpp | 30 +++++++++++++++++++ src/codex/middle/PromptCoordinator.h | 4 +++ tests/codex/ConversationCardsTest.cpp | 22 +++++++++++--- tests/codex/ConversationProjectionTest.cpp | 32 ++++++++++++++++++++- 10 files changed, 93 insertions(+), 27 deletions(-) diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 37478e6..c0c30b6 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -492,8 +492,9 @@ The composer opens the same custom file browser in multi-file mode. It supports up to sixteen unique files and reports detected MIME type and size. Local admission moves the prompt and attachments into a per-thread pending card and immediately clears the composer so another prompt can be entered. Images become -native `localImage` input, audio becomes `localAudio`, and other files become -native `mention` input. +native `localImage` input and audio becomes `localAudio`. Other files are +appended to the admitted prompt as Markdown links to their local paths, so the +temporary and authoritative cards carry the same durable representation. These are app-server local-path references, not bytes uploaded through `codex-bridge`. The provider must be able to access the selected path. This is diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index af110ef..7ae6fe5 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -74,6 +74,10 @@ Submitting a prompt creates a client-local pending prompt card at the bottom of the destination thread immediately. The card uses a muted version of the normal blue user-card treatment, with a brighter blue highlight sweeping left and right across it until the app-server acknowledges the operation. +Ordinary attached files appear as local Markdown links at the bottom of that +card from its first frame. The same composed Markdown is sent to app-server and +retained by the authoritative user message, so acknowledgment does not reflow +the attachment presentation. Each pending prompt has a process-wide client-local submission ID and remains associated with its destination thread. It therefore remains visible when the diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 5523aaf..19d3d51 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -1071,6 +1071,7 @@ bool ShellWidget::Impl::submitPrompt(QString prompt, prompt = prompt.trimmed(); if (prompt.isEmpty()) return false; + prompt = middle::promptWithFileLinks(std::move(prompt), attachments); const std::string visiblySelected = middleRegion->threads().visiblySelectedThreadId(); if (!visiblySelected.empty() && visiblySelected != selectedThreadId) { @@ -1264,10 +1265,6 @@ void ShellWidget::Impl::dispatchPrompt(middle::PromptDispatch dispatch) { else if (attachment.mimeType.startsWith(QStringLiteral("audio/"))) input.push_back( {{"type", "localAudio"}, {"path", attachment.path.toStdString()}}); - else - input.push_back({{"type", "mention"}, - {"name", attachment.name.toStdString()}, - {"path", attachment.path.toStdString()}}); } const std::string threadId = dispatch.threadId; diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index b031d14..6b19ba8 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -294,7 +294,6 @@ bool presentationEquals(const VisibleCardData &left, const auto &first = std::get(left.payload); const auto &second = std::get(right.payload); return first.prompt == second.prompt && - first.attachmentCount == second.attachmentCount && first.imagePaths == second.imagePaths && first.state == second.state && first.acceptedAtMilliseconds == second.acceptedAtMilliseconds && @@ -585,7 +584,7 @@ class ConversationCard::Impl final { owner->setStyleSheet(QStringLiteral( "QFrame#pendingPromptCard{background:transparent;border:0;}")); title = makeLabel(QStringLiteral("You"), "title", owner); - body = makeLabel({}, "body", owner); + body = makeMarkdownLabel({}, owner); metadata = makeLabel({}, "meta", owner); layout->addWidget(title); layout->addWidget(body); @@ -700,8 +699,7 @@ class ConversationCard::Impl final { } case CardKind::LocalPrompt: { const auto &prompt = std::get(data.payload); - body->setText(prompt.prompt); - body->show(); + setVisibleMarkdown(body, prompt.prompt); setImages(prompt.imagePaths); refreshPendingPresentation(); break; @@ -738,16 +736,6 @@ class ConversationCard::Impl final { ? QStringLiteral("Not sent") : QStringLiteral("Not sent: %1").arg(prompt->error); - if (prompt->attachmentCount > 0) { - const QString attachments = - QStringLiteral("%1 attachment%2") - .arg(prompt->attachmentCount) - .arg(prompt->attachmentCount == 1 ? QString{} - : QStringLiteral("s")); - status = status.isEmpty() - ? attachments - : status + QStringLiteral(" | ") + attachments; - } changed = setVisibleText(metadata, status) || changed; if (waiting || transitioning) { diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp index 4d649e8..9794816 100644 --- a/src/codex/middle/ConversationProjection.cpp +++ b/src/codex/middle/ConversationProjection.cpp @@ -299,7 +299,6 @@ ConversationSnapshot ConversationProjection::project( turnId, {}, LocalPromptData{submission.id, submission.prompt, - static_cast(submission.attachments.size()), submission.state == PromptState::Queued ? PromptState::InFlight : submission.state, diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 9df2fe2..9b6a2a4 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -129,7 +129,6 @@ struct GenericActivityData { struct LocalPromptData { std::uint64_t submissionId = 0; QString prompt; - int attachmentCount = 0; PromptState state = PromptState::Queued; qint64 acceptedAtMilliseconds = 0; QString error; diff --git a/src/codex/middle/PromptCoordinator.cpp b/src/codex/middle/PromptCoordinator.cpp index bcfc684..311e837 100644 --- a/src/codex/middle/PromptCoordinator.cpp +++ b/src/codex/middle/PromptCoordinator.cpp @@ -2,6 +2,7 @@ #include "codex/middle/PromptCoordinator.h" +#include #include #include #include @@ -39,6 +40,15 @@ QString userMessageText(const nlohmann::json &item) { return parts.join(QStringLiteral("\n")); } +QString markdownLinkLabel(QString label) { + label.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); + label.replace(QLatin1Char('['), QStringLiteral("\\[")); + label.replace(QLatin1Char(']'), QStringLiteral("\\]")); + label.replace(QLatin1Char('\r'), QLatin1Char(' ')); + label.replace(QLatin1Char('\n'), QLatin1Char(' ')); + return label; +} + std::vector> orderedItems(const std::string &threadId, const ThreadPresentation &thread) { std::vector> result; @@ -59,6 +69,26 @@ orderedItems(const std::string &threadId, const ThreadPresentation &thread) { } // namespace +QString promptWithFileLinks(QString prompt, + std::span attachments) { + QStringList links; + for (const AttachmentDraft &attachment : attachments) { + if (attachment.mimeType.startsWith(QStringLiteral("image/")) || + attachment.mimeType.startsWith(QStringLiteral("audio/"))) + continue; + QString target = + QUrl::fromLocalFile(attachment.path).toString(QUrl::FullyEncoded); + target.replace(QLatin1Char('('), QStringLiteral("%28")); + target.replace(QLatin1Char(')'), QStringLiteral("%29")); + links.push_back(QStringLiteral("- [%1](%2)") + .arg(markdownLinkLabel(attachment.name), target)); + } + if (links.empty()) + return prompt; + return prompt + QStringLiteral("\n\nAttached files:\n") + + links.join(QLatin1Char('\n')); +} + bool PromptSubmission::acceptedTransitionActive( qint64 nowMilliseconds) const noexcept { return state == PromptState::Accepted && acceptedAtMilliseconds > 0 && diff --git a/src/codex/middle/PromptCoordinator.h b/src/codex/middle/PromptCoordinator.h index 59e6837..62e40b0 100644 --- a/src/codex/middle/PromptCoordinator.h +++ b/src/codex/middle/PromptCoordinator.h @@ -21,6 +21,10 @@ namespace codexui::codex::middle { +[[nodiscard]] QString +promptWithFileLinks(QString prompt, + std::span attachments); + struct PromptSubmission { std::uint64_t id = 0; std::uint64_t admissionOrdinal = 0; diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 88af893..32c0c34 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -302,7 +302,6 @@ bool testPromptAdmissionFollowOwnership() { {}, LocalPromptData{1001, QStringLiteral("a newly admitted pending prompt"), - 0, PromptState::InFlight, 0, {}}}; @@ -387,8 +386,12 @@ bool testMutableCardsAndCommandOutput() { thread, {}, {}, - LocalPromptData{ - 77, QStringLiteral("pending"), 0, PromptState::InFlight, 0, {}}}, + LocalPromptData{77, + QStringLiteral("pending\n\nAttached files:\n" + "- [report.pdf](file:///tmp/report.pdf)"), + PromptState::InFlight, + 0, + {}}}, }; ConversationSnapshot snapshot{thread, {section}, 0, false}; ConversationView view; @@ -428,6 +431,18 @@ bool testMutableCardsAndCommandOutput() { commandText->height() < commandText->maximumHeight() && commandText->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, "short command text trims empty lines and uses its content height"); + auto *pendingCard = identities[stableKey(CardKey{LocalPromptKey{77}})]; + result &= expect( + std::ranges::any_of( + pendingCard->findChildren(), + [](QLabel *label) { + return label->property("markdownSource") + .toString() + .contains(QStringLiteral( + "[report.pdf](file:///tmp/report.pdf)")) && + label->textFormat() == Qt::RichText; + }), + "pending prompts render file links before authoritative replacement"); auto &cards = snapshot.sections.front().cards; std::get(cards[0].payload).text += @@ -718,7 +733,6 @@ bool testPendingPromptAnimation() { {}, LocalPromptData{901, QStringLiteral("pending prompt"), - 0, PromptState::InFlight, 0, {}}}; diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp index 51d4644..7f18ebf 100644 --- a/tests/codex/ConversationProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -411,7 +411,7 @@ bool testUserMessageImages() { const auto *localCard = local.find(LocalPromptKey{41}); const auto *localPrompt = localCard ? std::get_if(&localCard->payload) : nullptr; - result &= expect(localPrompt && localPrompt->attachmentCount == 2 && + result &= expect(localPrompt && localPrompt->imagePaths == QStringList{QStringLiteral("/tmp/pending.png")}, "temporary prompts expose only their image attachment paths"); @@ -457,6 +457,35 @@ bool testUserMessageImages() { return result; } +bool testFileLinksArePartOfTheCanonicalPrompt() { + const std::vector attachments{ + {QStringLiteral("/tmp/review notes [final] (2).pdf"), + QStringLiteral("review notes [final] (2).pdf"), + QStringLiteral("application/pdf"), 10}, + {QStringLiteral("/tmp/image.png"), QStringLiteral("image.png"), + QStringLiteral("image/png"), 10}, + {QStringLiteral("/tmp/audio.ogg"), QStringLiteral("audio.ogg"), + QStringLiteral("audio/ogg"), 10}}; + const QString composed = + promptWithFileLinks(QStringLiteral("Review this"), attachments); + const QString expected = QStringLiteral( + "Review this\n\nAttached files:\n" + "- [review notes \\[final\\] (2).pdf]" + "(file:///tmp/review%20notes%20%5Bfinal%5D%20%282%29.pdf)"); + bool result = expect(composed == expected, + "ordinary files become escaped durable Markdown links"); + + PromptCoordinator prompts; + const auto id = + prompts.admit("thread-files", composed, attachments, + nlohmann::json::object(), nullptr, std::nullopt, 100); + const auto dispatch = prompts.beginNext("thread-files"); + result &= + expect(dispatch && dispatch->id == id && dispatch->prompt == composed, + "temporary presentation and transport share one prompt"); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -469,6 +498,7 @@ int main() { result &= testAnchoredDuplicatePrompts(); result &= testCommandOutputVisibility(); result &= testUserMessageImages(); + result &= testFileLinksArePartOfTheCanonicalPrompt(); if (result) std::cout << "Conversation projection tests passed\n"; return result ? 0 : 1; From 835ce59a98dab05be53a3492257f882c1d5d20b5 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 26 Aug 2026 23:54:08 +0200 Subject: [PATCH 30/36] Apply semantic colors to UI states --- src/codex/ConnectionDialog.cpp | 2 +- src/codex/FileSelectionDialog.cpp | 2 +- src/codex/NewThreadDialog.cpp | 2 +- src/codex/middle/ComposerPane.cpp | 2 ++ src/codex/middle/ConversationCards.cpp | 37 +++++++++++++++++++- src/codex/middle/InspectorPane.cpp | 46 +++++++++++++++++++++---- src/codex/middle/MiddleRegionWidget.cpp | 24 +++++++------ src/codex/middle/ThreadPane.cpp | 30 ++++++++++++++-- src/codex/ui/UiStyle.cpp | 7 ++++ tests/codex/ApplicationLayoutTest.cpp | 37 +++++++++++++++++++- tests/codex/ConversationCardsTest.cpp | 3 +- ui-review/UX-DESIGN-DECISIONS.md | 8 +++++ 12 files changed, 175 insertions(+), 25 deletions(-) diff --git a/src/codex/ConnectionDialog.cpp b/src/codex/ConnectionDialog.cpp index ece1496..f65dd83 100644 --- a/src/codex/ConnectionDialog.cpp +++ b/src/codex/ConnectionDialog.cpp @@ -90,7 +90,7 @@ ConnectionDialog::ConnectionDialog(nlohmann::json settings, QWidget *parent) "meta"); root->addWidget(tlsNotice); errorLabel = dialogLabel({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); + errorLabel->setProperty("tone", "danger"); errorLabel->hide(); root->addWidget(errorLabel); root->addStretch(); diff --git a/src/codex/FileSelectionDialog.cpp b/src/codex/FileSelectionDialog.cpp index d2baf81..22fa6f9 100644 --- a/src/codex/FileSelectionDialog.cpp +++ b/src/codex/FileSelectionDialog.cpp @@ -143,7 +143,7 @@ FileSelectionDialog::FileSelectionDialog( } errorLabel = dialogLabel({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); + errorLabel->setProperty("tone", "danger"); errorLabel->hide(); root->addWidget(errorLabel); diff --git a/src/codex/NewThreadDialog.cpp b/src/codex/NewThreadDialog.cpp index 4637f13..6c839c2 100644 --- a/src/codex/NewThreadDialog.cpp +++ b/src/codex/NewThreadDialog.cpp @@ -115,7 +115,7 @@ NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) root->addWidget(scroll, 1); errorLabel = label({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); + errorLabel->setProperty("tone", "danger"); errorLabel->hide(); root->addWidget(errorLabel); diff --git a/src/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp index 9488e85..c83e519 100644 --- a/src/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -93,6 +93,8 @@ ComposerPane::ComposerPane(QWidget *anchor) attentionLayout->addStretch(); auto *deny = new QPushButton(QStringLiteral("Deny"), attention_); auto *review = new QPushButton(QStringLiteral("Review"), attention_); + deny->setProperty("kind", "destructive"); + review->setProperty("kind", "request"); attentionLayout->addWidget(deny); attentionLayout->addWidget(review); connect(deny, &QPushButton::clicked, this, [this] { diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 6b19ba8..5d8a318 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -220,18 +221,48 @@ bool setVisibleMarkdown(QLabel *label, const QString &markdown) { QString displayStatus(const QString &status) { if (status == QStringLiteral("inProgress") || - status == QStringLiteral("active")) + status == QStringLiteral("active") || + status == QStringLiteral("running") || + status == QStringLiteral("started")) return QStringLiteral("Running"); if (status == QStringLiteral("completed") || status == QStringLiteral("idle")) return QStringLiteral("Completed"); if (status == QStringLiteral("failed") || status == QStringLiteral("systemError")) return QStringLiteral("Failed"); + if (status == QStringLiteral("interrupted")) + return QStringLiteral("Interrupted"); if (status.isEmpty()) return QStringLiteral("Unknown"); return status; } +QString statusTone(const QString &status) { + if (status == QStringLiteral("inProgress") || + status == QStringLiteral("active") || + status == QStringLiteral("running") || + status == QStringLiteral("started")) + return QStringLiteral("active"); + if (status == QStringLiteral("completed") || status == QStringLiteral("idle")) + return QStringLiteral("success"); + if (status == QStringLiteral("failed") || + status == QStringLiteral("systemError")) + return QStringLiteral("danger"); + if (status == QStringLiteral("interrupted")) + return QStringLiteral("warning"); + return {}; +} + +void setStatusTone(QLabel *label, const QString &status) { + const QString tone = statusTone(status); + if (label->property("tone").toString() == tone) + return; + label->setProperty("tone", tone); + label->style()->unpolish(label); + label->style()->polish(label); + label->update(); +} + QString commandMetadata(const CommandExecutionData &command) { QStringList metadata{displayStatus(command.status)}; if (command.exitCode) @@ -660,12 +691,15 @@ class ConversationCard::Impl final { output->restoreScrollState({true, 0}); } metadata->setText(commandMetadata(execution)); + setStatusTone(metadata, execution.status); metadata->show(); break; } case CardKind::AgentActivity: { const auto &activity = std::get(data.payload); metadata->setText(agentMetadata(activity)); + setStatusTone(metadata, activity.status.isEmpty() ? activity.kind + : activity.status); metadata->show(); setVisibleText(body, activity.prompt); setVisibleMarkdown(detail, activity.resultText); @@ -681,6 +715,7 @@ class ConversationCard::Impl final { QStringList values{displayStatus(changes.status)}; values << QStringLiteral("%1 paths").arg(changes.pathCount); metadata->setText(values.join(QStringLiteral(" | "))); + setStatusTone(metadata, changes.status); metadata->show(); break; } diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index dd1c918..f75f77e 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -65,15 +65,31 @@ std::string stringValue(const nlohmann::json &object, const char *key) { } QString displayStatus(const std::string &status) { - if (status == "inProgress" || status == "active") + if (status == "inProgress" || status == "active" || status == "running" || + status == "started") return QStringLiteral("Running"); if (status == "completed" || status == "idle") return QStringLiteral("Completed"); if (status == "failed" || status == "systemError") return QStringLiteral("Failed"); + if (status == "interrupted") + return QStringLiteral("Interrupted"); return status.empty() ? QStringLiteral("Unknown") : text(status); } +const char *statusTone(const std::string &status) { + if (status == "inProgress" || status == "active" || status == "running" || + status == "started") + return "active"; + if (status == "completed" || status == "idle") + return "success"; + if (status == "failed" || status == "systemError") + return "danger"; + if (status == "interrupted") + return "warning"; + return nullptr; +} + QLabel *makeLabel(QString value, const char *kind = "body") { auto *label = new QLabel(std::move(value)); label->setProperty("kind", kind); @@ -85,6 +101,13 @@ QLabel *makeLabel(QString value, const char *kind = "body") { return label; } +QLabel *statusLabel(const std::string &status) { + auto *label = makeLabel(displayStatus(status), "meta"); + if (const char *tone = statusTone(status)) + label->setProperty("tone", tone); + return label; +} + QLabel *makeMarkdownLabel(const QString &value) { QTextDocument document; document.setMarkdown( @@ -150,13 +173,23 @@ QFrame *agentFrame(const AgentPresentation &agent) { : tool.empty() ? QStringLiteral("Agent activity") : QStringLiteral("Agent %1").arg(text(tool)); layout->addWidget(makeLabel(title, "title")); - QStringList metadata{displayStatus(agent.status)}; + QStringList metadata; for (const char *key : {"agentPath", "tool", "model", "reasoningEffort"}) { const QString value = text(stringValue(agent.raw, key)); if (!value.isEmpty()) metadata << value; } - layout->addWidget(makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); + auto *metadataRow = new QHBoxLayout; + metadataRow->setContentsMargins(0, 0, 0, 0); + metadataRow->setSpacing(6); + metadataRow->addWidget(statusLabel(agent.status)); + if (!metadata.isEmpty()) + metadataRow->addWidget( + makeLabel(QStringLiteral("| ") + + metadata.join(QStringLiteral(" | ")), + "meta")); + metadataRow->addStretch(); + layout->addLayout(metadataRow); const QString prompt = text(stringValue(agent.raw, "prompt")); if (!prompt.isEmpty()) layout->addWidget(makeLabel(prompt)); @@ -486,8 +519,7 @@ void InspectorPane::refreshPlan() { layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); layout->addWidget(makeLabel(text(stringValue(step, "step")))); - layout->addWidget( - makeLabel(displayStatus(stringValue(step, "status")), "meta")); + layout->addWidget(statusLabel(stringValue(step, "status"))); planLayout->addWidget(row); } } else if (planItem) { @@ -587,6 +619,7 @@ void InspectorPane::refreshRequests() { currentModel->pendingRequestPresentations()) { auto *frame = new QFrame; frame->setProperty("kind", "raised"); + frame->setProperty("tone", "warning"); auto *layout = new QVBoxLayout(frame); layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); @@ -622,7 +655,8 @@ void InspectorPane::refreshRequests() { actions->setContentsMargins(0, 2, 0, 0); auto *deny = new QPushButton(QStringLiteral("Deny")); auto *review = new QPushButton(QStringLiteral("Review")); - review->setProperty("kind", "primary"); + deny->setProperty("kind", "destructive"); + review->setProperty("kind", "request"); deny->setFixedHeight(28); review->setFixedHeight(28); connect(deny, &QPushButton::clicked, this, [this, id] { diff --git a/src/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp index 72e537e..0ae449b 100644 --- a/src/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -16,9 +16,11 @@ #include #include #include +#include #include #include +#include #include namespace codexui::codex::middle { @@ -123,13 +125,11 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { noticeBar = new QFrame; noticeBar->setObjectName(QStringLiteral("conversationNoticeBar")); - noticeBar->setStyleSheet(QStringLiteral( - "QFrame#conversationNoticeBar{background:#fff0f2;" - "border:1px solid #efb8c0;border-radius:7px;}")); + noticeBar->setProperty("tone", "danger"); auto *noticeLayout = new QHBoxLayout(noticeBar); noticeLayout->setContentsMargins(10, 6, 8, 6); noticeLabel = makeLabel({}, "meta"); - noticeLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); + noticeLabel->setProperty("tone", "danger"); auto *dismiss = new QPushButton(QStringLiteral("Dismiss")); dismiss->setProperty("kind", "subtle"); dismiss->setFixedHeight(28); @@ -188,13 +188,15 @@ void MiddleRegionWidget::showNotice(QString message, bool error) { if (message.trimmed().isEmpty()) return; noticeLabel->setText(std::move(message)); - noticeBar->setStyleSheet( - error ? QStringLiteral("QFrame#conversationNoticeBar{background:#fff0f2;" - "border:1px solid #efb8c0;border-radius:7px;}") - : QStringLiteral("QFrame#conversationNoticeBar{background:#fff6df;" - "border:1px solid #e5c77d;border-radius:7px;}")); - noticeLabel->setStyleSheet(error ? QStringLiteral("color:#982f3d;") - : QStringLiteral("color:#8a5208;")); + const QString tone = + error ? QStringLiteral("danger") : QStringLiteral("warning"); + const std::array tonedWidgets{noticeBar, noticeLabel}; + for (QWidget *widget : tonedWidgets) { + widget->setProperty("tone", tone); + widget->style()->unpolish(widget); + widget->style()->polish(widget); + widget->update(); + } noticeBar->show(); } diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index 40ffdac..7ca2aba 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -61,15 +61,33 @@ QString text(const std::string &value) { } QString displayStatus(const std::string &status) { - if (status == "inProgress" || status == "active") + if (status == "inProgress" || status == "active" || status == "running" || + status == "started") return QStringLiteral("Running"); if (status == "completed" || status == "idle") return QStringLiteral("Completed"); if (status == "failed" || status == "systemError") return QStringLiteral("Failed"); + if (status == "interrupted") + return QStringLiteral("Interrupted"); return status.empty() ? QStringLiteral("Unknown") : text(status); } +QString statusTone(const std::string &status, std::size_t requestCount) { + if (requestCount != 0) + return QStringLiteral("warning"); + if (status == "active" || status == "inProgress" || status == "running" || + status == "started") + return QStringLiteral("active"); + if (status == "completed" || status == "idle") + return QStringLiteral("success"); + if (status == "failed" || status == "systemError") + return QStringLiteral("danger"); + if (status == "interrupted") + return QStringLiteral("warning"); + return {}; +} + QLabel *makeLabel(QString value, const char *kind = "body") { auto *label = new QLabel(std::move(value)); label->setProperty("kind", kind); @@ -100,10 +118,18 @@ void updateRow(QWidget *row, const ThreadPresentation &thread, titleText.prepend(QStringLiteral("! ")); title->setText(titleText); status->setText(displayStatus(thread.status)); + const QString tone = statusTone(thread.status, requestCount); + if (status->property("tone").toString() != tone) { + status->setProperty("tone", tone); + status->style()->unpolish(status); + status->style()->polish(status); + status->update(); + } QString color = QStringLiteral("#cacccf"); if (requestCount != 0) color = QStringLiteral("#a85d0c"); - else if (thread.status == "active" || thread.status == "inProgress") + else if (thread.status == "active" || thread.status == "inProgress" || + thread.status == "running" || thread.status == "started") color = QStringLiteral("#2f6feb"); else if (thread.status == "failed" || thread.status == "systemError") color = QStringLiteral("#c43d4d"); diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 232103e..16fe607 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -100,6 +100,10 @@ QString applicationStyleSheet() { QLabel[kind="body"] { font-size: %2pt; } QLabel[kind="meta"] { color: #667085; font-size: %1pt; } QLabel[kind="small"] { color: #667085; font-size: %1pt; } + QLabel[tone="active"] { color: #285fca; } + QLabel[tone="success"] { color: #176b45; } + QLabel[tone="warning"] { color: #8a5208; } + QLabel[tone="danger"] { color: #982f3d; } QLabel[kind="imageThumbnail"] { background: #f8fafc; border: 1px solid #d7dee8; @@ -185,6 +189,7 @@ QString applicationStyleSheet() { } QFrame[kind="panel"] { background: #ffffff; } QFrame[kind="raised"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } + QFrame[kind="raised"][tone="warning"] { background: #fff6df; border-color: #e5c77d; } QFrame[messageRole="user"] { background: #eaf2ff; border: 1px solid #bfd3f9; border-radius: 8px; } QFrame[messageRole="agent"] { background: #ffffff; border: 0; border-radius: 8px; } QFrame[kind="summary"] { background: #f8fafc; border: 1px solid #d7dee8; border-radius: 7px; } @@ -192,6 +197,8 @@ QString applicationStyleSheet() { QFrame[kind="greenBadge"] { background: #e9f7f0; border: 1px solid #a9d8c1; border-radius: 6px; } QFrame[kind="blueBadge"] { background: #e5eeff; border-radius: 5px; } QFrame[kind="orangeBadge"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } + QFrame#conversationNoticeBar[tone="warning"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } + QFrame#conversationNoticeBar[tone="danger"] { background: #fff0f2; border: 1px solid #efb8c0; border-radius: 7px; } QWidget#composerOverlay { background: #f6f8fb; } QFrame[kind="composer"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } QFrame[kind="composer"][focused="true"] { border: 2px solid #2f6feb; } diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 6286190..ee46c1c 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -292,6 +292,8 @@ bool testOverlayGeometryAndRegionRouting() { region.composer().testAttribute(Qt::WA_StyledBackground) && UiStyle::applicationStyleSheet().contains( QStringLiteral("QWidget#composerOverlay")) && + UiStyle::applicationStyleSheet().contains( + QStringLiteral("QLabel[tone=\"success\"]")) && stableComposerGeometry(), "compact composer has an opaque surface and canonical section gaps"); view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); @@ -422,7 +424,10 @@ bool testThreadSelectionProjection() { 1, 1, "thread.upsert", {{"thread", {{"id", "thread-a"}, {"name", "A"}}}}, presentation::Authority::Merge, {{"threadId", "thread-a"}})); model.applyEvent(presentation::event( - 2, 1, "thread.upsert", {{"thread", {{"id", "thread-b"}, {"name", "B"}}}}, + 2, 1, "thread.upsert", + {{"thread", {{"id", "thread-b"}, + {"name", "B"}, + {"status", {{"type", "active"}}}}}}, presentation::Authority::Merge, {{"threadId", "thread-b"}})); ThreadPane pane; @@ -473,6 +478,7 @@ bool testThreadSelectionProjection() { sortButton->property("codexChevron").toBool() && title->property("kind").toString() == QStringLiteral("title") && status->property("kind").toString() == QStringLiteral("meta") && + status->property("tone").toString() == QStringLiteral("active") && title->textInteractionFlags().testFlag(Qt::TextSelectableByMouse) && status->textInteractionFlags().testFlag(Qt::TextSelectableByMouse), "thread cards keep their status dot and shared chevron styling inside " @@ -946,10 +952,39 @@ bool testInspectorDetailParity() { QStringLiteral("thread child-thread | sender sender-thread | " "receivers receiver-one, receiver-two")), "Agents show child, sender, and receiver thread identities"); + QLabel *agentStatus = nullptr; + for (QLabel *label : inspector.findChildren()) { + if (label->text() == QStringLiteral("Running")) { + agentStatus = label; + break; + } + } + result &= expect(agentStatus && agentStatus->property("tone") == "active", + "running agent status uses the canonical active tone"); inspector.tabs()->setCurrentIndex(3); spin(20); result &= expect(hasLabelContaining(inspector, QStringLiteral("3 questions")), "Requests show their retained question count"); + QFrame *requestFrame = nullptr; + for (QFrame *frame : inspector.findChildren()) { + if (frame->property("tone") == "warning") { + requestFrame = frame; + break; + } + } + QPushButton *denyButton = nullptr; + QPushButton *reviewButton = nullptr; + for (QPushButton *button : inspector.findChildren()) { + if (button->text() == QStringLiteral("Deny")) + denyButton = button; + else if (button->text() == QStringLiteral("Review")) + reviewButton = button; + } + result &= expect( + requestFrame && denyButton && reviewButton && + denyButton->property("kind") == "destructive" && + reviewButton->property("kind") == "request", + "pending requests use warning surfaces and semantic actions"); return result; } diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 32c0c34..0fe2d5f 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -633,7 +633,8 @@ bool testBottomAnchoredCommandOutputGrowth() { QStringLiteral("commandOutputView"))) : nullptr; bool result = expect(commandCard && metadata && output && - output->isHidden() && view.isAtBottom(), + output->isHidden() && view.isAtBottom() && + metadata->property("tone") == "active", "live command starts with a hidden zero-line output"); if (!commandCard || !metadata || !output) return false; diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index d1d2707..4561973 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -68,6 +68,14 @@ creating a separate indicator palette. The existing gray palette is unchanged; only inactive thread dots use the lighter, less saturated `#cacccf` so active blue threads retain clear visual priority. +Semantic color is reserved for state-bearing UI: running status text uses blue, +successful completion and connection use green, pending requests and warnings +use orange, and failures, denials, stop, removal, and validation errors use red. +Thread dots continue to describe activity rather than outcome, so completed or +otherwise inactive threads retain the canonical light-gray dot. Reasoning prose +and metadata without an authoritative status remain neutral because their +content does not provide a reliable success, warning, or failure classification. + ## Application layout The window consists of a 64-pixel identity/status bar, a hideable thread From 9e8491232335e1918beec1acaf23578792f5f9b8 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 00:14:47 +0200 Subject: [PATCH 31/36] Render generated images without encoded payloads --- docs/codex-architecture.md | 5 ++ docs/ui-behavior.md | 4 ++ src/codex/middle/ConversationCards.cpp | 34 ++++++++++- src/codex/middle/ConversationProjection.cpp | 10 ++++ src/codex/middle/MiddleTypes.h | 11 +++- tests/codex/ConversationCardsTest.cpp | 62 +++++++++++++++++++++ tests/codex/ConversationProjectionTest.cpp | 27 +++++++++ 7 files changed, 151 insertions(+), 2 deletions(-) diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index c0c30b6..5e60d02 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -508,6 +508,11 @@ prompt; selecting one opens a non-modal, fit-to-window viewer. CodexUI never fetches remote image URLs implicitly, and missing local images remain visible as unavailable placeholders. +Authoritative `imageGeneration` items use their app-server `savedPath` and the +same thumbnail/viewer. Their Base64 `result` is transport data and is never +rendered as text. Unknown item types retain a generic diagnostic card, but its +visible JSON is bounded before Qt performs text layout. + ### 7.4 Changes and Diff Presentation The Changes inspector is authoritative over the local Git worktrees associated diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 7ae6fe5..263afdd 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -122,6 +122,10 @@ Authoritative user-message text is rendered as Markdown through the same safe `MarkdownNoHTML` path as agent messages. The locally admitted prompt remains a plain-text transitional card until its authoritative item arrives. +Generated-image items show the app-server-saved image as a bounded thumbnail. +Selecting it opens the shared non-modal image viewer; encoded image data is +never displayed as generic activity text. + ## Conversation scrolling The message view smoothly follows incoming content only while it is already at diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 5d8a318..f25bbf4 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -42,6 +42,7 @@ constexpr qint64 PendingHalfCycleMilliseconds = 850; constexpr int ThumbnailMaximumWidth = 280; constexpr int ThumbnailMaximumHeight = 180; constexpr int ViewerMaximumImageExtent = 4096; +constexpr qsizetype MaximumGenericActivityCharacters = 4096; void openImageViewer(const QString &path); @@ -283,6 +284,14 @@ QString agentMetadata(const AgentActivityData &activity) { return metadata.join(QStringLiteral(" | ")); } +QString boundedGenericActivity(const nlohmann::json &raw) { + QString rendered = QString::fromStdString(raw.dump(2)); + if (rendered.size() <= MaximumGenericActivityCharacters) + return rendered; + rendered.truncate(MaximumGenericActivityCharacters); + return rendered + QStringLiteral("\n\n[Activity details truncated]"); +} + bool acceptedTransitionActive(const LocalPromptData &prompt, qint64 now) { return prompt.acceptedTransitionActive(now); } @@ -315,6 +324,9 @@ bool presentationEquals(const VisibleCardData &left, // conversation-card invalidation source. return first.status == second.status && first.pathCount == second.pathCount; } + case CardKind::ImageGeneration: + return std::get(left.payload) == + std::get(right.payload); case CardKind::Plan: return std::get(left.payload) == std::get(right.payload); @@ -598,6 +610,15 @@ class ConversationCard::Impl final { layout->addWidget(title); layout->addWidget(metadata); break; + case CardKind::ImageGeneration: + title = makeLabel(QStringLiteral("Generated image"), "title", owner); + metadata = makeLabel({}, "meta", owner); + body = makeLabel({}, "body", owner); + layout->addWidget(title); + layout->addWidget(metadata); + layout->addWidget(body); + createImageContainer(); + break; case CardKind::Plan: title = makeLabel(QStringLiteral("plan"), "title", owner); body = makeMarkdownLabel({}, owner); @@ -719,6 +740,16 @@ class ConversationCard::Impl final { metadata->show(); break; } + case CardKind::ImageGeneration: { + const auto &image = std::get(data.payload); + metadata->setText(displayStatus(image.status)); + setStatusTone(metadata, image.status); + metadata->show(); + setVisibleText(body, image.revisedPrompt); + setImages(image.path.isEmpty() ? QStringList{} + : QStringList{image.path}); + break; + } case CardKind::Plan: { const auto &plan = std::get(data.payload); setVisibleMarkdown(body, plan.text); @@ -728,7 +759,8 @@ class ConversationCard::Impl final { const auto &activity = std::get(data.payload); title->setText(activity.type.isEmpty() ? QStringLiteral("Activity") : activity.type); - metadata->setText(QString::fromStdString(activity.raw.dump(2))); + metadata->setText(boundedGenericActivity(activity.raw)); + metadata->setObjectName(QStringLiteral("genericActivityMetadata")); metadata->show(); break; } diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp index 9794816..11f572a 100644 --- a/src/codex/middle/ConversationProjection.cpp +++ b/src/codex/middle/ConversationProjection.cpp @@ -156,6 +156,16 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, result.payload = FileChangesData{ text(stringValue(item, "status")), changes.is_array() ? static_cast(changes.size()) : 0, changes}; + } else if (type == "imageGeneration") { + std::string path = stringValue(item, "savedPath"); + if (path.empty()) + path = stringValue(item, "saved_path"); + std::string revisedPrompt = stringValue(item, "revisedPrompt"); + if (revisedPrompt.empty()) + revisedPrompt = stringValue(item, "revised_prompt"); + result.kind = CardKind::ImageGeneration; + result.payload = ImageGenerationData{ + text(path), text(stringValue(item, "status")), text(revisedPrompt)}; } else if (type == "plan") { const QString plan = messageText(item); if (!plan.isEmpty()) { diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 9b6a2a4..a69d719 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -54,6 +54,7 @@ enum class CardKind { AgentActivity, Reasoning, FileChanges, + ImageGeneration, Plan, GenericActivity, LocalPrompt, @@ -113,6 +114,14 @@ struct FileChangesData { } }; +struct ImageGenerationData { + QString path; + QString status; + QString revisedPrompt; + + bool operator==(const ImageGenerationData &) const = default; +}; + struct PlanData { QString text; @@ -148,7 +157,7 @@ struct LocalPromptData { using CardPayload = std::variant; + ImageGenerationData, GenericActivityData, LocalPromptData>; struct VisibleCardData { CardKey key; diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 0fe2d5f..87e7d4f 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -849,6 +849,67 @@ bool testMessageImagePresentation() { return result; } +bool testGeneratedImagePresentationAndGenericBound() { + QTemporaryDir directory; + const QString path = directory.filePath(QStringLiteral("generated.png")); + QImage source(800, 450, QImage::Format_ARGB32_Premultiplied); + source.fill(QColor(QStringLiteral("#e9f7f0"))); + bool result = expect(directory.isValid() && source.save(path), + "generated-image fixture is readable"); + + VisibleCardData generated{ + AuthoritativeItemKey{"generated", "turn", "image"}, + CardKind::ImageGeneration, + "generated", + "turn", + "image", + ImageGenerationData{path, QStringLiteral("completed"), + QStringLiteral("A generated UI proposal")}}; + ConversationCard generatedCard(generated); + generatedCard.show(); + spin(); + auto *thumbnail = generatedCard.findChild( + QStringLiteral("messageImageThumbnail")); + result &= expect(thumbnail && thumbnail->property("imageAvailable").toBool(), + "generated-image card reuses the bounded thumbnail"); + if (thumbnail) { + const QPointF local(thumbnail->rect().center()); + QMouseEvent click(QEvent::MouseButtonPress, local, local, + thumbnail->mapToGlobal(local.toPoint()), Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(thumbnail, &click); + spin(); + } + QWidget *viewer = nullptr; + for (QWidget *candidate : QApplication::topLevelWidgets()) + if (candidate->objectName() == QStringLiteral("messageImageViewer")) + viewer = candidate; + result &= expect(viewer && viewer->isVisible(), + "generated-image thumbnail opens the shared image viewer"); + if (viewer) + viewer->close(); + spin(); + + VisibleCardData generic{ + AuthoritativeItemKey{"generated", "turn", "unknown"}, + CardKind::GenericActivity, + "generated", + "turn", + "unknown", + GenericActivityData{QStringLiteral("Unknown activity"), + {{"large", std::string(100000, 'x')}}}}; + ConversationCard genericCard(generic); + genericCard.show(); + spin(); + auto *details = genericCard.findChild( + QStringLiteral("genericActivityMetadata")); + result &= expect( + details && details->text().size() < 4200 && + details->text().endsWith(QStringLiteral("[Activity details truncated]")), + "unknown activity text is bounded before Qt lays it out"); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -864,6 +925,7 @@ int main(int argc, char **argv) { result &= testCommandOutputStateAcrossNavigation(); result &= testPendingPromptAnimation(); result &= testMessageImagePresentation(); + result &= testGeneratedImagePresentationAndGenericBound(); if (result) std::cout << "Conversation card tests passed\n"; return result ? 0 : 1; diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp index 7f18ebf..023c792 100644 --- a/tests/codex/ConversationProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -457,6 +457,32 @@ bool testUserMessageImages() { return result; } +bool testGeneratedImageProjection() { + ThreadPresentation thread = baseThread("generated-image-thread"); + appendItem( + thread, "turn-1", + item("generated-image", + {{"type", "imageGeneration"}, + {"status", "completed"}, + {"savedPath", "/tmp/generated.png"}, + {"revisedPrompt", "A restrained CodexUI color proposal"}, + {"result", std::string(100000, 'A')}})); + + const ConversationSnapshot snapshot = ConversationProjection::project( + thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); + const VisibleCardData *card = snapshot.find(AuthoritativeItemKey{ + thread.id, "turn-1", "generated-image"}); + const auto *image = + card ? std::get_if(&card->payload) : nullptr; + return expect( + card && card->kind == CardKind::ImageGeneration && image && + image->path == QStringLiteral("/tmp/generated.png") && + image->status == QStringLiteral("completed") && + image->revisedPrompt == + QStringLiteral("A restrained CodexUI color proposal"), + "generated images project their saved path without exposing base64"); +} + bool testFileLinksArePartOfTheCanonicalPrompt() { const std::vector attachments{ {QStringLiteral("/tmp/review notes [final] (2).pdf"), @@ -498,6 +524,7 @@ int main() { result &= testAnchoredDuplicatePrompts(); result &= testCommandOutputVisibility(); result &= testUserMessageImages(); + result &= testGeneratedImageProjection(); result &= testFileLinksArePartOfTheCanonicalPrompt(); if (result) std::cout << "Conversation projection tests passed\n"; From 0ff82cdf80a13c70bc998bc85113ce3d40fb6cc2 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 01:08:52 +0200 Subject: [PATCH 32/36] Polish conversation activity cards --- docs/codex-architecture.md | 19 +- docs/ui-behavior.md | 17 ++ src/codex/middle/ConversationCards.cpp | 128 ++++++++++--- src/codex/middle/ConversationProjection.cpp | 190 +++++++++++++++++--- src/codex/middle/MiddleTypes.cpp | 6 + src/codex/middle/MiddleTypes.h | 47 +++-- tests/codex/ConversationCardsTest.cpp | 163 +++++++++++------ tests/codex/ConversationProjectionTest.cpp | 179 ++++++++++++++---- ui-review/UX-DESIGN-DECISIONS.md | 4 + 9 files changed, 591 insertions(+), 162 deletions(-) diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 5e60d02..02f9260 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -510,8 +510,10 @@ as unavailable placeholders. Authoritative `imageGeneration` items use their app-server `savedPath` and the same thumbnail/viewer. Their Base64 `result` is transport data and is never -rendered as text. Unknown item types retain a generic diagnostic card, but its -visible JSON is bounded before Qt performs text layout. +rendered as text. Authoritative `imageView` items use their local `path` and the +same presentation with the neutral title `Image`. Unknown item types retain a +generic diagnostic card, but its visible JSON is bounded before Qt performs +text layout. ### 7.4 Changes and Diff Presentation @@ -672,11 +674,14 @@ steps with `pending`, `inProgress`, or `completed` status. Plan presentation is retained across tab and thread switching. It changes only for the identified turn and is cleared only by an explicit authoritative empty -or replacement event for that turn. A completed textual plan item may be shown -as conversation activity. When no structured plan survives a fresh -`thread/read`, the Plan inspector renders the newest retained textual plan item -as a read-only compatibility view; it never overrides a newer authoritative -structured turn plan. +or replacement event for that turn. The Inspector is the production owner of +structured plans, so they are not duplicated in the conversation. The typed +turn-level conversation key, conversion, placement, and renderer are retained +behind a disabled projection switch for narrow reactivation. Textual `plan` +items remain supported conversation content and use the same card renderer. +When no structured plan survives a fresh `thread/read`, the Plan inspector +renders the newest retained textual plan item as a read-only compatibility +view; it never overrides a newer authoritative structured turn plan. ### 8.2 Agents diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 263afdd..951fa9b 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -126,6 +126,23 @@ Generated-image items show the app-server-saved image as a bounded thumbnail. Selecting it opens the shared non-modal image viewer; encoded image data is never displayed as generic activity text. +Process cards remain neutral so they support rather than dominate the user and +Codex conversation. Status text alone uses canonical semantic state colors. + +Reasoning items remain visible as stable progress cards even when the app-server +provides no public summary; later content updates the same card in place. +File-change cards list each supplied path and change kind and derive compact +addition and deletion totals from the supplied per-file unified diffs. They do +not duplicate the full review surface owned by the Changes inspector. Optional +Command duration and Agent model, reasoning effort, child identity, path, +sender, and receivers are shown only when app-server supplied them. + +Textual `plan` items remain conversation content. Structured +`turn/plan/updated` state is shown only in the Inspector Plan tab, avoiding a +duplicate representation in the conversation. Its typed conversation key, +conversion, placement, and renderer remain implemented behind a disabled +projection switch so this policy can be reactivated narrowly if required. + ## Conversation scrolling The message view smoothly follows incoming content only while it is already at diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index f25bbf4..7573eaf 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -143,9 +143,8 @@ class ImageViewer final : public QDialog { const QSize available = scroll_->viewport()->size() - QSize(8, 8); if (available.isEmpty()) return; - imageLabel_->setPixmap(QPixmap::fromImage( - image_.scaled(available, Qt::KeepAspectRatio, - Qt::SmoothTransformation))); + imageLabel_->setPixmap(QPixmap::fromImage(image_.scaled( + available, Qt::KeepAspectRatio, Qt::SmoothTransformation))); } QImage image_; @@ -172,10 +171,9 @@ QLabel *makeLabel(const QString &value, const char *kind = "body", QString markdownHtml(const QString &markdown) { QTextDocument document; - document.setMarkdown( - markdown, - QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub) | - QTextDocument::MarkdownNoHTML); + document.setMarkdown(markdown, QTextDocument::MarkdownFeatures( + QTextDocument::MarkdownDialectGitHub) | + QTextDocument::MarkdownNoHTML); return document.toHtml(); } @@ -270,6 +268,11 @@ QString commandMetadata(const CommandExecutionData &command) { metadata << QStringLiteral("exit %1").arg(*command.exitCode); if (!command.cwd.isEmpty()) metadata << command.cwd; + if (command.durationMilliseconds) { + const qreal seconds = qreal(*command.durationMilliseconds) / 1000.0; + metadata << QStringLiteral("%1 s").arg(seconds, 0, 'f', + seconds < 10.0 ? 1 : 0); + } return metadata.join(QStringLiteral(" | ")); } @@ -281,9 +284,79 @@ QString agentMetadata(const AgentActivityData &activity) { : activity.status); if (!activity.receivers.isEmpty()) metadata << activity.receivers.join(QStringLiteral(", ")); + if (!activity.model.isEmpty()) + metadata << activity.model; + if (!activity.reasoningEffort.isEmpty()) + metadata << activity.reasoningEffort; + if (!activity.childThreadId.isEmpty()) + metadata << QStringLiteral("thread %1").arg(activity.childThreadId); + if (!activity.agentPath.isEmpty()) + metadata << activity.agentPath; + if (!activity.senderThreadId.isEmpty()) + metadata << QStringLiteral("sender %1").arg(activity.senderThreadId); return metadata.join(QStringLiteral(" | ")); } +QString displayChangeKind(const QString &kind) { + if (kind.isEmpty()) + return QStringLiteral("Changed"); + QString result = kind; + result[0] = result[0].toUpper(); + return result; +} + +struct DiffCounts { + int additions = 0; + int deletions = 0; +}; + +QString fileChangesText(const FileChangesData &data) { + QStringList rows; + for (const FileChangeData &change : data.changes) { + if (change.path.isEmpty()) + continue; + QString row = QStringLiteral("%1 · %2") + .arg(change.path, displayChangeKind(change.kind)); + if (change.additions && change.deletions) + row += QStringLiteral(" +%1 −%2") + .arg(*change.additions) + .arg(*change.deletions); + rows << row; + } + return rows.join(QLatin1Char('\n')); +} + +std::optional totalDiffCounts(const FileChangesData &data) { + DiffCounts total; + bool available = false; + for (const FileChangeData &change : data.changes) { + if (!change.additions || !change.deletions) + continue; + available = true; + total.additions += *change.additions; + total.deletions += *change.deletions; + } + return available ? std::optional{total} : std::nullopt; +} + +QString planMarkdown(const PlanData &plan) { + if (!plan.legacyText.isEmpty()) + return plan.legacyText; + QStringList rows; + if (!plan.explanation.isEmpty()) + rows << plan.explanation; + if (!plan.steps.empty() && !rows.empty()) + rows << QString{}; + for (const PlanStepData &step : plan.steps) { + const QString marker = + step.status == QStringLiteral("completed") ? QStringLiteral("✓") + : step.status == QStringLiteral("inProgress") ? QStringLiteral("◉") + : QStringLiteral("○"); + rows << QStringLiteral("%1 %2 ").arg(marker, step.text); + } + return rows.join(QLatin1Char('\n')); +} + QString boundedGenericActivity(const nlohmann::json &raw) { QString rendered = QString::fromStdString(raw.dump(2)); if (rendered.size() <= MaximumGenericActivityCharacters) @@ -316,14 +389,9 @@ bool presentationEquals(const VisibleCardData &left, case CardKind::Reasoning: return std::get(left.payload) == std::get(right.payload); - case CardKind::FileChanges: { - const auto &first = std::get(left.payload); - const auto &second = std::get(right.payload); - // The card presents the aggregate status and path count. The detailed - // change JSON belongs to the Changes inspector and is deliberately not a - // conversation-card invalidation source. - return first.status == second.status && first.pathCount == second.pathCount; - } + case CardKind::FileChanges: + return std::get(left.payload) == + std::get(right.payload); case CardKind::ImageGeneration: return std::get(left.payload) == std::get(right.payload); @@ -529,7 +597,6 @@ class ConversationCard::Impl final { owner->setProperty("conversationCardKey", QString::fromStdString(stableKey(initial.key))); owner->setProperty("conversationCardKind", static_cast(initial.kind)); - layout = new QVBoxLayout(owner); layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); @@ -576,9 +643,9 @@ class ConversationCard::Impl final { command = new ContentSizedTextView(MaximumCommandTextHeight, owner); command->setProperty("kind", "command"); command->setObjectName(QStringLiteral("commandTextView")); - command->setStyleSheet(QStringLiteral( - "QTextEdit#commandTextView{background:#f8fafc;" - "border:1px solid #d7dee8;border-radius:6px;}")); + command->setStyleSheet( + QStringLiteral("QTextEdit#commandTextView{background:#f8fafc;" + "border:1px solid #d7dee8;border-radius:6px;}")); output = new CommandOutputView({}, owner); output->hide(); metadata = makeLabel({}, "meta", owner); @@ -607,7 +674,9 @@ class ConversationCard::Impl final { case CardKind::FileChanges: title = makeLabel(QStringLiteral("File changes"), "title", owner); metadata = makeLabel({}, "meta", owner); + body = makeLabel({}, "body", owner); layout->addWidget(title); + layout->addWidget(body); layout->addWidget(metadata); break; case CardKind::ImageGeneration: @@ -620,7 +689,7 @@ class ConversationCard::Impl final { createImageContainer(); break; case CardKind::Plan: - title = makeLabel(QStringLiteral("plan"), "title", owner); + title = makeLabel(QStringLiteral("Plan"), "title", owner); body = makeMarkdownLabel({}, owner); layout->addWidget(title); layout->addWidget(body); @@ -733,8 +802,13 @@ class ConversationCard::Impl final { } case CardKind::FileChanges: { const auto &changes = std::get(data.payload); + setVisibleText(body, fileChangesText(changes)); QStringList values{displayStatus(changes.status)}; - values << QStringLiteral("%1 paths").arg(changes.pathCount); + values << QStringLiteral("%1 paths").arg(changes.changes.size()); + if (const auto counts = totalDiffCounts(changes)) + values << QStringLiteral("+%1 −%2") + .arg(counts->additions) + .arg(counts->deletions); metadata->setText(values.join(QStringLiteral(" | "))); setStatusTone(metadata, changes.status); metadata->show(); @@ -742,17 +816,19 @@ class ConversationCard::Impl final { } case CardKind::ImageGeneration: { const auto &image = std::get(data.payload); - metadata->setText(displayStatus(image.status)); + const bool generated = !image.status.isEmpty() || + !image.revisedPrompt.isEmpty(); + title->setText(generated ? QStringLiteral("Generated image") + : QStringLiteral("Image")); + setVisibleText(metadata, displayStatus(image.status)); setStatusTone(metadata, image.status); - metadata->show(); setVisibleText(body, image.revisedPrompt); - setImages(image.path.isEmpty() ? QStringList{} - : QStringList{image.path}); + setImages(image.path.isEmpty() ? QStringList{} : QStringList{image.path}); break; } case CardKind::Plan: { const auto &plan = std::get(data.payload); - setVisibleMarkdown(body, plan.text); + setVisibleMarkdown(body, planMarkdown(plan)); break; } case CardKind::GenericActivity: { diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp index 11f572a..86097ef 100644 --- a/src/codex/middle/ConversationProjection.cpp +++ b/src/codex/middle/ConversationProjection.cpp @@ -6,12 +6,17 @@ #include #include #include +#include #include #include namespace codexui::codex::middle { namespace { +// The Inspector is the production owner of structured turn plans. Keep the +// complete Conversation projection available for a one-line policy reversal. +constexpr bool projectStructuredPlansInConversation = false; + QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -24,6 +29,41 @@ std::string stringValue(const nlohmann::json &object, const char *key) { : std::string{}; } +std::optional integerValue(const nlohmann::json &object, + const char *key) { + if (!object.is_object()) + return std::nullopt; + const auto value = object.find(key); + if (value == object.end() || !value->is_number_integer()) + return std::nullopt; + return value->get(); +} + +std::optional optionalText(const nlohmann::json &object, + const char *key) { + if (!object.is_object()) + return std::nullopt; + const auto value = object.find(key); + if (value == object.end() || !value->is_string()) + return std::nullopt; + return text(value->get()); +} + +std::pair unifiedDiffCounts(QStringView diff) { + int additions = 0; + int deletions = 0; + for (const QStringView line : diff.split(QLatin1Char('\n'))) { + if (line.startsWith(QStringLiteral("+++ ")) || + line.startsWith(QStringLiteral("--- "))) + continue; + if (line.startsWith(QLatin1Char('+'))) + ++additions; + else if (line.startsWith(QLatin1Char('-'))) + ++deletions; + } + return {additions, deletions}; +} + QString messageText(const nlohmann::json &item) { const std::string type = stringValue(item, "type"); if (type == "agentMessage" || type == "plan") @@ -91,6 +131,29 @@ QStringList stringList(const nlohmann::json &value) { return result; } +bool hasStructuredPlan(const TurnPresentation &turn) { + if (!turn.plan.is_object()) + return false; + const auto steps = turn.plan.find("steps"); + return !stringValue(turn.plan, "explanation").empty() || + (steps != turn.plan.end() && steps->is_array() && !steps->empty()); +} + +PlanData structuredPlan(const TurnPresentation &turn) { + PlanData result; + result.explanation = text(stringValue(turn.plan, "explanation")); + const auto steps = turn.plan.find("steps"); + if (steps == turn.plan.end() || !steps->is_array()) + return result; + result.steps.reserve(steps->size()); + for (const nlohmann::json &step : *steps) { + const QString value = text(stringValue(step, "step")); + if (!value.isEmpty()) + result.steps.push_back({value, text(stringValue(step, "status"))}); + } + return result; +} + std::string sectionComponent(std::string_view prefix, std::string_view threadId, std::string_view suffix) { std::string result(prefix); @@ -132,10 +195,15 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, const auto rawExitCode = item.find("exitCode"); if (rawExitCode != item.end() && rawExitCode->is_number_integer()) exitCode = rawExitCode->get(); - result.payload = - CommandExecutionData{text(stringValue(item, "command")), output, - text(stringValue(item, "status")), - text(stringValue(item, "cwd")), exitCode}; + std::optional duration = integerValue(item, "durationMs"); + if (!duration) + duration = integerValue(item, "duration_ms"); + result.payload = CommandExecutionData{text(stringValue(item, "command")), + output, + text(stringValue(item, "status")), + text(stringValue(item, "cwd")), + exitCode, + duration}; } else if (type == "collabAgentToolCall" || type == "subAgentActivity") { result.kind = CardKind::AgentActivity; result.payload = AgentActivityData{ @@ -144,7 +212,12 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, text(stringValue(item, "kind")), text(stringValue(item, "prompt")), text(stringValue(item, "resultText")), - stringList(item.value("receiverThreadIds", nlohmann::json::array()))}; + stringList(item.value("receiverThreadIds", nlohmann::json::array())), + text(stringValue(item, "model")), + text(stringValue(item, "reasoningEffort")), + text(stringValue(item, "agentThreadId")), + text(stringValue(item, "agentPath")), + text(stringValue(item, "senderThreadId"))}; } else if (type == "reasoning") { result.kind = CardKind::Reasoning; result.payload = ReasoningData{ @@ -153,11 +226,26 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, result.kind = CardKind::FileChanges; const nlohmann::json changes = item.value("changes", nlohmann::json::array()); - result.payload = FileChangesData{ - text(stringValue(item, "status")), - changes.is_array() ? static_cast(changes.size()) : 0, changes}; - } else if (type == "imageGeneration") { - std::string path = stringValue(item, "savedPath"); + FileChangesData projected{text(stringValue(item, "status")), {}}; + if (changes.is_array()) { + projected.changes.reserve(changes.size()); + for (const nlohmann::json &change : changes) { + FileChangeData entry{text(stringValue(change, "path")), + text(stringValue(change, "kind")), std::nullopt, + std::nullopt}; + if (const auto diff = optionalText(change, "diff")) { + const auto [additions, deletions] = unifiedDiffCounts(*diff); + entry.additions = additions; + entry.deletions = deletions; + } + projected.changes.push_back(std::move(entry)); + } + } + result.payload = std::move(projected); + } else if (type == "imageGeneration" || type == "imageView") { + std::string path = stringValue(item, "path"); + if (path.empty()) + path = stringValue(item, "savedPath"); if (path.empty()) path = stringValue(item, "saved_path"); std::string revisedPrompt = stringValue(item, "revisedPrompt"); @@ -170,7 +258,7 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, const QString plan = messageText(item); if (!plan.isEmpty()) { result.kind = CardKind::Plan; - result.payload = PlanData{plan}; + result.payload = PlanData{{}, {}, plan}; } } return result; @@ -270,11 +358,61 @@ ConversationSnapshot ConversationProjection::project( : submissionPosition(*binding->second, authoritativeItems, index); const std::uint64_t tieBreaker = binding == bindings.end() ? 0 : binding->second->admissionOrdinal; + VisibleCardData card = + authoritativeCard(item.key, *item.presentation, std::move(visualKey)); nodes.push_back({position, tieBreaker, sectionComponent("turn:", threadId, item.key.turnId), - item.key.turnId, - authoritativeCard(item.key, *item.presentation, - std::move(visualKey))}); + item.key.turnId, std::move(card)}); + } + + if (projectStructuredPlansInConversation && authoritativeThread) { + std::unordered_map firstItemIndexes; + std::unordered_map lastItemIndexes; + for (std::size_t index = 0; index < authoritativeItems.size(); ++index) { + firstItemIndexes.try_emplace(authoritativeItems[index].key.turnId, index); + lastItemIndexes[authoritativeItems[index].key.turnId] = index; + } + std::unordered_map> nextItemIndexes; + std::optional nextItemIndex; + for (auto turnId = authoritativeThread->turnOrder.rbegin(); + turnId != authoritativeThread->turnOrder.rend(); ++turnId) { + nextItemIndexes.emplace(*turnId, nextItemIndex); + const auto first = firstItemIndexes.find(*turnId); + if (first != firstItemIndexes.end()) + nextItemIndex = first->second; + } + + for (const std::string &turnId : authoritativeThread->turnOrder) { + const auto turn = authoritativeThread->turns.find(turnId); + if (turn == authoritativeThread->turns.end() || + !hasStructuredPlan(turn->second)) + continue; + + const auto last = lastItemIndexes.find(turnId); + const std::optional lastItemIndex = + last == lastItemIndexes.end() + ? std::nullopt + : std::optional{last->second}; + const auto next = nextItemIndexes.find(turnId); + const std::optional followingItemIndex = + next == nextItemIndexes.end() ? std::nullopt : next->second; + if (lastItemIndex && *lastItemIndex < firstVisible) + continue; + const std::size_t position = lastItemIndex ? *lastItemIndex * 2 + 2 + : followingItemIndex + ? *followingItemIndex * 2 + : authoritativeItems.size() * 2 + 2; + nodes.push_back({position, + 0, + sectionComponent("turn:", threadId, turnId), + turnId, + {TurnPlanKey{threadId, turnId}, + CardKind::Plan, + threadId, + turnId, + {}, + structuredPlan(turn->second)}}); + } } for (const PromptSubmission &submission : localSubmissions) { @@ -302,18 +440,18 @@ ConversationSnapshot ConversationProjection::project( const std::string sectionKey = knownTurn ? sectionComponent("turn:", threadId, turnId) : "pending:" + std::to_string(submission.id); - VisibleCardData card{ - LocalPromptKey{submission.id}, - CardKind::LocalPrompt, - threadId, - turnId, - {}, - LocalPromptData{submission.id, submission.prompt, - submission.state == PromptState::Queued - ? PromptState::InFlight - : submission.state, - submission.acceptedAtMilliseconds, submission.error, - localImagePaths(submission)}}; + VisibleCardData card{LocalPromptKey{submission.id}, + CardKind::LocalPrompt, + threadId, + turnId, + {}, + LocalPromptData{submission.id, submission.prompt, + submission.state == PromptState::Queued + ? PromptState::InFlight + : submission.state, + submission.acceptedAtMilliseconds, + submission.error, + localImagePaths(submission)}}; nodes.push_back({position, submission.admissionOrdinal, sectionKey, turnId, std::move(card)}); } diff --git a/src/codex/middle/MiddleTypes.cpp b/src/codex/middle/MiddleTypes.cpp index 9dc9556..bf6dc20 100644 --- a/src/codex/middle/MiddleTypes.cpp +++ b/src/codex/middle/MiddleTypes.cpp @@ -23,6 +23,12 @@ std::string stableKey(const CardKey &key) { appendComponent(result, authoritative->itemId); return result; } + if (const auto *plan = std::get_if(&key)) { + std::string result = "plan:"; + appendComponent(result, plan->threadId); + appendComponent(result, plan->turnId); + return result; + } return "prompt:" + std::to_string(std::get(key).submissionId); } diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index a69d719..a9ede08 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -39,7 +39,14 @@ struct LocalPromptKey { auto operator<=>(const LocalPromptKey &) const = default; }; -using CardKey = std::variant; +struct TurnPlanKey { + std::string threadId; + std::string turnId; + + auto operator<=>(const TurnPlanKey &) const = default; +}; + +using CardKey = std::variant; [[nodiscard]] std::string stableKey(const CardKey &key); [[nodiscard]] bool terminalOutputHasVisibleText(QStringView output); @@ -80,6 +87,7 @@ struct CommandExecutionData { QString status; QString cwd; std::optional exitCode; + std::optional durationMilliseconds; bool operator==(const CommandExecutionData &) const = default; }; @@ -91,6 +99,11 @@ struct AgentActivityData { QString prompt; QString resultText; QStringList receivers; + QString model; + QString reasoningEffort; + QString childThreadId; + QString agentPath; + QString senderThreadId; bool operator==(const AgentActivityData &) const = default; }; @@ -101,17 +114,20 @@ struct ReasoningData { bool operator==(const ReasoningData &) const = default; }; +struct FileChangeData { + QString path; + QString kind; + std::optional additions; + std::optional deletions; + + bool operator==(const FileChangeData &) const = default; +}; + struct FileChangesData { QString status; - int pathCount = 0; - nlohmann::json changes = nlohmann::json::array(); - - // The conversation card shows only status and path count. Diff contents are - // owned by the Changes inspector and must not turn a visually identical - // conversation projection into a layout mutation. - bool operator==(const FileChangesData &other) const { - return status == other.status && pathCount == other.pathCount; - } + std::vector changes; + + bool operator==(const FileChangesData &) const = default; }; struct ImageGenerationData { @@ -122,8 +138,17 @@ struct ImageGenerationData { bool operator==(const ImageGenerationData &) const = default; }; -struct PlanData { +struct PlanStepData { QString text; + QString status; + + bool operator==(const PlanStepData &) const = default; +}; + +struct PlanData { + QString explanation; + std::vector steps; + QString legacyText; bool operator==(const PlanData &) const = default; }; diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 87e7d4f..ab3a746 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -11,10 +11,10 @@ #include #include #include +#include #include #include #include -#include #include #include @@ -349,8 +349,8 @@ bool testMutableCardsAndCommandOutput() { section.cards = { {AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, thread, "turn", "user", - UserMessageData{QStringLiteral( - "hello **Markdown**\n\n| Value | Rating |\n|---|---|\n| State | 10 |")}}, + UserMessageData{QStringLiteral("hello **Markdown**\n\n| Value | Rating " + "|\n|---|---|\n| State | 10 |")}}, {AuthoritativeItemKey{thread, "turn", "agent"}, CardKind::AgentMessage, thread, "turn", "agent", AgentMessageData{QStringLiteral("answer"), false}}, @@ -373,10 +373,16 @@ bool testMutableCardsAndCommandOutput() { thread, "turn", "reasoning", ReasoningData{QStringLiteral("summary")}}, {AuthoritativeItemKey{thread, "turn", "files"}, CardKind::FileChanges, thread, "turn", "files", - FileChangesData{QStringLiteral("inProgress"), 1, - nlohmann::json::array()}}, + FileChangesData{ + QStringLiteral("inProgress"), + {{QStringLiteral("src/card.cpp"), QStringLiteral("update"), 2, 1}}}}, {AuthoritativeItemKey{thread, "turn", "plan"}, CardKind::Plan, thread, - "turn", "plan", PlanData{QStringLiteral("plan step")}}, + "turn", "plan", + PlanData{ + QStringLiteral("Keep the card compact"), + {{QStringLiteral("Inspect data"), QStringLiteral("completed")}, + {QStringLiteral("Render cards"), QStringLiteral("inProgress")}}, + {}}}, {AuthoritativeItemKey{thread, "turn", "generic"}, CardKind::GenericActivity, thread, "turn", "generic", GenericActivityData{QStringLiteral("custom activity"), @@ -403,28 +409,50 @@ bool testMutableCardsAndCommandOutput() { std::unordered_map identities; for (const auto &value : snapshot.sections.front().cards) identities[stableKey(value.key)] = card(view, stableKey(value.key)); + auto containsLabelText = [](QWidget *parent, const QString &needle) { + return std::ranges::any_of( + parent->findChildren(), [&needle](QLabel *label) { + return label->text().contains(needle) || + label->property("markdownSource").toString().contains(needle); + }); + }; auto *commandCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "command"}})]; auto *output = dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))); + commandCard->findChild(QStringLiteral("commandOutputView"))); auto *commandText = dynamic_cast( - commandCard->findChild( - QStringLiteral("commandTextView"))); + commandCard->findChild(QStringLiteral("commandTextView"))); bool result = expect(output && output->isHidden(), "empty-line command output has no black surface"); auto *userCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "user"}})]; const auto userLabels = userCard->findChildren(); - result &= expect( - std::ranges::any_of(userLabels, [](QLabel *label) { - return label->property("markdownSource").toString() == - QStringLiteral("hello **Markdown**\n\n| Value | Rating |\n" + result &= + expect(std::ranges::any_of( + userLabels, + [](QLabel *label) { + return label->property("markdownSource").toString() == + QStringLiteral( + "hello **Markdown**\n\n| Value | Rating |\n" "|---|---|\n| State | 10 |") && - label->textFormat() == Qt::RichText && - label->text().contains(QStringLiteral("textFormat() == Qt::RichText && + label->text().contains(QStringLiteral("toPlainText() == QStringLiteral("printf test") && @@ -456,8 +484,11 @@ bool testMutableCardsAndCommandOutput() { std::get(cards[3].payload).resultText = QStringLiteral("result"); std::get(cards[4].payload).summary += QStringLiteral(" more"); - std::get(cards[5].payload).pathCount = 2; - std::get(cards[6].payload).text += QStringLiteral(" updated"); + std::get(cards[5].payload) + .changes.push_back( + {QStringLiteral("tests/card.cpp"), QStringLiteral("add"), 3, 0}); + std::get(cards[6].payload).steps[1].status = + QStringLiteral("completed"); auto &generic = std::get(cards[7].payload); generic.type = QStringLiteral("updated custom activity"); generic.raw["detail"] = "updated"; @@ -582,8 +613,8 @@ bool testInitialCommandGeometrySettlement() { outputView->sizeHint().height() == immediateHint, "initial wrapped output has no delayed geometry settlement"); - const int glyphWidth = - std::max(1, outputView->fontMetrics().horizontalAdvance(QLatin1Char('W'))); + const int glyphWidth = std::max( + 1, outputView->fontMetrics().horizontalAdvance(QLatin1Char('W'))); const int charactersPerLine = std::max(1, outputView->viewport()->width() / glyphWidth); auto &execution = std::get( @@ -597,7 +628,8 @@ bool testInitialCommandGeometrySettlement() { wrappedBlock.layout() && wrappedBlock.layout()->lineCount() == 2 && outputView->verticalScrollBar()->maximum() == 0 && outputView->viewport()->height() >= - static_cast(std::ceil(outputView->document()->size().height())), + static_cast( + std::ceil(outputView->document()->size().height())), "two visual output lines are fully visible without inner scrolling"); return result; } @@ -632,10 +664,10 @@ bool testBottomAnchoredCommandOutputGrowth() { commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; - bool result = expect(commandCard && metadata && output && - output->isHidden() && view.isAtBottom() && - metadata->property("tone") == "active", - "live command starts with a hidden zero-line output"); + bool result = + expect(commandCard && metadata && output && output->isHidden() && + view.isAtBottom() && metadata->property("tone") == "active", + "live command starts with a hidden zero-line output"); if (!commandCard || !metadata || !output) return false; const int metadataBottomBefore = @@ -727,16 +759,14 @@ bool testCommandOutputStateAcrossNavigation() { } bool testPendingPromptAnimation() { - VisibleCardData pending{LocalPromptKey{901}, - CardKind::LocalPrompt, - "prompt-thread", - {}, - {}, - LocalPromptData{901, - QStringLiteral("pending prompt"), - PromptState::InFlight, - 0, - {}}}; + VisibleCardData pending{ + LocalPromptKey{901}, + CardKind::LocalPrompt, + "prompt-thread", + {}, + {}, + LocalPromptData{ + 901, QStringLiteral("pending prompt"), PromptState::InFlight, 0, {}}}; ConversationCard card(pending); card.resize(560, 92); card.show(); @@ -782,15 +812,14 @@ bool testMessageImagePresentation() { auto *thumbnail = card->findChild(QStringLiteral("messageImageThumbnail")); const QPixmap thumbnailPixmap = thumbnail ? thumbnail->pixmap() : QPixmap{}; - result &= expect(thumbnail && thumbnail->property("imageAvailable").toBool() && - !thumbnailPixmap.isNull() && - thumbnailPixmap.width() <= 280 && - thumbnailPixmap.height() <= 180, - "a local image is decoded directly to a bounded thumbnail"); + result &= + expect(thumbnail && thumbnail->property("imageAvailable").toBool() && + !thumbnailPixmap.isNull() && thumbnailPixmap.width() <= 280 && + thumbnailPixmap.height() <= 180, + "a local image is decoded directly to a bounded thumbnail"); if (thumbnail) { const QPointF local(thumbnail->rect().center()); - QMouseEvent click(QEvent::MouseButtonPress, - local, local, + QMouseEvent click(QEvent::MouseButtonPress, local, local, thumbnail->mapToGlobal(local.toPoint()), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(thumbnail, &click); @@ -802,10 +831,9 @@ bool testMessageImagePresentation() { viewer = candidate; result &= expect(viewer && viewer->isVisible(), "clicking a thumbnail opens the non-modal image viewer"); - const auto *viewerImage = - viewer ? viewer->findChild( - QStringLiteral("messageImageViewerImage")) - : nullptr; + const auto *viewerImage = viewer ? viewer->findChild(QStringLiteral( + "messageImageViewerImage")) + : nullptr; result &= expect(viewerImage && !viewerImage->pixmap().isNull(), "the shown viewer contains a fitted image pixmap"); if (viewer) @@ -818,10 +846,10 @@ bool testMessageImagePresentation() { "changing the image list invalidates card presentation"); thumbnail = card->findChild(QStringLiteral("messageImageThumbnail")); - result &= expect(thumbnail && - !thumbnail->property("imageAvailable").toBool() && - thumbnail->text().contains(QStringLiteral("unavailable")), - "an unreadable image has a stable restrained placeholder"); + result &= + expect(thumbnail && !thumbnail->property("imageAvailable").toBool() && + thumbnail->text().contains(QStringLiteral("unavailable")), + "an unreadable image has a stable restrained placeholder"); payload.imagePaths = {path}; card->apply(message); @@ -890,6 +918,29 @@ bool testGeneratedImagePresentationAndGenericBound() { viewer->close(); spin(); + VisibleCardData viewed{ + AuthoritativeItemKey{"generated", "turn", "view"}, + CardKind::ImageGeneration, + "generated", + "turn", + "view", + ImageGenerationData{path, {}, {}}}; + ConversationCard viewedCard(viewed); + viewedCard.show(); + spin(); + const auto viewedLabels = viewedCard.findChildren(); + result &= expect( + std::ranges::any_of(viewedLabels, [](QLabel *label) { + return label->property("kind").toString() == QStringLiteral("title") && + label->text() == QStringLiteral("Image"); + }) && + std::ranges::any_of(viewedLabels, [](QLabel *label) { + return label->objectName() == + QStringLiteral("messageImageThumbnail") && + label->property("imageAvailable").toBool(); + }), + "plain image-view cards use a neutral title and the shared thumbnail"); + VisibleCardData generic{ AuthoritativeItemKey{"generated", "turn", "unknown"}, CardKind::GenericActivity, @@ -903,10 +954,10 @@ bool testGeneratedImagePresentationAndGenericBound() { spin(); auto *details = genericCard.findChild( QStringLiteral("genericActivityMetadata")); - result &= expect( - details && details->text().size() < 4200 && - details->text().endsWith(QStringLiteral("[Activity details truncated]")), - "unknown activity text is bounded before Qt lays it out"); + result &= expect(details && details->text().size() < 4200 && + details->text().endsWith( + QStringLiteral("[Activity details truncated]")), + "unknown activity text is bounded before Qt lays it out"); return result; } diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp index 023c792..2760d44 100644 --- a/tests/codex/ConversationProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -364,19 +364,18 @@ bool testCommandOutputVisibility() { bool testUserMessageImages() { ThreadPresentation thread = baseThread("image-thread"); - appendItem( - thread, "turn-1", - item("user-images", - {{"type", "userMessage"}, - {"content", - {{{"type", "text"}, {"text", "image prompt"}}, - {{"type", "localImage"}, {"path", "/tmp/first.png"}}, - {{"type", "localImage"}, {"path", "/tmp/second.jpg"}}}}})); + appendItem(thread, "turn-1", + item("user-images", + {{"type", "userMessage"}, + {"content", + {{{"type", "text"}, {"text", "image prompt"}}, + {{"type", "localImage"}, {"path", "/tmp/first.png"}}, + {{"type", "localImage"}, {"path", "/tmp/second.jpg"}}}}})); appendItem(thread, "turn-1", item("user-image-only", {{"type", "userMessage"}, - {"content", {{{"type", "localImage"}, - {"path", "/tmp/only.png"}}}}})); + {"content", + {{{"type", "localImage"}, {"path", "/tmp/only.png"}}}}})); const ConversationSnapshot authoritative = ConversationProjection::project( thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); @@ -385,9 +384,8 @@ bool testUserMessageImages() { const auto *imageOnly = std::get_if(&cards[3].payload); bool result = expect( mixed && mixed->text == QStringLiteral("image prompt") && - mixed->imagePaths == - QStringList{QStringLiteral("/tmp/first.png"), - QStringLiteral("/tmp/second.jpg")}, + mixed->imagePaths == QStringList{QStringLiteral("/tmp/first.png"), + QStringLiteral("/tmp/second.jpg")}, "authoritative user messages retain text and local image paths"); result &= expect(imageOnly && imageOnly->text.isEmpty() && imageOnly->imagePaths == @@ -406,15 +404,15 @@ bool testUserMessageImages() { QStringLiteral("text/plain"), 10}}; const std::array submissions{pending}; const ConversationSnapshot local = ConversationProjection::project( - thread, submissions, ConversationProjection::DefaultAuthoritativeItemLimit, - 10); + thread, submissions, + ConversationProjection::DefaultAuthoritativeItemLimit, 10); const auto *localCard = local.find(LocalPromptKey{41}); const auto *localPrompt = localCard ? std::get_if(&localCard->payload) : nullptr; - result &= expect(localPrompt && - localPrompt->imagePaths == - QStringList{QStringLiteral("/tmp/pending.png")}, - "temporary prompts expose only their image attachment paths"); + result &= + expect(localPrompt && localPrompt->imagePaths == + QStringList{QStringLiteral("/tmp/pending.png")}, + "temporary prompts expose only their image attachment paths"); ThreadPresentation replacement = baseThread("replacement-thread"); addTurn(replacement, "turn-image"); @@ -425,10 +423,10 @@ bool testUserMessageImages() { QStringLiteral("replacement.png"), QStringLiteral("image/png"), 10}}, nlohmann::json::object(), &replacement, std::nullopt, 100); const auto dispatch = prompts.beginNext(replacement.id); - result &= expect(dispatch && prompts.acknowledge( - replacement.id, submissionId, - std::string("turn-image"), 200), - "image prompt receives a real acknowledgement"); + result &= + expect(dispatch && prompts.acknowledge(replacement.id, submissionId, + std::string("turn-image"), 200), + "image prompt receives a real acknowledgement"); appendItem( replacement, "turn-image", item("authoritative-image", @@ -436,8 +434,7 @@ bool testUserMessageImages() { {"clientId", dispatch ? dispatch->clientUserMessageId : ""}, {"content", {{{"type", "text"}, {"text", "replacement image"}}, - {{"type", "localImage"}, - {"path", "/tmp/replacement.png"}}}}})); + {{"type", "localImage"}, {"path", "/tmp/replacement.png"}}}}})); prompts.reconcile(replacement.id, replacement); prompts.compactResolved(replacement.id, 800); const ConversationSnapshot replaced = ConversationProjection::project( @@ -459,28 +456,137 @@ bool testUserMessageImages() { bool testGeneratedImageProjection() { ThreadPresentation thread = baseThread("generated-image-thread"); - appendItem( - thread, "turn-1", - item("generated-image", - {{"type", "imageGeneration"}, - {"status", "completed"}, - {"savedPath", "/tmp/generated.png"}, - {"revisedPrompt", "A restrained CodexUI color proposal"}, - {"result", std::string(100000, 'A')}})); + appendItem(thread, "turn-1", + item("generated-image", + {{"type", "imageGeneration"}, + {"status", "completed"}, + {"savedPath", "/tmp/generated.png"}, + {"revisedPrompt", "A restrained CodexUI color proposal"}, + {"result", std::string(100000, 'A')}})); + appendItem(thread, "turn-1", + item("image-view", {{"type", "imageView"}, + {"path", "/tmp/review.png"}})); const ConversationSnapshot snapshot = ConversationProjection::project( thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); - const VisibleCardData *card = snapshot.find(AuthoritativeItemKey{ - thread.id, "turn-1", "generated-image"}); + const VisibleCardData *card = snapshot.find( + AuthoritativeItemKey{thread.id, "turn-1", "generated-image"}); const auto *image = card ? std::get_if(&card->payload) : nullptr; - return expect( + const VisibleCardData *viewCard = snapshot.find( + AuthoritativeItemKey{thread.id, "turn-1", "image-view"}); + const auto *viewImage = + viewCard ? std::get_if(&viewCard->payload) : nullptr; + bool result = expect( card && card->kind == CardKind::ImageGeneration && image && image->path == QStringLiteral("/tmp/generated.png") && image->status == QStringLiteral("completed") && image->revisedPrompt == QStringLiteral("A restrained CodexUI color proposal"), "generated images project their saved path without exposing base64"); + result &= expect(viewCard && viewCard->kind == CardKind::ImageGeneration && + viewImage && + viewImage->path == QStringLiteral("/tmp/review.png") && + viewImage->status.isEmpty() && + viewImage->revisedPrompt.isEmpty(), + "image-view items reuse the local image presentation"); + return result; +} + +bool testTruthfulActivityProjection() { + ThreadPresentation thread = baseThread("activity-thread"); + TurnPresentation &turn = thread.turns.at("turn-1"); + turn.plan = { + {"explanation", "Keep the conversation chronology compact"}, + {"steps", + nlohmann::json::array( + {{{"step", "Inspect protocol data"}, {"status", "completed"}}, + {{"step", "Render the cards"}, {"status", "inProgress"}}})}}; + appendItem(thread, "turn-1", + item("text-plan", {{"type", "plan"}, + {"text", "A textual plan-mode response"}})); + appendItem(thread, "turn-1", + item("empty-reasoning", {{"type", "reasoning"}, + {"summary", nlohmann::json::array()}})); + appendItem(thread, "turn-1", + item("command", {{"type", "commandExecution"}, + {"command", "true"}, + {"status", "completed"}, + {"durationMs", 2400}})); + appendItem( + thread, "turn-1", + item("files", + {{"type", "fileChange"}, + {"status", "completed"}, + {"changes", + nlohmann::json::array( + {{{"path", "src/card.cpp"}, + {"kind", "update"}, + {"diff", "--- a/src/card.cpp\n+++ b/src/card.cpp\n-old\n" + "+new\n++++literal\n+extra\n"}}, + {{"path", "tests/card.cpp"}, + {"kind", "add"}, + {"diff", + "--- /dev/null\n+++ b/tests/card.cpp\n+test\n"}}})}})); + appendItem(thread, "turn-1", + item("agent", {{"type", "subAgentActivity"}, + {"status", "inProgress"}, + {"agentThreadId", "child-thread"}, + {"model", "gpt-current"}, + {"reasoningEffort", "medium"}, + {"senderThreadId", "activity-thread"}})); + + const ConversationSnapshot snapshot = ConversationProjection::project( + thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); + const VisibleCardData *structured = + snapshot.find(TurnPlanKey{thread.id, "turn-1"}); + const VisibleCardData *textual = + snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "text-plan"}); + const VisibleCardData *reasoning = snapshot.find( + AuthoritativeItemKey{thread.id, "turn-1", "empty-reasoning"}); + const VisibleCardData *command = + snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "command"}); + const VisibleCardData *files = + snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "files"}); + const VisibleCardData *agent = + snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "agent"}); + const auto *textPlan = + textual ? std::get_if(&textual->payload) : nullptr; + const auto *execution = + command ? std::get_if(&command->payload) : nullptr; + const auto *fileData = + files ? std::get_if(&files->payload) : nullptr; + const auto *agentData = + agent ? std::get_if(&agent->payload) : nullptr; + + bool result = expect( + !structured, + "structured plan state remains Inspector-only in production projection"); + result &= + expect(textPlan && + textPlan->legacyText == + QStringLiteral("A textual plan-mode response"), + "textual plan items remain supported conversation content"); + const auto *reasoningData = + reasoning ? std::get_if(&reasoning->payload) : nullptr; + result &= expect(reasoningData && reasoningData->summary.isEmpty(), + "reasoning remains a stable progress card without a public summary"); + result &= expect(execution && execution->durationMilliseconds == 2400, + "command duration is retained when supplied"); + result &= expect( + fileData && fileData->changes.size() == 2 && + fileData->changes[0].additions == 3 && + fileData->changes[0].deletions == 1 && + fileData->changes[1].additions == 1 && + fileData->changes[1].deletions == 0, + "file-change rows and unified-diff counts are projected truthfully"); + result &= expect( + agentData && agentData->childThreadId == QStringLiteral("child-thread") && + agentData->model == QStringLiteral("gpt-current") && + agentData->reasoningEffort == QStringLiteral("medium") && + agentData->senderThreadId == QStringLiteral("activity-thread"), + "available agent identity and execution settings are retained"); + return result; } bool testFileLinksArePartOfTheCanonicalPrompt() { @@ -525,6 +631,7 @@ int main() { result &= testCommandOutputVisibility(); result &= testUserMessageImages(); result &= testGeneratedImageProjection(); + result &= testTruthfulActivityProjection(); result &= testFileLinksArePartOfTheCanonicalPrompt(); if (result) std::cout << "Conversation projection tests passed\n"; diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 4561973..48f603a 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -76,6 +76,10 @@ otherwise inactive threads retain the canonical light-gray dot. Reasoning prose and metadata without an authoritative status remain neutral because their content does not provide a reliable success, warning, or failure classification. +Conversation activity cards remain neutral so supporting process information +does not compete with the user/Codex exchange. Color on those cards is reserved +for authoritative running, completed, warning/interrupted, and failed status. + ## Application layout The window consists of a 64-pixel identity/status bar, a hideable thread From c0c01be499f28af779833f87c525f654fd62faf2 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 02:22:33 +0200 Subject: [PATCH 33/36] Add universal conversation card folding --- CMakeLists.txt | 2 + docs/codex-architecture.md | 9 + docs/ui-behavior.md | 13 + src/codex/middle/ConversationCards.cpp | 211 +++++++++++----- src/codex/middle/ConversationCards.h | 7 + src/codex/middle/ConversationView.cpp | 103 +++++++- src/codex/middle/ConversationView.h | 7 + src/codex/ui/UiStyle.cpp | 11 + tests/codex/ConversationCardsTest.cpp | 326 +++++++++++++++++++++++-- ui-review/UX-DESIGN-DECISIONS.md | 4 + 10 files changed, 610 insertions(+), 83 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e70ee8..a79e5f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -229,6 +229,8 @@ if(BUILD_TESTING) src/codex/middle/ConversationView.h src/codex/middle/MiddleTypes.cpp src/codex/middle/MiddleTypes.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h ) target_compile_features( codexui-conversation-cards-test PRIVATE cxx_std_20 diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 02f9260..73286a7 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -515,6 +515,15 @@ same presentation with the neutral title `Image`. Unknown item types retain a generic diagnostic card, but its visible JSON is bounded before Qt performs text layout. +Conversation-card folding is presentation state, not protocol state. Each +stable visual card key retains its user-selected collapsed state in the +`ConversationView` for the UI session. New message cards default expanded and +new activity cards default collapsed. The card owns one header and one content +container, so streamed payload updates remain live while folded without +changing visible height. `ConversationView` owns the fold geometry transaction, +including title anchoring and lower-limit compensation, alongside its existing +single-owner scrolling calculations. + ### 7.4 Changes and Diff Presentation The Changes inspector is authoritative over the local Git worktrees associated diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 951fa9b..38edeae 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -129,6 +129,19 @@ never displayed as generic activity text. Process cards remain neutral so they support rather than dominate the user and Codex conversation. Status text alone uses canonical semantic state colors. +Every conversation card uses the same keyboard-focusable disclosure chevron. +You, Codex, and temporary You cards initially render expanded; Reasoning, +Command execution, File changes, Agent activity, Image, Plan, and fallback +activity cards initially render collapsed. A user-selected state survives +streaming updates, authoritative prompt replacement, and thread switching for +the lifetime of the CodexUI process. + +Folding is an explicit geometry transaction. The selected title row keeps its +exact viewport position: collapsing shifts only following cards upward, while +expanding grows only downward. The gesture pauses follow-latest. At the lower +scroll limit, bounded bottom compensation prevents scrollbar clamping from +moving the selected title; later expansion consumes that compensation. + Reasoning items remain visible as stable progress cards even when the app-server provides no public summary; later content updates the same card in place. File-change cards list each supplied path and change kind and derive compact diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 7573eaf..e2c8a29 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -2,11 +2,14 @@ #include "codex/middle/ConversationCards.h" +#include "codex/ui/UiStyle.h" + #include #include #include #include #include +#include #include #include #include @@ -23,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +48,47 @@ constexpr int ThumbnailMaximumHeight = 180; constexpr int ViewerMaximumImageExtent = 4096; constexpr qsizetype MaximumGenericActivityCharacters = 4096; +bool initiallyCollapsed(CardKind kind) { + return kind != CardKind::UserMessage && kind != CardKind::AgentMessage && + kind != CardKind::LocalPrompt; +} + +class CardDisclosureButton final : public QToolButton { +public: + explicit CardDisclosureButton(QWidget *parent = nullptr) + : QToolButton(parent) { + setObjectName(QStringLiteral("cardDisclosureButton")); + setProperty("kind", "subtle"); + setFixedSize(24, 24); + setCursor(Qt::PointingHandCursor); + setFocusPolicy(Qt::StrongFocus); + setAccessibleName(QStringLiteral("Expand card")); + setToolTip(accessibleName()); + } + + void setExpanded(bool expanded) { + if (expanded_ == expanded) + return; + expanded_ = expanded; + setAccessibleName(expanded ? QStringLiteral("Collapse card") + : QStringLiteral("Expand card")); + setToolTip(accessibleName()); + update(); + } + +protected: + void paintEvent(QPaintEvent *event) override { + QToolButton::paintEvent(event); + UiStyle::drawChevron(this, rect().adjusted(3, 3, -3, -3), isEnabled(), + underMouse() || hasFocus(), + expanded_ ? UiStyle::ChevronDirection::Down + : UiStyle::ChevronDirection::Right); + } + +private: + bool expanded_ = false; +}; + void openImageViewer(const QString &path); class ImageThumbnail final : public QLabel { @@ -592,7 +637,8 @@ bool CommandOutputView::isAtBottom() const { class ConversationCard::Impl final { public: Impl(ConversationCard *owner, const VisibleCardData &initial) - : owner(owner), current(initial) { + : owner(owner), current(initial), + collapsed(initiallyCollapsed(initial.kind)) { owner->setObjectName(QStringLiteral("conversationCard")); owner->setProperty("conversationCardKey", QString::fromStdString(stableKey(initial.key))); @@ -600,8 +646,31 @@ class ConversationCard::Impl final { layout = new QVBoxLayout(owner); layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); + + header = new QWidget(owner); + header->setObjectName(QStringLiteral("conversationCardHeader")); + headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(0, 0, 0, 0); + headerLayout->setSpacing(6); + title = makeLabel({}, "title", header); + title->setWordWrap(false); + disclosure = new CardDisclosureButton(header); + headerLayout->addWidget(title, 1); + headerLayout->addWidget(disclosure, 0, Qt::AlignRight | Qt::AlignVCenter); + layout->addWidget(header); + + content = new QWidget(owner); + content->setObjectName(QStringLiteral("conversationCardContent")); + contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(0, 0, 0, 0); + contentLayout->setSpacing(6); + layout->addWidget(content); + + QObject::connect(disclosure, &QToolButton::clicked, owner, + [this] { emit this->owner->foldRequested(!collapsed); }); createChildren(initial.kind); applyPayload(initial); + refreshFoldPresentation(); } bool apply(const VisibleCardData &next) { @@ -615,101 +684,113 @@ class ConversationCard::Impl final { if (!presentationChanged) return false; applyPayload(next); + refreshFoldPresentation(); owner->updateGeometry(); owner->update(); return true; } + void setCollapsed(bool next) { + if (collapsed == next) + return; + collapsed = next; + refreshFoldPresentation(); + owner->updateGeometry(); + owner->update(); + } + + [[nodiscard]] bool hasVisibleContent() const { + for (int index = 0; index < contentLayout->count(); ++index) { + if (QWidget *widget = contentLayout->itemAt(index)->widget(); + widget && !widget->isHidden()) + return true; + } + return false; + } + + void refreshFoldPresentation() { + disclosure->setExpanded(!collapsed); + content->setVisible(!collapsed && hasVisibleContent()); + } + void createChildren(CardKind kind) { owner->setProperty("kind", "raised"); switch (kind) { case CardKind::UserMessage: owner->setProperty("messageRole", "user"); - title = makeLabel(QStringLiteral("You"), "title", owner); - body = makeMarkdownLabel({}, owner); - layout->addWidget(title); - layout->addWidget(body); + title->setText(QStringLiteral("You")); + body = makeMarkdownLabel({}, content); + contentLayout->addWidget(body); createImageContainer(); break; case CardKind::AgentMessage: owner->setProperty("messageRole", "agent"); - title = makeLabel({}, "title", owner); - body = makeMarkdownLabel({}, owner); - layout->addWidget(title); - layout->addWidget(body); + body = makeMarkdownLabel({}, content); + contentLayout->addWidget(body); break; case CardKind::CommandExecution: - title = makeLabel(QStringLiteral("Command execution"), "title", owner); - command = new ContentSizedTextView(MaximumCommandTextHeight, owner); + title->setText(QStringLiteral("Command execution")); + command = new ContentSizedTextView(MaximumCommandTextHeight, content); command->setProperty("kind", "command"); command->setObjectName(QStringLiteral("commandTextView")); command->setStyleSheet( QStringLiteral("QTextEdit#commandTextView{background:#f8fafc;" "border:1px solid #d7dee8;border-radius:6px;}")); - output = new CommandOutputView({}, owner); + output = new CommandOutputView({}, content); output->hide(); - metadata = makeLabel({}, "meta", owner); + metadata = makeLabel({}, "meta", content); metadata->setObjectName(QStringLiteral("commandMetadata")); - layout->addWidget(title); - layout->addWidget(command); - layout->addWidget(output); - layout->addWidget(metadata); + contentLayout->addWidget(command); + contentLayout->addWidget(output); + contentLayout->addWidget(metadata); break; case CardKind::AgentActivity: - title = makeLabel(QStringLiteral("Agent activity"), "title", owner); - metadata = makeLabel({}, "meta", owner); - body = makeLabel({}, "body", owner); - detail = makeMarkdownLabel({}, owner); - layout->addWidget(title); - layout->addWidget(metadata); - layout->addWidget(body); - layout->addWidget(detail); + title->setText(QStringLiteral("Agent activity")); + metadata = makeLabel({}, "meta", content); + body = makeLabel({}, "body", content); + detail = makeMarkdownLabel({}, content); + contentLayout->addWidget(metadata); + contentLayout->addWidget(body); + contentLayout->addWidget(detail); break; case CardKind::Reasoning: - title = makeLabel(QStringLiteral("Reasoning"), "title", owner); - body = makeMarkdownLabel({}, owner); - layout->addWidget(title); - layout->addWidget(body); + title->setText(QStringLiteral("Reasoning")); + body = makeMarkdownLabel({}, content); + contentLayout->addWidget(body); break; case CardKind::FileChanges: - title = makeLabel(QStringLiteral("File changes"), "title", owner); - metadata = makeLabel({}, "meta", owner); - body = makeLabel({}, "body", owner); - layout->addWidget(title); - layout->addWidget(body); - layout->addWidget(metadata); + title->setText(QStringLiteral("File changes")); + metadata = makeLabel({}, "meta", content); + body = makeLabel({}, "body", content); + contentLayout->addWidget(body); + contentLayout->addWidget(metadata); break; case CardKind::ImageGeneration: - title = makeLabel(QStringLiteral("Generated image"), "title", owner); - metadata = makeLabel({}, "meta", owner); - body = makeLabel({}, "body", owner); - layout->addWidget(title); - layout->addWidget(metadata); - layout->addWidget(body); + title->setText(QStringLiteral("Generated image")); + metadata = makeLabel({}, "meta", content); + body = makeLabel({}, "body", content); + contentLayout->addWidget(metadata); + contentLayout->addWidget(body); createImageContainer(); break; case CardKind::Plan: - title = makeLabel(QStringLiteral("Plan"), "title", owner); - body = makeMarkdownLabel({}, owner); - layout->addWidget(title); - layout->addWidget(body); + title->setText(QStringLiteral("Plan")); + body = makeMarkdownLabel({}, content); + contentLayout->addWidget(body); break; case CardKind::GenericActivity: - title = makeLabel({}, "title", owner); - metadata = makeLabel({}, "meta", owner); - layout->addWidget(title); - layout->addWidget(metadata); + metadata = makeLabel({}, "meta", content); + contentLayout->addWidget(metadata); break; case CardKind::LocalPrompt: owner->setObjectName(QStringLiteral("pendingPromptCard")); owner->setStyleSheet(QStringLiteral( "QFrame#pendingPromptCard{background:transparent;border:0;}")); - title = makeLabel(QStringLiteral("You"), "title", owner); - body = makeMarkdownLabel({}, owner); - metadata = makeLabel({}, "meta", owner); - layout->addWidget(title); - layout->addWidget(body); - layout->addWidget(metadata); + title->setText(QStringLiteral("You")); + body = makeMarkdownLabel({}, content); + metadata = makeLabel({}, "meta", content); + contentLayout->addWidget(body); + contentLayout->addWidget(metadata); createImageContainer(); animationTimer = new QTimer(owner); animationTimer->setInterval(PendingAnimationIntervalMilliseconds); @@ -723,14 +804,14 @@ class ConversationCard::Impl final { } void createImageContainer() { - images = new QWidget(owner); + images = new QWidget(content); images->setObjectName(QStringLiteral("messageImages")); imageLayout = new QVBoxLayout(images); imageLayout->setContentsMargins(0, 0, 0, 0); imageLayout->setSpacing(8); imageLayout->setAlignment(Qt::AlignLeft); images->hide(); - layout->addWidget(images); + contentLayout->addWidget(images); } void setImages(const QStringList &paths) { @@ -816,8 +897,8 @@ class ConversationCard::Impl final { } case CardKind::ImageGeneration: { const auto &image = std::get(data.payload); - const bool generated = !image.status.isEmpty() || - !image.revisedPrompt.isEmpty(); + const bool generated = + !image.status.isEmpty() || !image.revisedPrompt.isEmpty(); title->setText(generated ? QStringLiteral("Generated image") : QStringLiteral("Image")); setVisibleText(metadata, displayStatus(image.status)); @@ -892,8 +973,14 @@ class ConversationCard::Impl final { ConversationCard *owner = nullptr; VisibleCardData current; + bool collapsed = false; QVBoxLayout *layout = nullptr; + QWidget *header = nullptr; + QHBoxLayout *headerLayout = nullptr; QLabel *title = nullptr; + CardDisclosureButton *disclosure = nullptr; + QWidget *content = nullptr; + QVBoxLayout *contentLayout = nullptr; QLabel *body = nullptr; QLabel *metadata = nullptr; QLabel *detail = nullptr; @@ -917,6 +1004,12 @@ const VisibleCardData &ConversationCard::data() const noexcept { return impl_->current; } +bool ConversationCard::isCollapsed() const noexcept { return impl_->collapsed; } + +void ConversationCard::setCollapsed(bool collapsed) { + impl_->setCollapsed(collapsed); +} + std::optional ConversationCard::commandOutputScrollState() const { if (!impl_->output) diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index a2e542e..a876d86 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -71,6 +71,8 @@ class CommandOutputView final : public ContentSizedTextView { }; class ConversationCard : public QFrame { + Q_OBJECT + public: explicit ConversationCard(const VisibleCardData &data, QWidget *parent = nullptr); @@ -78,6 +80,8 @@ class ConversationCard : public QFrame { [[nodiscard]] CardKind cardKind() const noexcept; [[nodiscard]] const VisibleCardData &data() const noexcept; + [[nodiscard]] bool isCollapsed() const noexcept; + void setCollapsed(bool collapsed); [[nodiscard]] std::optional commandOutputScrollState() const; void @@ -88,6 +92,9 @@ class ConversationCard : public QFrame { // changed. Passing a different key or kind is a programming error. bool apply(const VisibleCardData &data); +signals: + void foldRequested(bool collapsed); + protected: void paintEvent(QPaintEvent *event) override; diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index ec0de3e..65ca007 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -9,12 +9,13 @@ #include #include #include -#include #include #include #include #include #include +#include +#include #include #include #include @@ -90,6 +91,9 @@ ConversationView::ConversationView(QWidget *parent) emptyMessage_ = empty_->text(); empty_->setParent(content_); contentLayout_->addWidget(empty_); + trailingSpace_ = + new QSpacerItem(0, 0, QSizePolicy::Minimum, QSizePolicy::Fixed); + contentLayout_->addItem(trailingSpace_); followAnimation_ = new QVariantAnimation(this); followAnimation_->setEasingCurve(QEasingCurve::OutCubic); @@ -178,6 +182,7 @@ void ConversationView::setThread(const std::string &threadId) { return; storeCurrentThreadState(); stopFollowingAnimation(); + foldBottomCompensation_ = 0; threadId_ = threadId; const auto saved = threadStates_.find(threadId_); mode_ = saved == threadStates_.end() ? Mode::Following : saved->second.mode; @@ -210,15 +215,15 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { int height = 0; for (const auto &[key, card] : cards_) { static_cast(key); - auto *output = - dynamic_cast(card->findChild( - QStringLiteral("commandOutputView"))); - if (output && !output->isHidden()) + auto *output = dynamic_cast( + card->findChild(QStringLiteral("commandOutputView"))); + if (output && output->isVisibleTo(card)) height += output->height(); } return height; }; const int outputFootprintBefore = visibleOutputFootprint(); + const int naturalHeightBefore = naturalContentHeight_; stopFollowingAnimation(); applying_ = true; @@ -303,6 +308,15 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { } card = createConversationCard(cardData, section); card->setProperty("conversationAnchorKey", QString::fromStdString(key)); + if (const auto collapsed = cardCollapsedStates_.find(key); + collapsed != cardCollapsedStates_.end()) + card->setCollapsed(collapsed->second); + connect(card, &ConversationCard::foldRequested, this, + [this, key, card](bool collapsed) { + const auto retained = cards_.find(key); + if (retained != cards_.end() && retained->second == card) + setCardCollapsed(key, card, collapsed); + }); if (const auto saved = commandOutputStates_.find(key); saved != commandOutputStates_.end()) { commandOutputRestorations.emplace_back(card, saved->second); @@ -358,6 +372,13 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { snapshot_ = snapshot; recomputeGeometry(); + if (!switchedThread && foldBottomCompensation_ > 0 && + naturalContentHeight_ > naturalHeightBefore) { + foldBottomCompensation_ = + std::max(0, foldBottomCompensation_ - + (naturalContentHeight_ - naturalHeightBefore)); + recomputeGeometry(); + } const bool outputGrew = visibleOutputFootprint() > outputFootprintBefore; for (const auto &[card, state] : commandOutputRestorations) card->restoreCommandOutputScrollState(state); @@ -389,6 +410,48 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { return visualChange; } +void ConversationView::setCardCollapsed(const std::string &key, + ConversationCard *card, + bool collapsed) { + if (!card || card->isCollapsed() == collapsed) + return; + + const int titleTop = card->mapTo(viewport(), QPoint{}).y(); + const int naturalHeightBefore = naturalContentHeight_; + stopFollowingAnimation(); + applying_ = true; + viewport()->setUpdatesEnabled(false); + content_->setUpdatesEnabled(false); + const QSignalBlocker scrollSignals(verticalScrollBar()); + + mode_ = Mode::Paused; + pausedByComposerGrowth_ = false; + cardCollapsedStates_[key] = collapsed; + card->setCollapsed(collapsed); + recomputeGeometry(); + if (foldBottomCompensation_ > 0 && + naturalContentHeight_ > naturalHeightBefore) { + foldBottomCompensation_ = + std::max(0, foldBottomCompensation_ - + (naturalContentHeight_ - naturalHeightBefore)); + recomputeGeometry(); + } + + int desiredValue = card->mapTo(content_, QPoint{}).y() - titleTop; + if (desiredValue > verticalScrollBar()->maximum()) { + foldBottomCompensation_ += desiredValue - verticalScrollBar()->maximum(); + recomputeGeometry(); + desiredValue = card->mapTo(content_, QPoint{}).y() - titleTop; + } + setScrollValue(desiredValue); + + applying_ = false; + content_->setUpdatesEnabled(true); + viewport()->setUpdatesEnabled(true); + viewport()->update(); + storeCurrentThreadState(); +} + void ConversationView::setTrailingSpaceHeight(int height) { height = std::max(0, height); if (height == trailingSpaceHeight_) @@ -578,6 +641,8 @@ void ConversationView::recomputeGeometry() { if (!content_ || !viewport()) return; const int width = std::max(0, viewport()->width()); + trailingSpace_->changeSize(0, 0, QSizePolicy::Minimum, QSizePolicy::Fixed); + contentLayout_->invalidate(); // Give every nested layout its final width before asking for height. This // makes wrapped labels and command output contribute to the same range @@ -588,13 +653,38 @@ void ConversationView::recomputeGeometry() { static_cast(key); section->layout()->activate(); } + for (const auto &[key, card] : cards_) { + static_cast(key); + if (QWidget *cardContent = card->findChild( + QStringLiteral("conversationCardContent")); + cardContent && cardContent->layout()) { + cardContent->layout()->invalidate(); + cardContent->layout()->activate(); + cardContent->updateGeometry(); + } + if (card->layout()) { + card->layout()->invalidate(); + card->layout()->activate(); + } + card->updateGeometry(); + } + for (const auto &[key, section] : sections_) { + static_cast(key); + section->layout()->invalidate(); + section->layout()->activate(); + } contentLayout_->activate(); int wanted = contentLayout_->hasHeightForWidth() ? contentLayout_->heightForWidth(width) : contentLayout_->sizeHint().height(); wanted = std::max(wanted, contentLayout_->minimumSize().height()); - wanted += trailingSpaceHeight_; + naturalContentHeight_ = wanted; + const int tailHeight = trailingSpaceHeight_ + foldBottomCompensation_; + trailingSpace_->changeSize(0, tailHeight, QSizePolicy::Minimum, + QSizePolicy::Fixed); + contentLayout_->invalidate(); + wanted += tailHeight; contentHeight_ = std::max(viewport()->height(), wanted); content_->resize(width, contentHeight_); contentLayout_->setGeometry(QRect(0, 0, width, contentHeight_)); @@ -608,6 +698,7 @@ void ConversationView::recomputeGeometry() { verticalScrollBar()->setRange( 0, std::max(0, contentHeight_ - viewport()->height())); positionContent(); + QCoreApplication::sendPostedEvents(content_, QEvent::LayoutRequest); } void ConversationView::positionContent() { diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index e566ce1..7efe78b 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -16,6 +16,7 @@ class QLabel; class QEvent; class QPushButton; +class QSpacerItem; class QVariantAnimation; class QVBoxLayout; class QWheelEvent; @@ -82,6 +83,8 @@ class ConversationView final : public QAbstractScrollArea { class TurnSectionWidget; void setThread(const std::string &threadId); + void setCardCollapsed(const std::string &key, ConversationCard *card, + bool collapsed); [[nodiscard]] Anchor captureAnchor() const; void restoreAnchor(const Anchor &anchor); void storeCurrentThreadState(); @@ -98,6 +101,7 @@ class ConversationView final : public QAbstractScrollArea { QWidget *content_ = nullptr; QVBoxLayout *contentLayout_ = nullptr; QPushButton *loadMore_ = nullptr; + QSpacerItem *trailingSpace_ = nullptr; QLabel *empty_ = nullptr; QVariantAnimation *followAnimation_ = nullptr; std::function loadMoreAction_; @@ -110,9 +114,12 @@ class ConversationView final : public QAbstractScrollArea { std::unordered_map threadStates_; std::unordered_map commandOutputStates_; + std::unordered_map cardCollapsedStates_; Mode mode_ = Mode::Following; int trailingSpaceHeight_ = 0; + int foldBottomCompensation_ = 0; + int naturalContentHeight_ = 0; int contentHeight_ = 0; QString emptyMessage_; bool applying_ = false; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 16fe607..8871686 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -168,6 +168,17 @@ QString applicationStyleSheet() { padding: 0; } QToolButton[kind="composerAction"]:hover { background: #f1f5fb; border-color: #b9c4d2; } + QToolButton#cardDisclosureButton { + background: transparent; + border: 0; + border-radius: 5px; + padding: 0; + } + QToolButton#cardDisclosureButton:hover { background: #f1f5fb; } + QToolButton#cardDisclosureButton:focus { + background: #e5eeff; + border: 1px solid #bfd3f9; + } QPushButton[kind="agentLink"] { background: #e5eeff; border-color: #bfd3f9; color: #2f6feb; text-align: left; } QPushButton[kind="success"] { background: #18865e; border-color: #18865e; color: white; } QPushButton[kind="success"]:hover { background: #14734f; border-color: #14734f; } diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index ab3a746..ae6f917 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -87,6 +88,25 @@ ConversationCard *card(ConversationView &view, const std::string &key) { return nullptr; } +QToolButton *disclosure(ConversationCard *card) { + return card ? card->findChild( + QStringLiteral("cardDisclosureButton")) + : nullptr; +} + +bool setFolded(ConversationCard *card, bool collapsed) { + if (!card) + return false; + if (card->isCollapsed() == collapsed) + return true; + QToolButton *button = disclosure(card); + if (!button) + return false; + button->click(); + spin(); + return card->isCollapsed() == collapsed; +} + std::pair firstVisible(ConversationView &view) { std::vector cards; for (QWidget *widget : view.findChildren()) @@ -567,6 +587,265 @@ bool testMutableCardsAndCommandOutput() { return result; } +bool testCardFoldingGeometryAndRetention() { + const std::string thread = "folding-thread"; + const VisibleCardData user{ + AuthoritativeItemKey{thread, "turn", "user"}, + CardKind::UserMessage, + thread, + "turn", + "user", + UserMessageData{QStringLiteral("Keep this message initially expanded."), + {}}}; + const VisibleCardData agent{ + AuthoritativeItemKey{thread, "turn", "agent"}, + CardKind::AgentMessage, + thread, + "turn", + "agent", + AgentMessageData{QStringLiteral("Codex also starts expanded."), true}}; + const VisibleCardData reasoning{ + AuthoritativeItemKey{thread, "turn", "reasoning"}, + CardKind::Reasoning, + thread, + "turn", + "reasoning", + ReasoningData{QStringLiteral("A retained public summary with enough " + "detail to create real height.\n\n" + "The second paragraph proves expansion uses " + "the final wrapped size.")}}; + const VisibleCardData command{ + AuthoritativeItemKey{thread, "turn", "command"}, + CardKind::CommandExecution, + thread, + "turn", + "command", + CommandExecutionData{ + QStringLiteral("produce output"), QStringLiteral("initial output"), + QStringLiteral("completed"), QStringLiteral("/workspace"), 0}}; + const VisibleCardData files{ + AuthoritativeItemKey{thread, "turn", "files"}, + CardKind::FileChanges, + thread, + "turn", + "files", + FileChangesData{ + QStringLiteral("completed"), + {{QStringLiteral("src/card.cpp"), QStringLiteral("update"), 4, 1}}}}; + const VisibleCardData activity{ + AuthoritativeItemKey{thread, "turn", "activity"}, + CardKind::AgentActivity, + thread, + "turn", + "activity", + AgentActivityData{QStringLiteral("spawn_agent"), + QStringLiteral("completed"), + {}, + QStringLiteral("Inspect folding"), + QStringLiteral("Inspection complete"), + {}}}; + const VisibleCardData image{ + AuthoritativeItemKey{thread, "turn", "image"}, + CardKind::ImageGeneration, + thread, + "turn", + "image", + ImageGenerationData{QStringLiteral("/tmp/folding-preview.png"), + QStringLiteral("completed"), + QStringLiteral("A folding preview")}}; + const VisibleCardData plan{AuthoritativeItemKey{thread, "turn", "plan"}, + CardKind::Plan, + thread, + "turn", + "plan", + PlanData{QStringLiteral("Verify folding"), + {{QStringLiteral("Inspect geometry"), + QStringLiteral("completed")}}, + {}}}; + const VisibleCardData generic{ + AuthoritativeItemKey{thread, "turn", "generic"}, + CardKind::GenericActivity, + thread, + "turn", + "generic", + GenericActivityData{QStringLiteral("Unknown activity"), + {{"detail", "bounded"}}}}; + ConversationSnapshot snapshot{thread, + {{"turn:folding", + "turn", + {user, agent, reasoning, command, files, + activity, image, plan, generic}}}, + 0, + false}; + + ConversationView view; + view.resize(700, 820); + view.show(); + bool result = expect(view.reconcile(snapshot), "folding fixture renders"); + spin(); + + ConversationCard *userCard = card(view, stableKey(user.key)); + ConversationCard *agentCardWidget = card(view, stableKey(agent.key)); + ConversationCard *reasoningCard = card(view, stableKey(reasoning.key)); + ConversationCard *commandCard = card(view, stableKey(command.key)); + ConversationCard *filesCard = card(view, stableKey(files.key)); + const std::vector additionalActionCards{ + card(view, stableKey(activity.key)), card(view, stableKey(image.key)), + card(view, stableKey(plan.key)), card(view, stableKey(generic.key))}; + result &= expect( + userCard && agentCardWidget && reasoningCard && commandCard && + filesCard && !userCard->isCollapsed() && + !agentCardWidget->isCollapsed() && reasoningCard->isCollapsed() && + commandCard->isCollapsed() && filesCard->isCollapsed() && + disclosure(userCard) && disclosure(agentCardWidget) && + disclosure(reasoningCard) && disclosure(commandCard) && + disclosure(filesCard), + "all cards share disclosure controls with role-correct initial state"); + result &= expect( + std::ranges::all_of(additionalActionCards, + [](ConversationCard *value) { + return value && value->isCollapsed() && + disclosure(value); + }), + "agent, image, plan, and fallback activity cards also start collapsed"); + if (!userCard || !agentCardWidget || !reasoningCard || !commandCard || + !filesCard) + return false; + + const int userTop = userCard->mapTo(view.viewport(), QPoint{}).y(); + const int reasoningTop = reasoningCard->mapTo(view.viewport(), QPoint{}).y(); + const int filesTop = filesCard->mapTo(view.viewport(), QPoint{}).y(); + const int foldedReasoningHeight = reasoningCard->height(); + result &= expect(setFolded(reasoningCard, false), + "reasoning expands through its disclosure control"); + const int expandedReasoningHeight = reasoningCard->height(); + result &= expect( + reasoningCard->mapTo(view.viewport(), QPoint{}).y() == reasoningTop && + userCard->mapTo(view.viewport(), QPoint{}).y() == userTop && + expandedReasoningHeight > foldedReasoningHeight && + filesCard->mapTo(view.viewport(), QPoint{}).y() == + filesTop + expandedReasoningHeight - foldedReasoningHeight, + "expansion fixes the affected title and grows only downward"); + + const int commandHeight = commandCard->height(); + auto &execution = std::get( + snapshot.sections.front().cards[3].payload); + execution.output = QStringLiteral( + "streamed line 1\nstreamed line 2\nstreamed line 3\nstreamed line 4"); + result &= expect(view.reconcile(snapshot), + "folded command accepts a streamed content update"); + auto *output = dynamic_cast( + commandCard->findChild(QStringLiteral("commandOutputView"))); + result &= expect( + commandCard->isCollapsed() && commandCard->height() == commandHeight && + output && + output->toPlainText().contains(QStringLiteral("streamed line 4")), + "streaming updates folded content without changing height"); + + const int userHeight = userCard->height(); + const int agentTop = agentCardWidget->mapTo(view.viewport(), QPoint{}).y(); + result &= expect(setFolded(userCard, true), + "You can be folded from its expanded default"); + result &= expect( + userCard->mapTo(view.viewport(), QPoint{}).y() == userTop && + agentCardWidget->mapTo(view.viewport(), QPoint{}).y() == + agentTop - (userHeight - userCard->height()), + "folding You fixes its title and shifts only following cards upward"); + + view.reconcile(conversation("folding-other-thread", 4)); + spin(); + view.reconcile(snapshot); + spin(); + userCard = card(view, stableKey(user.key)); + reasoningCard = card(view, stableKey(reasoning.key)); + commandCard = card(view, stableKey(command.key)); + result &= expect( + userCard && reasoningCard && commandCard && userCard->isCollapsed() && + !reasoningCard->isCollapsed() && commandCard->isCollapsed(), + "user fold choices survive thread switching and updates"); + + const std::string promptThread = "folding-prompt-replacement"; + const LocalPromptKey promptKey{4242}; + VisibleCardData localPrompt{ + promptKey, + CardKind::LocalPrompt, + promptThread, + {}, + {}, + LocalPromptData{4242, + QStringLiteral("A temporary prompt"), + PromptState::InFlight, + 0, + {}, + {}}}; + ConversationSnapshot promptSnapshot{ + promptThread, {{"local:folding-prompt", {}, {localPrompt}}}, 0, false}; + view.reconcile(promptSnapshot); + spin(); + ConversationCard *promptCard = card(view, stableKey(promptKey)); + result &= expect(promptCard && !promptCard->isCollapsed() && + setFolded(promptCard, true), + "temporary You prompts start expanded and can be folded"); + promptSnapshot.sections.front().cards.front() = { + promptKey, CardKind::UserMessage, + promptThread, "turn", + "user", UserMessageData{QStringLiteral("A temporary prompt"), {}}}; + view.reconcile(promptSnapshot); + spin(); + promptCard = card(view, stableKey(promptKey)); + result &= + expect(promptCard && promptCard->cardKind() == CardKind::UserMessage && + promptCard->isCollapsed(), + "fold state survives authoritative prompt replacement"); + + const std::string edgeThread = "folding-bottom-edge"; + ConversationSnapshot edge = conversation(edgeThread, 12); + QString longOutput; + for (int line = 0; line < 70; ++line) + longOutput += QStringLiteral("bottom-edge line %1\n").arg(line); + VisibleCardData edgeCommand{ + AuthoritativeItemKey{edgeThread, "turn-2", "edge-command"}, + CardKind::CommandExecution, + edgeThread, + "turn-2", + "edge-command", + CommandExecutionData{QStringLiteral("produce capped output"), + longOutput, + QStringLiteral("completed"), + {}, + 0}}; + edge.sections.back().cards.push_back(edgeCommand); + ConversationView edgeView; + edgeView.resize(650, 520); + edgeView.show(); + edgeView.reconcile(edge); + spin(); + ConversationCard *edgeCard = card(edgeView, stableKey(edgeCommand.key)); + const int collapsedTop = + edgeCard ? edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() : 0; + result &= expect(setFolded(edgeCard, false), + "bottom-edge command expands from its compact default"); + result &= + expect(edgeCard && edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() == + collapsedTop, + "bottom-edge expansion keeps the selected title fixed"); + wheel(edgeView, -10000); + const int followedTitleTop = + edgeCard ? edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() : 0; + result &= expect(edgeView.isAtBottom() && followedTitleTop >= 0, + "expanded lower-limit fixture exposes its title at bottom"); + result &= expect(setFolded(edgeCard, true), + "expanded bottom-edge command collapses"); + spin(120); + result &= + expect(edgeCard && + edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() == + followedTitleTop && + edgeView.mode() == ConversationView::Mode::Paused, + "bottom compensation defeats range clamping and fixes the title"); + return result; +} + bool testInitialCommandGeometrySettlement() { const std::string thread = "initial-command-thread"; QString output; @@ -593,6 +872,8 @@ bool testInitialCommandGeometrySettlement() { bool result = expect(view.reconcile(snapshot), "initial visible command output is inserted"); ConversationCard *commandCard = card(view, stableKey(command.key)); + result &= expect(setFolded(commandCard, false), + "initially folded command can be expanded for inspection"); auto *outputView = commandCard ? dynamic_cast( commandCard->findChild( QStringLiteral("commandOutputView"))) @@ -656,6 +937,9 @@ bool testBottomAnchoredCommandOutputGrowth() { view.reconcile(snapshot); spin(); ConversationCard *commandCard = card(view, stableKey(command.key)); + bool result = expect(setFolded(commandCard, false), + "live command expands from its compact default"); + wheel(view, -10000); auto *metadata = commandCard ? commandCard->findChild(QStringLiteral("commandMetadata")) @@ -664,7 +948,7 @@ bool testBottomAnchoredCommandOutputGrowth() { commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; - bool result = + result &= expect(commandCard && metadata && output && output->isHidden() && view.isAtBottom() && metadata->property("tone") == "active", "live command starts with a hidden zero-line output"); @@ -725,12 +1009,14 @@ bool testCommandOutputStateAcrossNavigation() { view.reconcile(commandThread); spin(); ConversationCard *commandCard = card(view, stableKey(command.key)); + bool result = expect(setFolded(commandCard, false), + "navigation command expands from its compact default"); auto *initialOutput = commandCard ? dynamic_cast( commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; - bool result = + result &= expect(initialOutput && initialOutput->verticalScrollBar()->maximum() > 0, "navigation test has independently scrollable output"); if (!initialOutput) @@ -918,27 +1204,30 @@ bool testGeneratedImagePresentationAndGenericBound() { viewer->close(); spin(); - VisibleCardData viewed{ - AuthoritativeItemKey{"generated", "turn", "view"}, - CardKind::ImageGeneration, - "generated", - "turn", - "view", - ImageGenerationData{path, {}, {}}}; + VisibleCardData viewed{AuthoritativeItemKey{"generated", "turn", "view"}, + CardKind::ImageGeneration, + "generated", + "turn", + "view", + ImageGenerationData{path, {}, {}}}; ConversationCard viewedCard(viewed); viewedCard.show(); spin(); const auto viewedLabels = viewedCard.findChildren(); result &= expect( - std::ranges::any_of(viewedLabels, [](QLabel *label) { - return label->property("kind").toString() == QStringLiteral("title") && - label->text() == QStringLiteral("Image"); - }) && - std::ranges::any_of(viewedLabels, [](QLabel *label) { - return label->objectName() == - QStringLiteral("messageImageThumbnail") && - label->property("imageAvailable").toBool(); - }), + std::ranges::any_of(viewedLabels, + [](QLabel *label) { + return label->property("kind").toString() == + QStringLiteral("title") && + label->text() == QStringLiteral("Image"); + }) && + std::ranges::any_of( + viewedLabels, + [](QLabel *label) { + return label->objectName() == + QStringLiteral("messageImageThumbnail") && + label->property("imageAvailable").toBool(); + }), "plain image-view cards use a neutral title and the shared thumbnail"); VisibleCardData generic{ @@ -971,6 +1260,7 @@ int main(int argc, char **argv) { result &= testThreadLocalScrollAndComposerExtent(); result &= testPromptAdmissionFollowOwnership(); result &= testMutableCardsAndCommandOutput(); + result &= testCardFoldingGeometryAndRetention(); result &= testInitialCommandGeometrySettlement(); result &= testBottomAnchoredCommandOutputGrowth(); result &= testCommandOutputStateAcrossNavigation(); diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 48f603a..0b83543 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -79,6 +79,10 @@ content does not provide a reliable success, warning, or failure classification. Conversation activity cards remain neutral so supporting process information does not compete with the user/Codex exchange. Color on those cards is reserved for authoritative running, completed, warning/interrupted, and failed status. +All conversation cards use one disclosure-header grammar. Message cards open +expanded and activity cards open collapsed; user choices remain session-local. +Folding is immediate rather than animated and anchors the selected title row, +so content only contracts upward or grows downward below the interaction point. ## Application layout From c33898f210c72ab54ff3da6f747cac0ad33c9a15 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 02:38:22 +0200 Subject: [PATCH 34/36] Polish conversation card disclosure behavior --- docs/ui-behavior.md | 5 +- src/codex/middle/ConversationCards.cpp | 10 ++-- src/codex/middle/ConversationView.cpp | 6 +-- src/codex/ui/UiStyle.cpp | 10 ++-- src/codex/ui/UiStyle.h | 2 +- tests/codex/ConversationCardsTest.cpp | 66 ++++++++++++++++++++++++-- ui-review/UX-DESIGN-DECISIONS.md | 5 +- 7 files changed, 83 insertions(+), 21 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 38edeae..f838bc4 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -129,7 +129,10 @@ never displayed as generic activity text. Process cards remain neutral so they support rather than dominate the user and Codex conversation. Status text alone uses canonical semantic state colors. -Every conversation card uses the same keyboard-focusable disclosure chevron. +Every conversation card with visible detail uses the same keyboard-focusable +disclosure chevron: down when expanded and left when collapsed. Title-only +cards, including Reasoning without a public summary, omit the chevron until +detail arrives. You, Codex, and temporary You cards initially render expanded; Reasoning, Command execution, File changes, Agent activity, Image, Plan, and fallback activity cards initially render collapsed. A user-selected state survives diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index e2c8a29..1936ae0 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -64,6 +64,7 @@ class CardDisclosureButton final : public QToolButton { setFocusPolicy(Qt::StrongFocus); setAccessibleName(QStringLiteral("Expand card")); setToolTip(accessibleName()); + setProperty("chevronDirection", "left"); } void setExpanded(bool expanded) { @@ -73,16 +74,17 @@ class CardDisclosureButton final : public QToolButton { setAccessibleName(expanded ? QStringLiteral("Collapse card") : QStringLiteral("Expand card")); setToolTip(accessibleName()); + setProperty("chevronDirection", expanded ? "down" : "left"); update(); } protected: void paintEvent(QPaintEvent *event) override { - QToolButton::paintEvent(event); + static_cast(event); UiStyle::drawChevron(this, rect().adjusted(3, 3, -3, -3), isEnabled(), underMouse() || hasFocus(), expanded_ ? UiStyle::ChevronDirection::Down - : UiStyle::ChevronDirection::Right); + : UiStyle::ChevronDirection::Left); } private: @@ -709,8 +711,10 @@ class ConversationCard::Impl final { } void refreshFoldPresentation() { + const bool expandable = hasVisibleContent(); disclosure->setExpanded(!collapsed); - content->setVisible(!collapsed && hasVisibleContent()); + disclosure->setVisible(expandable); + content->setVisible(expandable && !collapsed); } void createChildren(CardKind kind) { diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 65ca007..dc73722 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -658,19 +658,14 @@ void ConversationView::recomputeGeometry() { if (QWidget *cardContent = card->findChild( QStringLiteral("conversationCardContent")); cardContent && cardContent->layout()) { - cardContent->layout()->invalidate(); cardContent->layout()->activate(); - cardContent->updateGeometry(); } if (card->layout()) { - card->layout()->invalidate(); card->layout()->activate(); } - card->updateGeometry(); } for (const auto &[key, section] : sections_) { static_cast(key); - section->layout()->invalidate(); section->layout()->activate(); } contentLayout_->activate(); @@ -698,6 +693,7 @@ void ConversationView::recomputeGeometry() { verticalScrollBar()->setRange( 0, std::max(0, contentHeight_ - viewport()->height())); positionContent(); + QCoreApplication::sendPostedEvents(content_, QEvent::LayoutRequest); } diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 8871686..cebfa22 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -25,6 +25,10 @@ void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, chevron.moveTo(center.x() - 1.5, center.y() - 3.5); chevron.lineTo(center.x() + 2.0, center.y()); chevron.lineTo(center.x() - 1.5, center.y() + 3.5); + } else if (direction == ChevronDirection::Left) { + chevron.moveTo(center.x() + 1.5, center.y() - 3.5); + chevron.lineTo(center.x() - 2.0, center.y()); + chevron.lineTo(center.x() + 1.5, center.y() + 3.5); } else { chevron.moveTo(center.x() - 3.5, center.y() - 1.5); chevron.lineTo(center.x(), center.y() + 2.0); @@ -171,14 +175,8 @@ QString applicationStyleSheet() { QToolButton#cardDisclosureButton { background: transparent; border: 0; - border-radius: 5px; padding: 0; } - QToolButton#cardDisclosureButton:hover { background: #f1f5fb; } - QToolButton#cardDisclosureButton:focus { - background: #e5eeff; - border: 1px solid #bfd3f9; - } QPushButton[kind="agentLink"] { background: #e5eeff; border-color: #bfd3f9; color: #2f6feb; text-align: left; } QPushButton[kind="success"] { background: #18865e; border-color: #18865e; color: white; } QPushButton[kind="success"]:hover { background: #14734f; border-color: #14734f; } diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index 81eea51..f7a4ffe 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -50,7 +50,7 @@ inline constexpr auto redText = "#982f3d"; inline constexpr auto purple = "#6941c6"; QString applicationStyleSheet(); -enum class ChevronDirection { Down, Right }; +enum class ChevronDirection { Down, Left, Right }; void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, bool highlighted, ChevronDirection direction = ChevronDirection::Down); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index ae6f917..fcf5654 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -36,6 +36,35 @@ bool expect(bool condition, const char *message) { return false; } +class LayoutRequestProbe final : public QObject { +public: + explicit LayoutRequestProbe(QWidget *root) : root_(root) { + qApp->installEventFilter(this); + } + + ~LayoutRequestProbe() override { qApp->removeEventFilter(this); } + + void start() { + count = 0; + active = true; + } + + int count = 0; + bool active = false; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override { + auto *widget = qobject_cast(watched); + if (active && event->type() == QEvent::LayoutRequest && widget && + (widget == root_ || root_->isAncestorOf(widget))) + ++count; + return false; + } + +private: + QWidget *root_ = nullptr; +}; + void spin(int milliseconds = 0) { QElapsedTimer timer; timer.start(); @@ -211,9 +240,14 @@ bool testFollowPauseAndStableAnchor() { "\nA reflowing upstream update.\nA second line.\nA third line."); } snapshot.sections.back().cards.push_back(agentCard("thread-a", "turn-2", 35)); + LayoutRequestProbe layoutRequests(&view); result &= expect(view.reconcile(snapshot), "paused incoming changes still materialize"); + layoutRequests.start(); spin(); + result &= expect(layoutRequests.count <= 12, + "a paused append leaves only bounded ancestor/new-card " + "layout settlement, not per-card deferred work"); const auto after = firstVisible(view); result &= expect(after.first == anchor.first && std::abs(after.second - anchor.second) <= 1, @@ -670,11 +704,19 @@ bool testCardFoldingGeometryAndRetention() { "generic", GenericActivityData{QStringLiteral("Unknown activity"), {{"detail", "bounded"}}}}; + const VisibleCardData emptyReasoning{ + AuthoritativeItemKey{thread, "turn", "empty-reasoning"}, + CardKind::Reasoning, + thread, + "turn", + "empty-reasoning", + ReasoningData{}}; ConversationSnapshot snapshot{thread, {{"turn:folding", "turn", {user, agent, reasoning, command, files, - activity, image, plan, generic}}}, + activity, image, plan, generic, + emptyReasoning}}}, 0, false}; @@ -692,6 +734,8 @@ bool testCardFoldingGeometryAndRetention() { const std::vector additionalActionCards{ card(view, stableKey(activity.key)), card(view, stableKey(image.key)), card(view, stableKey(plan.key)), card(view, stableKey(generic.key))}; + ConversationCard *emptyReasoningCard = + card(view, stableKey(emptyReasoning.key)); result &= expect( userCard && agentCardWidget && reasoningCard && commandCard && filesCard && !userCard->isCollapsed() && @@ -699,7 +743,9 @@ bool testCardFoldingGeometryAndRetention() { commandCard->isCollapsed() && filesCard->isCollapsed() && disclosure(userCard) && disclosure(agentCardWidget) && disclosure(reasoningCard) && disclosure(commandCard) && - disclosure(filesCard), + disclosure(filesCard) && + disclosure(userCard)->property("chevronDirection") == "down" && + disclosure(reasoningCard)->property("chevronDirection") == "left", "all cards share disclosure controls with role-correct initial state"); result &= expect( std::ranges::all_of(additionalActionCards, @@ -708,10 +754,23 @@ bool testCardFoldingGeometryAndRetention() { disclosure(value); }), "agent, image, plan, and fallback activity cards also start collapsed"); + result &= expect(emptyReasoningCard && emptyReasoningCard->isCollapsed() && + disclosure(emptyReasoningCard) && + disclosure(emptyReasoningCard)->isHidden(), + "title-only reasoning omits a meaningless disclosure"); if (!userCard || !agentCardWidget || !reasoningCard || !commandCard || - !filesCard) + !filesCard || !emptyReasoningCard) return false; + std::get(snapshot.sections.front().cards.back().payload) + .summary = QStringLiteral("Public reasoning summary arrived"); + result &= expect(view.reconcile(snapshot), + "empty reasoning accepts later public content"); + result &= expect(!disclosure(emptyReasoningCard)->isHidden() && + disclosure(emptyReasoningCard) + ->property("chevronDirection") == "left", + "reasoning disclosure appears collapsed when detail arrives"); + const int userTop = userCard->mapTo(view.viewport(), QPoint{}).y(); const int reasoningTop = reasoningCard->mapTo(view.viewport(), QPoint{}).y(); const int filesTop = filesCard->mapTo(view.viewport(), QPoint{}).y(); @@ -723,6 +782,7 @@ bool testCardFoldingGeometryAndRetention() { reasoningCard->mapTo(view.viewport(), QPoint{}).y() == reasoningTop && userCard->mapTo(view.viewport(), QPoint{}).y() == userTop && expandedReasoningHeight > foldedReasoningHeight && + disclosure(reasoningCard)->property("chevronDirection") == "down" && filesCard->mapTo(view.viewport(), QPoint{}).y() == filesTop + expandedReasoningHeight - foldedReasoningHeight, "expansion fixes the affected title and grows only downward"); diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 0b83543..9234623 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -79,8 +79,9 @@ content does not provide a reliable success, warning, or failure classification. Conversation activity cards remain neutral so supporting process information does not compete with the user/Codex exchange. Color on those cards is reserved for authoritative running, completed, warning/interrupted, and failed status. -All conversation cards use one disclosure-header grammar. Message cards open -expanded and activity cards open collapsed; user choices remain session-local. +Conversation cards with detail use one disclosure-header grammar; title-only +cards omit the control. Message cards open expanded and activity cards open +collapsed; user choices remain session-local. Folding is immediate rather than animated and anchors the selected title row, so content only contracts upward or grows downward below the interaction point. From c765dcbf8c1667068a211a81b2bdce28ed4c96d1 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 09:40:58 +0200 Subject: [PATCH 35/36] Align card disclosure chevrons --- src/codex/middle/ConversationCards.cpp | 7 +++++- tests/codex/ConversationCardsTest.cpp | 30 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 1936ae0..23da17c 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -81,7 +81,12 @@ class CardDisclosureButton final : public QToolButton { protected: void paintEvent(QPaintEvent *event) override { static_cast(event); - UiStyle::drawChevron(this, rect().adjusted(3, 3, -3, -3), isEnabled(), + QRect indicator = rect().adjusted(3, 3, -3, -3); + // Keep the full 24 px hit target while aligning the visible stroke with + // the card's canonical right inset. The narrower left glyph needs two + // pixels more optical compensation than the down glyph. + indicator.translate(expanded_ ? 7 : 9, 0); + UiStyle::drawChevron(this, indicator, isEnabled(), underMouse() || hasFocus(), expanded_ ? UiStyle::ChevronDirection::Down : UiStyle::ChevronDirection::Left); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index fcf5654..526b777 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -123,6 +123,22 @@ QToolButton *disclosure(ConversationCard *card) { : nullptr; } +QRect paintedDisclosureBounds(QToolButton *button) { + if (!button) + return {}; + QImage image(button->size(), QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + button->render(&image, QPoint{}, QRegion{}, QWidget::DrawChildren); + QRect bounds; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (qAlpha(image.pixel(x, y)) > 0) + bounds |= QRect(x, y, 1, 1); + } + } + return bounds; +} + bool setFolded(ConversationCard *card, bool collapsed) { if (!card) return false; @@ -747,6 +763,13 @@ bool testCardFoldingGeometryAndRetention() { disclosure(userCard)->property("chevronDirection") == "down" && disclosure(reasoningCard)->property("chevronDirection") == "left", "all cards share disclosure controls with role-correct initial state"); + const QRect collapsedDisclosure = + paintedDisclosureBounds(disclosure(reasoningCard)); + result &= expect( + collapsedDisclosure.isValid() && + collapsedDisclosure.left() > disclosure(reasoningCard)->width() / 2 && + collapsedDisclosure.right() >= disclosure(reasoningCard)->width() - 3, + "collapsed disclosure paints only a right-inset left chevron"); result &= expect( std::ranges::all_of(additionalActionCards, [](ConversationCard *value) { @@ -786,6 +809,13 @@ bool testCardFoldingGeometryAndRetention() { filesCard->mapTo(view.viewport(), QPoint{}).y() == filesTop + expandedReasoningHeight - foldedReasoningHeight, "expansion fixes the affected title and grows only downward"); + const QRect expandedDisclosure = + paintedDisclosureBounds(disclosure(reasoningCard)); + result &= expect( + expandedDisclosure.isValid() && + expandedDisclosure.left() > disclosure(reasoningCard)->width() / 2 && + expandedDisclosure.right() >= disclosure(reasoningCard)->width() - 3, + "expanded disclosure paints only a right-inset down chevron"); const int commandHeight = commandCard->height(); auto &execution = std::get( From 7778f67e60963559ba32c4d1ba6786f73b503667 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 09:59:55 +0200 Subject: [PATCH 36/36] Stabilize attachment link encoding across Qt versions --- src/codex/middle/PromptCoordinator.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/codex/middle/PromptCoordinator.cpp b/src/codex/middle/PromptCoordinator.cpp index 311e837..098dfa5 100644 --- a/src/codex/middle/PromptCoordinator.cpp +++ b/src/codex/middle/PromptCoordinator.cpp @@ -78,6 +78,8 @@ QString promptWithFileLinks(QString prompt, continue; QString target = QUrl::fromLocalFile(attachment.path).toString(QUrl::FullyEncoded); + target.replace(QLatin1Char('['), QStringLiteral("%5B")); + target.replace(QLatin1Char(']'), QStringLiteral("%5D")); target.replace(QLatin1Char('('), QStringLiteral("%28")); target.replace(QLatin1Char(')'), QStringLiteral("%29")); links.push_back(QStringLiteral("- [%1](%2)")