From 4da97258f186d8ab9878e9fa5b3fa4fb4ef5b876 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 19:19:02 +0200 Subject: [PATCH 1/6] Retain prompt cards through acknowledgement --- src/codex/middle/ConversationCards.cpp | 39 +++++++++++++++-- src/codex/middle/ConversationCards.h | 8 ++-- src/codex/middle/ConversationProjection.cpp | 3 +- src/codex/middle/ConversationView.cpp | 4 +- src/codex/middle/PromptCoordinator.cpp | 46 ++++++++++++++++++++- src/codex/middle/PromptCoordinator.h | 8 +++- tests/codex/ConversationCardsTest.cpp | 19 +++++++-- tests/codex/ConversationProjectionTest.cpp | 24 +++++++---- 8 files changed, 129 insertions(+), 22 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index d3b00d4..2af0d25 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -666,13 +666,25 @@ class ConversationCard::Impl final { refreshFoldPresentation(); } + [[nodiscard]] bool canApply(const VisibleCardData &next) const noexcept { + return current.key == next.key && (current.kind == next.kind || + (current.kind == CardKind::LocalPrompt && + next.kind == CardKind::UserMessage)); + } + bool apply(const VisibleCardData &next) { - if (current.key != next.key || current.kind != next.kind) { + if (!canApply(next)) { Q_ASSERT_X(false, "ConversationCard::apply", - "a persistent conversation card cannot change key or kind"); + "a persistent conversation card received an incompatible " + "key or kind"); return false; } - const bool presentationChanged = !presentationEquals(current, next); + const bool becomingAuthoritative = current.kind == CardKind::LocalPrompt && + next.kind == CardKind::UserMessage; + const bool presentationChanged = + becomingAuthoritative || !presentationEquals(current, next); + if (becomingAuthoritative) + promoteToAuthoritativeUserMessage(); current = next; if (!presentationChanged) return false; @@ -683,6 +695,23 @@ class ConversationCard::Impl final { return true; } + void promoteToAuthoritativeUserMessage() { + if (animationTimer) + animationTimer->stop(); + owner->setObjectName(QStringLiteral("conversationCard")); + owner->setProperty("conversationCardKind", + static_cast(CardKind::UserMessage)); + owner->setProperty("messageRole", "user"); + owner->setStyleSheet(QString{}); + for (QLabel *label : {title, body, metadata}) + if (label) + label->setStyleSheet(QString{}); + if (metadata) { + metadata->clear(); + metadata->hide(); + } + } + void setCollapsed(bool next) { if (collapsed == next) return; @@ -1022,6 +1051,10 @@ bool ConversationCard::apply(const VisibleCardData &data) { return impl_->apply(data); } +bool ConversationCard::canApply(const VisibleCardData &data) const noexcept { + return impl_->canApply(data); +} + void ConversationCard::paintEvent(QPaintEvent *event) { QFrame::paintEvent(event); if (impl_->current.kind != CardKind::LocalPrompt) diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index a876d86..1cfe51b 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -87,9 +87,11 @@ class ConversationCard : public QFrame { void restoreCommandOutputScrollState(const CommandOutputView::ScrollState &state); - // A key and kind identify the persistent widget. apply() updates all card - // kinds in place and returns false when neither content nor presentation - // changed. Passing a different key or kind is a programming error. + [[nodiscard]] bool canApply(const VisibleCardData &data) const noexcept; + + // A key identifies the persistent widget. apply() updates matching card + // kinds in place and also performs the one supported semantic transition + // from an admitted local prompt to its authoritative user message. bool apply(const VisibleCardData &data); signals: diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp index d74e275..3781be4 100644 --- a/src/codex/middle/ConversationProjection.cpp +++ b/src/codex/middle/ConversationProjection.cpp @@ -321,7 +321,8 @@ ConversationSnapshot ConversationProjection::project( if (binding != bindings.end() && binding->second->localCardVisible(nowMilliseconds)) continue; - CardKey visualKey = item.key; + CardKey visualKey = + item.localPromptKey ? CardKey{*item.localPromptKey} : CardKey{item.key}; if (binding != bindings.end()) visualKey = LocalPromptKey{binding->second->id}; const std::size_t position = diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 067ab48..b89b2e1 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -330,7 +330,7 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { if (desired != desiredCards.end() && card != cards_.end() && desired->second.section == section && desired->second.position == static_cast(index) && - card->second->cardKind() == desired->second.data->kind) + card->second->canApply(*desired->second.data)) continue; delete section->cards->takeAt(static_cast(index)); if (desired != desiredCards.end()) @@ -342,7 +342,7 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { 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->second->canApply(*desired->second.data)) { ++iterator; continue; } diff --git a/src/codex/middle/PromptCoordinator.cpp b/src/codex/middle/PromptCoordinator.cpp index 3c308b2..3a36fef 100644 --- a/src/codex/middle/PromptCoordinator.cpp +++ b/src/codex/middle/PromptCoordinator.cpp @@ -239,9 +239,24 @@ bool PromptCoordinator::reassignThread(const std::string &fromThreadId, const std::string &toThreadId) { if (fromThreadId == toThreadId) return true; + const auto reassignAliases = [this, &fromThreadId, &toThreadId] { + auto aliases = visualAliasesByThread.find(fromThreadId); + if (aliases == visualAliasesByThread.end()) + return; + auto moved = std::move(aliases->second); + visualAliasesByThread.erase(aliases); + auto &target = visualAliasesByThread[toThreadId]; + for (const auto &[key, localKey] : moved) { + AuthoritativeItemKey reassigned = key; + reassigned.threadId = toThreadId; + target.insert_or_assign(std::move(reassigned), localKey); + } + }; auto source = byThread.find(fromThreadId); - if (source == byThread.end()) + if (source == byThread.end()) { + reassignAliases(); return true; + } auto destination = byThread.find(toThreadId); const bool sourceInFlight = std::any_of(source->second.begin(), source->second.end(), @@ -270,6 +285,7 @@ bool PromptCoordinator::reassignThread(const std::string &fromThreadId, target.insert(target.end(), std::make_move_iterator(moved.begin()), std::make_move_iterator(moved.end())); std::ranges::sort(target, {}, &PromptSubmission::admissionOrdinal); + reassignAliases(); return true; } @@ -284,10 +300,15 @@ void PromptCoordinator::reconcile(const std::string &threadId, void PromptCoordinator::reconcile(const std::string &threadId, AuthoritativeItemIndex &authoritativeItems, qint64 nowMilliseconds) { + applyVisualAliases(threadId, authoritativeItems); auto found = byThread.find(threadId); if (found == byThread.end()) return; std::vector claimed(authoritativeItems.ordered.size()); + for (std::size_t index = 0; index < authoritativeItems.ordered.size(); + ++index) + if (authoritativeItems.ordered[index].localPromptKey) + claimed[index] = true; for (const PromptSubmission &submission : found->second) if (submission.materializedItem) { const auto position = @@ -354,11 +375,20 @@ void PromptCoordinator::reconcile(const std::string &threadId, claimed[index] = true; authoritativeItems.userMessagesByText.erase(candidate); } + for (const PromptSubmission &submission : found->second) { + if (submission.state != PromptState::Accepted || + !submission.materializedItem || + submission.acceptedTransitionActive(nowMilliseconds)) + continue; + visualAliasesByThread[threadId].insert_or_assign( + *submission.materializedItem, LocalPromptKey{submission.id}); + } std::erase_if(found->second, [nowMilliseconds](const auto &submission) { return submission.state == PromptState::Accepted && submission.materializedItem && !submission.acceptedTransitionActive(nowMilliseconds); }); + applyVisualAliases(threadId, authoritativeItems); } std::span @@ -402,6 +432,20 @@ std::vector PromptCoordinator::queuedThreadIds() const { void PromptCoordinator::clearThread(const std::string &threadId) { byThread.erase(threadId); + visualAliasesByThread.erase(threadId); +} + +void PromptCoordinator::applyVisualAliases( + const std::string &threadId, + AuthoritativeItemIndex &authoritativeItems) const { + const auto aliases = visualAliasesByThread.find(threadId); + if (aliases == visualAliasesByThread.end()) + return; + for (const auto &[key, localKey] : aliases->second) { + const auto position = authoritativeItems.position(key); + if (position) + authoritativeItems.ordered[*position].localPromptKey = localKey; + } } PromptSubmission *PromptCoordinator::find(const std::string &threadId, diff --git a/src/codex/middle/PromptCoordinator.h b/src/codex/middle/PromptCoordinator.h index dbbd1c6..8891489 100644 --- a/src/codex/middle/PromptCoordinator.h +++ b/src/codex/middle/PromptCoordinator.h @@ -61,6 +61,7 @@ struct PromptDispatch { struct AuthoritativeItem { AuthoritativeItemKey key; const ItemPresentation *presentation = nullptr; + std::optional localPromptKey; }; struct AuthoritativeItemIndex { @@ -114,7 +115,8 @@ class PromptCoordinator final { // Correlates prompts with authoritative userMessage items. Exact client ids // may bind before acknowledgement so the awaiting card is never duplicated; // the content fallback is used only after the real operation callback. Fully - // resolved submissions are removed after their accepted transition. + // resolved submissions are removed after their accepted transition while a + // compact visual-key alias retains the admitted card identity. void reconcile(const std::string &threadId, const ThreadPresentation &authoritativeThread, qint64 nowMilliseconds); @@ -135,8 +137,12 @@ class PromptCoordinator final { private: [[nodiscard]] PromptSubmission *find(const std::string &threadId, std::uint64_t submissionId) noexcept; + void applyVisualAliases(const std::string &threadId, + AuthoritativeItemIndex &authoritativeItems) const; std::map> byThread; + std::map> + visualAliasesByThread; std::uint64_t nextSubmissionId = 1; std::uint64_t nextAdmissionOrdinal = 1; }; diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index c2163c2..7cd02bd 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -929,6 +930,7 @@ bool testCardFoldingGeometryAndRetention() { result &= expect(promptCard && !promptCard->isCollapsed() && setFolded(promptCard, true), "temporary You prompts start expanded and can be folded"); + ConversationCard *const admittedPromptCard = promptCard; promptSnapshot.sections.front().cards.front() = { promptKey, CardKind::UserMessage, promptThread, "turn", @@ -936,10 +938,19 @@ bool testCardFoldingGeometryAndRetention() { view.reconcile(promptSnapshot); spin(); promptCard = card(view, stableKey(promptKey)); - result &= - expect(promptCard && promptCard->cardKind() == CardKind::UserMessage && - promptCard->isCollapsed(), - "fold state survives authoritative prompt replacement"); + auto *promptAnimation = admittedPromptCard->findChild( + QString{}, Qt::FindDirectChildrenOnly); + result &= expect( + promptCard && promptCard == admittedPromptCard && + promptCard->cardKind() == CardKind::UserMessage && + promptCard->isCollapsed() && promptAnimation && + !promptAnimation->isActive() && + promptCard->property("messageRole") == QStringLiteral("user") && + promptCard->property("conversationCardKind").toInt() == + static_cast(CardKind::UserMessage) && + promptCard->objectName() == QStringLiteral("conversationCard") && + promptCard->styleSheet().isEmpty(), + "acknowledgement morphs the retained You card in place"); const std::string edgeThread = "folding-bottom-edge"; ConversationSnapshot edge = conversation(edgeThread, 12); diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp index abf7c55..6cf5aa1 100644 --- a/tests/codex/ConversationProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -171,17 +171,27 @@ bool testQueueIsolationAndRealAcknowledgement() { "the authoritative user item assumes the local stable key"); result &= expect(stableKey(local->key) == stableKey(authoritative->key), "materialization does not change the visual identity"); - prompts.reconcile(first.id, first, 700); + auto compactedItems = indexAuthoritativeItems(first.id, &first); + prompts.reconcile(first.id, compactedItems, 700); accepted = prompts.submission(first.id, firstId); const ConversationSnapshot compacted = ConversationProjection::project( - first, prompts.submissions(first.id), 80, 701); + compactedItems, &first, prompts.submissions(first.id), 80, 701); result &= expect( !accepted && prompts.submissions(first.id).empty() && - compacted.find( - AuthoritativeItemKey{first.id, "turn-2", "user-new"}) && - compacted.find(AuthoritativeItemKey{first.id, "turn-2", "user-new"}) - ->kind == CardKind::UserMessage, - "fully resolved submissions leave only authoritative presentation"); + compacted.find(LocalPromptKey{firstId}) && + compacted.find(LocalPromptKey{firstId})->kind == + CardKind::UserMessage && + !compacted.find(AuthoritativeItemKey{first.id, "turn-2", "user-new"}), + "submission cleanup retains the compact local visual identity alias"); + auto retainedAliasItems = indexAuthoritativeItems(first.id, &first); + prompts.reconcile(first.id, retainedAliasItems, 701); + const ConversationSnapshot retainedAlias = ConversationProjection::project( + retainedAliasItems, &first, prompts.submissions(first.id), 80, 702); + result &= + expect(retainedAlias.find(LocalPromptKey{firstId}) && + retainedAlias.find(LocalPromptKey{firstId})->kind == + CardKind::UserMessage, + "a later projection reapplies the retained visual identity alias"); return result; } From 631af0c5c5530c01367ea837e1cc8186c63efa2f Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 18:58:17 +0200 Subject: [PATCH 2/6] Keep agent metadata visible --- src/codex/middle/InspectorPane.cpp | 16 ++++--- tests/codex/ApplicationLayoutTest.cpp | 62 ++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index 2e69637..647aff6 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -199,12 +199,16 @@ QFrame *InspectorPane::agentFrame(const AgentSnapshot &agent) { 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")); + auto *status = statusLabel(agent.status); + status->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + metadataRow->addWidget(status); + if (!metadata.isEmpty()) { + auto *details = makeLabel( + QStringLiteral("| ") + metadata.join(QStringLiteral(" | ")), + "meta"); + details->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + metadataRow->addWidget(details); + } metadataRow->addStretch(); layout->addLayout(metadataRow); if (!agent.prompt.empty()) diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index b76589c..85452b7 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -934,8 +934,20 @@ bool testInspectorDetailParity() { {"status", "inProgress"}, {"agentThreadId", "child-thread"}, {"resultText", - "Agent result summary.\n\n* First rendered finding.\n* Second " - "rendered finding."}, + "No blocking Inspector findings.\n\n" + "* Typed snapshots cover all rendered Plan, Agent, and Request " + "fields, with default equality and optional first-render state.\n" + "* Rendering now uses those typed projections directly.\n" + "* Request thread titles participate in equality, so rename-only " + "changes invalidate correctly.\n" + "* optional size correctly distinguishes absent questions from a " + "visible zero questions.\n" + "* The added Application Layout regression is minimal and well " + "targeted: render the original title, rename without changing the " + "request, refresh, then require the new label and reject the old " + "one.\n" + "* Application Layout tests pass offscreen.\n\n" + "No files were edited."}, {"senderThreadId", "sender-thread"}, {"receiverThreadIds", nlohmann::json::array({"receiver-one", "receiver-two"})}}}}, @@ -944,7 +956,25 @@ bool testInspectorDetailParity() { {"turnId", "turn-one"}, {"itemId", "agent-one"}})); model.applyEvent(presentation::event( - 3, 1, "pending-request.upsert", + 3, 1, "agents.activity.upsert", + {{"activity", + {{"id", "agent-two"}, + {"type", "subAgentActivity"}, + {"status", "completed"}, + {"agentThreadId", "child-thread-two"}, + {"resultText", + "No blocking Git snapshot issues found.\n\n" + "* Add defaulted equality to the file and snapshot records.\n" + "* Replace both retained snapshot hashes with optional typed " + "snapshots so an initial empty result still renders.\n" + "* Preserve the current snapshot fallback and repository context " + "behavior."}}}}, + presentation::Authority::Merge, + {{"threadId", "owner-thread"}, + {"turnId", "turn-one"}, + {"itemId", "agent-two"}})); + model.applyEvent(presentation::event( + 4, 1, "pending-request.upsert", {{"requestId", "request-one"}, {"category", "userInput"}, {"request", @@ -976,9 +1006,29 @@ bool testInspectorDetailParity() { "running agent status uses the canonical active tone"); auto *agentResult = inspector.findChild(QStringLiteral("agentResult")); + auto *agentFrame = + agentResult ? qobject_cast(agentResult->parentWidget()) : nullptr; + const int statusBottom = + agentStatus && agentFrame + ? agentStatus->mapTo(agentFrame, QPoint()).y() + agentStatus->height() + : 0; + const int resultTop = agentResult && agentFrame + ? agentResult->mapTo(agentFrame, QPoint()).y() + : 0; + const int resultHeightForWidth = + agentResult ? agentResult->heightForWidth(agentResult->width()) : -1; + result &= expect(agentStatus && agentStatus->width() > 0 && + !agentStatus->visibleRegion().isEmpty(), + "agent status occupies its metadata row instead of " + "leaving an invisible gap"); result &= expect( - agentResult && agentResult->alignment().testFlag(Qt::AlignTop), - "agent Markdown starts at the top of any surplus result-label height"); + agentFrame && agentFrame->layout() && agentResult && + resultTop - statusBottom <= agentFrame->layout()->spacing() && + agentResult->alignment().testFlag(Qt::AlignTop) && + resultHeightForWidth >= 0 && + agentResult->height() >= resultHeightForWidth - 1 && + agentResult->height() <= resultHeightForWidth + 1, + "long agent Markdown follows visible metadata without surplus height"); inspector.tabs()->setCurrentIndex(3); spin(20); result &= expect( @@ -986,7 +1036,7 @@ bool testInspectorDetailParity() { hasLabelContaining(inspector, QStringLiteral("3 questions")), "Requests show their thread title and retained question count"); model.applyEvent(presentation::event( - 4, 1, "thread.name.changed", {{"name", "Renamed title"}}, + 5, 1, "thread.name.changed", {{"name", "Renamed title"}}, presentation::Authority::Replace, {{"threadId", "owner-thread"}})); inspector.refresh(model, "owner-thread"); spin(20); From 0da1ee363c920f7db38defae1a44f2b78291225e Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 19:54:07 +0200 Subject: [PATCH 3/6] Settle card geometry before first paint --- src/codex/middle/ConversationCards.cpp | 5 +- src/codex/middle/ConversationView.cpp | 4 +- tests/codex/ConversationCardsTest.cpp | 234 ++++++++++++++++++++++++- 3 files changed, 238 insertions(+), 5 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 2af0d25..f4da016 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -808,8 +808,9 @@ class ConversationCard::Impl final { break; case CardKind::LocalPrompt: owner->setObjectName(QStringLiteral("pendingPromptCard")); - owner->setStyleSheet(QStringLiteral( - "QFrame#pendingPromptCard{background:transparent;border:0;}")); + owner->setStyleSheet( + QStringLiteral("QFrame#pendingPromptCard{background:transparent;" + "border:1px solid transparent;border-radius:8px;}")); title->setText(QStringLiteral("You")); body = makeMarkdownLabel({}, content); metadata = makeLabel({}, "meta", content); diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index b89b2e1..1b28e00 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -393,8 +393,10 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { desired.insert = true; visualChange = true; } - if (desired.insert) + if (desired.insert) { section->cards->insertWidget(cardIndex, card); + card->show(); + } ++cardIndex; } section->cardKeys = std::move(desiredSection.cardKeys); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 7cd02bd..dbf624b 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -2,6 +2,7 @@ #include "codex/middle/ConversationCards.h" #include "codex/middle/ConversationView.h" +#include "codex/ui/UiStyle.h" #include #include @@ -92,6 +93,75 @@ VisibleCardData agentCard(const std::string &threadId, AgentMessageData{std::move(text), index % 3 == 0}}; } +VisibleCardData cardForAppearanceAudit(const std::string &threadId, + CardKind kind, int index) { + const std::string itemId = "appearance-" + std::to_string(index); + CardKey key = + kind == CardKind::LocalPrompt + ? CardKey{LocalPromptKey{9000U + static_cast(index)}} + : CardKey{AuthoritativeItemKey{threadId, "turn-2", itemId}}; + CardPayload payload = GenericActivityData{}; + switch (kind) { + case CardKind::UserMessage: + payload = UserMessageData{QStringLiteral("User appearance audit"), {}}; + break; + case CardKind::AgentMessage: + payload = AgentMessageData{QStringLiteral("Agent appearance audit"), true}; + break; + case CardKind::CommandExecution: + payload = CommandExecutionData{ + QStringLiteral("printf audit"), {}, QStringLiteral("inProgress"), + QStringLiteral("/workspace"), {}, {}}; + break; + case CardKind::AgentActivity: + payload = AgentActivityData{QStringLiteral("spawn_agent"), + QStringLiteral("inProgress"), + QStringLiteral("tool"), + QStringLiteral("Inspect appearance"), + {}, + {}, + {}, + {}, + {}, + {}, + {}}; + break; + case CardKind::Reasoning: + payload = ReasoningData{QStringLiteral("Initial reasoning summary")}; + break; + case CardKind::FileChanges: + payload = FileChangesData{ + QStringLiteral("inProgress"), + {{QStringLiteral("src/a.cpp"), QStringLiteral("update"), 1, 0}}}; + break; + case CardKind::ImageGeneration: + payload = ImageGenerationData{{}, + QStringLiteral("inProgress"), + QStringLiteral("Initial image prompt")}; + break; + case CardKind::Plan: + payload = + PlanData{QStringLiteral("Initial plan"), + {{QStringLiteral("Inspect"), QStringLiteral("inProgress")}}, + {}}; + break; + case CardKind::GenericActivity: + payload = GenericActivityData{ + QStringLiteral("unknownActivity"), + {{"type", "unknownActivity"}, {"status", "inProgress"}}}; + break; + case CardKind::LocalPrompt: + payload = LocalPromptData{9000U + static_cast(index), + QStringLiteral("Local prompt appearance audit"), + PromptState::InFlight, + 0, + {}, + {}}; + break; + } + return {std::move(key), kind, threadId, "turn-2", itemId, std::move(payload)}; +} + ConversationSnapshot conversation(const std::string &threadId, int count) { ConversationSnapshot result; result.threadId = threadId; @@ -191,6 +261,42 @@ std::pair firstVisible(ConversationView &view) { return {}; } +class PaintAnchorProbe final : public QObject { +public: + explicit PaintAnchorProbe(ConversationView &view) : view_(view) { + view_.viewport()->installEventFilter(this); + } + + ~PaintAnchorProbe() override { view_.viewport()->removeEventFilter(this); } + + void start(QWidget *tracked = nullptr) { + anchors.clear(); + trackedGeometries.clear(); + tracked_ = tracked; + active = true; + } + + std::vector> anchors; + std::vector trackedGeometries; + bool active = false; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override { + if (active && watched == view_.viewport() && + event->type() == QEvent::Paint) { + anchors.push_back(firstVisible(view_)); + if (tracked_) + trackedGeometries.emplace_back( + tracked_->mapTo(view_.viewport(), QPoint{}), tracked_->size()); + } + return false; + } + +private: + ConversationView &view_; + QWidget *tracked_ = nullptr; +}; + void wheel(ConversationView &view, int pixelDelta) { const QPointF local(view.viewport()->rect().center()); QWheelEvent event(local, view.viewport()->mapToGlobal(local.toPoint()), @@ -358,6 +464,112 @@ bool testFollowPauseAndStableAnchor() { return result; } +bool testPausedExpandedCommandStaysPainted() { + const QString originalStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); + const std::string thread = "paused-expanded-command"; + ConversationSnapshot snapshot = conversation(thread, 24); + QString output; + for (int line = 0; line < 48; ++line) + output += QStringLiteral("completed command output %1\n").arg(line); + VisibleCardData completedCommand{ + AuthoritativeItemKey{thread, "turn-2", "completed-command"}, + CardKind::CommandExecution, + thread, + "turn-2", + "completed-command", + CommandExecutionData{QStringLiteral("run completed command"), output, + QStringLiteral("completed"), + QStringLiteral("/workspace"), 0, 1250}}; + snapshot.sections.back().cards.push_back(completedCommand); + + ConversationView view; + view.resize(620, 420); + view.show(); + bool result = expect(view.reconcile(snapshot), + "expanded-command audit renders its conversation"); + spin(); + ConversationCard *const commandCard = + card(view, stableKey(completedCommand.key)); + result &= expect(setFolded(commandCard, false), + "completed command is expanded before incoming cards"); + auto *outputView = commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + if (outputView && outputView->verticalScrollBar()->maximum() > 0) { + outputView->verticalScrollBar()->setValue( + outputView->verticalScrollBar()->maximum() / 2); + spin(); + } + result &= expect(commandCard && outputView && + view.mode() == ConversationView::Mode::Paused, + "expanded completed command owns a paused viewport"); + + PaintAnchorProbe paintProbe(view); + const std::vector incomingKinds{ + CardKind::UserMessage, CardKind::AgentMessage, + CardKind::CommandExecution, CardKind::AgentActivity, + CardKind::Reasoning, CardKind::FileChanges, + CardKind::ImageGeneration, CardKind::Plan, + CardKind::GenericActivity, CardKind::LocalPrompt}; + const auto stableAgainst = [](const std::pair &reference, + const std::pair &candidate) { + return candidate.first == reference.first && + std::abs(candidate.second - reference.second) <= 1; + }; + for (std::size_t index = 0; index < incomingKinds.size(); ++index) { + const auto anchorBefore = firstVisible(view); + const QRect commandBefore(commandCard->mapTo(view.viewport(), QPoint{}), + commandCard->size()); + const auto outputStateBefore = commandCard->commandOutputScrollState(); + snapshot.sections.back().cards.push_back(cardForAppearanceAudit( + thread, incomingKinds[index], 100 + static_cast(index))); + paintProbe.start(commandCard); + const bool changed = view.reconcile(snapshot); + const auto immediateAnchor = firstVisible(view); + const QRect immediateCommand(commandCard->mapTo(view.viewport(), QPoint{}), + commandCard->size()); + const std::string incomingKey = + stableKey(snapshot.sections.back().cards.back().key); + ConversationCard *const incomingCard = card(view, incomingKey); + const int immediateIncomingHeight = + incomingCard ? incomingCard->height() : -1; + const int immediateRange = view.verticalScrollBar()->maximum(); + spin(80); + paintProbe.active = false; + const auto settledAnchor = firstVisible(view); + const QRect settledCommand(commandCard->mapTo(view.viewport(), QPoint{}), + commandCard->size()); + const int settledIncomingHeight = + incomingCard ? incomingCard->height() : -1; + const bool paintedAnchorStable = + std::ranges::all_of(paintProbe.anchors, [&](const auto &anchor) { + return stableAgainst(anchorBefore, anchor); + }); + const bool paintedStable = std::ranges::all_of( + paintProbe.trackedGeometries, [&commandBefore](const QRect &geometry) { + return geometry == commandBefore; + }); + result &= expect( + changed && card(view, stableKey(completedCommand.key)) == commandCard && + view.mode() == ConversationView::Mode::Paused && + stableAgainst(anchorBefore, immediateAnchor) && + stableAgainst(anchorBefore, settledAnchor) && + immediateCommand == commandBefore && + settledCommand == commandBefore && paintedAnchorStable && + paintedStable && incomingCard && + immediateIncomingHeight == settledIncomingHeight && + view.verticalScrollBar()->maximum() == immediateRange && + commandCard->commandOutputScrollState() == outputStateBefore, + "incoming card preserves a visible expanded command in every paint"); + } + + qApp->setStyleSheet(originalStyleSheet); + spin(); + return result; +} + bool testThreadLocalScrollAndComposerExtent() { ConversationView view; view.resize(620, 340); @@ -692,6 +904,8 @@ bool testMutableCardsAndCommandOutput() { } bool testCardFoldingGeometryAndRetention() { + const QString originalStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); const std::string thread = "folding-thread"; const VisibleCardData user{ AuthoritativeItemKey{thread, "turn", "user"}, @@ -931,6 +1145,16 @@ bool testCardFoldingGeometryAndRetention() { setFolded(promptCard, true), "temporary You prompts start expanded and can be folded"); ConversationCard *const admittedPromptCard = promptCard; + QWidget *const admittedPromptHeader = + admittedPromptCard ? admittedPromptCard->findChild( + QStringLiteral("conversationCardHeader")) + : nullptr; + const QSize admittedPromptSize = + admittedPromptCard ? admittedPromptCard->size() : QSize{}; + const QRect admittedPromptHeaderGeometry = + admittedPromptHeader ? admittedPromptHeader->geometry() : QRect{}; + const int admittedPromptFrameWidth = + admittedPromptCard ? admittedPromptCard->frameWidth() : -1; promptSnapshot.sections.front().cards.front() = { promptKey, CardKind::UserMessage, promptThread, "turn", @@ -949,8 +1173,11 @@ bool testCardFoldingGeometryAndRetention() { promptCard->property("conversationCardKind").toInt() == static_cast(CardKind::UserMessage) && promptCard->objectName() == QStringLiteral("conversationCard") && - promptCard->styleSheet().isEmpty(), - "acknowledgement morphs the retained You card in place"); + promptCard->styleSheet().isEmpty() && + promptCard->size() == admittedPromptSize && admittedPromptHeader && + admittedPromptHeader->geometry() == admittedPromptHeaderGeometry && + promptCard->frameWidth() == admittedPromptFrameWidth, + "acknowledgement morphs the retained You card without geometry drift"); const std::string edgeThread = "folding-bottom-edge"; ConversationSnapshot edge = conversation(edgeThread, 12); @@ -1014,6 +1241,8 @@ bool testCardFoldingGeometryAndRetention() { edgeCard->height() <= edgeView.viewport()->height() - ComposerOverlayHeight, "fold round trip reveals the complete card above a grown composer"); + qApp->setStyleSheet(originalStyleSheet); + spin(); return result; } @@ -1429,6 +1658,7 @@ int main(int argc, char **argv) { using namespace codexui::codex::middle; bool result = testStructuralOrderAndIdentity(); result &= testFollowPauseAndStableAnchor(); + result &= testPausedExpandedCommandStaysPainted(); result &= testThreadLocalScrollAndComposerExtent(); result &= testPromptAdmissionFollowOwnership(); result &= testMutableCardsAndCommandOutput(); From 189d8874876bd95d4aea3a59150b877689fe1fab Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 20:05:35 +0200 Subject: [PATCH 4/6] Label final Codex answers explicitly --- src/codex/middle/ConversationCards.cpp | 5 +++-- tests/codex/ConversationCardsTest.cpp | 20 ++++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index f4da016..d1e1ba2 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -861,8 +861,9 @@ class ConversationCard::Impl final { } case CardKind::AgentMessage: { const auto &message = std::get(data.payload); - title->setText(message.finalAnswer ? QStringLiteral("Codex") - : QStringLiteral("Codex activity")); + title->setText(message.finalAnswer + ? QStringLiteral("Codex * Final answer") + : QStringLiteral("Codex activity")); layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, message.finalAnswer ? 10 : 8); setVisibleMarkdown(body, message.text); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index dbf624b..965f245 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -752,6 +752,13 @@ bool testMutableCardsAndCommandOutput() { label->property("markdownSource").toString().contains(needle); }); }; + auto titleText = [](QWidget *parent) { + const auto labels = parent->findChildren(); + const auto title = std::ranges::find_if(labels, [](QLabel *label) { + return label->property("kind").toString() == QStringLiteral("title"); + }); + return title == labels.end() ? QString{} : (*title)->text(); + }; auto *commandCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "command"}})]; auto *output = dynamic_cast( @@ -762,6 +769,8 @@ bool testMutableCardsAndCommandOutput() { "empty-line command output has no black surface"); auto *userCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "user"}})]; + auto *agentCardWidget = identities[stableKey( + CardKey{AuthoritativeItemKey{thread, "turn", "agent"}})]; const auto userLabels = userCard->findChildren(); result &= expect(std::ranges::any_of( @@ -775,6 +784,9 @@ bool testMutableCardsAndCommandOutput() { label->text().contains(QStringLiteral("(cards[0].payload).text += QStringLiteral(" updated"); - std::get(cards[1].payload).text += - QStringLiteral(" updated"); + auto &agent = std::get(cards[1].payload); + agent.text += QStringLiteral(" updated"); + agent.finalAnswer = true; auto &command = std::get(cards[2].payload); command.output = QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible\n\n \t"); @@ -845,6 +858,9 @@ bool testMutableCardsAndCommandOutput() { result &= expect(card(view, stableKey(value.key)) == identities[stableKey(value.key)], "same-key same-kind card updates in place"); + result &= expect(titleText(agentCardWidget) == + QStringLiteral("Codex * Final answer"), + "final agent messages identify their answer phase plainly"); result &= expect(!output->isHidden() && output->minimumHeight() == 0 && output->maximumHeight() == 220 && From c93f16c3b7fcc74ac6d724d4ffb6ad5a079e83af Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 20:11:06 +0200 Subject: [PATCH 5/6] Refine Codex message phase titles --- src/codex/middle/ConversationCards.cpp | 4 ++-- tests/codex/ConversationCardsTest.cpp | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index d1e1ba2..51c2b99 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -862,8 +862,8 @@ class ConversationCard::Impl final { case CardKind::AgentMessage: { const auto &message = std::get(data.payload); title->setText(message.finalAnswer - ? QStringLiteral("Codex * Final answer") - : QStringLiteral("Codex activity")); + ? QStringLiteral("Codex • final answer") + : QStringLiteral("Codex • update")); layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, message.finalAnswer ? 10 : 8); setVisibleMarkdown(body, message.text); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 965f245..452473e 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -785,8 +785,8 @@ bool testMutableCardsAndCommandOutput() { }), "authoritative user messages render GitHub Markdown tables"); result &= - expect(titleText(agentCardWidget) == QStringLiteral("Codex activity"), - "interim agent messages retain their activity title"); + expect(titleText(agentCardWidget) == QStringLiteral("Codex • update"), + "interim agent messages identify their update phase plainly"); auto *filesCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "files"}})]; auto *planCard = identities[stableKey( @@ -859,7 +859,7 @@ bool testMutableCardsAndCommandOutput() { identities[stableKey(value.key)], "same-key same-kind card updates in place"); result &= expect(titleText(agentCardWidget) == - QStringLiteral("Codex * Final answer"), + QStringLiteral("Codex • final answer"), "final agent messages identify their answer phase plainly"); result &= expect(!output->isHidden() && output->minimumHeight() == 0 && From e4c0afbf340f819e8262cf852c7091ee76865d5f Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 27 Aug 2026 20:15:24 +0200 Subject: [PATCH 6/6] Color Codex message phases --- src/codex/middle/ConversationCards.cpp | 28 ++++++++++++++++++--- src/codex/ui/UiStyle.cpp | 1 + tests/codex/ConversationCardsTest.cpp | 35 +++++++++++++++++++++----- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 51c2b99..b7c1bc1 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -749,6 +749,22 @@ class ConversationCard::Impl final { break; case CardKind::AgentMessage: owner->setProperty("messageRole", "agent"); + title->setText(QStringLiteral("Codex")); + title->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + phaseSeparator = makeLabel(QStringLiteral("•"), "messagePhase", header); + phaseSeparator->setObjectName( + QStringLiteral("agentMessagePhaseSeparator")); + phaseSeparator->setWordWrap(false); + phaseSeparator->setSizePolicy(QSizePolicy::Fixed, + QSizePolicy::Preferred); + phase = makeLabel({}, "messagePhase", header); + phase->setObjectName(QStringLiteral("agentMessagePhase")); + phase->setWordWrap(false); + phase->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + headerLayout->setStretch(0, 0); + headerLayout->insertWidget(1, phaseSeparator, 0, Qt::AlignVCenter); + headerLayout->insertWidget(2, phase, 0, Qt::AlignVCenter); + headerLayout->insertStretch(3, 1); body = makeMarkdownLabel({}, content); contentLayout->addWidget(body); break; @@ -861,9 +877,13 @@ class ConversationCard::Impl final { } case CardKind::AgentMessage: { const auto &message = std::get(data.payload); - title->setText(message.finalAnswer - ? QStringLiteral("Codex • final answer") - : QStringLiteral("Codex • update")); + phase->setText(message.finalAnswer ? QStringLiteral("final answer") + : QStringLiteral("update")); + const QString phaseStatus = message.finalAnswer + ? QStringLiteral("completed") + : QStringLiteral("inProgress"); + setStatusTone(phaseSeparator, phaseStatus); + setStatusTone(phase, phaseStatus); layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, message.finalAnswer ? 10 : 8); setVisibleMarkdown(body, message.text); @@ -1004,6 +1024,8 @@ class ConversationCard::Impl final { QWidget *header = nullptr; QHBoxLayout *headerLayout = nullptr; QLabel *title = nullptr; + QLabel *phaseSeparator = nullptr; + QLabel *phase = nullptr; CardDisclosureButton *disclosure = nullptr; QWidget *content = nullptr; QVBoxLayout *contentLayout = nullptr; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index cebfa22..abe22ce 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -101,6 +101,7 @@ QString applicationStyleSheet() { QLabel[kind="applicationTitle"] { font-weight: 700; } QLabel[kind="brand"] { font-size: %3pt; font-weight: 600; } QLabel[kind="title"] { font-size: %2pt; font-weight: 600; } + QLabel[kind="messagePhase"] { font-size: %2pt; font-weight: 400; } QLabel[kind="body"] { font-size: %2pt; } QLabel[kind="meta"] { color: #667085; font-size: %1pt; } QLabel[kind="small"] { color: #667085; font-size: %1pt; } diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 452473e..5fc4c86 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -680,6 +681,8 @@ bool testPromptAdmissionFollowOwnership() { } bool testMutableCardsAndCommandOutput() { + const QString originalStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); const std::string thread = "card-thread"; TurnSection section{"turn:cards", "turn", {}}; section.cards = { @@ -771,6 +774,10 @@ bool testMutableCardsAndCommandOutput() { CardKey{AuthoritativeItemKey{thread, "turn", "user"}})]; auto *agentCardWidget = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "agent"}})]; + auto *agentPhaseSeparator = agentCardWidget->findChild( + QStringLiteral("agentMessagePhaseSeparator")); + auto *agentPhase = agentCardWidget->findChild( + QStringLiteral("agentMessagePhase")); const auto userLabels = userCard->findChildren(); result &= expect(std::ranges::any_of( @@ -784,9 +791,16 @@ bool testMutableCardsAndCommandOutput() { label->text().contains(QStringLiteral("text() == QStringLiteral("•") && agentPhase && + agentPhase->text() == QStringLiteral("update") && + agentPhase->property("tone").toString() == QStringLiteral("active") && + agentPhaseSeparator->property("tone").toString() == + QStringLiteral("active") && + agentPhase->font().weight() == QFont::Normal, + "interim agent messages show a normal-weight active update phase"); auto *filesCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "files"}})]; auto *planCard = identities[stableKey( @@ -858,9 +872,16 @@ bool testMutableCardsAndCommandOutput() { result &= expect(card(view, stableKey(value.key)) == identities[stableKey(value.key)], "same-key same-kind card updates in place"); - result &= expect(titleText(agentCardWidget) == - QStringLiteral("Codex • final answer"), - "final agent messages identify their answer phase plainly"); + result &= expect( + titleText(agentCardWidget) == QStringLiteral("Codex") && + agentPhaseSeparator && agentPhase && + agentPhase->text() == QStringLiteral("final answer") && + agentPhase->property("tone").toString() == + QStringLiteral("success") && + agentPhaseSeparator->property("tone").toString() == + QStringLiteral("success") && + agentPhase->font().weight() == QFont::Normal, + "final agent messages show a normal-weight success answer phase"); result &= expect(!output->isHidden() && output->minimumHeight() == 0 && output->maximumHeight() == 220 && @@ -916,6 +937,8 @@ bool testMutableCardsAndCommandOutput() { view.verticalScrollBar()->maximum() == hiddenOuterRange && commandCard->height() == hiddenCommandHeight, "hidden command output causes no delayed outer reflow"); + qApp->setStyleSheet(originalStyleSheet); + spin(); return result; }