diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index e0bf133..544038d 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -521,8 +521,11 @@ stable visual card key retains its user-selected collapsed state in the 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. +including title anchoring within the natural scroll range, alongside its +existing single-owner scrolling calculations. Expansion scrolls only as needed +to reveal the complete card when it fits in the unobscured viewport above any +grown composer overlay. At the lower limit, normal range clamping may move the +selected title rather than creating artificial blank space. ### 7.4 Changes and Diff Presentation diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index f838bc4..90fd474 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -139,11 +139,14 @@ 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. +Folding is an explicit geometry transaction. Collapsing keeps the selected +title row fixed while the natural scroll range permits and shifts following +cards upward. Expanding grows downward when the complete card remains visible; +otherwise the viewport scrolls only enough to reveal it, so the title may move +upward. The visible boundary excludes any extra composer height currently +overlaying the conversation. The gesture pauses follow-latest. At the lower +scroll limit, the viewport accepts the natural clamp instead of retaining +artificial blank space. 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. diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 2d55972..a27e732 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -6,6 +6,7 @@ #include "codex/PresentationStatus.h" #include +#include namespace codexui::codex { namespace { @@ -576,15 +577,6 @@ std::size_t PresentationModel::pendingRequestCount() const noexcept { return pendingRequests.size(); } -std::size_t PresentationModel::pendingRequestCount( - const std::string &threadId) const noexcept { - return static_cast( - std::count_if(pendingRequests.begin(), pendingRequests.end(), - [&threadId](const auto &entry) { - return entry.second.threadId == threadId; - })); -} - const ConnectionPresentation &PresentationModel::connection() const noexcept { return connectionState; } @@ -612,24 +604,29 @@ void PresentationModel::mergeThreadList(const nlohmann::json &listedThreads) { if (!listedThreads.is_array()) return; - std::vector listedIds; + std::unordered_set listedIds; listedIds.reserve(listedThreads.size()); + std::vector nextOrder; + nextOrder.reserve(listedThreads.size() + orderedThreads.size()); for (const auto &raw : listedThreads) { const std::string id = stringValue(raw, "id"); if (id.empty()) continue; - upsertThread(raw, false); - listedIds.push_back(id); + upsertThread(raw, false, false); + if (listedIds.insert(id).second) + nextOrder.push_back(id); } - for (const std::string &id : listedIds) - std::erase(orderedThreads, id); - orderedThreads.insert(orderedThreads.begin(), listedIds.begin(), - listedIds.end()); + for (const std::string &id : orderedThreads) { + if (!listedIds.contains(id)) + nextOrder.push_back(id); + } + orderedThreads = std::move(nextOrder); } ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, - bool replaceTurns) { + bool replaceTurns, + bool prependNewThread) { const std::string id = stringValue(raw, "id"); if (id.empty()) { static ThreadPresentation ignored; @@ -639,7 +636,8 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, ThreadPresentation &result = iterator->second; if (inserted) { result.id = id; - orderedThreads.insert(orderedThreads.begin(), id); + if (prependNewThread) + orderedThreads.insert(orderedThreads.begin(), id); } const std::string previousThreadStatus = result.status; std::unordered_map terminalTurnStatuses; diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 42af052..551dd08 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -101,8 +101,6 @@ class PresentationModel final { [[nodiscard]] std::optional activeTurnId(const std::string &threadId) const; [[nodiscard]] std::size_t pendingRequestCount() const noexcept; - [[nodiscard]] std::size_t - pendingRequestCount(const std::string &threadId) const noexcept; [[nodiscard]] const ConnectionPresentation &connection() const noexcept; [[nodiscard]] const nlohmann::json &modelCatalog() const noexcept; [[nodiscard]] const std::unordered_map & @@ -117,7 +115,8 @@ class PresentationModel final { void applyValidatedEvent(const nlohmann::json &event); void mergeThreadList(const nlohmann::json &listedThreads); ThreadPresentation &upsertThread(const nlohmann::json &raw, - bool replaceTurns); + bool replaceTurns, + bool prependNewThread = true); TurnPresentation &upsertTurn(ThreadPresentation &thread, const nlohmann::json &raw, bool replaceItems); ItemPresentation &upsertItem(ThreadPresentation &thread, diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index cefac1d..88dc135 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -700,6 +700,13 @@ void ShellWidget::Impl::refreshStatus() { } } const bool active = model.activeTurnId(selectedThreadId).has_value(); + const std::size_t selectedPending = static_cast(std::count_if( + model.pendingRequestPresentations().begin(), + model.pendingRequestPresentations().end(), + [this](const auto &entry) { + return entry.second.threadId == selectedThreadId; + })); + const std::size_t totalPending = model.pendingRequestCount(); const std::string serialized = nlohmann::json{ {"connected", connection.connected}, {"retrying", connection.retrying}, @@ -714,9 +721,8 @@ void ShellWidget::Impl::refreshStatus() { {"agentCount", thread ? thread->agents.size() : 0U}, {"runningAgents", runningAgents}, {"active", active}, - {"selectedPending", model.pendingRequestCount(selectedThreadId)}, - {"totalPending", - model.pendingRequestCount()}}.dump(); + {"selectedPending", selectedPending}, + {"totalPending", totalPending}}.dump(); const QByteArray next(serialized.data(), static_cast(serialized.size())); if (next == statusSnapshot) @@ -764,9 +770,6 @@ void ShellWidget::Impl::refreshStatus() { : 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); diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index dc73722..067ab48 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -22,7 +22,6 @@ #include #include -#include #include namespace codexui::codex::middle { @@ -54,6 +53,7 @@ class ConversationView::TurnSectionWidget final : public QWidget { } QVBoxLayout *cards = nullptr; + std::vector cardKeys; }; ConversationView::ConversationView(QWidget *parent) @@ -182,7 +182,6 @@ 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; @@ -223,7 +222,6 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { return height; }; const int outputFootprintBefore = visibleOutputFootprint(); - const int naturalHeightBefore = naturalContentHeight_; stopFollowingAnimation(); applying_ = true; @@ -251,8 +249,22 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { snapshot.hiddenAuthoritativeItemCount))); } - std::unordered_set wantedSections; - std::unordered_set wantedCards; + struct DesiredSection { + TurnSectionWidget *widget = nullptr; + int position = 0; + bool insert = false; + std::vector cardKeys; + }; + struct DesiredCard { + TurnSectionWidget *section = nullptr; + int position = 0; + const VisibleCardData *data = nullptr; + bool insert = false; + }; + std::unordered_map desiredSections; + std::unordered_map desiredCards; + std::vector desiredSectionKeys; + desiredSectionKeys.reserve(snapshot.sections.size()); std::vector displayedKeys; std::vector> commandOutputRestorations; @@ -264,13 +276,14 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { else commandOutputStates_.erase(key); }; - int sectionIndex = 1; // load-more owns index zero (also while hidden). + int sectionIndex = 0; for (const TurnSection §ionData : snapshot.sections) { - wantedSections.insert(sectionData.key); + desiredSectionKeys.push_back(sectionData.key); TurnSectionWidget *section = nullptr; const auto existingSection = sections_.find(sectionData.key); - if (existingSection == sections_.end()) { + const bool newSection = existingSection == sections_.end(); + if (newSection) { section = new TurnSectionWidget(content_); section->setProperty("turnSectionKey", QString::fromStdString(sectionData.key)); @@ -281,31 +294,79 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { } section->setProperty("turnId", QString::fromStdString(sectionData.turnId)); - if (contentLayout_->indexOf(section) != sectionIndex) { - contentLayout_->removeWidget(section); - contentLayout_->insertWidget(sectionIndex, section); + DesiredSection desiredSection{section, sectionIndex++, newSection}; + desiredSection.cardKeys.reserve(sectionData.cards.size()); + int cardIndex = 0; + for (const VisibleCardData &cardData : sectionData.cards) { + desiredSection.cardKeys.push_back(stableKey(cardData.key)); + const std::string &key = desiredSection.cardKeys.back(); + displayedKeys.push_back(key); + desiredCards.emplace(key, DesiredCard{section, cardIndex++, &cardData}); + } + desiredSections.emplace(sectionData.key, std::move(desiredSection)); + } + + for (std::size_t offset = displayedSectionKeys_.size(); offset > 0; + --offset) { + const std::size_t index = offset - 1; + const std::string &key = displayedSectionKeys_[index]; + const auto desired = desiredSections.find(key); + if (desired != desiredSections.end() && + desired->second.position == static_cast(index)) + continue; + delete contentLayout_->takeAt(1 + static_cast(index)); + if (desired != desiredSections.end()) + desired->second.insert = true; + visualChange = true; + } + + for (const auto &[sectionKey, section] : sections_) { + static_cast(sectionKey); + for (std::size_t offset = section->cardKeys.size(); offset > 0; --offset) { + const std::size_t index = offset - 1; + const std::string &key = section->cardKeys[index]; + const auto desired = desiredCards.find(key); + const auto card = cards_.find(key); + if (desired != desiredCards.end() && card != cards_.end() && + desired->second.section == section && + desired->second.position == static_cast(index) && + card->second->cardKind() == desired->second.data->kind) + continue; + delete section->cards->takeAt(static_cast(index)); + if (desired != desiredCards.end()) + desired->second.insert = true; visualChange = true; } - ++sectionIndex; + } + for (auto iterator = cards_.begin(); iterator != cards_.end();) { + const auto desired = desiredCards.find(iterator->first); + if (desired != desiredCards.end() && + iterator->second->cardKind() == desired->second.data->kind) { + ++iterator; + continue; + } + retainCommandOutputState(iterator->first, iterator->second); + delete iterator->second; + iterator = cards_.erase(iterator); + visualChange = true; + } + + for (const TurnSection §ionData : snapshot.sections) { + DesiredSection &desiredSection = desiredSections.at(sectionData.key); + TurnSectionWidget *section = desiredSection.widget; int cardIndex = 0; for (const VisibleCardData &cardData : sectionData.cards) { - const std::string key = stableKey(cardData.key); - wantedCards.insert(key); - displayedKeys.push_back(key); + const std::string &key = + desiredSection.cardKeys[static_cast(cardIndex)]; + DesiredCard &desired = desiredCards.at(key); ConversationCard *card = nullptr; const auto existingCard = cards_.find(key); - if (existingCard != cards_.end() && - existingCard->second->cardKind() == cardData.kind) { + if (existingCard != cards_.end()) { card = existingCard->second; visualChange = card->apply(cardData) || visualChange; } else { - if (existingCard != cards_.end()) { - retainCommandOutputState(key, existingCard->second); - delete existingCard->second; - cards_.erase(existingCard); - } card = createConversationCard(cardData, section); card->setProperty("conversationAnchorKey", QString::fromStdString(key)); if (const auto collapsed = cardCollapsedStates_.find(key); @@ -323,38 +384,27 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { commandOutputStates_.erase(saved); } cards_.emplace(key, card); + desired.insert = true; visualChange = true; } if (card->parentWidget() != section) { - if (QWidget *oldParent = card->parentWidget(); - oldParent && oldParent->layout()) - oldParent->layout()->removeWidget(card); card->setParent(section); + desired.insert = true; visualChange = true; } - if (section->cards->indexOf(card) != cardIndex) { - section->cards->removeWidget(card); + if (desired.insert) section->cards->insertWidget(cardIndex, card); - visualChange = true; - } ++cardIndex; } + section->cardKeys = std::move(desiredSection.cardKeys); section->setVisible(!sectionData.cards.empty()); + if (desiredSection.insert) + contentLayout_->insertWidget(1 + desiredSection.position, section); } - for (auto iterator = cards_.begin(); iterator != cards_.end();) { - if (wantedCards.contains(iterator->first)) { - ++iterator; - continue; - } - retainCommandOutputState(iterator->first, iterator->second); - delete iterator->second; - iterator = cards_.erase(iterator); - visualChange = true; - } for (auto iterator = sections_.begin(); iterator != sections_.end();) { - if (wantedSections.contains(iterator->first)) { + if (desiredSections.contains(iterator->first)) { ++iterator; continue; } @@ -362,6 +412,7 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { iterator = sections_.erase(iterator); visualChange = true; } + displayedSectionKeys_ = std::move(desiredSectionKeys); const bool empty = displayedKeys.empty(); if (empty_->isVisible() != empty) { @@ -372,13 +423,6 @@ 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); @@ -417,7 +461,6 @@ void ConversationView::setCardCollapsed(const std::string &key, return; const int titleTop = card->mapTo(viewport(), QPoint{}).y(); - const int naturalHeightBefore = naturalContentHeight_; stopFollowingAnimation(); applying_ = true; viewport()->setUpdatesEnabled(false); @@ -429,21 +472,14 @@ void ConversationView::setCardCollapsed(const std::string &key, 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); + const int visibleHeight = + std::max(0, viewport()->height() - trailingSpaceHeight_); + const int visibleTop = + collapsed + ? titleTop + : std::clamp(titleTop, 0, + std::max(0, visibleHeight - card->height())); + setScrollValue(card->mapTo(content_, QPoint{}).y() - visibleTop); applying_ = false; content_->setUpdatesEnabled(true); @@ -675,11 +711,10 @@ void ConversationView::recomputeGeometry() { : contentLayout_->sizeHint().height(); wanted = std::max(wanted, contentLayout_->minimumSize().height()); naturalContentHeight_ = wanted; - const int tailHeight = trailingSpaceHeight_ + foldBottomCompensation_; - trailingSpace_->changeSize(0, tailHeight, QSizePolicy::Minimum, + trailingSpace_->changeSize(0, trailingSpaceHeight_, QSizePolicy::Minimum, QSizePolicy::Fixed); contentLayout_->invalidate(); - wanted += tailHeight; + 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 7efe78b..227d75f 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -110,6 +110,7 @@ class ConversationView final : public QAbstractScrollArea { std::string threadId_; std::unordered_map sections_; std::unordered_map cards_; + std::vector displayedSectionKeys_; std::vector displayedCardKeys_; std::unordered_map threadStates_; std::unordered_map @@ -118,7 +119,6 @@ class ConversationView final : public QAbstractScrollArea { Mode mode_ = Mode::Following; int trailingSpaceHeight_ = 0; - int foldBottomCompensation_ = 0; int naturalContentHeight_ = 0; int contentHeight_ = 0; QString emptyMessage_; diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index 23c7808..2ef7ffe 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -360,14 +360,13 @@ void ThreadPane::refresh(const PresentationModel &model, currentModel = &model; projectedSelectedThreadId = selectedThreadId; const std::vector &authoritativeOrder = model.threadOrder(); + const std::unordered_set authoritativeIds( + authoritativeOrder.begin(), authoritativeOrder.end()); std::erase_if(retainedVisibleThreads, [&](const std::string &id) { - return !model.thread(id) || - std::find(authoritativeOrder.begin(), authoritativeOrder.end(), - id) != authoritativeOrder.end(); + return !model.thread(id) || authoritativeIds.contains(id); }); if (!selectedThreadId.empty() && model.thread(selectedThreadId) && - std::find(authoritativeOrder.begin(), authoritativeOrder.end(), - selectedThreadId) == authoritativeOrder.end() && + !authoritativeIds.contains(selectedThreadId) && std::find(retainedVisibleThreads.begin(), retainedVisibleThreads.end(), selectedThreadId) == retainedVisibleThreads.end()) { retainedVisibleThreads.insert(retainedVisibleThreads.begin(), @@ -377,6 +376,19 @@ void ThreadPane::refresh(const PresentationModel &model, visibleOrder.insert(visibleOrder.end(), authoritativeOrder.begin(), authoritativeOrder.end()); sortVisibleThreads(visibleOrder, model); + + std::unordered_map pendingByThread; + pendingByThread.reserve(model.pendingRequestCount()); + for (const auto &[requestId, request] : + model.pendingRequestPresentations()) { + static_cast(requestId); + ++pendingByThread[request.threadId]; + } + const auto pendingCount = [&pendingByThread](const std::string &id) { + const auto found = pendingByThread.find(id); + return found == pendingByThread.end() ? std::size_t{} : found->second; + }; + nlohmann::json visible = nlohmann::json::array(); for (const std::string &id : visibleOrder) { const ThreadPresentation *thread = model.thread(id); @@ -386,7 +398,7 @@ void ThreadPane::refresh(const PresentationModel &model, {"title", thread->title}, {"cwd", thread->cwd}, {"status", thread->status}, - {"pending", model.pendingRequestCount(id)}}); + {"pending", pendingCount(id)}}); } const std::string serialized = nlohmann::json{ {"selected", selectedThreadId}, @@ -404,51 +416,65 @@ void ThreadPane::refresh(const PresentationModel &model, // existing row. list->clearSelection(); list->setCurrentRow(-1); - std::unordered_set retained; - int wantedIndex = 0; + + const std::unordered_set wanted(visibleOrder.begin(), + visibleOrder.end()); + for (int index = list->count() - 1; index >= 0; --index) { + QListWidgetItem *item = list->item(index); + const std::string id = + item->data(Qt::UserRole).toString().toStdString(); + if (wanted.contains(id)) + continue; + rows.erase(id); + delete list->takeItem(index); + } + + std::unordered_map existingPositions; + existingPositions.reserve(rows.size()); + int existingIndex = 0; for (const std::string &id : visibleOrder) { - const ThreadPresentation *thread = model.thread(id); - if (!thread) + if (rows.contains(id)) + existingPositions.emplace(id, existingIndex++); + } + + std::unordered_set moved; + moved.reserve(rows.size()); + for (int index = list->count() - 1; index >= 0; --index) { + QListWidgetItem *item = list->item(index); + const std::string id = + item->data(Qt::UserRole).toString().toStdString(); + if (existingPositions.at(id) == index) continue; - retained.insert(id); - QListWidgetItem *item = nullptr; - const auto found = rows.find(id); + // Removing an index widget transfers it into Qt's deferred-deletion path. + // A changed row receives a fresh widget when its item is reinserted. + list->removeItemWidget(item); + list->takeItem(index); + moved.insert(id); + } + int wantedIndex = 0; + for (const std::string &id : visibleOrder) { + auto found = rows.find(id); if (found == rows.end()) { - item = new QListWidgetItem; + auto *item = new QListWidgetItem; item->setSizeHint(QSize(0, 54)); item->setData(Qt::UserRole, text(id)); list->insertItem(wantedIndex, item); list->setItemWidget(item, createRow()); - rows[id] = item; - } else { - item = found->second; - const int currentIndex = list->row(item); - if (currentIndex != wantedIndex) { - // Removing an index widget transfers it into Qt's deferred-deletion - // path. It must never be attached again after moving the item. - list->removeItemWidget(item); - item = list->takeItem(currentIndex); - list->insertItem(wantedIndex, item); - list->setItemWidget(item, createRow()); - rows[id] = item; - } + found = rows.emplace(id, item).first; + } else if (moved.contains(id)) { + list->insertItem(wantedIndex, found->second); + list->setItemWidget(found->second, createRow()); } + QListWidgetItem *item = found->second; + const ThreadPresentation *thread = model.thread(id); item->setToolTip(text(thread->cwd)); - updateRow(list->itemWidget(item), *thread, model.pendingRequestCount(id)); + updateRow(list->itemWidget(item), *thread, pendingCount(id)); if (id == contextThreadId) setContextHighlight(id, true); if (id == selectedThreadId) list->setCurrentItem(item); ++wantedIndex; } - for (auto it = rows.begin(); it != rows.end();) { - if (retained.contains(it->first)) { - ++it; - continue; - } - delete list->takeItem(list->row(it->second)); - it = rows.erase(it); - } list->setUpdatesEnabled(true); list->blockSignals(false); } diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index ee46c1c..0abfff0 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -729,10 +729,19 @@ bool testThreadRowReorderOwnership() { spin(); result &= expect(!threadB->data(Qt::UserRole + 1).toBool(), "closing row actions clears the native context hover"); + QPointer stableThreadARow = list->itemWidget(threadA); QPointer originalRow = list->itemWidget(threadB); + model.applyEvent(presentation::event( + 3, 1, "thread.status.changed", {{"status", "completed"}}, + presentation::Authority::Merge, {{"threadId", "thread-b"}})); + pane.refresh(model, "thread-a"); + result &= expect(stableThreadARow == list->itemWidget(threadA) && + originalRow == list->itemWidget(threadB), + "content-only refreshes preserve thread row widgets"); + model.applyEvent(presentation::result( - 3, 1, "threads.list", "reordered-threads", true, + 4, 1, "threads.list", "reordered-threads", true, {{"threads", nlohmann::json::array({{{"id", "thread-a"}, {"name", "Z"}}, {{"id", "thread-b"}, {"name", "B"}}})}}, diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 526b777..c2163c2 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -117,6 +117,25 @@ ConversationCard *card(ConversationView &view, const std::string &key) { return nullptr; } +std::vector visualCardKeys(ConversationView &view) { + std::vector cards; + for (QWidget *widget : view.findChildren()) + if (auto *candidate = dynamic_cast(widget)) + cards.push_back(candidate); + std::ranges::sort(cards, [&view](QWidget *left, QWidget *right) { + return left->mapTo(view.viewport(), QPoint{}).y() < + right->mapTo(view.viewport(), QPoint{}).y(); + }); + + std::vector keys; + keys.reserve(cards.size()); + for (ConversationCard *candidate : cards) + keys.push_back(candidate->property("conversationAnchorKey") + .toString() + .toStdString()); + return keys; +} + QToolButton *disclosure(ConversationCard *card) { return card ? card->findChild( QStringLiteral("cardDisclosureButton")) @@ -189,6 +208,40 @@ void mouseWheelNotch(ConversationView &view, int angleDelta) { spin(); } +bool testStructuralOrderAndIdentity() { + ConversationView view; + view.resize(620, 420); + view.show(); + ConversationSnapshot snapshot = conversation("structural-order", 8); + view.reconcile(snapshot); + spin(); + + std::unordered_map identities; + for (const TurnSection §ion : snapshot.sections) + for (const VisibleCardData &value : section.cards) + identities.emplace(stableKey(value.key), card(view, stableKey(value.key))); + + for (TurnSection §ion : snapshot.sections) + std::ranges::reverse(section.cards); + std::ranges::reverse(snapshot.sections); + std::vector expectedKeys; + for (const TurnSection §ion : snapshot.sections) + for (const VisibleCardData &value : section.cards) + expectedKeys.push_back(stableKey(value.key)); + + bool result = expect(view.reconcile(snapshot), + "structural order changes reconcile"); + spin(); + result &= expect(visualCardKeys(view) == expectedKeys, + "section and card order follows the projection exactly"); + bool retainedIdentity = true; + for (const auto &[key, identity] : identities) + retainedIdentity = retainedIdentity && card(view, key) == identity; + result &= expect(retainedIdentity, + "structural moves preserve same-kind card identity"); + return result; +} + bool testFollowPauseAndStableAnchor() { ConversationView view; view.resize(620, 340); @@ -915,24 +968,41 @@ bool testCardFoldingGeometryAndRetention() { 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"); + result &= expect( + edgeCard && edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() < + collapsedTop && + edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() + + edgeCard->height() <= + edgeView.viewport()->height(), + "bottom-edge expansion shifts upward to reveal the complete card"); wheel(edgeView, -10000); const int followedTitleTop = edgeCard ? edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() : 0; + const int expandedScrollMaximum = edgeView.verticalScrollBar()->maximum(); 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"); + result &= expect( + edgeCard && edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() > + followedTitleTop && + edgeView.verticalScrollBar()->maximum() < expandedScrollMaximum && + edgeView.isAtBottom() && + edgeView.mode() == ConversationView::Mode::Paused, + "bottom-edge collapse accepts the natural range without a blank tail"); + constexpr int ComposerOverlayHeight = 80; + edgeView.setTrailingSpaceHeight(ComposerOverlayHeight); + result &= expect(setFolded(edgeCard, false), + "bottom-edge command expands again"); + spin(120); + result &= expect( + edgeView.verticalScrollBar()->maximum() == + expandedScrollMaximum + ComposerOverlayHeight && + edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() + + edgeCard->height() <= + edgeView.viewport()->height() - ComposerOverlayHeight, + "fold round trip reveals the complete card above a grown composer"); return result; } @@ -1346,7 +1416,8 @@ bool testGeneratedImagePresentationAndGenericBound() { int main(int argc, char **argv) { QApplication application(argc, argv); using namespace codexui::codex::middle; - bool result = testFollowPauseAndStableAnchor(); + bool result = testStructuralOrderAndIdentity(); + result &= testFollowPauseAndStableAnchor(); result &= testThreadLocalScrollAndComposerExtent(); result &= testPromptAdmissionFollowOwnership(); result &= testMutableCardsAndCommandOutput(); diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index 908743b..d20c6dd 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -322,6 +322,28 @@ int main() { std::vector{"lib/example.cpp", "removed.txt"}, "authoritative thread hydration retains compact repository hints"); + PresentationModel orderingModel; + orderingModel.applyEvent(codexui::codex::presentation::event( + 1, 1, "thread.upsert", {{"thread", {{"id", "retained-a"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "retained-a"}})); + orderingModel.applyEvent(codexui::codex::presentation::event( + 2, 1, "thread.upsert", {{"thread", {{"id", "retained-b"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "retained-b"}})); + orderingModel.applyEvent(codexui::codex::presentation::result( + 3, 1, "threads.list", "ordered-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "provider-a"}}, + {{"id", "provider-b"}}, + {{"id", "provider-a"}}})}}, + codexui::codex::presentation::Authority::Merge)); + passed &= expect( + orderingModel.threadOrder() == + std::vector{"provider-a", "provider-b", "retained-b", + "retained-a"}, + "thread discovery preserves provider order and one retained tail"); + normalizer.bridgeEvent({{"kind", "bridge.provider"}, {"state", "disconnected"}, {"providerGeneration", std::uint64_t{1}},