diff --git a/src/ui/ConversationWidget.cpp b/src/ui/ConversationWidget.cpp index 700d5d8..2322397 100644 --- a/src/ui/ConversationWidget.cpp +++ b/src/ui/ConversationWidget.cpp @@ -17,8 +17,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -26,17 +26,19 @@ #include #include #include +#include #include #include #include #include #include #include -#include +#include #include -#include #include +#include +#include #include #include @@ -52,16 +54,27 @@ constexpr qsizetype maximumRenderedTimelineItems = 256; constexpr std::size_t maximumActivityItemsPerSegment = 16; constexpr qsizetype largeMessageEditorThreshold = 64 * 1024; constexpr int largeMessageEditorHeight = 240; -constexpr int streamingMarkdownRenderIntervalMs = 75; struct ActivityPresentation { QString title; QString detail; - QString output; QString status; QString tail; bool truncated = false; + std::optional detailChannel; + struct DeferredItemText + { + sdk::State state; + ai::openai::codex::typed::ItemId itemId; + ai::openai::codex::typed::ThreadId threadId; + ai::openai::codex::typed::TurnId turnId; + sdk::ItemContentChannel channel = sdk::ItemContentChannel::AgentText; + std::uint64_t contentRevision = 0; + std::uint64_t utf8Bytes = 0; + }; + std::optional deferredDetail; + std::optional deferredOutput; }; QString fromUtf8(std::string_view value) @@ -74,6 +87,86 @@ QString fromUtf8(const std::string& value) return QString::fromStdString(value); } +std::string_view itemContent(const sdk::ItemState& item, + sdk::ItemContentChannel channel) noexcept +{ + const std::optional* content = nullptr; + switch (channel) + { + case sdk::ItemContentChannel::AgentText: + content = &item.agentText; + break; + case sdk::ItemContentChannel::ReasoningText: + content = &item.reasoningText; + break; + case sdk::ItemContentChannel::ReasoningSummary: + content = &item.reasoningSummary; + break; + case sdk::ItemContentChannel::CommandOutput: + content = &item.commandOutput; + break; + } + return content && *content ? std::string_view(**content) : std::string_view{}; +} + +std::optional deferredItemText( + const sdk::State& state, + const ai::openai::codex::typed::ThreadId& threadId, + const ai::openai::codex::typed::TurnId& turnId, + const ai::openai::codex::typed::ItemId& itemId, + sdk::ItemContentChannel channel) +{ + const auto descriptor = state.itemContentDescriptor( + threadId, turnId, itemId, channel); + if (!descriptor || !descriptor->present + || descriptor->retainedUtf8Bytes == 0) + return std::nullopt; + return ActivityPresentation::DeferredItemText{ + state, + itemId, + threadId, + turnId, + channel, + descriptor->contentRevision, + descriptor->retainedUtf8Bytes}; +} + +std::optional deferredItemText( + const sdk::State& state, + const sdk::ItemState& item, + sdk::ItemContentChannel channel) +{ + const std::string_view content = itemContent(item, channel); + if (content.empty() || !item.threadId || !item.turnId) + return std::nullopt; + auto source = deferredItemText( + state, *item.threadId, *item.turnId, item.id, channel); + if (!source + || source->utf8Bytes != static_cast(content.size())) + return std::nullopt; + return source; +} + +bool sameDeferredItemText( + const ActivityPresentation::DeferredItemText& left, + const ActivityPresentation::DeferredItemText& right) noexcept +{ + return left.contentRevision == right.contentRevision + && left.itemId == right.itemId + && left.threadId == right.threadId + && left.turnId == right.turnId + && left.channel == right.channel + && left.utf8Bytes == right.utf8Bytes; +} + +QString materializeDeferredItemText( + const ActivityPresentation::DeferredItemText& source) +{ + const sdk::ItemState* item = source.state.item( + source.threadId, source.turnId, source.itemId); + return item ? fromUtf8(itemContent(*item, source.channel)) : QString{}; +} + QString humanize(QString value) { value.replace(QLatin1Char('_'), QLatin1Char(' ')); @@ -125,11 +218,8 @@ QLabel* textLabel(const QString& text, const char* kind = nullptr) class WrappingLabel final : public QLabel { public: - explicit WrappingLabel(const QString& text, - bool markdown = false, - std::function deferredRenderCompleted = {}) + explicit WrappingLabel(const QString& text, bool markdown = false) : markdown(markdown) - , deferredRenderCompleted(std::move(deferredRenderCompleted)) { setTextFormat(markdown ? Qt::RichText : Qt::PlainText); setWordWrap(true); @@ -145,44 +235,25 @@ class WrappingLabel final : public QLabel || url.scheme() == QStringLiteral("http")) (void)QDesktopServices::openUrl(url); }); - markdownRenderTimer.setSingleShot(true); - markdownRenderTimer.setInterval(streamingMarkdownRenderIntervalMs); - connect(&markdownRenderTimer, &QTimer::timeout, this, - [this] { renderMarkdownNow(true); }); } setContent(text); } - void setContent(const QString& text, bool coalesceMarkdownRender = false) + bool setContent(const QString& text) { - if (text == sourceText) { - if (markdown && !coalesceMarkdownRender && markdownRenderTimer.isActive()) - renderMarkdownNow(); - return; - } - const QString previousSource = sourceText; + if (text == sourceText) + return false; sourceText = text; setProperty("sourceText", sourceText); if (!markdown) { + const int previousHeight = preferredHeight(); heightCache.clear(); QLabel::setText(text); updateGeometry(); - return; + return previousHeight != preferredHeight(); } - const bool appendOnly = !previousSource.isEmpty() - && text.size() > previousSource.size() - && text.startsWith(previousSource); - if (coalesceMarkdownRender && appendOnly && !renderedSourceText.isEmpty()) { - // Streaming deltas update the authoritative source immediately, but - // coalesce the expensive full Markdown parse at a bounded cadence. - // Do not restart an active timer: this is throttling, not an - // indefinitely postponable debounce. - if (!markdownRenderTimer.isActive()) - markdownRenderTimer.start(); - return; - } - renderMarkdownNow(); + return renderMarkdownNow(); } [[nodiscard]] const QString& content() const noexcept { return sourceText; } @@ -208,15 +279,21 @@ class WrappingLabel final : public QLabel } private: - void renderMarkdownNow(bool notifyDeferredCompletion = false) + [[nodiscard]] int preferredHeight() const + { + const int availableWidth = width(); + return availableWidth > 0 ? heightForWidth(availableWidth) : sizeHint().height(); + } + + bool renderMarkdownNow() { - markdownRenderTimer.stop(); - renderedSourceText = sourceText; + const int previousHeight = preferredHeight(); heightCache.clear(); + setTextFormat(Qt::RichText); QLabel::setText(safeMarkdownHtml(sourceText, font())); updateGeometry(); - if (notifyDeferredCompletion && deferredRenderCompleted) - deferredRenderCompleted(); + setProperty("markdownRenderMode", QStringLiteral("markdown")); + return previousHeight != preferredHeight(); } static QString safeMarkdownHtml(const QString& markdownText, const QFont& renderFont) @@ -258,9 +335,175 @@ class WrappingLabel final : public QLabel bool markdown = false; QString sourceText; - QString renderedSourceText; - QTimer markdownRenderTimer; - std::function deferredRenderCompleted; + mutable QHash heightCache; +}; + +class StreamingMessageView final : public QTextEdit +{ +public: + explicit StreamingMessageView(const QString& text) + : sourceText(text) + , sourceUtf8Bytes(static_cast(text.toUtf8().size())) + { + measurementDocument = new QTextDocument(this); + QSizePolicy policy(QSizePolicy::Ignored, QSizePolicy::Preferred); + policy.setHeightForWidth(true); + setSizePolicy(policy); + setReadOnly(true); + setUndoRedoEnabled(false); + setAcceptRichText(false); + setFrameStyle(QFrame::NoFrame); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setStyleSheet(QStringLiteral("QTextEdit{background:transparent;border:0;padding:0;}")); + viewport()->setAutoFillBackground(false); + document()->setDocumentMargin(0.0); + document()->setDefaultFont(font()); + document()->setPlainText(text); + measurementDocument->setDocumentMargin(0.0); + measurementDocument->setDefaultFont(font()); + measurementDocument->setPlainText(text); + setProperty("sourceUtf8Bytes", static_cast(sourceUtf8Bytes)); + setProperty("streamAppendCount", 0); + setProperty("fullReplacementCount", 0); + setProperty("geometryInvalidationCount", 0); + setProperty("markdownRenderMode", QStringLiteral("streaming-plain")); + } + + [[nodiscard]] const QString& content() const noexcept { return sourceText; } + [[nodiscard]] std::uint64_t utf8Bytes() const noexcept { return sourceUtf8Bytes; } + + bool replaceContent(const QString& text) + { + if (text == sourceText) + return false; + if (text.startsWith(sourceText)) + { + const auto applied = applyAppend( + sourceUtf8Bytes, 0, text.mid(sourceText.size())); + return applied.value_or(false); + } + const int previousHeight = preferredHeight(); + sourceText = text; + sourceUtf8Bytes = static_cast(text.toUtf8().size()); + document()->setPlainText(text); + measurementDocument->setPlainText(text); + heightCache.clear(); + const bool geometryChanged = previousHeight != preferredHeight(); + setProperty("sourceUtf8Bytes", static_cast(sourceUtf8Bytes)); + setProperty("fullReplacementCount", property("fullReplacementCount").toULongLong() + 1); + if (geometryChanged) + invalidateGeometry(); + return geometryChanged; + } + + std::optional applyAppend(std::uint64_t baseContentBytes, + std::uint64_t discardPrefixBytes, + const QString& delta) + { + if (baseContentBytes != sourceUtf8Bytes || discardPrefixBytes > sourceUtf8Bytes) + return std::nullopt; + + const int previousHeight = preferredHeight(); + const QByteArray deltaUtf8 = delta.toUtf8(); + if (discardPrefixBytes == 0) + { + sourceText.append(delta); + QTextCursor cursor(document()); + cursor.movePosition(QTextCursor::End); + cursor.insertText(delta); + QTextCursor measurementCursor(measurementDocument); + measurementCursor.movePosition(QTextCursor::End); + measurementCursor.insertText(delta); + } + else + { + const QByteArray previousUtf8 = sourceText.toUtf8(); + const QByteArray nextUtf8 = previousUtf8.mid( + static_cast(discardPrefixBytes)) + deltaUtf8; + sourceText = QString::fromUtf8(nextUtf8); + document()->setPlainText(sourceText); + measurementDocument->setPlainText(sourceText); + } + sourceUtf8Bytes = baseContentBytes - discardPrefixBytes + + static_cast(deltaUtf8.size()); + heightCache.clear(); + const bool geometryChanged = previousHeight != preferredHeight(); + setProperty("sourceUtf8Bytes", static_cast(sourceUtf8Bytes)); + setProperty("streamAppendCount", property("streamAppendCount").toULongLong() + 1); + if (geometryChanged) + invalidateGeometry(); + return geometryChanged; + } + + bool hasHeightForWidth() const override { return true; } + + int heightForWidth(int width) const override + { + const auto found = heightCache.constFind(width); + if (found != heightCache.cend()) + return *found; + const qreal textWidth = qMax(1, width); + if (measurementDocument->textWidth() != textWidth) + measurementDocument->setTextWidth(textWidth); + const int height = qCeil(measurementDocument->size().height()); + heightCache.insert(width, height); + return height; + } + + QSize sizeHint() const override + { + const int preferredWidth = width() > 0 ? width() : 480; + return QSize(preferredWidth, heightForWidth(preferredWidth)); + } + +protected: + void wheelEvent(QWheelEvent* event) override + { + // This view grows with its document; the enclosing conversation owns + // vertical navigation. + event->ignore(); + } + + void resizeEvent(QResizeEvent* event) override + { + QTextEdit::resizeEvent(event); + const qreal textWidth = qMax(1, viewport()->width()); + if (document()->textWidth() != textWidth) + document()->setTextWidth(textWidth); + } + + void changeEvent(QEvent* event) override + { + if (event->type() == QEvent::FontChange || event->type() == QEvent::StyleChange) + { + document()->setDefaultFont(font()); + if (measurementDocument) + measurementDocument->setDefaultFont(font()); + heightCache.clear(); + invalidateGeometry(); + } + QTextEdit::changeEvent(event); + } + +private: + void invalidateGeometry() + { + setProperty( + "geometryInvalidationCount", + property("geometryInvalidationCount").toULongLong() + 1); + updateGeometry(); + } + + [[nodiscard]] int preferredHeight() const + { + const int availableWidth = width(); + return availableWidth > 0 ? heightForWidth(availableWidth) : sizeHint().height(); + } + + QString sourceText; + std::uint64_t sourceUtf8Bytes = 0; + QTextDocument* measurementDocument = nullptr; mutable QHash heightCache; }; @@ -271,12 +514,17 @@ QLabel* wrappingLabel(const QString& text, const char* kind = nullptr) return result; } -QWidget* messageContentWidget(const QString& text, - const std::function& deferredRenderCompleted = {}) +QWidget* messageContentWidget(const QString& text, bool streaming) { + if (text.size() <= largeMessageEditorThreshold && streaming) + { + auto* result = new StreamingMessageView(text); + result->setProperty("kind", "body"); + return result; + } if (text.size() <= largeMessageEditorThreshold) { - auto* result = new WrappingLabel(text, true, deferredRenderCompleted); + auto* result = new WrappingLabel(text, true); result->setProperty("kind", "body"); return result; } @@ -290,6 +538,10 @@ QWidget* messageContentWidget(const QString& text, result->setFixedHeight(largeMessageEditorHeight); result->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); result->setPlainText(text); + result->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); + result->setProperty("streamAppendCount", 0); + result->setProperty("fullReplacementCount", 0); + result->setProperty("markdownRenderMode", QStringLiteral("large-plain")); return result; } @@ -297,32 +549,83 @@ QString messageContentText(const QWidget* content) { if (const auto* label = dynamic_cast(content)) return label->content(); + if (const auto* streaming = dynamic_cast(content)) + return streaming->content(); if (const auto* editor = qobject_cast(content)) return editor->toPlainText(); return {}; } -void setMessageContentText(QWidget* content, - const QString& text, - bool coalesceMarkdownRender = false) +bool setMessageContentText(QWidget* content, + const QString& text) { if (auto* label = dynamic_cast(content)) - label->setContent(text, coalesceMarkdownRender); + return label->setContent(text); + if (auto* streamingView = dynamic_cast(content)) + return streamingView->replaceContent(text); else if (auto* editor = qobject_cast(content); editor && editor->toPlainText() != text) + { editor->setPlainText(text); + editor->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); + editor->setProperty("fullReplacementCount", editor->property("fullReplacementCount").toULongLong() + 1); + return false; + } + return false; +} + +std::optional appendMessageContent(QWidget* content, + std::uint64_t baseContentBytes, + std::uint64_t discardPrefixBytes, + const QString& delta) +{ + if (auto* streamingView = dynamic_cast(content)) + return streamingView->applyAppend(baseContentBytes, discardPrefixBytes, delta); + + auto* editor = qobject_cast(content); + if (!editor) + return std::nullopt; + const std::uint64_t currentBytes = editor->property("sourceUtf8Bytes").toULongLong(); + if (currentBytes != baseContentBytes || discardPrefixBytes > currentBytes) + return std::nullopt; + + const QByteArray deltaUtf8 = delta.toUtf8(); + + if (discardPrefixBytes == 0) + { + QTextCursor cursor = editor->textCursor(); + cursor.movePosition(QTextCursor::End); + cursor.insertText(delta); + } + else + { + const QByteArray previousUtf8 = editor->toPlainText().toUtf8(); + editor->setPlainText( + QString::fromUtf8(previousUtf8.mid(static_cast(discardPrefixBytes)) + + deltaUtf8)); + } + const std::uint64_t nextBytes = baseContentBytes - discardPrefixBytes + + static_cast(deltaUtf8.size()); + editor->setProperty("sourceUtf8Bytes", static_cast(nextBytes)); + editor->setProperty("streamAppendCount", editor->property("streamAppendCount").toULongLong() + 1); + return false; } QWidget* ensureMessageContentWidget(QVBoxLayout* layout, QWidget* content, const QString& text, - const std::function& deferredRenderCompleted = {}) + bool streaming) { const bool needsEditor = text.size() > largeMessageEditorThreshold; const bool hasEditor = qobject_cast(content) != nullptr; - if (needsEditor == hasEditor) + const bool needsStreamingView = !needsEditor && streaming; + const bool hasStreamingView = dynamic_cast(content) != nullptr; + const bool hasMarkdownView = dynamic_cast(content) != nullptr; + if ((needsEditor && hasEditor) + || (needsStreamingView && hasStreamingView) + || (!needsEditor && !needsStreamingView && hasMarkdownView)) return content; - QWidget* replacement = messageContentWidget(text, deferredRenderCompleted); + QWidget* replacement = messageContentWidget(text, streaming); replacement->setObjectName(QStringLiteral("conversationMessageContent")); delete layout->replaceWidget(content, replacement); content->hide(); @@ -481,13 +784,21 @@ bool streamingMessageStatus(const QString& status) || normalized.contains(QStringLiteral("stream")); } -MessagePresentation messagePresentation(const sdk::ItemState& item, bool user) +bool turnStreamsMessages(const sdk::TurnState& turn) noexcept +{ + return !turn.terminal && (turn.active || turn.connectionInvalidated); +} + +MessagePresentation messagePresentationMetadata(const sdk::ItemState& item, + bool user, + bool turnStreaming) { MessagePresentation result; const QString itemStatusText = itemStatus(item); result.status = itemStatusText; result.statusColor = statusColor(itemStatusText); - result.streaming = !user && streamingMessageStatus(itemStatusText); + result.streaming = !user + && (turnStreaming || streamingMessageStatus(itemStatusText)); if (!user) { @@ -497,6 +808,18 @@ MessagePresentation messagePresentation(const sdk::ItemState& item, bool user) result.status += QStringLiteral(" · ") + humanize(fromUtf8(*agent->phase)); } + const auto userMessage = user ? sdk::userMessageSemanticView(item) : std::nullopt; + result.truncation = userMessage ? userMessageTruncationText(*userMessage) + : truncationText(item); + return result; +} + +MessagePresentation messagePresentation(const sdk::ItemState& item, + bool user, + bool turnStreaming) +{ + MessagePresentation result = messagePresentationMetadata( + item, user, turnStreaming); const auto userMessage = user ? sdk::userMessageSemanticView(item) : std::nullopt; if (user) { @@ -527,18 +850,13 @@ MessagePresentation messagePresentation(const sdk::ItemState& item, bool user) } } - // A valid typed user-message view is the authoritative statement about - // retained text. Generic item-detail bounds may describe unrelated - // metadata and must not turn a complete prompt into a truncation warning. - result.truncation = userMessage ? userMessageTruncationText(*userMessage) - : truncationText(item); return result; } -void applyMessagePresentation(QLabel* status, - QWidget* content, - QLabel* truncation, - const MessagePresentation& presentation) +bool applyMessageMetadata(QLabel* status, + QWidget* content, + QLabel* truncation, + const MessagePresentation& presentation) { if (status->text() != presentation.status) status->setText(presentation.status); @@ -554,11 +872,28 @@ void applyMessagePresentation(QLabel* status, content->style()->unpolish(content); content->style()->polish(content); } - setMessageContentText(content, presentation.content, presentation.streaming); + bool geometryChanged = false; if (truncation->text() != presentation.truncation) + { truncation->setText(presentation.truncation); - truncation->setVisible(!presentation.truncation.isEmpty()); + geometryChanged = truncation->isVisible(); + } + const bool truncationVisible = !presentation.truncation.isEmpty(); + geometryChanged = geometryChanged || truncation->isVisible() != truncationVisible; + truncation->setVisible(truncationVisible); + return geometryChanged; +} + +bool applyMessagePresentation(QLabel* status, + QWidget* content, + QLabel* truncation, + const MessagePresentation& presentation) +{ + const bool metadataGeometryChanged = applyMessageMetadata( + status, content, truncation, presentation); + return setMessageContentText(content, presentation.content) + || metadataGeometryChanged; } QString pendingRequestDetail(const sdk::State& state, const sdk::ItemState& item) @@ -583,13 +918,16 @@ QString pendingRequestDetail(const sdk::State& state, const sdk::ItemState& item ActivityPresentation activityPresentation(const sdk::State& state, const sdk::ItemState& item, - bool includeOutput = true) + bool includeOutput = true, + bool includeReasoningContent = true) { ActivityPresentation result; result.title = item.kind.known ? knownKindTitle(*item.kind.known) : QStringLiteral("Unknown item"); result.status = itemStatus(item); result.truncated = item.truncated || item.contentTruncated || !item.omittedFields.empty(); - if (item.summary && !item.summary->empty()) result.detail = fromUtf8(*item.summary); + if (!item.kind.is(frontend::ThreadItemKind::Reasoning) + && item.summary && !item.summary->empty()) + result.detail = fromUtf8(*item.summary); const auto semantic = sdk::itemSemanticView(item); if (semantic) @@ -682,15 +1020,24 @@ ActivityPresentation activityPresentation(const sdk::State& state, // Command execution is the common source, but file-change and future typed // activities may also carry the canonical command-output channel. - if (includeOutput && item.commandOutput && !item.commandOutput->empty()) - result.output = fromUtf8(*item.commandOutput); + if (includeOutput) + result.deferredOutput = deferredItemText( + state, item, sdk::ItemContentChannel::CommandOutput); - if (item.kind.is(frontend::ThreadItemKind::Reasoning)) + if (includeReasoningContent && item.kind.is(frontend::ThreadItemKind::Reasoning)) { if (item.reasoningSummary && !item.reasoningSummary->empty()) - result.detail = fromUtf8(*item.reasoningSummary); + { + result.detailChannel = sdk::ItemContentChannel::ReasoningSummary; + result.deferredDetail = deferredItemText( + state, item, *result.detailChannel); + } else if (item.reasoningText && !item.reasoningText->empty()) - result.detail = fromUtf8(*item.reasoningText); + { + result.detailChannel = sdk::ItemContentChannel::ReasoningText; + result.deferredDetail = deferredItemText( + state, item, *result.detailChannel); + } } if (!item.kind.known) @@ -770,64 +1117,391 @@ QPlainTextEdit* activityOutputWidget(const QString& text) "QScrollBar::add-line:horizontal,QScrollBar::sub-line:horizontal{background:transparent;border:0;width:0;}" "QScrollBar::add-page:horizontal,QScrollBar::sub-page:horizontal{background:transparent;}")); output->setPlainText(text); - output->setProperty("activityOutputText", text); + output->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); + output->setProperty("streamAppendCount", 0); + output->setProperty("fullReplacementCount", 0); return output; } -void updateActivityOutput(QPlainTextEdit* output, const QString& text) +class ActivityDetails final : public QWidget { - if (!output) - return; - const bool followsEnd = output->verticalScrollBar()->maximum() - - output->verticalScrollBar()->value() <= 2; - const int previousScroll = output->verticalScrollBar()->value(); - const QString previous = output->property("activityOutputText").toString(); - if (previous != text) +public: + bool replaceDeferredDetail( + std::optional source) + { + const bool previouslyAvailable = hasDetail(); + const bool contentChanged = source.has_value() != deferredDetail.has_value() + || (source && deferredDetail + && !sameDeferredItemText( + *source, *deferredDetail)); + deferredDetail = std::move(source); + deferredDetailDirty = deferredDetail.has_value() + && (deferredDetailDirty || contentChanged); + detailBytes = deferredDetail ? deferredDetail->utf8Bytes : 0; + if (deferredDetail) + detailChannel = deferredDetail->channel; + else + detailChannel.reset(); + setProperty( + "deferredDetailBytes", + static_cast(detailBytes)); + return previouslyAvailable != hasDetail(); + } + + void clearDeferredDetail() { - if (!previous.isEmpty() && text.startsWith(previous)) + deferredDetail.reset(); + deferredDetailDirty = false; + detailBytes = 0; + detailChannel.reset(); + setProperty("deferredDetailBytes", 0ULL); + } + + bool ensureDeferredDetail(QVBoxLayout* layout) + { + if (!layout || !deferredDetail || !deferredDetailDirty) + return false; + + const QString text = materializeDeferredItemText(*deferredDetail); + auto* detail = findChild( + QStringLiteral("conversationActivityDetail")); + auto* streaming = dynamic_cast(detail); + const bool compatible = streaming + && detail->property("activityContentChannel").toInt() + == static_cast(deferredDetail->channel); + bool geometryChanged = false; + if (!compatible) { - QTextCursor cursor = output->textCursor(); - cursor.movePosition(QTextCursor::End); - cursor.insertText(text.mid(previous.size())); + auto* replacement = new StreamingMessageView(text); + replacement->setObjectName( + QStringLiteral("conversationActivityDetail")); + replacement->setProperty("kind", "meta"); + replacement->setProperty( + "activityContentChannel", + static_cast(deferredDetail->channel)); + replacement->style()->unpolish(replacement); + replacement->style()->polish(replacement); + if (detail) + { + delete layout->replaceWidget(detail, replacement); + detail->hide(); + detail->deleteLater(); + } + else + { + layout->insertWidget(0, replacement); + } + geometryChanged = true; + } + else + { + geometryChanged = streaming->replaceContent(text); + } + if (auto* current = findChild( + QStringLiteral("conversationActivityDetail")); + current && current->isHidden()) + { + current->show(); + geometryChanged = true; + } + deferredDetailDirty = false; + ++detailMaterializationCount; + setProperty( + "detailMaterializationCount", + static_cast(detailMaterializationCount)); + return geometryChanged; + } + + [[nodiscard]] bool acceptsDetailAppend( + sdk::ItemContentChannel channel, + std::uint64_t baseContentBytes, + std::uint64_t discardPrefixBytes) const noexcept + { + return baseContentBytes == detailBytes + && discardPrefixBytes <= detailBytes + && (!detailChannel || *detailChannel == channel); + } + + void recordMaterializedDetailSource( + ActivityPresentation::DeferredItemText source) + { + detailBytes = source.utf8Bytes; + detailChannel = source.channel; + deferredDetail = std::move(source); + deferredDetailDirty = false; + setProperty( + "deferredDetailBytes", + static_cast(detailBytes)); + } + + bool replaceOutput( + std::optional source) + { + const bool previouslyAvailable = hasOutput(); + const bool contentChanged = source.has_value() != deferredOutput.has_value() + || (source && deferredOutput + && !sameDeferredItemText( + *source, *deferredOutput)); + deferredOutput = std::move(source); + outputBytes = deferredOutput ? deferredOutput->utf8Bytes : 0; + deferredOutputDirty = deferredOutput.has_value() + && (deferredOutputDirty || contentChanged); + setProperty("deferredOutputBytes", static_cast(outputBytes)); + if (!deferredOutput && outputEditor) + { + outputEditor->hide(); + if (outputHeading) + outputHeading->hide(); + } + return previouslyAvailable != hasOutput(); + } + + bool materializeOutput(QVBoxLayout* layout) + { + if (!layout || !deferredOutput || !deferredOutputDirty) + return false; + const QString text = materializeDeferredItemText(*deferredOutput); + bool geometryChanged = false; + if (!outputEditor) + { + if (!outputHeading) + { + outputHeading = textLabel(QStringLiteral("Output"), "small"); + outputHeading->setObjectName( + QStringLiteral("conversationActivityOutputHeading")); + outputHeading->setStyleSheet( + QStringLiteral("font-size:10px;font-weight:600;color:#475467;")); + const auto* incomplete = findChild( + QStringLiteral("conversationActivityIncomplete")); + const int headingPosition = incomplete + ? layout->indexOf(incomplete) + : layout->count(); + layout->insertWidget(headingPosition, outputHeading); + } + outputEditor = activityOutputWidget(text); + const auto* incomplete = findChild( + QStringLiteral("conversationActivityIncomplete")); + const int position = incomplete + ? layout->indexOf(incomplete) + : layout->count(); + layout->insertWidget(position, outputEditor); + geometryChanged = true; } else { - output->setPlainText(text); + const bool followsEnd = outputEditor->verticalScrollBar()->maximum() + - outputEditor->verticalScrollBar()->value() <= 2; + const int previousScroll = outputEditor->verticalScrollBar()->value(); + geometryChanged = setMessageContentText(outputEditor, text); + outputEditor->verticalScrollBar()->setValue( + followsEnd ? outputEditor->verticalScrollBar()->maximum() + : qMin(previousScroll, + outputEditor->verticalScrollBar()->maximum())); } + outputEditor->show(); + if (outputHeading) + outputHeading->show(); + deferredOutputDirty = false; + ++outputMaterializationCount; + setProperty( + "outputMaterializationCount", + static_cast(outputMaterializationCount)); + return geometryChanged; + } + + void recordMaterializedOutputSource( + ActivityPresentation::DeferredItemText source) + { + outputBytes = source.utf8Bytes; + deferredOutput = std::move(source); + deferredOutputDirty = false; + setProperty( + "deferredOutputBytes", + static_cast(outputBytes)); + } + + std::optional applyOutputAppend(std::uint64_t baseContentBytes, + std::uint64_t discardPrefixBytes, + const QString& delta) + { + if (!outputEditor || deferredOutputDirty + || baseContentBytes != outputBytes + || discardPrefixBytes > outputBytes) + return std::nullopt; + + const QByteArray deltaUtf8 = delta.toUtf8(); + const bool followsEnd = outputEditor->verticalScrollBar()->maximum() + - outputEditor->verticalScrollBar()->value() <= 2; + const int previousScroll = outputEditor->verticalScrollBar()->value(); + const auto applied = appendMessageContent( + outputEditor, baseContentBytes, discardPrefixBytes, delta); + if (!applied) + return std::nullopt; + outputBytes = baseContentBytes - discardPrefixBytes + + static_cast(deltaUtf8.size()); + outputEditor->verticalScrollBar()->setValue( + followsEnd ? outputEditor->verticalScrollBar()->maximum() + : qMin(previousScroll, outputEditor->verticalScrollBar()->maximum())); + return *applied; + } + + QPlainTextEdit* ensureOutput(QVBoxLayout* layout) + { + if (!layout || !hasOutput()) + return outputEditor; + static_cast(materializeOutput(layout)); + return outputEditor; + } + + [[nodiscard]] bool hasDetail() const noexcept + { + if (detailBytes != 0) + return true; + const auto* detail = findChild( + QStringLiteral("conversationActivityDetail")); + return detail && !detail->isHidden(); + } + [[nodiscard]] bool hasOutput() const noexcept { return outputBytes != 0; } + [[nodiscard]] std::uint64_t retainedDetailBytes() const noexcept + { + return detailBytes; + } + [[nodiscard]] std::uint64_t retainedOutputBytes() const noexcept { return outputBytes; } + [[nodiscard]] QPlainTextEdit* output() const noexcept { return outputEditor; } + [[nodiscard]] QLabel* heading() const noexcept { return outputHeading; } + +private: + std::optional deferredDetail; + std::optional deferredOutput; + std::optional detailChannel; + std::uint64_t detailBytes = 0; + std::uint64_t outputBytes = 0; + std::uint64_t detailMaterializationCount = 0; + std::uint64_t outputMaterializationCount = 0; + bool deferredDetailDirty = false; + bool deferredOutputDirty = false; + QPlainTextEdit* outputEditor = nullptr; + QLabel* outputHeading = nullptr; +}; + +QWidget* activityDetailWidget(const ActivityPresentation& presentation) +{ + QWidget* detail = nullptr; + if (presentation.detailChannel) + { + auto* streaming = new StreamingMessageView(presentation.detail); + streaming->setProperty( + "activityContentChannel", + static_cast(*presentation.detailChannel)); + detail = streaming; + } + else + { + detail = wrappingLabel(presentation.detail, "meta"); } - output->setProperty("activityOutputText", text); - output->setVisible(!text.isEmpty()); - output->verticalScrollBar()->setValue( - followsEnd ? output->verticalScrollBar()->maximum() - : qMin(previousScroll, output->verticalScrollBar()->maximum())); + detail->setObjectName(QStringLiteral("conversationActivityDetail")); + detail->setProperty("kind", "meta"); + detail->style()->unpolish(detail); + detail->style()->polish(detail); + return detail; } -QPlainTextEdit* ensureActivityOutput(QWidget* details, QVBoxLayout* layout) +bool updateActivityDetail(ActivityDetails* details, + QVBoxLayout* layout, + const ActivityPresentation& presentation) { if (!details || !layout) - return nullptr; - auto* output = details->findChild(QStringLiteral("conversationActivityOutput")); - const QString text = details->property("activityOutputText").toString(); - if (!output && !text.isEmpty()) - { - auto* heading = details->findChild( - QStringLiteral("conversationActivityOutputHeading")); - if (!heading) + return false; + if (presentation.deferredDetail) + { + bool changed = details->replaceDeferredDetail( + presentation.deferredDetail); + if (details->isVisible()) + changed = details->ensureDeferredDetail(layout) || changed; + return changed; + } + details->clearDeferredDetail(); + QWidget* detail = details->findChild( + QStringLiteral("conversationActivityDetail")); + if (presentation.detail.isEmpty()) + { + const bool changed = detail && !detail->isHidden(); + if (detail) + detail->hide(); + return changed; + } + + const auto expectedChannel = presentation.detailChannel; + const auto* streaming = dynamic_cast(detail); + const bool compatible = expectedChannel + ? streaming + && detail->property("activityContentChannel").toInt() + == static_cast(*expectedChannel) + : detail && !streaming; + bool geometryChanged = false; + if (!compatible) + { + QWidget* replacement = activityDetailWidget(presentation); + if (detail) { - heading = textLabel(QStringLiteral("Output"), "small"); - heading->setObjectName(QStringLiteral("conversationActivityOutputHeading")); - heading->setStyleSheet(QStringLiteral("font-size:10px;font-weight:600;color:#475467;")); - const auto* incomplete = details->findChild( - QStringLiteral("conversationActivityIncomplete")); - const int headingPosition = incomplete ? layout->indexOf(incomplete) : layout->count(); - layout->insertWidget(headingPosition, heading); + delete layout->replaceWidget(detail, replacement); + detail->hide(); + detail->deleteLater(); } - output = activityOutputWidget(text); - const auto* incomplete = details->findChild(QStringLiteral("conversationActivityIncomplete")); - const int position = incomplete ? layout->indexOf(incomplete) : layout->count(); - layout->insertWidget(position, output); + else + { + layout->insertWidget(0, replacement); + } + detail = replacement; + geometryChanged = true; } - return output; + else if (auto* streamingDetail = dynamic_cast(detail)) + { + geometryChanged = streamingDetail->replaceContent(presentation.detail); + } + else if (auto* label = dynamic_cast(detail)) + { + geometryChanged = label->setContent(presentation.detail); + } + if (detail->isHidden()) + { + detail->show(); + geometryChanged = true; + } + return geometryChanged; +} + +bool updateActivityDisclosureAvailability(QWidget* row) +{ + if (!row) + return false; + auto* details = dynamic_cast( + row->findChild(QStringLiteral("conversationActivityDetails"))); + auto* disclosure = row->findChild(QStringLiteral("activityDisclosure")); + if (!details || !disclosure) + return false; + const auto* incomplete = details->findChild( + QStringLiteral("conversationActivityIncomplete")); + const bool hasDetails = details->hasDetail() || details->hasOutput() + || (incomplete && !incomplete->isHidden()); + bool changed = disclosure->isVisible() != hasDetails; + if (auto* prefix = row->findChild( + QStringLiteral("conversationActivityPrefix"))) + { + changed = changed || prefix->width() != (hasDetails ? 31 : 14); + prefix->setFixedWidth(hasDetails ? 31 : 14); + } + if (auto* leadingLayout = row->findChild( + QStringLiteral("conversationActivityLeadingLayout"))) + { + changed = changed || leadingLayout->spacing() != (hasDetails ? 0 : 6); + leadingLayout->setSpacing(hasDetails ? 0 : 6); + } + disclosure->setEnabled(hasDetails); + disclosure->setVisible(hasDetails); + if (!hasDetails) + setDisclosureState(disclosure, details, false); + return changed; } void addActivityRow(QVBoxLayout* rows, @@ -850,7 +1524,10 @@ void addActivityRow(QVBoxLayout* rows, layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(6); - const bool hasDetails = !item.detail.isEmpty() || !item.output.isEmpty() || item.truncated; + const bool hasDetails = !item.detail.isEmpty() + || item.deferredDetail.has_value() + || item.deferredOutput.has_value() + || item.truncated; const QString color = statusColor(item.status); auto* prefix = new QWidget; prefix->setObjectName(QStringLiteral("conversationActivityPrefix")); @@ -902,10 +1579,10 @@ void addActivityRow(QVBoxLayout* rows, auto* tail = textLabel(item.tail, "meta"); tail->setObjectName(QStringLiteral("conversationActivityTail")); - tail->setVisible(!item.tail.isEmpty()); tail->setFixedHeight(disclosure->height()); tail->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); layout->addWidget(tail, 0, Qt::AlignTop); + tail->setVisible(!item.tail.isEmpty()); auto* state = textLabel(item.status); state->setObjectName(QStringLiteral("conversationActivityStatus")); state->setStyleSheet(QStringLiteral("color:%1;font-size:9px;font-weight:600;").arg(color)); @@ -914,29 +1591,26 @@ void addActivityRow(QVBoxLayout* rows, layout->addWidget(state, 0, Qt::AlignTop); lineLayout->addWidget(summary); - auto* details = new QWidget; + auto* details = new ActivityDetails; details->setObjectName(QStringLiteral("conversationActivityDetails")); auto* detailsLayout = new QVBoxLayout(details); detailsLayout->setContentsMargins(42, 0, 4, 4); detailsLayout->setSpacing(6); - if (!item.detail.isEmpty()) + if (item.deferredDetail) { - auto* detail = wrappingLabel(item.detail, "meta"); - detail->setObjectName(QStringLiteral("conversationActivityDetail")); - detail->setTextInteractionFlags(Qt::TextSelectableByMouse); - detailsLayout->addWidget(detail); + details->replaceDeferredDetail(item.deferredDetail); + if (expanded) + details->ensureDeferredDetail(detailsLayout); } - if (!item.output.isEmpty()) + else if (!item.detail.isEmpty()) { - details->setProperty("activityOutputText", item.output); + detailsLayout->addWidget(activityDetailWidget(item)); + } + if (item.deferredOutput) + { + details->replaceOutput(item.deferredOutput); if (expanded) - { - auto* heading = textLabel(QStringLiteral("Output"), "small"); - heading->setObjectName(QStringLiteral("conversationActivityOutputHeading")); - heading->setStyleSheet(QStringLiteral("font-size:10px;font-weight:600;color:#475467;")); - detailsLayout->addWidget(heading); - detailsLayout->addWidget(activityOutputWidget(item.output)); - } + details->ensureOutput(detailsLayout); } if (item.truncated) { @@ -945,14 +1619,17 @@ void addActivityRow(QVBoxLayout* rows, omitted->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); detailsLayout->addWidget(omitted); } - setDisclosureState(disclosure, details, expanded && hasDetails); lineLayout->addWidget(details); + setDisclosureState(disclosure, details, expanded && hasDetails); QObject::connect(disclosure, &QToolButton::clicked, line, [disclosure, details, detailsLayout, layoutChanged](bool) { const bool next = !details->isVisible(); if (next) - ensureActivityOutput(details, detailsLayout); + { + details->ensureDeferredDetail(detailsLayout); + details->ensureOutput(detailsLayout); + } setDisclosureState(disclosure, details, next); if (layoutChanged) layoutChanged(); @@ -960,7 +1637,10 @@ void addActivityRow(QVBoxLayout* rows, rows->addWidget(line); } -bool updateActivityRow(QWidget* row, const ActivityPresentation& item) +bool updateActivityRowMetadata(QWidget* row, + const ActivityPresentation& item, + bool contentGeometryChanged, + bool* geometryChanged) { if (!row) return false; @@ -969,7 +1649,8 @@ bool updateActivityRow(QWidget* row, const ActivityPresentation& item) auto* symbol = row->findChild(QStringLiteral("conversationActivitySymbol")); auto* tail = row->findChild(QStringLiteral("conversationActivityTail")); auto* status = row->findChild(QStringLiteral("conversationActivityStatus")); - auto* details = row->findChild(QStringLiteral("conversationActivityDetails")); + auto* details = dynamic_cast( + row->findChild(QStringLiteral("conversationActivityDetails"))); auto* disclosure = row->findChild(QStringLiteral("activityDisclosure")); if (!title || !symbol || !tail || !status || !details || !disclosure) return false; @@ -977,43 +1658,21 @@ bool updateActivityRow(QWidget* row, const ActivityPresentation& item) if (!detailsLayout) return false; - title->setContent(item.title); + bool changed = contentGeometryChanged || title->setContent(item.title); title->setToolTip(plainTooltip(item.title)); disclosure->setAccessibleName(QStringLiteral("Activity details: %1").arg(item.title)); symbol->setText(statusGlyph(item.status)); symbol->setStyleSheet( QStringLiteral("color:%1;font-size:12px;font-weight:600;").arg(statusColor(item.status))); - tail->setText(item.tail); - tail->setVisible(!item.tail.isEmpty()); + if (tail->text() != item.tail) + tail->setText(item.tail); + const bool tailVisible = !item.tail.isEmpty(); + changed = changed || tail->isVisible() != tailVisible; + tail->setVisible(tailVisible); status->setText(item.status); status->setStyleSheet( QStringLiteral("color:%1;font-size:9px;font-weight:600;").arg(statusColor(item.status))); - auto* detail = dynamic_cast( - details->findChild(QStringLiteral("conversationActivityDetail"))); - if (!detail && !item.detail.isEmpty()) - { - detail = static_cast(wrappingLabel({}, "meta")); - detail->setObjectName(QStringLiteral("conversationActivityDetail")); - detail->setTextInteractionFlags(Qt::TextSelectableByMouse); - detailsLayout->insertWidget(0, detail); - } - if (detail) - { - detail->setContent(item.detail); - detail->setVisible(!item.detail.isEmpty()); - } - - details->setProperty("activityOutputText", item.output); - auto* output = details->findChild(QStringLiteral("conversationActivityOutput")); - if (!output && !details->isHidden() && !item.output.isEmpty()) - output = ensureActivityOutput(details, detailsLayout); - if (output) - updateActivityOutput(output, item.output); - if (auto* outputHeading = details->findChild( - QStringLiteral("conversationActivityOutputHeading"))) - outputHeading->setVisible(!item.output.isEmpty()); - auto* incomplete = details->findChild(QStringLiteral("conversationActivityIncomplete")); if (!incomplete && item.truncated) { @@ -1021,36 +1680,58 @@ bool updateActivityRow(QWidget* row, const ActivityPresentation& item) incomplete->setObjectName(QStringLiteral("conversationActivityIncomplete")); incomplete->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); detailsLayout->addWidget(incomplete); + changed = true; } if (incomplete) + { + changed = changed || incomplete->isVisible() != item.truncated; incomplete->setVisible(item.truncated); + } - const bool hasDetails = !item.detail.isEmpty() || !item.output.isEmpty() || item.truncated; - if (auto* prefix = row->findChild(QStringLiteral("conversationActivityPrefix"))) - prefix->setFixedWidth(hasDetails ? 31 : 14); - if (auto* leadingLayout = row->findChild( - QStringLiteral("conversationActivityLeadingLayout"))) - leadingLayout->setSpacing(hasDetails ? 0 : 6); - disclosure->setEnabled(hasDetails); - disclosure->setVisible(hasDetails); - if (!hasDetails) - setDisclosureState(disclosure, details, false); + changed = updateActivityDisclosureAvailability(row) || changed; - // A wrapping label can become shorter during an in-place canonical update. - // Invalidate the nested layouts synchronously so the fixed timeline host does - // not retain their previous height until a platform-specific layout event. - detailsLayout->invalidate(); - detailsLayout->activate(); - details->updateGeometry(); - if (QLayout* rowLayout = row->layout()) + if (changed) { - rowLayout->invalidate(); - rowLayout->activate(); + detailsLayout->invalidate(); + details->updateGeometry(); + if (QLayout* rowLayout = row->layout()) + rowLayout->invalidate(); + row->updateGeometry(); } - row->updateGeometry(); + if (geometryChanged) + *geometryChanged = changed; return true; } +bool updateActivityRow(QWidget* row, + const ActivityPresentation& item, + bool* geometryChanged) +{ + auto* details = row + ? dynamic_cast(row->findChild( + QStringLiteral("conversationActivityDetails"))) + : nullptr; + auto* detailsLayout = details ? qobject_cast(details->layout()) : nullptr; + if (!details || !detailsLayout) + return false; + + bool contentGeometryChanged = updateActivityDetail(details, detailsLayout, item); + contentGeometryChanged = details->replaceOutput(item.deferredOutput) + || contentGeometryChanged; + if (details->isVisible()) + { + contentGeometryChanged = details->ensureDeferredDetail(detailsLayout) + || contentGeometryChanged; + if (details->hasOutput()) + { + contentGeometryChanged = details->materializeOutput(detailsLayout) + || contentGeometryChanged; + } + } + return updateActivityRowMetadata( + row, item, contentGeometryChanged, geometryChanged); +} + QFrame* activityCard(const sdk::State& state, const std::vector& items, bool typedPlanAvailable, @@ -1068,6 +1749,7 @@ QFrame* activityCard(const sdk::State& state, layout->setSpacing(0); auto* header = new QHBoxLayout; + layout->addLayout(header); auto* disclosure = disclosureButton(expanded, QStringLiteral("Activity group")); header->addWidget(disclosure); auto* title = textLabel(QStringLiteral("Activity")); @@ -1079,15 +1761,14 @@ QFrame* activityCard(const sdk::State& state, }); auto* planAvailable = textLabel(QStringLiteral("Plan available"), "small"); planAvailable->setObjectName(QStringLiteral("conversationActivityPlanAvailable")); - planAvailable->setVisible(typedPlanAvailable || legacyPlanAvailable); header->addWidget(planAvailable); + planAvailable->setVisible(typedPlanAvailable || legacyPlanAvailable); header->addSpacing(8); auto* count = textLabel( QStringLiteral("%1 activit%2").arg(items.size()).arg(items.size() == 1 ? "y" : "ies"), "small"); count->setObjectName(QStringLiteral("conversationActivityCount")); header->addWidget(count); - layout->addLayout(header); auto* body = new QWidget; body->setObjectName(QStringLiteral("conversationActivityBody")); auto* bodyLayout = new QVBoxLayout(body); @@ -1108,12 +1789,28 @@ QFrame* activityCard(const sdk::State& state, layoutChanged); } bodyLayout->addLayout(rows); - setDisclosureState(disclosure, body, expanded); layout->addWidget(body); + setDisclosureState(disclosure, body, expanded); QObject::connect(disclosure, &QToolButton::clicked, card, [disclosure, body, layoutChanged](bool) { const bool next = !body->isVisible(); + if (next) + { + for (auto* candidate : body->findChildren( + QStringLiteral("conversationActivityDetails"))) + { + auto* details = dynamic_cast(candidate); + if (!details) + continue; + if (details->isHidden()) + continue; + auto* detailsLayout = qobject_cast( + details->layout()); + details->ensureDeferredDetail(detailsLayout); + details->ensureOutput(detailsLayout); + } + } setDisclosureState(disclosure, body, next); if (layoutChanged) layoutChanged(); @@ -1124,9 +1821,10 @@ QFrame* activityCard(const sdk::State& state, void addMessage(QVBoxLayout* timeline, const sdk::ItemState& item, bool user, - const std::function& layoutChanged) + bool turnStreaming) { - const MessagePresentation presentation = messagePresentation(item, user); + const MessagePresentation presentation = messagePresentation( + item, user, turnStreaming); auto* header = new QHBoxLayout; header->addWidget(textLabel(user ? QStringLiteral("YOU") : QStringLiteral("CODEX"), "section")); header->addStretch(); @@ -1158,7 +1856,7 @@ void addMessage(QVBoxLayout* timeline, layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(5); } - auto* copy = messageContentWidget(presentation.content, layoutChanged); + auto* copy = messageContentWidget(presentation.content, presentation.streaming); copy->setObjectName(QStringLiteral("conversationMessageContent")); layout->addWidget(copy); auto* marker = textLabel({}, "small"); @@ -1243,6 +1941,31 @@ void addPresentationValue(QCryptographicHash& hash, bool value) addPresentationValue(hash, value ? QByteArrayLiteral("1") : QByteArrayLiteral("0")); } +bool addItemContentIdentity(QCryptographicHash& hash, + const sdk::State& state, + const sdk::ItemState& item, + sdk::ItemContentChannel channel) +{ + if (!item.threadId || !item.turnId) + { + addPresentationValue(hash, false); + return false; + } + const auto descriptor = state.itemContentDescriptor( + *item.threadId, *item.turnId, item.id, channel); + addPresentationValue(hash, descriptor.has_value()); + if (!descriptor) + return false; + addPresentationValue(hash, descriptor->present); + addPresentationValue( + hash, + QByteArray::number(static_cast(descriptor->retainedUtf8Bytes))); + addPresentationValue( + hash, + QByteArray::number(static_cast(descriptor->contentRevision))); + return true; +} + void addEmptyState(QVBoxLayout* timeline, const QString& title, const QString& detail) { auto* empty = new QFrame; @@ -1421,12 +2144,14 @@ TimelineWindow latestTimelineWindow(const sdk::State& state, const sdk::ThreadSt QByteArray segmentPresentationKey(const sdk::State& state, const TimelineSegment& segment, - bool typedPlanAvailable) + bool typedPlanAvailable, + bool turnStreaming) { QCryptographicHash hash(QCryptographicHash::Sha256); addPresentationValue(hash, segment.id); addPresentationValue(hash, segment.missing); addPresentationValue(hash, typedPlanAvailable); + addPresentationValue(hash, turnStreaming); for (const auto* item : segment.items) { addPresentationValue(hash, item != nullptr); @@ -1442,16 +2167,24 @@ QByteArray segmentPresentationKey(const sdk::State& state, addPresentationValue(hash, message.has_value()); if (message) { + // User text has no append channel and is normally immutable; + // hash it directly so an equal-length authoritative repair is + // never mistaken for unchanged content. addPresentationValue(hash, message->text); addPresentationValue(hash, userMessageTruncationText(*message)); } } else if (item->kind.is(frontend::ThreadItemKind::AgentMessage)) { - const QString content = item->agentText && !item->agentText->empty() - ? fromUtf8(*item->agentText) - : (item->summary ? fromUtf8(*item->summary) : QString{}); - addPresentationValue(hash, content); + const std::string_view content = item->agentText && !item->agentText->empty() + ? std::string_view(*item->agentText) + : (item->summary + ? std::string_view(*item->summary) + : std::string_view{}); + if (!item->agentText || item->agentText->empty() + || !addItemContentIdentity( + hash, state, *item, sdk::ItemContentChannel::AgentText)) + addPresentationValue(hash, content); const auto semantic = sdk::itemSemanticView(*item); const auto* agent = semantic ? std::get_if(&semantic->details) : nullptr; addPresentationValue(hash, @@ -1463,21 +2196,26 @@ QByteArray segmentPresentationKey(const sdk::State& state, // hash its complete text merely to discover that an immutable item // revision changed; exact content updates explicitly bypass an // equal key during reconciliation below. - const ActivityPresentation presentation = activityPresentation(state, *item, false); + const bool reasoning = item->kind.is(frontend::ThreadItemKind::Reasoning); + const ActivityPresentation presentation = activityPresentation( + state, *item, false, !reasoning); addPresentationValue(hash, presentation.title); addPresentationValue(hash, presentation.detail); - addPresentationValue( - hash, - QByteArray::number(static_cast( - item->commandOutput ? item->commandOutput->size() : 0))); - if (item->stamp) - addPresentationValue( - hash, - QByteArray::number(static_cast(item->stamp->generation))); - else if (item->commandOutput) - // Compatibility states without a source stamp cannot provide - // a cheaper authoritative revision identity. + if (!addItemContentIdentity( + hash, state, *item, sdk::ItemContentChannel::CommandOutput) + && item->commandOutput) addPresentationValue(hash, std::string_view(*item->commandOutput)); + if (reasoning) + { + if (!addItemContentIdentity( + hash, state, *item, sdk::ItemContentChannel::ReasoningText) + && item->reasoningText) + addPresentationValue(hash, std::string_view(*item->reasoningText)); + if (!addItemContentIdentity( + hash, state, *item, sdk::ItemContentChannel::ReasoningSummary) + && item->reasoningSummary) + addPresentationValue(hash, std::string_view(*item->reasoningSummary)); + } addPresentationValue(hash, presentation.status); addPresentationValue(hash, presentation.tail); addPresentationValue(hash, presentation.truncated); @@ -1489,6 +2227,7 @@ QByteArray segmentPresentationKey(const sdk::State& state, QWidget* timelineSegmentWidget(const sdk::State& state, const TimelineSegment& segment, bool typedPlanAvailable, + bool turnStreaming, const ActivityExpansionState& activityExpansion, const std::function& layoutChanged) { @@ -1503,13 +2242,12 @@ QWidget* timelineSegmentWidget(const sdk::State& state, if (segment.missing) { - ActivityPresentation omitted{ - QStringLiteral("Unavailable item"), - QStringLiteral("The ordered item shell is not retained in current State"), - {}, - QStringLiteral("Omitted"), - {}, - true}; + ActivityPresentation omitted; + omitted.title = QStringLiteral("Unavailable item"); + omitted.detail = QStringLiteral( + "The ordered item shell is not retained in current State"); + omitted.status = QStringLiteral("Omitted"); + omitted.truncated = true; auto* card = new QFrame; card->setProperty("kind", "panel"); auto* rows = new QVBoxLayout(card); @@ -1530,7 +2268,7 @@ QWidget* timelineSegmentWidget(const sdk::State& state, const auto* item = segment.items.front(); const bool user = item->kind.is(frontend::ThreadItemKind::UserMessage); host->setProperty("messageUser", user); - addMessage(layout, *item, user, layoutChanged); + addMessage(layout, *item, user, turnStreaming); } else { @@ -1545,11 +2283,133 @@ QWidget* timelineSegmentWidget(const sdk::State& state, return host; } +std::optional exactAppendResultBytes( + const ConversationContentAppend& append) noexcept +{ + if (append.discardPrefixBytes > append.baseContentBytes + || append.baseContentBytes - append.discardPrefixBytes + > std::numeric_limits::max() + - append.deltaUtf8Bytes) + return std::nullopt; + return append.baseContentBytes - append.discardPrefixBytes + + append.deltaUtf8Bytes; +} + +bool applyExactActivityAppend(const sdk::State& state, + const ai::openai::codex::typed::ThreadId& threadId, + QWidget* row, + const ConversationContentUpdate& update, + bool* geometryChanged, + bool* mayShrink) +{ + if (!row || !update.append) + return false; + const ConversationContentAppend& append = *update.append; + auto* details = dynamic_cast( + row->findChild(QStringLiteral("conversationActivityDetails"))); + auto* detailsLayout = details ? qobject_cast(details->layout()) : nullptr; + if (!details || !detailsLayout) + return false; + + const auto expectedBytes = exactAppendResultBytes(append); + if (!expectedBytes) + return false; + auto source = deferredItemText( + state, + threadId, + ai::openai::codex::typed::TurnId{update.turnId.toStdString()}, + ai::openai::codex::typed::ItemId{update.itemId.toStdString()}, + update.channel); + if (!source || source->utf8Bytes != *expectedBytes) + return false; + + bool contentGeometryChanged = false; + if (update.channel == sdk::ItemContentChannel::CommandOutput) + { + if (!details->isVisible()) + { + if (details->retainedOutputBytes() != append.baseContentBytes + || append.discardPrefixBytes > append.baseContentBytes) + return false; + contentGeometryChanged = details->replaceOutput(source); + } + else + { + const auto applied = details->applyOutputAppend( + append.baseContentBytes, append.discardPrefixBytes, append.delta); + if (!applied) + return false; + contentGeometryChanged = *applied; + details->recordMaterializedOutputSource(std::move(*source)); + } + } + else if ((update.channel == sdk::ItemContentChannel::ReasoningText + || update.channel == sdk::ItemContentChannel::ReasoningSummary)) + { + if (!details->acceptsDetailAppend( + update.channel, + append.baseContentBytes, + append.discardPrefixBytes)) + return false; + if (!details->isVisible()) + { + contentGeometryChanged = details->replaceDeferredDetail(source); + } + else + { + auto* detail = dynamic_cast( + details->findChild(QStringLiteral("conversationActivityDetail"))); + if (!detail && append.baseContentBytes == 0 + && append.discardPrefixBytes == 0) + { + contentGeometryChanged = details->replaceDeferredDetail(source); + contentGeometryChanged = details->ensureDeferredDetail(detailsLayout) + || contentGeometryChanged; + } + else + { + if (!detail + || detail->property("activityContentChannel").toInt() + != static_cast(update.channel)) + return false; + const auto applied = detail->applyAppend( + append.baseContentBytes, append.discardPrefixBytes, append.delta); + if (!applied) + return false; + contentGeometryChanged = *applied; + details->recordMaterializedDetailSource(std::move(*source)); + } + } + } + else + { + return false; + } + + contentGeometryChanged = updateActivityDisclosureAvailability(row) + || contentGeometryChanged; + if (contentGeometryChanged) + { + detailsLayout->invalidate(); + details->updateGeometry(); + if (QLayout* rowLayout = row->layout()) + rowLayout->invalidate(); + row->updateGeometry(); + } + if (geometryChanged) + *geometryChanged = contentGeometryChanged; + if (mayShrink) + *mayShrink = append.discardPrefixBytes > append.deltaUtf8Bytes; + return true; +} + bool updateTimelineActivitySegment(QWidget* host, const sdk::State& state, const TimelineSegment& segment, bool typedPlanAvailable, - const QStringList* exactChangedItemIds, + const ConversationContentUpdates* exactContentChanges, + bool* geometryChanged, + bool* mayShrink, const std::function& layoutChanged) { if (!host || segment.missing || segment.items.empty()) @@ -1580,14 +2440,45 @@ bool updateTimelineActivitySegment(QWidget* host, if (!item || rows.at(index)->property("itemId").toString() != fromUtf8(item->id.value)) return false; } + bool anyGeometryChanged = false; + bool anyMayShrink = false; for (std::size_t index = 0; index < rows.size(); ++index) { const auto* item = segment.items.at(index); - if (exactChangedItemIds - && !exactChangedItemIds->contains(fromUtf8(item->id.value))) - continue; - if (!updateActivityRow(rows.at(index), activityPresentation(state, *item))) - return false; + bool rowGeometryChanged = false; + bool rowMayShrink = false; + bool handledExactly = false; + if (exactContentChanges) + { + for (const ConversationContentUpdate& update : *exactContentChanges) + { + if (update.itemId != fromUtf8(item->id.value)) + continue; + if (item->threadId) + handledExactly = applyExactActivityAppend( + state, *item->threadId, rows.at(index), update, + &rowGeometryChanged, &rowMayShrink); + if (!handledExactly) + break; + } + if (std::none_of( + exactContentChanges->cbegin(), exactContentChanges->cend(), + [item](const ConversationContentUpdate& update) + { return update.itemId == fromUtf8(item->id.value); })) + continue; + } + if (!handledExactly) + { + if (!updateActivityRow( + rows.at(index), activityPresentation(state, *item), + &rowGeometryChanged)) + return false; + // A full authoritative replacement may shorten any wrapping + // detail or hide output/truncation UI. + rowMayShrink = rowGeometryChanged; + } + anyGeometryChanged = anyGeometryChanged || rowGeometryChanged; + anyMayShrink = anyMayShrink || rowMayShrink; } for (std::size_t index = rows.size(); index < segment.items.size(); ++index) { @@ -1599,29 +2490,40 @@ bool updateTimelineActivitySegment(QWidget* host, activityPresentation(state, *item), false, layoutChanged); + anyGeometryChanged = true; } - count->setText(QStringLiteral("%1 activit%2") - .arg(segment.items.size()) - .arg(segment.items.size() == 1 ? "y" : "ies")); - planAvailable->setVisible(typedPlanAvailable - || std::ranges::any_of(segment.items, [](const sdk::ItemState* item) { - return item && item->kind.is(frontend::ThreadItemKind::Plan); - })); - rowsLayout->invalidate(); - rowsLayout->activate(); - if (QLayout* hostLayout = host->layout()) - { - hostLayout->invalidate(); - hostLayout->activate(); - } - host->updateGeometry(); + const QString countText = QStringLiteral("%1 activit%2") + .arg(segment.items.size()) + .arg(segment.items.size() == 1 ? "y" : "ies"); + anyGeometryChanged = anyGeometryChanged || count->text() != countText; + count->setText(countText); + const bool nextPlanVisible = typedPlanAvailable + || std::ranges::any_of( + segment.items, [](const sdk::ItemState* item) { + return item && item->kind.is(frontend::ThreadItemKind::Plan); + }); + anyGeometryChanged = anyGeometryChanged + || planAvailable->isVisible() != nextPlanVisible; + planAvailable->setVisible(nextPlanVisible); + if (anyGeometryChanged) + { + rowsLayout->invalidate(); + if (QLayout* hostLayout = host->layout()) + hostLayout->invalidate(); + host->updateGeometry(); + } + if (geometryChanged) + *geometryChanged = anyGeometryChanged; + if (mayShrink) + *mayShrink = anyMayShrink; return true; } bool updateTimelineMessageSegment(QWidget* host, const TimelineSegment& segment, - bool* mayShrink, - const std::function& layoutChanged) + bool turnStreaming, + bool* geometryChanged, + bool* mayShrink) { if (!host || segment.missing || segment.items.size() != 1) return false; @@ -1645,7 +2547,8 @@ bool updateTimelineMessageSegment(QWidget* host, const QString previousTruncation = truncation->text(); const bool previousTruncationVisible = truncation->isVisible(); const QString previousKind = contentWidget->property("kind").toString(); - const MessagePresentation presentation = messagePresentation(*item, user); + const MessagePresentation presentation = messagePresentation( + *item, user, turnStreaming); if (mayShrink) { const QString nextKind = presentation.missing ? QStringLiteral("meta") @@ -1658,12 +2561,16 @@ bool updateTimelineMessageSegment(QWidget* host, auto* contentLayout = qobject_cast(contentWidget->parentWidget()->layout()); if (!contentLayout) return false; + QWidget* previousContentWidget = contentWidget; contentWidget = ensureMessageContentWidget( - contentLayout, contentWidget, presentation.content, layoutChanged); - applyMessagePresentation(status, - contentWidget, - truncation, - presentation); + contentLayout, contentWidget, presentation.content, presentation.streaming); + const bool rendererChanged = previousContentWidget != contentWidget; + if (mayShrink) + *mayShrink = *mayShrink || rendererChanged; + const bool presentationGeometryChanged = applyMessagePresentation( + status, contentWidget, truncation, presentation); + if (geometryChanged) + *geometryChanged = rendererChanged || presentationGeometryChanged; return true; } @@ -1776,24 +2683,19 @@ ConversationWidget::ConversationWidget(QWidget* parent) : QWidget(parent) scrollArea->setWidgetResizable(true); scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); auto* conversationScroll = scrollArea->verticalScrollBar(); - scrollAnimation = new QPropertyAnimation(conversationScroll, "value", this); - scrollAnimation->setEasingCurve(QEasingCurve::OutCubic); layoutSettleTimer = new QTimer(this); layoutSettleTimer->setSingleShot(true); layoutSettleTimer->setInterval(16); connect(layoutSettleTimer, &QTimer::timeout, this, &ConversationWidget::settleTimelineLayout); - connect(scrollAnimation, &QPropertyAnimation::finished, this, - [this] { followingLatest = false; }); connect(conversationScroll, &QScrollBar::rangeChanged, this, [this, conversationScroll](int, int maximum) { - if (pinLatestDuringLayout) + if (pinLatestDuringLayout || followingLatest) conversationScroll->setValue(maximum); }); connect(conversationScroll, &QScrollBar::actionTriggered, this, [this, conversationScroll](int) { - scrollAnimation->stop(); followingLatest = false; pendingFollowLatest = false; pendingPreviousScroll = conversationScroll->value(); @@ -1802,12 +2704,18 @@ ConversationWidget::ConversationWidget(QWidget* parent) : QWidget(parent) connect(conversationScroll, &QScrollBar::sliderPressed, this, [this, conversationScroll] { - scrollAnimation->stop(); followingLatest = false; pendingFollowLatest = false; pendingPreviousScroll = conversationScroll->value(); pendingViewportAnchor.clear(); }); + connect(conversationScroll, &QScrollBar::valueChanged, this, + [this, conversationScroll] + { + if (conversationScroll->maximum() - conversationScroll->value() > 72) + followingLatest = false; + requestDeferredPresentationAtTail(); + }); auto* content = new QWidget; content->setStyleSheet(QStringLiteral("background:transparent;")); auto* conversation = new QVBoxLayout(content); @@ -1861,10 +2769,48 @@ void ConversationWidget::setModelCatalog( upcomingTurnDock->setModelCatalog(catalog); } +bool ConversationWidget::shouldFreezePresentation(const QString& threadId, + bool newThreadDraft) const +{ + if (threadId.isEmpty() || threadId != renderedThreadId + || newThreadDraft != renderedNewThreadDraft || pinLatestDuringLayout) + return false; + const auto* bar = scrollArea->verticalScrollBar(); + return bar->maximum() - bar->value() > 72; +} + +void ConversationWidget::markPresentationDeferred() +{ + deferredPresentationPending = true; +} + +void ConversationWidget::requestDeferredPresentationAtTail() +{ + if (!deferredPresentationPending || deferredPresentationRequestScheduled) + return; + const auto* bar = scrollArea->verticalScrollBar(); + if (bar->maximum() - bar->value() > 72) + return; + + deferredPresentationRequestScheduled = true; + QTimer::singleShot(0, this, + [this] + { + deferredPresentationRequestScheduled = false; + if (!deferredPresentationPending) + return; + const auto* settledBar = scrollArea->verticalScrollBar(); + if (settledBar->maximum() - settledBar->value() > 72) + return; + deferredPresentationPending = false; + emit latestPresentationRequested(); + }); +} + void ConversationWidget::render(const sdk::State& state, const QString& threadId, bool newThreadDraft, - const QHash* exactContentChanges) + const ConversationContentUpdates* exactContentChanges) { auto* scrollBar = scrollArea->verticalScrollBar(); const int previousScroll = scrollBar->value(); @@ -1878,15 +2824,31 @@ void ConversationWidget::render(const sdk::State& state, newThreadDraft); if (!thread && !threadChanged && threadId.isEmpty()) return; + if (!threadChanged && shouldFreezePresentation(threadId, newThreadDraft)) + { + markPresentationDeferred(); + return; + } + if (exactContentChanges && !threadChanged && thread && !newThreadDraft + && updateExactMessageContent(state, threadId, *exactContentChanges)) + return; + if (threadChanged) + { + deferredPresentationPending = false; + deferredPresentationRequestScheduled = false; + } const bool followLatest = threadChanged || wasNearBottom || followingLatest; const bool exactContentOnly = exactContentChanges && !threadChanged && thread && !newThreadDraft && !renderedSummaryKey.isEmpty(); const std::uint64_t generation = ++renderGeneration; bool timelineShrank = false; + bool timelineGeometryChanged = threadChanged; if (threadChanged) pendingViewportAnchor.clear(); - else if (!layoutSettleTimer->isActive()) + else if (!followLatest && !layoutSettleTimer->isActive()) captureTimelineAnchor(); + else if (followLatest) + pendingViewportAnchor.clear(); renderedThreadId = threadId; renderedNewThreadDraft = newThreadDraft; if (threadChanged) @@ -1896,12 +2858,11 @@ void ConversationWidget::render(const sdk::State& state, pinLatestGeneration = generation; scrollArea->viewport()->setUpdatesEnabled(false); } - else - scrollAnimation->stop(); - const auto clearTimelineState = [this, &timelineShrank] + const auto clearTimelineState = [this, &timelineShrank, &timelineGeometryChanged] { timelineShrank = timelineShrank || timeline->count() > 0; + timelineGeometryChanged = timelineGeometryChanged || timeline->count() > 0; renderedTurnIds.clear(); renderedTurnWidgets.clear(); renderedTurnLabels.clear(); @@ -1940,6 +2901,7 @@ void ConversationWidget::render(const sdk::State& state, : QStringLiteral("No thread selected"), newThreadDraft ? QStringLiteral("Type a prompt below. Backend defaults will be used for the new thread.") : QStringLiteral("Choose a synchronized thread from the sidebar.")); + timelineGeometryChanged = true; } else { @@ -2004,6 +2966,7 @@ void ConversationWidget::render(const sdk::State& state, thread->fullyLoaded ? QStringLiteral("Use the upcoming-turn dock below to start this thread.") : QStringLiteral("No turn projection is currently retained for this thread.")); + timelineGeometryChanged = true; } } else @@ -2052,7 +3015,7 @@ void ConversationWidget::render(const sdk::State& state, for (const VisibleTimelineTurn& visibleTurn : visibleTurns) visibleTurnIds.append(fromUtf8(visibleTurn.turn->id.value)); - const auto removeRenderedTurn = [this, &timelineShrank](const QString& turnId) + const auto removeRenderedTurn = [this, &timelineShrank, &timelineGeometryChanged](const QString& turnId) { for (const QString& segmentId : renderedSegmentIds.take(turnId)) { @@ -2072,6 +3035,7 @@ void ConversationWidget::render(const sdk::State& state, widget->hide(); widget->deleteLater(); timelineShrank = true; + timelineGeometryChanged = true; } }; @@ -2136,6 +3100,7 @@ void ConversationWidget::render(const sdk::State& state, renderedTurnLabels.insert(turnId, turnLabel); renderedTurnItemLayouts.insert(turnId, itemLayout); renderedTurnStatusLabels.insert(turnId, statusLabel); + timelineGeometryChanged = true; } else { @@ -2231,6 +3196,7 @@ void ConversationWidget::render(const sdk::State& state, widget->hide(); widget->deleteLater(); timelineShrank = true; + timelineGeometryChanged = true; } renderedSegmentKeys.remove(storage); } @@ -2240,42 +3206,52 @@ void ConversationWidget::render(const sdk::State& state, { const QString storage = segmentStorageKey(turnId, segment->id); QWidget* oldWidget = renderedSegmentWidgets.value(storage); - const QStringList* exactChangedItemIds = nullptr; + const ConversationContentUpdates* segmentContentChanges = nullptr; + ConversationContentUpdates segmentContentStorage; bool explicitlyAffected = false; if (oldWidget && exactContentOnly) { - const auto changedItems = exactContentChanges->constFind(turnId); - explicitlyAffected = changedItems != exactContentChanges->cend() - && std::any_of( - segment->items.cbegin(), - segment->items.cend(), - [&changedItems](const sdk::ItemState* item) - { - return item - && changedItems->contains( - fromUtf8(item->id.value)); - }); + for (const ConversationContentUpdate& update : *exactContentChanges) + { + if (update.turnId != turnId) + continue; + const bool segmentContainsItem = std::any_of( + segment->items.cbegin(), + segment->items.cend(), + [&update](const sdk::ItemState* item) + { + return item && update.itemId == fromUtf8(item->id.value); + }); + if (segmentContainsItem) + segmentContentStorage.push_back(update); + } + explicitlyAffected = !segmentContentStorage.empty(); if (!explicitlyAffected) continue; - exactChangedItemIds = &changedItems.value(); + segmentContentChanges = &segmentContentStorage; } const bool typedPlanAvailable = turn->plan.has_value(); + const bool turnStreaming = turnStreamsMessages(*turn); const QByteArray segmentKey = segmentPresentationKey( - state, *segment, typedPlanAvailable); + state, *segment, typedPlanAvailable, turnStreaming); if (oldWidget && !explicitlyAffected && renderedSegmentKeys.value(storage) == segmentKey) continue; bool messageMayShrink = false; + bool messageGeometryChanged = false; if (oldWidget && updateTimelineMessageSegment( oldWidget, *segment, - &messageMayShrink, - [this] { activityLayoutChanged(); })) + turnStreaming, + &messageGeometryChanged, + &messageMayShrink)) { renderedSegmentKeys.insert(storage, segmentKey); timelineShrank = timelineShrank || messageMayShrink; + timelineGeometryChanged = timelineGeometryChanged + || messageGeometryChanged; continue; } @@ -2285,11 +3261,15 @@ void ConversationWidget::render(const sdk::State& state, state, *segment, typedPlanAvailable, - exactChangedItemIds, + segmentContentChanges, + &messageGeometryChanged, + &messageMayShrink, [this] { activityLayoutChanged(); })) { renderedSegmentKeys.insert(storage, segmentKey); - timelineShrank = true; + timelineShrank = timelineShrank || messageMayShrink; + timelineGeometryChanged = timelineGeometryChanged + || messageGeometryChanged; continue; } @@ -2298,6 +3278,7 @@ void ConversationWidget::render(const sdk::State& state, state, *segment, typedPlanAvailable, + turnStreaming, expansion, [this] { activityLayoutChanged(); }); newWidget->setProperty("turnId", turnId); @@ -2314,10 +3295,12 @@ void ConversationWidget::render(const sdk::State& state, if (replacesAnchor) pendingViewportAnchor = newWidget; timelineShrank = true; + timelineGeometryChanged = true; } else { itemLayout->addWidget(newWidget, 0, Qt::AlignTop); + timelineGeometryChanged = true; } renderedSegmentWidgets.insert(storage, newWidget); renderedSegmentKeys.insert(storage, segmentKey); @@ -2328,96 +3311,146 @@ void ConversationWidget::render(const sdk::State& state, } } - scheduleTimelineLayout(previousScroll, followLatest, threadChanged, timelineShrank); + if (timelineGeometryChanged) + scheduleTimelineLayout(previousScroll, followLatest, threadChanged, timelineShrank); + else if (followLatest) + scrollBar->setValue(scrollBar->maximum()); } bool ConversationWidget::updateExactMessageContent( const sdk::State& state, const QString& threadId, - const QHash& exactContentChanges) + const ConversationContentUpdates& exactContentChanges) { if (threadId.isEmpty() || renderedThreadId != threadId || renderedNewThreadDraft - || exactContentChanges.isEmpty()) - return false; - const auto* thread = state.thread(threadId.toStdString()); - if (!thread) + || exactContentChanges.empty()) return false; - - struct PendingMessageUpdate + if (shouldFreezePresentation(threadId, false)) { - QString storage; - QWidget* widget = nullptr; - TimelineSegment segment; - QByteArray presentationKey; - }; - std::vector updates; - for (auto turnIterator = exactContentChanges.cbegin(); - turnIterator != exactContentChanges.cend(); - ++turnIterator) + markPresentationDeferred(); + return true; + } + auto* scrollBar = scrollArea->verticalScrollBar(); + const int previousScroll = scrollBar->value(); + const bool followLatest = scrollBar->maximum() - previousScroll <= 72 + || followingLatest; + if (!followLatest && !layoutSettleTimer->isActive()) + captureTimelineAnchor(); + else if (followLatest) + pendingViewportAnchor.clear(); + + bool timelineShrank = false; + bool geometryChanged = false; + for (const ConversationContentUpdate& update : exactContentChanges) { - const ai::openai::codex::typed::TurnId turnIdentity{turnIterator.key().toStdString()}; - const auto* turn = state.turn(thread->id, turnIdentity); - if (!turn) + if (!update.append) return false; - const bool turnVisible = renderedTurnIds.contains(turnIterator.key()); - for (const QString& itemId : turnIterator.value()) + if (!renderedTurnIds.contains(update.turnId)) + continue; + + const QString messageSegmentId = QStringLiteral("message:") + update.itemId; + const QString messageStorage = segmentStorageKey(update.turnId, messageSegmentId); + QWidget* messageWidget = renderedSegmentWidgets.value(messageStorage); + bool contentMayShrink = false; + bool contentGeometryChanged = false; + QString affectedStorage; + if (messageWidget) { - const ai::openai::codex::typed::ItemId itemIdentity{itemId.toStdString()}; - const auto* item = state.item(thread->id, turn->id, itemIdentity); - if (!item) + if (messageWidget->property("messageUser").toBool() + || update.channel != sdk::ItemContentChannel::AgentText) + return false; + const auto expectedBytes = exactAppendResultBytes(*update.append); + const auto descriptor = state.itemContentDescriptor( + ai::openai::codex::typed::ThreadId{threadId.toStdString()}, + ai::openai::codex::typed::TurnId{update.turnId.toStdString()}, + ai::openai::codex::typed::ItemId{update.itemId.toStdString()}, + update.channel); + if (!expectedBytes || !descriptor || !descriptor->present + || descriptor->retainedUtf8Bytes != *expectedBytes) + return false; + auto* content = messageWidget->findChild( + QStringLiteral("conversationMessageContent")); + if (!content) + return false; + const QByteArray currentUtf8 = messageContentText(content).toUtf8(); + if (static_cast(currentUtf8.size()) + != update.append->baseContentBytes + || update.append->discardPrefixBytes + > update.append->baseContentBytes) return false; - const bool user = item->kind.is(frontend::ThreadItemKind::UserMessage); - const bool agent = item->kind.is(frontend::ThreadItemKind::AgentMessage); - if (!user && !agent) + + auto* contentLayout = qobject_cast( + content->parentWidget()->layout()); + if (!contentLayout) + return false; + QWidget* const previousContent = content; + // Content deltas are the authoritative streaming boundary. Some + // provider/result items report a terminal-looking status before + // their final append arrives, so status alone must not repeatedly + // send the growing text through the Markdown renderer. + content = ensureMessageContentWidget( + contentLayout, + content, + QString::fromUtf8(currentUtf8), + true); + const auto applied = appendMessageContent( + content, + update.append->baseContentBytes, + update.append->discardPrefixBytes, + update.append->delta); + if (!applied) + return false; + contentGeometryChanged = previousContent != content || *applied; + contentMayShrink = update.append->discardPrefixBytes + > update.append->deltaUtf8Bytes; + affectedStorage = messageStorage; + } + else + { + QWidget* activityRow = nullptr; + for (const QString& segmentId : renderedSegmentIds.value(update.turnId)) { - if (turnVisible) - return false; - continue; + QWidget* candidate = renderedSegmentWidgets.value( + segmentStorageKey(update.turnId, segmentId)); + if (!candidate) + continue; + const auto rows = candidate->findChildren( + QStringLiteral("conversationActivityRow")); + const auto found = std::find_if( + rows.cbegin(), rows.cend(), [&update](const QWidget* row) { + return row->property("itemId").toString() == update.itemId; + }); + if (found == rows.cend()) + continue; + activityRow = *found; + affectedStorage = segmentStorageKey(update.turnId, segmentId); + break; } - if (!turnVisible) - continue; - - const QString segmentId = QStringLiteral("message:") + itemId; - if (!renderedSegmentIds.value(turnIterator.key()).contains(segmentId)) + if (!activityRow) continue; - const QString storage = segmentStorageKey(turnIterator.key(), segmentId); - QWidget* widget = renderedSegmentWidgets.value(storage); - if (!widget || !widget->property("messageUser").isValid() - || widget->property("messageUser").toBool() != user) + if (!applyExactActivityAppend( + state, + ai::openai::codex::typed::ThreadId{threadId.toStdString()}, + activityRow, + update, + &contentGeometryChanged, &contentMayShrink)) return false; - TimelineSegment segment{segmentId, {item}, false}; - updates.push_back( - {storage, - widget, - segment, - segmentPresentationKey(state, segment, turn->plan.has_value())}); } + renderedSegmentKeys.remove(affectedStorage); + timelineShrank = timelineShrank || contentMayShrink; + geometryChanged = geometryChanged || contentGeometryChanged; } - - if (updates.empty()) - return true; - auto* scrollBar = scrollArea->verticalScrollBar(); - const int previousScroll = scrollBar->value(); - const bool followLatest = scrollBar->maximum() - previousScroll <= 72 - || followingLatest; - if (!layoutSettleTimer->isActive()) - captureTimelineAnchor(); - scrollAnimation->stop(); - - bool timelineShrank = false; - for (PendingMessageUpdate& update : updates) - { - bool messageMayShrink = false; - if (!updateTimelineMessageSegment( - update.widget, - update.segment, - &messageMayShrink, - [this] { activityLayoutChanged(); })) - return false; - renderedSegmentKeys.insert(update.storage, update.presentationKey); - timelineShrank = timelineShrank || messageMayShrink; + if (geometryChanged) + { + // Document and layout repaints are queued. Hide the intermediate old + // extent until the existing settle pass has resized and pinned the + // conversation, then expose one final frame. + if (followLatest && scrollArea->viewport()->updatesEnabled()) + scrollArea->viewport()->setUpdatesEnabled(false); + scheduleTimelineLayout(previousScroll, followLatest, false, timelineShrank); } - scheduleTimelineLayout(previousScroll, followLatest, false, timelineShrank); + else if (followLatest) + scrollBar->setValue(scrollBar->maximum()); return true; } @@ -2442,9 +3475,10 @@ void ConversationWidget::activityLayoutChanged() const int previousScroll = bar->value(); const bool followLatest = bar->maximum() - previousScroll <= 72 || followingLatest; - if (!layoutSettleTimer->isActive()) + if (!followLatest && !layoutSettleTimer->isActive()) captureTimelineAnchor(); - scrollAnimation->stop(); + else if (followLatest) + pendingViewportAnchor.clear(); scheduleTimelineLayout(previousScroll, followLatest, false, true); } @@ -2488,17 +3522,24 @@ void ConversationWidget::settleTimelineLayout() if (threadChanged || pinLatestDuringLayout) { - scrollAnimation->stop(); - followingLatest = false; - settleThreadSwitchLayout(pinLatestGeneration, 2); + settleThreadSwitchLayout(pinLatestGeneration, 1); return; } synchronizeTimelineHeight(timelineShrank); scrollArea->widget()->layout()->activate(); - scrollArea->widget()->adjustSize(); auto* bar = scrollArea->verticalScrollBar(); + if (followLatest) + { + pendingViewportAnchor.clear(); + followingLatest = !renderedThreadId.isEmpty(); + bar->setValue(bar->maximum()); + if (!scrollArea->viewport()->updatesEnabled()) + scrollArea->viewport()->setUpdatesEnabled(true); + return; + } + bar->setValue(qMin(previousScroll, bar->maximum())); if (pendingViewportAnchor) { @@ -2509,30 +3550,8 @@ void ConversationWidget::settleTimelineLayout() } pendingViewportAnchor.clear(); if (!scrollArea->viewport()->updatesEnabled()) - { scrollArea->viewport()->setUpdatesEnabled(true); - scrollArea->viewport()->update(); - } - if (!followLatest) - { - scrollAnimation->stop(); - followingLatest = false; - return; - } - - scrollAnimation->stop(); - const int distance = bar->maximum() - bar->value(); - if (distance <= 0 || renderedThreadId.isEmpty()) - { - followingLatest = false; - bar->setValue(bar->maximum()); - return; - } - followingLatest = true; - scrollAnimation->setDuration(qBound(90, distance, 220)); - scrollAnimation->setStartValue(bar->value()); - scrollAnimation->setEndValue(bar->maximum()); - scrollAnimation->start(); + followingLatest = false; } void ConversationWidget::settleThreadSwitchLayout(std::uint64_t generation, int remainingPasses) @@ -2554,33 +3573,10 @@ void ConversationWidget::settleThreadSwitchLayout(std::uint64_t generation, int auto* bar = scrollArea->verticalScrollBar(); bar->setValue(bar->maximum()); - QTimer::singleShot(100, this, - [this, generation] - { - if (generation != pinLatestGeneration || !pinLatestDuringLayout) - return; - synchronizeTimelineHeight(true); - scrollArea->widget()->layout()->activate(); - scrollArea->widget()->adjustSize(); - auto* settledBar = scrollArea->verticalScrollBar(); - settledBar->setValue(settledBar->maximum()); - scrollArea->viewport()->setUpdatesEnabled(true); - scrollArea->viewport()->update(); - QTimer::singleShot(100, this, - [this, generation] - { - if (generation != pinLatestGeneration - || !pinLatestDuringLayout) - return; - synchronizeTimelineHeight(true); - scrollArea->widget()->layout()->activate(); - scrollArea->widget()->adjustSize(); - auto* finalBar = scrollArea->verticalScrollBar(); - finalBar->setValue(finalBar->maximum()); - pinLatestDuringLayout = false; - pendingViewportAnchor.clear(); - }); - }); + followingLatest = true; + pinLatestDuringLayout = false; + pendingViewportAnchor.clear(); + scrollArea->viewport()->setUpdatesEnabled(true); } void ConversationWidget::synchronizeTimelineHeight(bool allowShrink) diff --git a/src/ui/ConversationWidget.h b/src/ui/ConversationWidget.h index 0993d67..fabe2c3 100644 --- a/src/ui/ConversationWidget.h +++ b/src/ui/ConversationWidget.h @@ -5,6 +5,8 @@ #include "app/AttachmentManager.h" +#include + #include #include @@ -14,11 +16,11 @@ #include #include +#include #include class QFrame; class QLabel; -class QPropertyAnimation; class QResizeEvent; class QScrollArea; class QTimer; @@ -37,6 +39,25 @@ class AnchoredTurnSurface; class UpcomingTurnDock; struct UpcomingTurnDraft; +struct ConversationContentAppend +{ + std::uint64_t baseContentBytes = 0; + std::uint64_t discardPrefixBytes = 0; + std::uint64_t deltaUtf8Bytes = 0; + QString delta; +}; + +struct ConversationContentUpdate +{ + QString turnId; + QString itemId; + ai::openai::codex::frontend::client::ItemContentChannel channel = + ai::openai::codex::frontend::client::ItemContentChannel::AgentText; + std::optional append; +}; + +using ConversationContentUpdates = std::vector; + class ConversationWidget : public QWidget { Q_OBJECT @@ -46,12 +67,12 @@ class ConversationWidget : public QWidget void render(const ai::openai::codex::frontend::client::State& state, const QString& threadId, bool newThreadDraft = false, - const QHash* exactContentChanges = nullptr); + const ConversationContentUpdates* exactContentChanges = nullptr); void setModelCatalog(const std::vector& catalog); [[nodiscard]] bool updateExactMessageContent( const ai::openai::codex::frontend::client::State& state, const QString& threadId, - const QHash& exactContentChanges); + const ConversationContentUpdates& exactContentChanges); void clearPrompt(); void clearPromptIfUnchanged(const QString& submittedPrompt); [[nodiscard]] const QList& attachments() const noexcept; @@ -76,6 +97,7 @@ class ConversationWidget : public QWidget void stopRequested(); void upcomingTurnSettingsChanged(); void turnDetailsRequested(const QString& turnId); + void latestPresentationRequested(); protected: void resizeEvent(QResizeEvent* event) override; @@ -90,6 +112,10 @@ class ConversationWidget : public QWidget void settleThreadSwitchLayout(std::uint64_t generation, int remainingPasses); void synchronizeTimelineHeight(bool allowShrink = true); void activityLayoutChanged(); + [[nodiscard]] bool shouldFreezePresentation(const QString& threadId, + bool newThreadDraft) const; + void markPresentationDeferred(); + void requestDeferredPresentationAtTail(); AnchoredTurnSurface* anchoredSurface = nullptr; UpcomingTurnDock* upcomingTurnDock = nullptr; QLabel* contextPath = nullptr; @@ -101,7 +127,6 @@ class ConversationWidget : public QWidget QLabel* timelineWindowDetail = nullptr; QWidget* timelineHost = nullptr; QVBoxLayout* timeline = nullptr; - QPropertyAnimation* scrollAnimation = nullptr; QTimer* layoutSettleTimer = nullptr; QString renderedThreadId; // Identity only; conversation content remains owned by immutable AISuite State. @@ -123,6 +148,8 @@ class ConversationWidget : public QWidget bool pendingThreadChanged = false; bool pendingTimelineShrink = false; bool resizeLayoutPending = false; + bool deferredPresentationPending = false; + bool deferredPresentationRequestScheduled = false; int pendingPreviousScroll = 0; int pendingViewportAnchorY = 0; bool renderedNewThreadDraft = false; diff --git a/src/ui/WorkbenchWidget.cpp b/src/ui/WorkbenchWidget.cpp index cb8ded8..43a7c2f 100644 --- a/src/ui/WorkbenchWidget.cpp +++ b/src/ui/WorkbenchWidget.cpp @@ -277,6 +277,8 @@ WorkbenchWidget::WorkbenchWidget(FrontendSession& session, QWidget* parent) selectedInspectorTurnId); inspector->showInfo(); }); + connect(conversation, &ConversationWidget::latestPresentationRequested, this, + [this] { refreshState(true, false, false); }); connect(&frontendSession, &FrontendSession::lifecycleChanged, this, &WorkbenchWidget::refreshLifecycle); connect(&frontendSession, &FrontendSession::statusChanged, this, &WorkbenchWidget::refreshLifecycle); connect(&frontendSession, &FrontendSession::stateChanged, this, &WorkbenchWidget::scheduleStateRefresh); @@ -335,9 +337,48 @@ void WorkbenchWidget::scheduleStateRefresh(const detail::StateUpdateScope& scope if (identity.threadId != selectedThreadId) continue; foundExactContent = true; - QStringList& itemIds = selectedContentRefreshPending[identity.turnId]; - if (!itemIds.contains(identity.itemId)) - itemIds.append(identity.itemId); + auto existing = std::find_if( + selectedContentRefreshPending.begin(), + selectedContentRefreshPending.end(), + [&identity](const ConversationContentUpdate& update) + { + return update.turnId == identity.turnId + && update.itemId == identity.itemId + && update.channel == identity.channel; + }); + ConversationContentUpdate next{ + identity.turnId, + identity.itemId, + identity.channel, + std::nullopt}; + if (identity.append) + { + next.append = ConversationContentAppend{ + identity.append->baseContentBytes, + identity.append->discardPrefixBytes, + static_cast(identity.append->deltaUtf8.size()), + QString::fromUtf8(identity.append->deltaUtf8)}; + } + if (existing == selectedContentRefreshPending.end()) + { + selectedContentRefreshPending.push_back(std::move(next)); + } + else if (existing->append && next.append + && existing->append->discardPrefixBytes == 0 + && next.append->discardPrefixBytes == 0 + && existing->append->baseContentBytes + + existing->append->deltaUtf8Bytes + == next.append->baseContentBytes) + { + existing->append->delta.append(next.append->delta); + existing->append->deltaUtf8Bytes += next.append->deltaUtf8Bytes; + } + else + { + // Ambiguous, rolling, or replacement updates retain the + // authoritative State fallback instead of guessing a delta. + existing->append.reset(); + } } // A conversation-affecting update without an exact item identity // must retain the existing bounded full reconciliation. @@ -364,8 +405,8 @@ void WorkbenchWidget::scheduleStateRefresh(const detail::StateUpdateScope& scope const bool refreshSidebar = sidebarRefreshPending; const bool exactContentOnly = refreshSelectedPresentation && !selectedPresentationFullRefreshPending - && !selectedContentRefreshPending.isEmpty(); - QHash exactContentChanges = std::move(selectedContentRefreshPending); + && !selectedContentRefreshPending.empty(); + ConversationContentUpdates exactContentChanges = std::move(selectedContentRefreshPending); selectedPresentationRefreshPending = false; selectedPresentationFullRefreshPending = false; selectedContentRefreshPending.clear(); @@ -446,7 +487,7 @@ void WorkbenchWidget::refreshLifecycle() void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, bool refreshInspector, bool refreshSidebar, - const QHash* exactContentChanges) + const ConversationContentUpdates* exactContentChanges) { stateRefreshPending = false; selectedPresentationRefreshPending = false; diff --git a/src/ui/WorkbenchWidget.h b/src/ui/WorkbenchWidget.h index 38d1c63..7c19093 100644 --- a/src/ui/WorkbenchWidget.h +++ b/src/ui/WorkbenchWidget.h @@ -4,6 +4,7 @@ #define CODEXUI_UI_WORKBENCHWIDGET_H #include "ui/InteractiveRequestDialog.h" +#include "ui/ConversationWidget.h" #include "ui/ThreadSetupDialog.h" #include "ui/UpcomingTurnDock.h" @@ -80,7 +81,7 @@ class WorkbenchWidget : public QWidget void refreshState(bool refreshSelectedPresentation = true, bool refreshInspector = true, bool refreshSidebar = true, - const QHash* exactContentChanges = nullptr); + const ConversationContentUpdates* exactContentChanges = nullptr); void refreshControls(); void refreshControllerStatus(); [[nodiscard]] bool writeOperationBusy() const noexcept; @@ -197,7 +198,7 @@ class WorkbenchWidget : public QWidget bool stateRefreshPending = false; bool selectedPresentationRefreshPending = false; bool selectedPresentationFullRefreshPending = false; - QHash selectedContentRefreshPending; + ConversationContentUpdates selectedContentRefreshPending; bool inspectorRefreshPending = false; bool sidebarRefreshPending = false; }; diff --git a/tests/ConversationLayoutTest.cpp b/tests/ConversationLayoutTest.cpp index e8612ec..c4864a3 100644 --- a/tests/ConversationLayoutTest.cpp +++ b/tests/ConversationLayoutTest.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include @@ -45,6 +47,7 @@ struct MessageFixture bool textTruncated = false; bool genericItemTruncatedOnly = false; std::string command; + std::string reasoningSummary; }; struct TurnFixture @@ -52,6 +55,10 @@ struct TurnFixture std::string id; std::vector messages; std::optional plan; + std::string status = "completed"; + bool active = false; + bool terminal = true; + bool connectionInvalidated = false; }; struct ThreadFixture @@ -75,6 +82,30 @@ bool expectAtLeast(int actual, int required, const char* message) return false; } +class ActivityTopLevelShowMonitor final : public QObject +{ +public: + [[nodiscard]] bool empty() const noexcept + { + return unexpectedObjectNames.isEmpty(); + } + +protected: + bool eventFilter(QObject* watched, QEvent* event) override + { + if (event->type() == QEvent::Show) { + const auto* widget = qobject_cast(watched); + if (widget && widget->isWindow() + && widget->objectName().startsWith(QStringLiteral("conversationActivity"))) + unexpectedObjectNames.append(widget->objectName()); + } + return QObject::eventFilter(watched, event); + } + +private: + QStringList unexpectedObjectNames; +}; + void settleEvents(int passes = 3, int delayMs = 25) { for (int pass = 0; pass < passes; ++pass) { @@ -134,8 +165,11 @@ frontend::Json messageJson(const std::string& threadId, {"status", fixture.status}, {"summary", summary}, {"agentText", fixture.kind == frontend::ThreadItemKind::AgentMessage ? fixture.text : ""}, - {"reasoningText", ""}, - {"reasoningSummary", ""}, + {"reasoningText", + fixture.kind == frontend::ThreadItemKind::Reasoning + ? fixture.text + : std::string{}}, + {"reasoningSummary", fixture.reasoningSummary}, {"commandOutput", initialCommandOutput}, {"droppedContentBytes", 0}, {"contentTruncated", @@ -200,9 +234,10 @@ client::State makeState(const std::vector& fixtures) items.push_back(messageJson(threadFixture.id, turnFixture.id, message)); frontend::Json turn{{"id", turnFixture.id}, {"threadId", threadFixture.id}, - {"status", "completed"}, - {"active", false}, - {"terminal", true}, + {"status", turnFixture.status}, + {"active", turnFixture.active}, + {"terminal", turnFixture.terminal}, + {"connectionInvalidated", turnFixture.connectionInvalidated}, {"effectiveExecutionConfiguration", executionConfiguration}, {"effectiveExecutionConfigurationProvenance", "turn_start_accepted"}, {"items", std::move(items)}, @@ -281,6 +316,33 @@ client::State makeState(const std::vector& fixtures) return sdk.state(); } +codexui::ConversationContentUpdates replacementUpdate( + QString turnId, + QString itemId, + client::ItemContentChannel channel) +{ + return {{std::move(turnId), std::move(itemId), channel, std::nullopt}}; +} + +codexui::ConversationContentUpdates appendUpdate( + QString turnId, + QString itemId, + client::ItemContentChannel channel, + std::uint64_t baseContentBytes, + QString delta, + std::uint64_t discardPrefixBytes = 0) +{ + const std::uint64_t deltaBytes = static_cast(delta.toUtf8().size()); + return {{std::move(turnId), + std::move(itemId), + channel, + codexui::ConversationContentAppend{ + baseContentBytes, + discardPrefixBytes, + deltaBytes, + std::move(delta)}}}; +} + ThreadFixture sequentialTurns(std::string threadId, int turnCount) { ThreadFixture result{std::move(threadId), {}}; @@ -346,11 +408,28 @@ QLabel* messageLabel(QWidget* messageSegment, const QString& objectName) return messageSegment ? messageSegment->findChild(objectName) : nullptr; } +QWidget* messageContent(QWidget* messageSegment) +{ + return messageSegment + ? messageSegment->findChild( + QStringLiteral("conversationMessageContent")) + : nullptr; +} + QString messageSourceText(const QLabel* label) { return label ? label->property("sourceText").toString() : QString{}; } +QString messageSourceText(const QWidget* widget) +{ + if (const auto* label = qobject_cast(widget)) + return messageSourceText(label); + if (const auto* editor = qobject_cast(widget)) + return editor->toPlainText(); + return {}; +} + bool segmentHasLabel(QWidget* messageSegment, const QString& text) { if (!messageSegment) @@ -359,6 +438,10 @@ bool segmentHasLabel(QWidget* messageSegment, const QString& text) if (label->text() == text || messageSourceText(label) == text) return true; } + for (QTextEdit* editor : messageSegment->findChildren()) { + if (editor->toPlainText() == text) + return true; + } return false; } @@ -368,6 +451,10 @@ bool hasLabel(codexui::ConversationWidget& conversation, const QString& text) if (label->text() == text || messageSourceText(label) == text) return true; } + for (QTextEdit* editor : conversation.findChildren()) { + if (editor->toPlainText() == text) + return true; + } return false; } @@ -530,8 +617,45 @@ bool testHotTurnWindow() && renderedActivities == activityHost->property("renderedTimelineItems").toLongLong() && activityHost->property("retainedTimelineItems").toLongLong() == 300, "a contiguous activity run must be chunked and remain within the same global item budget"); - passed &= expect(hasLabel(activityConversation, QStringLiteral("activity activity 299")), - "the bounded activity window must retain its newest exact detail"); + QWidget* newestActivityRow = nullptr; + for (QWidget* row : activityConversation.findChildren( + QStringLiteral("conversationActivityRow"))) + { + if (row->property("itemId").toString() == QStringLiteral("item-activity-299")) + { + newestActivityRow = row; + break; + } + } + auto* newestActivityDetails = newestActivityRow + ? newestActivityRow->findChild( + QStringLiteral("conversationActivityDetails")) + : nullptr; + auto* newestActivityDisclosure = newestActivityRow + ? newestActivityRow->findChild( + QStringLiteral("activityDisclosure")) + : nullptr; + passed &= expect(newestActivityRow && newestActivityDetails + && newestActivityDetails->isHidden() + && !newestActivityRow->findChild( + QStringLiteral("conversationActivityDetail")) + && newestActivityDetails->property("detailMaterializationCount").toULongLong() == 0 + && newestActivityDetails->property("deferredDetailBytes").toULongLong() + == std::string_view("activity activity 299").size(), + "the bounded activity window must retain its newest detail without materializing collapsed text"); + if (newestActivityDisclosure) + newestActivityDisclosure->click(); + settleTimeline(); + auto* newestActivityDetail = newestActivityRow + ? newestActivityRow->findChild( + QStringLiteral("conversationActivityDetail")) + : nullptr; + passed &= expect(newestActivityDetail + && newestActivityDetail->toPlainText() + == QStringLiteral("activity activity 299") + && newestActivityDetails + && newestActivityDetails->property("detailMaterializationCount").toULongLong() == 1, + "expanding the newest bounded activity must materialize its exact retained detail once"); const auto activityCards = activityConversation.findChildren( QStringLiteral("conversationActivityCard")); passed &= expect(!activityCards.isEmpty() @@ -566,7 +690,10 @@ bool testActivityDisclosureAndFullOutput() codexui::ConversationWidget conversation; conversation.resize(900, 700); conversation.show(); + ActivityTopLevelShowMonitor topLevelShowMonitor; + qApp->installEventFilter(&topLevelShowMonitor); conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); + qApp->removeEventFilter(&topLevelShowMonitor); settleTimeline(); auto* card = conversation.findChild(QStringLiteral("conversationActivityCard")); @@ -589,10 +716,14 @@ bool testActivityDisclosureAndFullOutput() } bool passed = true; + passed &= expect(topLevelShowMonitor.empty(), + "rendering activity content must never show temporary top-level widgets"); passed &= expect(card && body && !body->isHidden() && row && details && details->isHidden(), "an activity group must start expanded while each activity starts collapsed"); - passed &= expect(detailDisclosure && detailDisclosure->isCheckable() - && !row->findChild(QStringLiteral("conversationActivityOutput")), + passed &= expect(row && details && detailDisclosure && detailDisclosure->isCheckable() + && !row->findChild(QStringLiteral("conversationActivityOutput")) + && details->property("outputMaterializationCount").toULongLong() == 0 + && details->property("deferredOutputBytes").toULongLong() == output.size(), "a collapsed activity must not materialize a potentially large output document"); passed &= expect(groupDisclosure && !groupDisclosure->styleSheet().contains( @@ -625,14 +756,17 @@ bool testActivityDisclosureAndFullOutput() + std::string(72 * 1024, 'c') + "\ncanonical collapsed-update final sentinel"; fixture.turns.front().messages.front().text = output; - const QHash exactOutputChange{ - {QStringLiteral("turn-activity-detail"), - QStringList{QStringLiteral("command-activity-detail")}}}; + const auto exactOutputChange = replacementUpdate( + QStringLiteral("turn-activity-detail"), + QStringLiteral("command-activity-detail"), + client::ItemContentChannel::CommandOutput); conversation.render( makeState({fixture}), QStringLiteral("activity-detail"), false, &exactOutputChange); settleTimeline(); - passed &= expect(row == rowAddress && details && details->isHidden() - && !row->findChild(QStringLiteral("conversationActivityOutput")), + passed &= expect(row && row == rowAddress && details && details->isHidden() + && !row->findChild(QStringLiteral("conversationActivityOutput")) + && details->property("outputMaterializationCount").toULongLong() == 0 + && details->property("deferredOutputBytes").toULongLong() == output.size(), "a canonical output update must keep a collapsed activity lazy until expansion"); const int collapsedActivityHeight = timeline(conversation) ? timeline(conversation)->height() @@ -645,7 +779,9 @@ bool testActivityDisclosureAndFullOutput() auto* outputHeading = row ? row->findChild(QStringLiteral("conversationActivityOutputHeading")) : nullptr; - passed &= expect(details && !details->isHidden() && outputView && outputView->isVisible(), + passed &= expect(details && !details->isHidden() && outputView && outputView->isVisible() + && details->property("outputMaterializationCount").toULongLong() == 1 + && details->property("deferredOutputBytes").toULongLong() == output.size(), "an individual activity disclosure must materialize and reveal its complete output"); passed &= expect(outputView && outputView->styleSheet().contains( @@ -672,18 +808,34 @@ bool testActivityDisclosureAndFullOutput() "expanding an activity must grow the fixed timeline host and its scroll range"); QPlainTextEdit* const outputAddress = outputView.data(); - output += "\nstreamed continuation"; + const std::uint64_t previousOutputBytes = output.size(); + const QString outputDelta = QStringLiteral("\nstreamed continuation"); + output += outputDelta.toStdString(); fixture.turns.front().messages.front().text = output; - fixture.turns.front().messages.front().status = "completed"; + const auto exactOutputAppend = appendUpdate( + QStringLiteral("turn-activity-detail"), + QStringLiteral("command-activity-detail"), + client::ItemContentChannel::CommandOutput, + previousOutputBytes, + outputDelta); conversation.render( - makeState({fixture}), QStringLiteral("activity-detail"), false, &exactOutputChange); + makeState({fixture}), QStringLiteral("activity-detail"), false, &exactOutputAppend); settleTimeline(); - auto* statusSymbol = row ? row->findChild(QStringLiteral("conversationActivitySymbol")) : nullptr; passed &= expect(row && row == rowAddress && outputView && outputView.data() == outputAddress && outputView->toPlainText() == QString::fromStdString(output) - && details && !details->isHidden() && statusSymbol - && statusSymbol->text() == QStringLiteral("✓"), - "streaming command output and completion status must update the expanded activity in place"); + && outputView->property("streamAppendCount").toULongLong() == 1 + && details + && details->property("outputMaterializationCount").toULongLong() == 1 + && details->property("deferredOutputBytes").toULongLong() == output.size() + && !details->isHidden(), + "streaming command output must update the expanded activity through the cursor append path"); + fixture.turns.front().messages.front().status = "completed"; + conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); + settleTimeline(); + auto* statusSymbol = row ? row->findChild(QStringLiteral("conversationActivitySymbol")) : nullptr; + passed &= expect(row == rowAddress && outputView && outputView.data() == outputAddress + && statusSymbol && statusSymbol->text() == QStringLiteral("✓"), + "the terminal item update must reconcile status without replacing streamed output"); const int longActivityHeight = timeline(conversation) ? timeline(conversation)->height() : 0; fixture.turns.front().messages.front().command = "true"; @@ -802,14 +954,32 @@ bool testPointerPreservingAppend() beforeFixture.turns.front().messages.push_back( {"item-append-257", frontend::ThreadItemKind::AgentMessage, "final answer"}); const client::State after = makeState({beforeFixture}); + int latestPresentationRequests = 0; + QObject::connect(&conversation, + &codexui::ConversationWidget::latestPresentationRequested, + &conversation, + [&latestPresentationRequests] { ++latestPresentationRequests; }); conversation.render(after, QStringLiteral("append")); settleTimeline(); QWidget* host = timeline(conversation); - const int anchorYAfter = scroll && readingAnchor - ? scroll->viewport()->mapFromGlobal(readingAnchor->mapToGlobal(QPoint{})).y() - : 0; + const int frozenAnchorY = scroll && readingAnchor + ? scroll->viewport()->mapFromGlobal(readingAnchor->mapToGlobal(QPoint{})).y() + : 0; bool passed = true; + passed &= expect(readingHistory && evicted && survivor + && survivor.data() == survivorAddress + && qAbs(frozenAnchorY - anchorYBefore) <= 2 + && !segment(conversation, QStringLiteral("message:item-append-256")), + "an off-bottom reader must see a completely frozen presentation while canonical state advances"); + if (scroll) + scroll->verticalScrollBar()->setValue(scroll->verticalScrollBar()->maximum()); + settleEvents(); + passed &= expect(latestPresentationRequests == 1, + "returning to the tail must request exactly one latest authoritative presentation"); + conversation.render(after, QStringLiteral("append")); + settleTimeline(); + passed &= expect(!evicted && survivor && survivor.data() == survivorAddress && survivor.data() == segment(conversation, QStringLiteral("message:item-append-2")), "rolling the bounded head must preserve every overlapping segment widget"); @@ -821,8 +991,6 @@ bool testPointerPreservingAppend() passed &= expect(host && host->property("renderedTimelineItems").toLongLong() <= host->property("maximumRenderedItems").toLongLong(), "appending at the rolling boundary must keep the live-item count bounded"); - passed &= expect(readingHistory && readingAnchor && qAbs(anchorYAfter - anchorYBefore) <= 2, - "rolling the bounded head must preserve a reader's surviving viewport segment"); codexui::ConversationWidget followingConversation; followingConversation.resize(900, 700); @@ -855,14 +1023,13 @@ bool testInPlaceMessageReplacement() QPointer agentSegment = segment(agentConversation, QStringLiteral("message:item-in-place-agent")); - QPointer agentContent = - messageLabel(agentSegment, QStringLiteral("conversationMessageContent")); + QPointer streamingContent = messageContent(agentSegment); QPointer agentStatus = messageLabel(agentSegment, QStringLiteral("conversationMessageStatus")); QPointer agentTruncation = messageLabel(agentSegment, QStringLiteral("conversationMessageTruncation")); QWidget* const agentSegmentAddress = agentSegment.data(); - QLabel* const agentContentAddress = agentContent.data(); + QWidget* const streamingContentAddress = streamingContent.data(); QLabel* const agentStatusAddress = agentStatus.data(); QLabel* const agentTruncationAddress = agentTruncation.data(); @@ -878,11 +1045,15 @@ bool testInPlaceMessageReplacement() && agentSegment.data() == segment(agentConversation, QStringLiteral("message:item-in-place-agent")) - && agentContent && agentContent.data() == agentContentAddress + && !streamingContent && agentStatus && agentStatus.data() == agentStatusAddress && agentTruncation && agentTruncation.data() == agentTruncationAddress, - "canonical agent-message replacement must preserve the segment and message labels"); - passed &= expect(agentContent + "a terminal agent-message update must preserve its segment metadata while replacing the streaming view"); + QPointer agentContent = + messageLabel(agentSegment, QStringLiteral("conversationMessageContent")); + QLabel* const agentContentAddress = agentContent.data(); + passed &= expect(streamingContentAddress && agentContent + && agentContent != streamingContentAddress && messageSourceText(agentContent) == QStringLiteral("streamed prefix and canonical continuation") && agentStatus && agentStatus->text() == QStringLiteral("Completed") @@ -890,7 +1061,7 @@ bool testInPlaceMessageReplacement() && agentTruncation->text().contains(QStringLiteral("truncated"), Qt::CaseInsensitive) && segmentHasLabel(agentSegment, QStringLiteral("CODEX")), - "the preserved agent-message widget must reflect canonical content, status and truncation"); + "the terminal agent-message widget must reflect canonical Markdown content, status and truncation"); agentFixture.turns.front().messages.front().text = "short canonical replacement"; agentFixture.turns.front().messages.front().status = "failed"; @@ -945,7 +1116,7 @@ bool testInPlaceMessageReplacement() return passed; } -bool testStreamingMarkdownRenderCoalescing() +bool testStreamingPlainTextAndTerminalMarkdown() { ThreadFixture fixture{ "streaming-markdown", @@ -962,55 +1133,278 @@ bool testStreamingMarkdownRenderCoalescing() QPointer message = segment(conversation, QStringLiteral("message:item-streaming-markdown")); - QPointer content = - messageLabel(message, QStringLiteral("conversationMessageContent")); + QPointer content = messageContent(message); QWidget* const messageAddress = message.data(); - QLabel* const contentAddress = content.data(); - const QString initialHtml = content ? content->text() : QString{}; - const QHash exactChange{ - {QStringLiteral("turn-streaming-markdown"), - QStringList{QStringLiteral("item-streaming-markdown")}}}; + QWidget* const contentAddress = content.data(); const QStringList streamedContent{ QStringLiteral("**stream** [docs](https://example.com)"), QStringLiteral("**stream** [docs](https://example.com)\n\n`code`"), QStringLiteral("**stream** [docs](https://example.com)\n\n`code`\n\n![secret](file:///etc/passwd)")}; bool passed = true; + QString previous = QStringLiteral("**stream**"); for (const QString& update : streamedContent) { fixture.turns.front().messages.front().text = update.toStdString(); - conversation.render( - makeState({fixture}), QStringLiteral("streaming-markdown"), false, &exactChange); + const QString delta = update.mid(previous.size()); + const auto exactChange = appendUpdate( + QStringLiteral("turn-streaming-markdown"), + QStringLiteral("item-streaming-markdown"), + client::ItemContentChannel::AgentText, + static_cast(previous.toUtf8().size()), + delta); + // Descriptor lookup verifies the authoritative retained byte boundary + // without materializing the canonical item or its lazy content chain. + const client::State updatedState = makeState({fixture}); + passed &= conversation.updateExactMessageContent( + updatedState, + QStringLiteral("streaming-markdown"), + exactChange); passed &= expect(message && message.data() == messageAddress && content && content.data() == contentAddress - && messageSourceText(content) == update, - "streaming Markdown updates must preserve the message widget and exact canonical source"); + && messageSourceText(content) == update + && content->property("markdownRenderMode").toString() + == QStringLiteral("streaming-plain"), + "streaming updates must append plain text in place without parsing Markdown"); + previous = update; } - passed &= expect(content && content->text() == initialHtml, - "append-only streaming updates must coalesce full Markdown reparsing before the render timer fires"); - - settleEvents(2, 100); - const QString coalescedHtml = content ? content->text() : QString{}; - passed &= expect(content && coalescedHtml != initialHtml - && coalescedHtml.contains(QStringLiteral("font-weight")) - && coalescedHtml.contains(QStringLiteral("https://example.com")) - && coalescedHtml.contains(QStringLiteral("code")) - && !coalescedHtml.contains(QStringLiteral("file:///etc/passwd")) - && !coalescedHtml.contains(QStringLiteral("property("streamAppendCount").toULongLong() + == static_cast(streamedContent.size()), + "each verified streaming delta must use the cursor append path"); const QString finalContent = streamedContent.back() + QStringLiteral("\n\n_final answer_"); fixture.turns.front().messages.front().text = finalContent.toStdString(); fixture.turns.front().messages.front().status = "completed"; - conversation.render( - makeState({fixture}), QStringLiteral("streaming-markdown"), false, &exactChange); - passed &= expect(content && messageSourceText(content) == finalContent - && content->text() != coalescedHtml - && content->text().contains(QStringLiteral("final answer")) - && content->text().contains(QStringLiteral("https://example.com")) - && !content->text().contains(QStringLiteral("file:///etc/passwd")) - && !content->text().contains(QStringLiteral(" finalContentWidget = messageContent(message); + auto* finalLabel = qobject_cast(finalContentWidget); + passed &= expect(message && message.data() == messageAddress + && finalContentWidget && finalContentWidget != contentAddress + && finalLabel && messageSourceText(finalLabel) == finalContent + && finalLabel->text().contains(QStringLiteral("final answer")) + && finalLabel->text().contains(QStringLiteral("https://example.com")) + && !finalLabel->text().contains(QStringLiteral("file:///etc/passwd")) + && !finalLabel->text().contains(QStringLiteral(" message = segment( + conversation, QStringLiteral("message:item-completed-streaming-markdown")); + QPointer streamingContent = messageContent(message); + QWidget* const messageAddress = message.data(); + QWidget* const streamingContentAddress = streamingContent.data(); + bool passed = expect( + streamingContent + && streamingContent->property("markdownRenderMode").toString() + == QStringLiteral("streaming-plain"), + "an active turn must keep a completed-looking agent message plain while content can still arrive"); + + conversation.render(makeState({fixture}), + QStringLiteral("completed-streaming-markdown")); + settleEvents(); + passed &= expect( + messageContent(message) == streamingContentAddress, + "an unrelated full refresh during the active turn must preserve the streaming view"); + + auto* streamingEditor = qobject_cast(streamingContent.data()); + const qreal liveDocumentWidth = streamingEditor + ? streamingEditor->document()->textWidth() + : 0.0; + const int speculativeHeight = streamingContent + ? streamingContent->heightForWidth(54) + : 0; + passed &= expect( + streamingEditor && speculativeHeight > 0 + && streamingEditor->document()->textWidth() == liveDocumentWidth, + "speculative height-for-width measurement must not reflow the visible streaming document"); + + const qulonglong geometryInvalidationsBeforeGrowth = + streamingContent + ? streamingContent->property("geometryInvalidationCount").toULongLong() + : 0; + const QString fullyReconciledGrowth = + QStringLiteral("**partial via full reconciliation"); + fixture.turns.front().messages.front().text = + fullyReconciledGrowth.toStdString(); + conversation.render(makeState({fixture}), + QStringLiteral("completed-streaming-markdown")); + settleEvents(); + passed &= expect( + messageContent(message) == streamingContentAddress + && messageSourceText(streamingContent) == fullyReconciledGrowth + && streamingContent->property("streamAppendCount").toULongLong() == 1 + && streamingContent->property("fullReplacementCount").toULongLong() == 0, + "a full active-turn reconciliation with grown canonical text must cursor-append in the existing streaming view"); + passed &= expect( + streamingContent + && streamingContent->property("geometryInvalidationCount").toULongLong() + == geometryInvalidationsBeforeGrowth, + "a same-line streaming append must not invalidate unchanged message geometry"); + + const QStringList streamedContent{ + QStringLiteral("**partial via full reconciliation result"), + QStringLiteral("**partial via full reconciliation result**\n\n- one"), + QStringLiteral("**partial via full reconciliation result**\n\n- one\n- two")}; + QString previous = fullyReconciledGrowth; + for (const QString& update : streamedContent) + { + fixture.turns.front().messages.front().text = update.toStdString(); + const QString delta = update.mid(previous.size()); + const auto exactChange = appendUpdate( + QStringLiteral("turn-completed-streaming-markdown"), + QStringLiteral("item-completed-streaming-markdown"), + client::ItemContentChannel::AgentText, + static_cast(previous.toUtf8().size()), + delta); + const client::State updatedState = makeState({fixture}); + const bool handled = conversation.updateExactMessageContent( + updatedState, + QStringLiteral("completed-streaming-markdown"), + exactChange); + settleEvents(); + + QWidget* const currentContent = messageContent(message); + passed &= expect( + handled && message && message.data() == messageAddress + && currentContent && currentContent == streamingContentAddress + && messageSourceText(currentContent) == update + && currentContent->property("markdownRenderMode").toString() + == QStringLiteral("streaming-plain"), + "append-v2 deltas must keep a completed agent message in one plain streaming view"); + previous = update; + } + passed &= expect( + streamingContent + && streamingContent->property("streamAppendCount").toULongLong() + == static_cast(streamedContent.size() + 1) + && streamingContent->property("fullReplacementCount").toULongLong() == 0, + "completed agent-message deltas must use only the cursor append path"); + auto* conversationScroll = conversation.findChild(); + passed &= expect( + conversationScroll && conversationScroll->viewport()->updatesEnabled() + && conversationScroll->verticalScrollBar()->value() + == conversationScroll->verticalScrollBar()->maximum(), + "a height-changing stream batch must expose its settled geometry once and remain pinned at the tail"); + + fixture.turns.front().status = "completed"; + fixture.turns.front().active = false; + fixture.turns.front().terminal = true; + const client::State finalState = makeState({fixture}); + conversation.render(finalState, + QStringLiteral("completed-streaming-markdown")); + settleTimeline(); + + QPointer finalContent = messageContent(message); + auto* finalLabel = qobject_cast(finalContent); + QWidget* const finalContentAddress = finalContent.data(); + const QString finalRenderedText = finalLabel ? finalLabel->text() : QString{}; + passed &= expect( + message && message.data() == messageAddress && finalContent + && finalContent.data() != streamingContentAddress && finalLabel + && messageSourceText(finalLabel) == streamedContent.back() + && finalContent->property("markdownRenderMode").toString() + == QStringLiteral("markdown") + && finalRenderedText.contains(QStringLiteral("font-weight")) + && finalRenderedText.contains(QStringLiteral("two")), + "the first non-delta terminal publication must promote the stream to Markdown once"); + + conversation.render(finalState, + QStringLiteral("completed-streaming-markdown")); + settleEvents(); + QWidget* const repeatedContent = messageContent(message); + passed &= expect( + repeatedContent == finalContentAddress + && qobject_cast(repeatedContent) + && qobject_cast(repeatedContent)->text() == finalRenderedText, + "an unchanged terminal publication must preserve the final Markdown widget"); + return passed; +} + +bool testTerminalMarkdownPromotionResettlesFollowedTail() +{ + ThreadFixture fixture = sequentialTurns("terminal-markdown-tail", 9); + auto& finalTurn = fixture.turns.back(); + finalTurn.status = "inProgress"; + finalTurn.active = true; + finalTurn.terminal = false; + const QString source = QStringLiteral("[compact](https://example.invalid/") + + QString(6000, QLatin1Char('x')) + + QLatin1Char(')'); + finalTurn.messages.front().text = source.toStdString(); + finalTurn.messages.front().status = "completed"; + + codexui::ConversationWidget conversation; + conversation.resize(900, 500); + conversation.show(); + conversation.render(makeState({fixture}), QStringLiteral("terminal-markdown-tail")); + settleTimeline(); + + auto* scroll = conversation.findChild(); + QWidget* timelineHost = timeline(conversation); + QPointer message = segment( + conversation, QStringLiteral("message:item-terminal-markdown-tail-8")); + QPointer streamingContent = messageContent(message); + const int streamingPreferredHeight = streamingContent + ? streamingContent->heightForWidth( + streamingContent->width()) + : 0; + const int streamingTimelineHeight = timelineHost ? timelineHost->height() : 0; + const int streamingMaximum = scroll ? scroll->verticalScrollBar()->maximum() : 0; + bool passed = expect( + scroll && timelineHost && streamingContent + && streamingContent->property("markdownRenderMode").toString() + == QStringLiteral("streaming-plain") + && streamingMaximum > 0 + && scroll->verticalScrollBar()->value() == streamingMaximum, + "the tall streaming source must begin at a genuinely followed tail"); + + finalTurn.status = "completed"; + finalTurn.active = false; + finalTurn.terminal = true; + conversation.render(makeState({fixture}), QStringLiteral("terminal-markdown-tail")); + settleTimeline(); + + QPointer finalContent = messageContent(message); + auto* finalLabel = qobject_cast(finalContent.data()); + const int finalPreferredHeight = finalContent + ? finalContent->heightForWidth(finalContent->width()) + : 0; + const int finalMaximum = scroll ? scroll->verticalScrollBar()->maximum() : 0; + passed &= expect( + finalLabel && finalContent != streamingContent + && finalContent->property("markdownRenderMode").toString() + == QStringLiteral("markdown") + && finalPreferredHeight < streamingPreferredHeight, + "terminal Markdown must replace the tall source with its compact rendered presentation"); + passed &= expect( + timelineHost && timelineHost->height() < streamingTimelineHeight + && finalMaximum > 0 && finalMaximum < streamingMaximum, + "terminal renderer replacement must shrink the timeline and its retained scroll range"); + passed &= expect( + scroll && scroll->verticalScrollBar()->value() == finalMaximum, + "terminal Markdown promotion must settle at the new true tail"); return passed; } @@ -1118,17 +1512,20 @@ bool testExactContentInvalidation() QPointer first = segment(conversation, QStringLiteral("message:item-exact-first")); QPointer second = segment(conversation, QStringLiteral("message:item-exact-second")); - QPointer firstContent = - messageLabel(first, QStringLiteral("conversationMessageContent")); + QPointer firstContent = messageContent(first); QPointer secondContent = messageLabel(second, QStringLiteral("conversationMessageContent")); QWidget* const firstAddress = first.data(); QWidget* const secondAddress = second.data(); - fixture.turns.front().messages.front().text = "first canonical continuation"; - const QHash exactChanges{ - {QStringLiteral("turn-exact-content"), - QStringList{QStringLiteral("item-exact-first")}}}; + const QString firstDelta = QStringLiteral(" canonical continuation"); + fixture.turns.front().messages.front().text = "first prefix canonical continuation"; + const auto exactChanges = appendUpdate( + QStringLiteral("turn-exact-content"), + QStringLiteral("item-exact-first"), + client::ItemContentChannel::AgentText, + std::string_view("first prefix").size(), + firstDelta); const auto updatedState = makeState({fixture}); const bool exactApplied = conversation.updateExactMessageContent( updatedState, QStringLiteral("exact-content"), exactChanges); @@ -1136,15 +1533,18 @@ bool testExactContentInvalidation() bool passed = true; passed &= expect(exactApplied && first && first.data() == firstAddress && firstContent - && messageSourceText(firstContent) == QStringLiteral("first canonical continuation"), + && messageSourceText(firstContent) + == QStringLiteral("first prefix canonical continuation") + && firstContent->property("streamAppendCount").toULongLong() == 1, "an exact content update must mutate its canonical message directly in place"); passed &= expect(second && second.data() == secondAddress && secondContent && messageSourceText(secondContent) == QStringLiteral("second stable"), "an exact content update must preserve unaffected segment widgets"); - const QHash activityChanges{ - {QStringLiteral("turn-exact-content"), - QStringList{QStringLiteral("item-exact-activity")}}}; + const auto activityChanges = replacementUpdate( + QStringLiteral("turn-exact-content"), + QStringLiteral("item-exact-activity"), + client::ItemContentChannel::CommandOutput); passed &= expect(!conversation.updateExactMessageContent( updatedState, QStringLiteral("exact-content"), activityChanges), "a non-message content update must retain the full activity-card reconciliation fallback"); @@ -1158,6 +1558,177 @@ bool testExactContentInvalidation() return passed; } +bool testExactReasoningChannels() +{ + ThreadFixture fixture{ + "reasoning-channels", + {{"turn-reasoning-channels", + {{"reasoning-text", + frontend::ThreadItemKind::Reasoning, + "working", + "in_progress"}, + {"reasoning-summary", + frontend::ThreadItemKind::Reasoning, + "", + "in_progress"}}}}}; + fixture.turns.front().messages.at(1).reasoningSummary = "summary"; + + codexui::ConversationWidget conversation; + conversation.resize(900, 700); + conversation.show(); + conversation.render(makeState({fixture}), QStringLiteral("reasoning-channels")); + settleTimeline(); + + QWidget* textRow = nullptr; + QWidget* summaryRow = nullptr; + for (QWidget* row : conversation.findChildren( + QStringLiteral("conversationActivityRow"))) + { + if (row->property("itemId").toString() == QStringLiteral("reasoning-text")) + textRow = row; + else if (row->property("itemId").toString() == QStringLiteral("reasoning-summary")) + summaryRow = row; + } + auto* textDetails = textRow + ? textRow->findChild( + QStringLiteral("conversationActivityDetails")) + : nullptr; + auto* summaryDetails = summaryRow + ? summaryRow->findChild( + QStringLiteral("conversationActivityDetails")) + : nullptr; + auto* textDisclosure = textRow + ? textRow->findChild( + QStringLiteral("activityDisclosure")) + : nullptr; + auto* summaryDisclosure = summaryRow + ? summaryRow->findChild( + QStringLiteral("activityDisclosure")) + : nullptr; + const QString textDelta = QStringLiteral(" through evidence"); + fixture.turns.front().messages.at(0).text += textDelta.toStdString(); + const auto textChange = appendUpdate( + QStringLiteral("turn-reasoning-channels"), + QStringLiteral("reasoning-text"), + client::ItemContentChannel::ReasoningText, + std::string_view("working").size(), + textDelta); + bool passed = expect(textRow && summaryRow && textDetails && summaryDetails + && textDetails->isHidden() && summaryDetails->isHidden() + && !textRow->findChild( + QStringLiteral("conversationActivityDetail")) + && !summaryRow->findChild( + QStringLiteral("conversationActivityDetail")) + && textDetails->property("detailMaterializationCount").toULongLong() == 0 + && summaryDetails->property("detailMaterializationCount").toULongLong() == 0 + && textDetails->property("deferredDetailBytes").toULongLong() + == std::string_view("working").size() + && summaryDetails->property("deferredDetailBytes").toULongLong() + == std::string_view("summary").size(), + "collapsed reasoning channels must retain their source bytes without materializing text documents"); + passed &= conversation.updateExactMessageContent( + makeState({fixture}), QStringLiteral("reasoning-channels"), textChange); + + const QString summaryDelta = QStringLiteral(" complete"); + fixture.turns.front().messages.at(1).reasoningSummary += summaryDelta.toStdString(); + const auto summaryChange = appendUpdate( + QStringLiteral("turn-reasoning-channels"), + QStringLiteral("reasoning-summary"), + client::ItemContentChannel::ReasoningSummary, + std::string_view("summary").size(), + summaryDelta); + passed &= conversation.updateExactMessageContent( + makeState({fixture}), QStringLiteral("reasoning-channels"), summaryChange); + passed &= expect(textDetails && summaryDetails + && textDetails->isHidden() && summaryDetails->isHidden() + && !textRow->findChild( + QStringLiteral("conversationActivityDetail")) + && !summaryRow->findChild( + QStringLiteral("conversationActivityDetail")) + && textDetails->property("detailMaterializationCount").toULongLong() == 0 + && summaryDetails->property("detailMaterializationCount").toULongLong() == 0 + && textDetails->property("deferredDetailBytes").toULongLong() + == std::string_view("working through evidence").size() + && summaryDetails->property("deferredDetailBytes").toULongLong() + == std::string_view("summary complete").size(), + "exact reasoning updates must advance collapsed deferred sources without creating hidden documents"); + + if (textDisclosure) + textDisclosure->click(); + if (summaryDisclosure) + summaryDisclosure->click(); + settleTimeline(); + QPointer textDetail = textRow + ? textRow->findChild( + QStringLiteral("conversationActivityDetail")) + : nullptr; + QPointer summaryDetail = summaryRow + ? summaryRow->findChild( + QStringLiteral("conversationActivityDetail")) + : nullptr; + passed &= expect(textDetail && summaryDetail && textDetails && summaryDetails + && textDetail->toPlainText() + == QStringLiteral("working through evidence") + && summaryDetail->toPlainText() + == QStringLiteral("summary complete") + && textDetails->property("detailMaterializationCount").toULongLong() == 1 + && summaryDetails->property("detailMaterializationCount").toULongLong() == 1 + && textDetail->property("streamAppendCount").toULongLong() == 0 + && summaryDetail->property("streamAppendCount").toULongLong() == 0, + "expanding reasoning rows must materialize each latest channel exactly once"); + + const QString expandedTextDelta = QStringLiteral(" after expansion"); + const std::uint64_t expandedTextBase = + fixture.turns.front().messages.at(0).text.size(); + fixture.turns.front().messages.at(0).text += expandedTextDelta.toStdString(); + const auto expandedTextChange = appendUpdate( + QStringLiteral("turn-reasoning-channels"), + QStringLiteral("reasoning-text"), + client::ItemContentChannel::ReasoningText, + expandedTextBase, + expandedTextDelta); + passed &= conversation.updateExactMessageContent( + makeState({fixture}), QStringLiteral("reasoning-channels"), expandedTextChange); + + const QString expandedSummaryDelta = QStringLiteral(" after expansion"); + const std::uint64_t expandedSummaryBase = + fixture.turns.front().messages.at(1).reasoningSummary.size(); + fixture.turns.front().messages.at(1).reasoningSummary += + expandedSummaryDelta.toStdString(); + const auto expandedSummaryChange = appendUpdate( + QStringLiteral("turn-reasoning-channels"), + QStringLiteral("reasoning-summary"), + client::ItemContentChannel::ReasoningSummary, + expandedSummaryBase, + expandedSummaryDelta); + passed &= conversation.updateExactMessageContent( + makeState({fixture}), QStringLiteral("reasoning-channels"), expandedSummaryChange); + passed &= expect(textDetail + && textDetail->toPlainText() + == QStringLiteral("working through evidence after expansion") + && textDetail->property("streamAppendCount").toULongLong() == 1, + "an expanded reasoning-text delta must append through the text cursor"); + passed &= expect(summaryDetail + && summaryDetail->toPlainText() + == QStringLiteral("summary complete after expansion") + && summaryDetail->property("streamAppendCount").toULongLong() == 1, + "an expanded reasoning-summary delta must append independently through its text cursor"); + + const auto wrongBase = appendUpdate( + QStringLiteral("turn-reasoning-channels"), + QStringLiteral("reasoning-text"), + client::ItemContentChannel::ReasoningText, + 1, + QStringLiteral("invalid")); + passed &= expect(!conversation.updateExactMessageContent( + makeState({fixture}), QStringLiteral("reasoning-channels"), wrongBase) + && textDetail + && textDetail->toPlainText() + == QStringLiteral("working through evidence after expansion"), + "a mismatched reasoning base must decline the exact path without corrupting presentation"); + return passed; +} + bool testSegmentReplacementShrink() { ThreadFixture fixture = singleTurn("replacement", 1); @@ -1498,9 +2069,12 @@ int main(int argc, char** argv) passed &= testActivityDisclosureAndFullOutput(); passed &= testPointerPreservingAppend(); passed &= testInPlaceMessageReplacement(); - passed &= testStreamingMarkdownRenderCoalescing(); + passed &= testStreamingPlainTextAndTerminalMarkdown(); + passed &= testCompletedAgentMessageStreamsBeforeTerminalMarkdown(); + passed &= testTerminalMarkdownPromotionResettlesFollowedTail(); passed &= testCompleteAndLargeUserMessagePresentation(); passed &= testExactContentInvalidation(); + passed &= testExactReasoningChannels(); passed &= testSegmentReplacementShrink(); passed &= testThreadSwitchWindow(); passed &= testInspectorRevisionOnlyUpdate();