From b90ebe09c008ee4fae46fc5784c0fdce10104dfe Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 16:43:43 +0200 Subject: [PATCH 01/39] Measure conversation widget scaling baseline --- CMakeLists.txt | 23 ++++ docs/qt-virtualized-conversation-view.md | 120 ++++++++++++++++ tests/codex/ConversationViewBenchmark.cpp | 158 ++++++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 docs/qt-virtualized-conversation-view.md create mode 100644 tests/codex/ConversationViewBenchmark.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d7e879..2253bba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -357,6 +357,29 @@ if(BUILD_TESTING) PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) + qt_add_executable( + codexui-conversation-view-benchmark + tests/codex/ConversationViewBenchmark.cpp + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h + ) + target_compile_features( + codexui-conversation-view-benchmark PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-conversation-view-benchmark PRIVATE src + ) + target_link_libraries( + codexui-conversation-view-benchmark + PRIVATE codexui-nodegraph Qt6::Widgets + ) + qt_add_executable( codexui-application-layout-test tests/codex/EstablishedUiUxTest.cpp diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md new file mode 100644 index 0000000..20e036c --- /dev/null +++ b/docs/qt-virtualized-conversation-view.md @@ -0,0 +1,120 @@ +# Qt virtualized conversation view + +## Scope + +This document records the focused migration of the native conversation surface +from one retained QWidget tree per loaded card to a Qt item model and +viewport-proportional item view. It does not redesign `NodeGraph`, SNode.C, +CodexBridge, the typed mailboxes, Inspector, ThreadPane, shell chrome, settings, +or the visual language. + +The authoritative product and ownership contracts remain +`two-thread-shared-node-graph.md`, `ui-ux-internal-api.md`, `ui-behavior.md`, and +`native-ui-ux-qualification-inventory.md`. Where those documents describe the +old retained-card implementation, this migration preserves the stated visible +result while replacing its loaded-history-sized QWidget and layout work. + +## Verified starting point + +- branch point: `f22f652e687631568929a622fc1058df09280839` +- local implementation branch: `codex/qt-virtualized-conversation-view` +- initial worktree: clean +- persistent incremental build directory: + `/home/voc/projects/drafts/CodexUI/build/Desktop_GCC-Debug` +- build: Debug, Ninja, Qt 6 Widgets +- native baseline: 17/17 suites pass. Sixteen pass in the managed sandbox; the + listener-dependent `codexui-client-runtime-dispatch` suite passes when run + outside it so its private Unix listener can be created. + +No remote operation is part of this work. + +## User-visible defect and diagnosed cause + +The existing surface bounds the default history window, targets ordinary card +updates well, and stages new cards invisibly. It nevertheless creates and +retains one `ConversationCard` subtree and one `TurnSectionWidget` for every +loaded row. Its structural fallback and width reflow traverse complete maps of +those widgets and their nested layouts. Loading more history therefore grows +QObject count, memory, construction time, layout work, focus/accessibility +surface, and worst-case event-loop latency with the loaded thread rather than +with the viewport. + +From the user's point of view this is the remaining source of delayed thread +selection, paging pauses, and intermittent scrolling/streaming contention in a +long heterogeneous conversation. Cached tail append paths reduce common-case +work, but do not change the history-sized ownership structure. + +## Reproducible baseline + +`codexui-conversation-view-benchmark` is a non-gating instrumentation target. +It drives the production view with heterogeneous card data, waits for its +atomic staging transaction, sweeps 240 scroll positions, and reports live +card, section, QWidget, geometry, time, and peak-resident-memory counters. + +Xcb measurements were taken under Xvfb at a 900 x 700 view size using the same +Debug binary and build directory: + +| Loaded rows | Initial reveal | Conversation cards | Turn sections | Descendant QWidgets | Peak resident memory | 240-position sweep | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 320 | 733 ms | 320 | 320 | 4,209 | 125,324 KiB | 329.7 ms | +| 1,280 | 4,040 ms | 1,280 | 1,280 | 16,809 | 281,616 KiB | 325.2 ms | + +The approximately 13.1 descendant widgets per row and one section per row are +the decisive baseline. A 10,000-row run is intentionally deferred until the +virtualized implementation exists; allocating the extrapolated old hierarchy +would add no architectural information and risks unnecessary memory pressure. + +The offscreen platform independently records 999/3,969/15,849 descendant +widgets for 80/320/1,280 rows and initial reveals of 148/633/3,529 ms. Platform +differences change constants, not the linear ownership result. + +## Required implementation shape + +The code follows the existing problem boundaries directly: + +1. `NodeGraph` remains the sole current domain state and the worker remains its + sole writer. +2. `NodeGraphUiAdapter` continues to make short nonblocking reads and returns + complete toolkit-neutral card values only after releasing the graph guard. +3. A thin Qt list model holds stable row indexing and last-rendered values. It + is not application authority, never interprets graph revisions as changes, + and never retains protocol payloads. +4. Structural snapshots are flattened into canonical card order with explicit + Turn/root/nested metadata. Stable `NodeRef` targets are carried unchanged. +5. The model emits the narrowest valid Qt signal for the actual ordered + difference. An exact targeted card update resolves directly to one row. +6. A variable-height index provides bounded prefix, row lookup, and height + update operations. Viewport anchoring is expressed as stable row identity + plus an exact pixel offset. +7. The item view owns only visible presentation plus small bounded overscan. + Passive presentation uses a delegate where established interaction permits; + real card widgets/editors exist only for visible rich interaction. +8. Fold, focus, selection, command inner-scroll, delayed prompt feedback, and + other genuinely local interaction state are keyed by stable row identity and + survive materialization changes. +9. Selection and Load 80 prepare the new model/geometry and initial visible + materialization behind the existing stable surface, then reveal one complete + frame. Ordinary streaming is coalesced within one GUI frame and affects only + the addressed row. + +No snapshot authority, event journal, projector, callback registry, generic +observer, message bus, third logic thread, or alternate transport is introduced. + +## Qualification counters + +The final implementation reports at least these inspectable values on the +conversation view so deterministic tests and full-application recordings can +correlate visible behavior with work: + +- model insert/remove/move/data-change/reset counts; +- height-index lookup and update counts; +- materialized row/editor count and peak count; +- row construction, release, layout, and paint counts; +- targeted visible and offscreen update counts; +- structural stage starts, commits, and maximum pass duration; +- complete-view geometry/repaint fallbacks, which must remain zero during + ordinary scrolling and streaming. + +Before/after values, interaction ownership, delegate/editor decisions, +sanitizer results, and movie artifacts will be appended as the migration is +qualified. diff --git a/tests/codex/ConversationViewBenchmark.cpp b/tests/codex/ConversationViewBenchmark.cpp new file mode 100644 index 0000000..16af187 --- /dev/null +++ b/tests/codex/ConversationViewBenchmark.cpp @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationView.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace codexui::codex::middle { +namespace { + +VisibleCardData cardData(std::size_t index) { + const std::string suffix = std::to_string(index); + VisibleCardData card; + card.key = AuthoritativeItemKey{"benchmark-thread", "turn-" + suffix, + "item-" + suffix}; + card.threadId = "benchmark-thread"; + card.turnId = "turn-" + suffix; + card.itemId = "item-" + suffix; + switch (index % 8) { + case 0: + card.kind = CardKind::UserMessage; + card.payload = UserMessageData{"Prompt " + suffix, {}}; + break; + case 1: + card.kind = CardKind::AgentMessage; + card.payload = AgentMessageData{ + "A compact final answer for benchmark row " + suffix + '.', true}; + break; + case 2: + card.kind = CardKind::CommandExecution; + card.payload = CommandExecutionData{"printf benchmark", "line one\nline two", + "completed", "/tmp", 0, 4}; + break; + case 3: + card.kind = CardKind::Reasoning; + card.payload = ReasoningData{"Reasoning summary " + suffix}; + break; + case 4: + card.kind = CardKind::AgentActivity; + card.payload = AgentActivityData{"worker", "completed", "completed", {}, + "Agent result " + suffix}; + break; + case 5: + card.kind = CardKind::FileChanges; + card.payload = FileChangesData{ + "completed", {{"src/example-" + suffix + ".cpp", "update", 2, 1}}, + "/tmp"}; + break; + case 6: + card.kind = CardKind::Plan; + card.payload = PlanData{"Plan " + suffix, + {{"Inspect", "completed"}, {"Change", "pending"}}, + {}}; + break; + default: + card.kind = CardKind::GenericActivity; + card.payload = GenericActivityData{"toolCall", {}, "completed", + "detail: benchmark " + suffix}; + break; + } + return card; +} + +ConversationSnapshot snapshot(std::size_t count) { + ConversationSnapshot result; + result.threadId = "benchmark-thread"; + result.sections.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + VisibleCardData card = cardData(index); + TurnSection section; + section.key = "turn-section-" + std::to_string(index); + section.turnId = card.turnId; + section.cards.push_back(std::move(card)); + result.sections.push_back(std::move(section)); + } + return result; +} + +void settle(ConversationView &view) { + QElapsedTimer deadline; + deadline.start(); + while (view.structuralStagingActive() && deadline.elapsed() < 120000) + QApplication::processEvents(QEventLoop::AllEvents, 20); + QApplication::sendPostedEvents(nullptr, QEvent::LayoutRequest); + QApplication::processEvents(QEventLoop::AllEvents, 50); +} + +} // namespace +} // namespace codexui::codex::middle + +int main(int argc, char **argv) { + QApplication application(argc, argv); + using namespace codexui::codex::middle; + + const std::size_t count = argc > 1 + ? std::max( + 1, std::strtoull(argv[1], nullptr, 10)) + : 80; + ConversationView view; + view.resize(900, 700); + view.show(); + QApplication::processEvents(); + + ConversationSnapshot data = snapshot(count); + QElapsedTimer initial; + initial.start(); + view.reconcileStaged(std::move(data)); + settle(view); + const qint64 initialMilliseconds = initial.elapsed(); + + QElapsedTimer scroll; + scroll.start(); + constexpr int ScrollSamples = 240; + const int maximum = view.verticalScrollBar()->maximum(); + for (int sample = 0; sample < ScrollSamples; ++sample) { + view.verticalScrollBar()->setValue( + maximum * sample / std::max(1, ScrollSamples - 1)); + QApplication::processEvents(QEventLoop::AllEvents, 2); + } + const qint64 scrollMicroseconds = scroll.nsecsElapsed() / 1000; + + const auto cards = view.findChildren(); + const auto widgets = view.findChildren(); + std::size_t sections = 0; + for (QWidget *widget : widgets) + if (widget->property("turnSectionKey").isValid()) + ++sections; + rusage usage{}; + static_cast(getrusage(RUSAGE_SELF, &usage)); + + QJsonObject result{ + {"rows", static_cast(count)}, + {"initialMilliseconds", initialMilliseconds}, + {"scrollSweepMicroseconds", scrollMicroseconds}, + {"scrollSamples", ScrollSamples}, + {"conversationCards", cards.size()}, + {"turnSections", static_cast(sections)}, + {"descendantWidgets", widgets.size()}, + {"peakResidentKiB", static_cast(usage.ru_maxrss)}, + {"scrollMaximum", maximum}, + {"geometryPasses", + static_cast( + view.property("conversationGeometryPasses").toULongLong())}}; + std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() + << '\n'; + return view.structuralStagingActive() ? 2 : 0; +} From 278658326e8374be05721479bd2d1155cf4b9c59 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 17:07:48 +0200 Subject: [PATCH 02/39] Add stable conversation item model foundation --- CMakeLists.txt | 33 ++ docs/qt-virtualized-conversation-view.md | 43 ++ src/codex/middle/ConversationHeightIndex.cpp | 185 ++++++++ src/codex/middle/ConversationHeightIndex.h | 61 +++ src/codex/middle/ConversationItemModel.cpp | 438 +++++++++++++++++++ src/codex/middle/ConversationItemModel.h | 112 +++++ tests/codex/ConversationItemModelTest.cpp | 304 +++++++++++++ tests/codex/ConversationViewBenchmark.cpp | 30 +- 8 files changed, 1191 insertions(+), 15 deletions(-) create mode 100644 src/codex/middle/ConversationHeightIndex.cpp create mode 100644 src/codex/middle/ConversationHeightIndex.h create mode 100644 src/codex/middle/ConversationItemModel.cpp create mode 100644 src/codex/middle/ConversationItemModel.h create mode 100644 tests/codex/ConversationItemModelTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2253bba..f6e4e4e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,10 @@ set( src/codex/middle/ComposerPane.h src/codex/middle/ConversationCards.cpp src/codex/middle/ConversationCards.h + src/codex/middle/ConversationHeightIndex.cpp + src/codex/middle/ConversationHeightIndex.h + src/codex/middle/ConversationItemModel.cpp + src/codex/middle/ConversationItemModel.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h src/codex/middle/InspectorPane.cpp @@ -357,6 +361,35 @@ if(BUILD_TESTING) PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) + qt_add_executable( + codexui-conversation-item-model-test + tests/codex/ConversationItemModelTest.cpp + src/codex/middle/ConversationHeightIndex.cpp + src/codex/middle/ConversationHeightIndex.h + src/codex/middle/ConversationItemModel.cpp + src/codex/middle/ConversationItemModel.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + ) + target_compile_features( + codexui-conversation-item-model-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-conversation-item-model-test PRIVATE src + ) + target_link_libraries( + codexui-conversation-item-model-test + PRIVATE codexui-nodegraph Qt6::Widgets + ) + add_test( + NAME codexui-conversation-item-model + COMMAND codexui-conversation-item-model-test + ) + set_tests_properties( + codexui-conversation-item-model + PROPERTIES TIMEOUT 15 + ) + qt_add_executable( codexui-conversation-view-benchmark tests/codex/ConversationViewBenchmark.cpp diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md index 20e036c..acc31a4 100644 --- a/docs/qt-virtualized-conversation-view.md +++ b/docs/qt-virtualized-conversation-view.md @@ -100,6 +100,49 @@ The code follows the existing problem boundaries directly: No snapshot authority, event journal, projector, callback registry, generic observer, message bus, third logic thread, or alternate transport is introduced. +## Item-model and height-index contract + +`middle::ConversationItemModel` is the thin Qt indexing surface. A row contains +one last-rendered `VisibleCardData`, its unchanged `NodeRef` action target, and +only the structural facts the view cannot infer safely: stable key, Turn +section, root/nested position, first/last position, presentation visibility, +and active-Turn emphasis. It does not accept graph revisions, protocol values, +or mutations. A graph read and DTO projection always precede a model call. + +The model deliberately does not expose `VisibleCardData` through `QVariant`. +Delegates and the view borrow it through the typed `card(row)` accessor on +Qt-main, avoiding another copy of large streamed text. Standard roles expose +only small identity, structure, visibility, title, and accessibility values. +`indexForTarget(NodeRef)` compares the pinned pointer identity as well as the +lookup result, so an action cannot silently retarget a replacement node with a +similar protocol ID. + +Model changes have these exact meanings: + +- a different `threadId` is a complete authority replacement and emits one + model reset; +- a retained same-thread key is moved with `beginMoveRows/endMoveRows` only + when its canonical position actually changes; +- absent/present same-thread keys use contiguous remove/insert ranges; +- a changed retained card or structural role emits `dataChanged` for that row + and the affected roles only; +- an identical snapshot, card, or visibility tuple emits no signal and does + not increment a presentation-work counter. + +`middle::ConversationHeightIndex` is the view's variable-row geometry index. +It stores integer row extents in a Fenwick prefix tree. `top`, `bottom`, total +extent, position-to-row lookup, and a changed row height are logarithmic. A +tail append extends the tree from prefix sums without traversing existing +heights. Non-tail insertion/removal/movement is uncommon structural work and +rebuilds the prefix tree from the already validated model order. Geometry +values are nonnegative and accumulated as `qint64`; scrollbar conversion is a +separate view concern. + +The deterministic foundation test exercises 10,000 rows and asserts no Qt +widget construction is involved. At that size, position lookup and one-row +height update each take at most 15 Fenwick steps, and appending rows leaves the +rebuild counter unchanged. + ## Qualification counters The final implementation reports at least these inspectable values on the diff --git a/src/codex/middle/ConversationHeightIndex.cpp b/src/codex/middle/ConversationHeightIndex.cpp new file mode 100644 index 0000000..1adf7ac --- /dev/null +++ b/src/codex/middle/ConversationHeightIndex.cpp @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationHeightIndex.h" + +#include +#include + +namespace codexui::codex::middle { +namespace { + +int validHeight(int height) noexcept { return std::max(0, height); } + +} // namespace + +void ConversationHeightIndex::clear() noexcept { + heights_.clear(); + tree_.assign(1, 0); + lastLookupSteps_ = 0; + lastUpdateSteps_ = 0; +} + +void ConversationHeightIndex::reset(std::size_t count, int estimatedHeight) { + heights_.assign(count, validHeight(estimatedHeight)); + rebuild(); +} + +void ConversationHeightIndex::assign(std::span heights) { + heights_.clear(); + heights_.reserve(heights.size()); + for (int height : heights) + heights_.push_back(validHeight(height)); + rebuild(); +} + +void ConversationHeightIndex::insert(std::size_t row, + std::span heights) { + row = std::min(row, heights_.size()); + if (heights.empty()) + return; + if (row == heights_.size()) { + heights_.reserve(heights_.size() + heights.size()); + tree_.reserve(tree_.size() + heights.size()); + for (int height : heights) + append(validHeight(height)); + return; + } + std::vector inserted; + inserted.reserve(heights.size()); + for (int height : heights) + inserted.push_back(validHeight(height)); + heights_.insert(heights_.begin() + static_cast(row), + inserted.begin(), inserted.end()); + rebuild(); +} + +void ConversationHeightIndex::remove(std::size_t row, std::size_t count) { + if (row >= heights_.size() || count == 0) + return; + count = std::min(count, heights_.size() - row); + if (row + count == heights_.size()) { + heights_.resize(row); + tree_.resize(row + 1); + lastUpdateSteps_ = 0; + return; + } + heights_.erase(heights_.begin() + static_cast(row), + heights_.begin() + static_cast(row + count)); + rebuild(); +} + +void ConversationHeightIndex::move(std::size_t sourceRow, std::size_t count, + std::size_t destinationRow) { + if (sourceRow >= heights_.size() || count == 0) + return; + count = std::min(count, heights_.size() - sourceRow); + destinationRow = std::min(destinationRow, heights_.size() - count); + if (sourceRow == destinationRow) + return; + std::vector moved( + heights_.begin() + static_cast(sourceRow), + heights_.begin() + static_cast(sourceRow + count)); + heights_.erase(heights_.begin() + static_cast(sourceRow), + heights_.begin() + + static_cast(sourceRow + count)); + heights_.insert(heights_.begin() + + static_cast(destinationRow), + std::make_move_iterator(moved.begin()), + std::make_move_iterator(moved.end())); + rebuild(); +} + +int ConversationHeightIndex::height(std::size_t row) const noexcept { + return row < heights_.size() ? heights_[row] : 0; +} + +bool ConversationHeightIndex::setHeight(std::size_t row, + int nextHeight) noexcept { + if (row >= heights_.size()) + return false; + nextHeight = validHeight(nextHeight); + const qint64 delta = static_cast(nextHeight) - heights_[row]; + if (delta == 0) { + lastUpdateSteps_ = 0; + return false; + } + heights_[row] = nextHeight; + lastUpdateSteps_ = 0; + for (std::size_t index = row + 1; index < tree_.size(); + index += index & (~index + 1)) { + tree_[index] += delta; + ++lastUpdateSteps_; + } + return true; +} + +qint64 ConversationHeightIndex::top(std::size_t row) const noexcept { + return prefix(std::min(row, heights_.size())); +} + +qint64 ConversationHeightIndex::bottom(std::size_t row) const noexcept { + return row < heights_.size() ? prefix(row + 1) : totalHeight(); +} + +qint64 ConversationHeightIndex::totalHeight() const noexcept { + return prefix(heights_.size()); +} + +std::size_t ConversationHeightIndex::rowAt(qint64 contentY) const noexcept { + lastLookupSteps_ = 0; + if (heights_.empty()) + return 0; + const qint64 total = totalHeight(); + if (total <= 0) + return 0; + contentY = std::clamp(contentY, 0, total - 1); + + std::size_t bit = 1; + while ((bit << 1) < tree_.size()) + bit <<= 1; + std::size_t index = 0; + qint64 sum = 0; + for (; bit != 0; bit >>= 1) { + ++lastLookupSteps_; + const std::size_t next = index + bit; + if (next < tree_.size() && sum + tree_[next] <= contentY) { + index = next; + sum += tree_[next]; + } + } + return std::min(index, heights_.size() - 1); +} + +qint64 ConversationHeightIndex::prefix(std::size_t count) const noexcept { + count = std::min(count, heights_.size()); + qint64 result = 0; + for (std::size_t index = count; index != 0; index -= index & (~index + 1)) + result += tree_[index]; + return result; +} + +void ConversationHeightIndex::append(int height) { + const std::size_t oldCount = heights_.size(); + const std::size_t index = oldCount + 1; + const std::size_t lowBit = index & (~index + 1); + const qint64 preceding = + prefix(oldCount) - prefix(index > lowBit ? index - lowBit : 0); + heights_.push_back(height); + tree_.push_back(preceding + height); + lastUpdateSteps_ = 1; +} + +void ConversationHeightIndex::rebuild() { + tree_.assign(heights_.size() + 1, 0); + for (std::size_t index = 1; index < tree_.size(); ++index) { + tree_[index] += heights_[index - 1]; + const std::size_t parent = index + (index & (~index + 1)); + if (parent < tree_.size()) + tree_[parent] += tree_[index]; + } + ++rebuildCount_; + lastLookupSteps_ = 0; + lastUpdateSteps_ = 0; +} + +} // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationHeightIndex.h b/src/codex/middle/ConversationHeightIndex.h new file mode 100644 index 0000000..35f55c8 --- /dev/null +++ b/src/codex/middle/ConversationHeightIndex.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONHEIGHTINDEX_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONHEIGHTINDEX_H + +#include + +#include +#include +#include + +namespace codexui::codex::middle { + +// Variable-height prefix index for the conversation's flat visual rows. +// Ordinary position lookup and one-row height changes are logarithmic. Tail +// appends extend the Fenwick tree without traversing retained rows; uncommon +// non-tail structure changes rebuild from the already validated model order. +class ConversationHeightIndex final { +public: + void clear() noexcept; + void reset(std::size_t count, int estimatedHeight); + void assign(std::span heights); + void insert(std::size_t row, std::span heights); + void remove(std::size_t row, std::size_t count); + void move(std::size_t sourceRow, std::size_t count, + std::size_t destinationRow); + + [[nodiscard]] std::size_t size() const noexcept { return heights_.size(); } + [[nodiscard]] bool empty() const noexcept { return heights_.empty(); } + [[nodiscard]] int height(std::size_t row) const noexcept; + [[nodiscard]] bool setHeight(std::size_t row, int height) noexcept; + [[nodiscard]] qint64 top(std::size_t row) const noexcept; + [[nodiscard]] qint64 bottom(std::size_t row) const noexcept; + [[nodiscard]] qint64 totalHeight() const noexcept; + [[nodiscard]] std::size_t rowAt(qint64 contentY) const noexcept; + + [[nodiscard]] std::size_t lastLookupSteps() const noexcept { + return lastLookupSteps_; + } + [[nodiscard]] std::size_t lastUpdateSteps() const noexcept { + return lastUpdateSteps_; + } + [[nodiscard]] std::size_t rebuildCount() const noexcept { + return rebuildCount_; + } + +private: + [[nodiscard]] qint64 prefix(std::size_t count) const noexcept; + void append(int height); + void rebuild(); + + std::vector heights_; + std::vector tree_{0}; + mutable std::size_t lastLookupSteps_ = 0; + std::size_t lastUpdateSteps_ = 0; + std::size_t rebuildCount_ = 0; +}; + +} // namespace codexui::codex::middle + +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONHEIGHTINDEX_H diff --git a/src/codex/middle/ConversationItemModel.cpp b/src/codex/middle/ConversationItemModel.cpp new file mode 100644 index 0000000..372523b --- /dev/null +++ b/src/codex/middle/ConversationItemModel.cpp @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationItemModel.h" + +#include + +#include +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +QString cardLabel(CardKind kind) { + switch (kind) { + case CardKind::UserMessage: + return QStringLiteral("You"); + case CardKind::AgentMessage: + return QStringLiteral("Codex"); + case CardKind::CommandExecution: + return QStringLiteral("Command execution"); + case CardKind::AgentActivity: + return QStringLiteral("Agent activity"); + case CardKind::Reasoning: + return QStringLiteral("Reasoning"); + case CardKind::FileChanges: + return QStringLiteral("File changes"); + case CardKind::ImageGeneration: + return QStringLiteral("Generated image"); + case CardKind::Plan: + return QStringLiteral("Plan"); + case CardKind::GenericActivity: + return QStringLiteral("Activity"); + case CardKind::LocalPrompt: + return QStringLiteral("You"); + } + return QStringLiteral("Activity"); +} + +bool compatible(const VisibleCardData &before, + const VisibleCardData &after) noexcept { + return before.key == after.key && + (before.kind == after.kind || (before.kind == CardKind::LocalPrompt && + after.kind == CardKind::UserMessage)); +} + +} // namespace + +ConversationItemModel::ConversationItemModel(QObject *parent) + : QAbstractListModel(parent) {} + +int ConversationItemModel::rowCount(const QModelIndex &parent) const { + return parent.isValid() ? 0 : static_cast(rows_.size()); +} + +QVariant ConversationItemModel::data(const QModelIndex &index, int role) const { + const Row *value = row(index.row()); + if (!value || index.column() != 0) + return {}; + switch (role) { + case Qt::DisplayRole: + case Qt::AccessibleTextRole: + return cardLabel(value->card.kind); + case StableKeyRole: + return QString::fromStdString(value->stableKey); + case ThreadIdRole: + return QString::fromStdString(value->card.threadId); + case TurnIdRole: + return QString::fromStdString(value->card.turnId); + case ItemIdRole: + return QString::fromStdString(value->card.itemId); + case CardKindRole: + return static_cast(value->card.kind); + case TargetIdentityRole: + return value->card.target + ? QString::fromStdString(value->card.target->id().canonical) + : QString{}; + case TurnSectionRole: + return QString::fromStdString(value->sectionKey); + case TurnRootRole: + return value->turnRoot; + case NestedCardRole: + return value->nested; + case FirstInTurnRole: + return value->firstInTurn; + case LastInTurnRole: + return value->lastInTurn; + case PresentedRole: + return value->presented; + case ActiveTurnRole: + return value->activeTurn; + case PresentationRole: + // Large card values intentionally remain available only through card(). + // Returning them through QVariant would copy streamed text. + return {}; + default: + return {}; + } +} + +Qt::ItemFlags ConversationItemModel::flags(const QModelIndex &index) const { + const Row *value = row(index.row()); + if (!value || !value->presented) + return Qt::NoItemFlags; + return Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +QHash ConversationItemModel::roleNames() const { + return { + {StableKeyRole, "stableKey"}, {ThreadIdRole, "threadId"}, + {TurnIdRole, "turnId"}, {ItemIdRole, "itemId"}, + {CardKindRole, "cardKind"}, {TargetIdentityRole, "targetIdentity"}, + {TurnSectionRole, "turnSection"}, {TurnRootRole, "turnRoot"}, + {NestedCardRole, "nestedCard"}, {FirstInTurnRole, "firstInTurn"}, + {LastInTurnRole, "lastInTurn"}, {PresentedRole, "presented"}, + {ActiveTurnRole, "activeTurn"}, {PresentationRole, "presentation"}}; +} + +bool ConversationItemModel::reconcile(ConversationSnapshot snapshot) { + const bool authorityReplacement = snapshot.threadId != threadId_; + const std::string nextThreadId = snapshot.threadId; + const std::size_t nextHiddenCount = snapshot.hiddenAuthoritativeItemCount; + const bool nextHasMore = snapshot.hasMore; + std::vector desired = flatten(std::move(snapshot)); + std::unordered_set unique; + unique.reserve(desired.size()); + for (const Row &row : desired) + if (!unique.insert(row.stableKey).second) + return false; + + const bool chromeChanged = nextHiddenCount != hiddenAuthoritativeItemCount_ || + nextHasMore != hasMore_; + hiddenAuthoritativeItemCount_ = nextHiddenCount; + hasMore_ = nextHasMore; + if (authorityReplacement) { + beginResetModel(); + rows_ = std::move(desired); + threadId_ = nextThreadId; + rebuildIndexes(); + endResetModel(); + incrementProperty("modelResetCount"); + return true; + } + + std::unordered_set desiredKeys; + desiredKeys.reserve(desired.size()); + for (const Row &row : desired) + desiredKeys.insert(row.stableKey); + + bool changed = chromeChanged; + bool indexesDirty = false; + for (std::size_t offset = rows_.size(); offset > 0;) { + std::size_t last = offset - 1; + if (desiredKeys.contains(rows_[last].stableKey)) { + offset = last; + continue; + } + std::size_t first = last; + while (first > 0 && !desiredKeys.contains(rows_[first - 1].stableKey)) + --first; + beginRemoveRows({}, static_cast(first), static_cast(last)); + rows_.erase(rows_.begin() + static_cast(first), + rows_.begin() + static_cast(last + 1)); + endRemoveRows(); + incrementProperty("modelRemoveCount"); + changed = true; + indexesDirty = true; + offset = first; + } + + std::vector inserted(desired.size(), false); + for (std::size_t position = 0; position < desired.size(); ++position) { + if (position < rows_.size() && + rows_[position].stableKey == desired[position].stableKey) + continue; + const auto found = + std::find_if(rows_.begin() + static_cast( + std::min(position, rows_.size())), + rows_.end(), [&](const Row &row) { + return row.stableKey == desired[position].stableKey; + }); + if (found == rows_.end()) { + std::size_t count = 1; + while (position + count < desired.size() && + std::ranges::none_of( + rows_, + [&](const Row &row) { + return row.stableKey == desired[position + count].stableKey; + })) + ++count; + beginInsertRows({}, static_cast(position), + static_cast(position + count - 1)); + rows_.insert( + rows_.begin() + static_cast(position), + std::make_move_iterator(desired.begin() + + static_cast(position)), + std::make_move_iterator( + desired.begin() + static_cast(position + count))); + endInsertRows(); + std::fill(inserted.begin() + static_cast(position), + inserted.begin() + + static_cast(position + count), + true); + incrementProperty("modelInsertCount"); + changed = true; + indexesDirty = true; + position += count - 1; + continue; + } + + const std::size_t source = + static_cast(std::distance(rows_.begin(), found)); + beginMoveRows({}, static_cast(source), static_cast(source), {}, + static_cast(position)); + Row moved = std::move(rows_[source]); + rows_.erase(rows_.begin() + static_cast(source)); + rows_.insert(rows_.begin() + static_cast(position), + std::move(moved)); + endMoveRows(); + incrementProperty("modelMoveCount"); + changed = true; + indexesDirty = true; + } + + for (std::size_t position = 0; position < desired.size(); ++position) { + if (inserted[position]) + continue; + if (rows_[position] == desired[position]) + continue; + indexesDirty = indexesDirty || + rows_[position].card.target != desired[position].card.target; + updateRow(static_cast(position), std::move(desired[position])); + changed = true; + } + if (indexesDirty) + rebuildIndexes(); + return changed; +} + +ConversationItemModel::CardUpdateResult +ConversationItemModel::updateCard(VisibleCardData card) { + const std::string key = stableKey(card.key); + const auto found = stableRows_.find(key); + if (found == stableRows_.end()) + return CardUpdateResult::Missing; + Row ¤t = rows_[static_cast(found->second)]; + if (!compatible(current.card, card)) + return CardUpdateResult::Incompatible; + if (current.card == card) + return CardUpdateResult::Unchanged; + + const nodegraph::Node *oldTarget = + current.card.target ? current.card.target.get() : nullptr; + const nodegraph::Node *newTarget = card.target ? card.target.get() : nullptr; + Row replacement; + replacement.card = std::move(card); + replacement.stableKey = current.stableKey; + replacement.sectionKey = current.sectionKey; + replacement.turnRoot = current.turnRoot; + replacement.nested = current.nested; + replacement.firstInTurn = current.firstInTurn; + replacement.lastInTurn = current.lastInTurn; + replacement.presented = isPresented(replacement.card); + replacement.activeTurn = current.activeTurn; + updateRow(found->second, std::move(replacement)); + if (oldTarget != newTarget) { + if (oldTarget) + targetRows_.erase(oldTarget); + if (newTarget) + targetRows_.insert_or_assign(newTarget, found->second); + } + return CardUpdateResult::Changed; +} + +bool ConversationItemModel::setVisibility(Visibility visibility) { + if (visibility_ == visibility) + return false; + visibility_ = visibility; + int first = -1; + bool changed = false; + for (std::size_t position = 0; position < rows_.size(); ++position) { + Row &row = rows_[position]; + const bool presented = isPresented(row.card); + if (presented == row.presented) { + if (first >= 0) { + emit dataChanged(index(first), index(static_cast(position) - 1), + {PresentedRole}); + incrementProperty("modelDataChangeCount"); + first = -1; + } + continue; + } + row.presented = presented; + changed = true; + if (first < 0) + first = static_cast(position); + } + if (first < 0) + return changed; + emit dataChanged(index(first), index(rowCount() - 1), {PresentedRole}); + incrementProperty("modelDataChangeCount"); + return true; +} + +const ConversationItemModel::Row * +ConversationItemModel::row(int rowIndex) const noexcept { + return rowIndex >= 0 && static_cast(rowIndex) < rows_.size() + ? &rows_[static_cast(rowIndex)] + : nullptr; +} + +const VisibleCardData * +ConversationItemModel::card(int rowIndex) const noexcept { + const Row *value = row(rowIndex); + return value ? &value->card : nullptr; +} + +QModelIndex +ConversationItemModel::indexForStableKey(const std::string &key) const { + const auto found = stableRows_.find(key); + return found == stableRows_.end() ? QModelIndex{} : index(found->second); +} + +QModelIndex +ConversationItemModel::indexForTarget(const nodegraph::NodeRef &target) const { + if (!target) + return {}; + const auto found = targetRows_.find(target.get()); + if (found == targetRows_.end()) + return {}; + const Row *candidate = row(found->second); + return candidate && candidate->card.target == target ? index(found->second) + : QModelIndex{}; +} + +std::vector +ConversationItemModel::flatten(ConversationSnapshot &&snapshot) const { + std::size_t count = 0; + for (const TurnSection §ion : snapshot.sections) + count += section.cards.size(); + std::vector result; + result.reserve(count); + for (TurnSection §ion : snapshot.sections) { + std::optional root; + if (section.rootCardKey) + root = stableKey(*section.rootCardKey); + const bool representedRoot = + root && std::ranges::any_of(section.cards, [&](const auto &card) { + return stableKey(card.key) == *root; + }); + for (std::size_t position = 0; position < section.cards.size(); + ++position) { + VisibleCardData card = std::move(section.cards[position]); + const std::string key = stableKey(card.key); + const bool turnRoot = representedRoot && key == *root; + result.push_back(Row{std::move(card), key, section.key, turnRoot, + representedRoot && !turnRoot, position == 0, + position + 1 == section.cards.size(), false, false}); + Row &row = result.back(); + row.presented = isPresented(row.card); + row.activeTurn = turnRoot && snapshot.activeTurnId && + row.card.turnId == *snapshot.activeTurnId; + } + } + return result; +} + +bool ConversationItemModel::isPresented( + const VisibleCardData &card) const noexcept { + if (card.kind == CardKind::Reasoning) + return visibility_.showReasoning; + if (card.kind != CardKind::AgentMessage) + return true; + const auto *message = std::get_if(&card.payload); + return !message || message->finalAnswer || visibility_.showCodexUpdates; +} + +void ConversationItemModel::rebuildIndexes() { + stableRows_.clear(); + targetRows_.clear(); + stableRows_.reserve(rows_.size()); + targetRows_.reserve(rows_.size()); + for (std::size_t position = 0; position < rows_.size(); ++position) { + Row &row = rows_[position]; + const int modelRow = static_cast(position); + stableRows_.emplace(row.stableKey, modelRow); + if (row.card.target) + targetRows_.emplace(row.card.target.get(), modelRow); + } + incrementProperty("modelIndexRebuildCount"); +} + +void ConversationItemModel::incrementProperty(const char *name) { + setProperty(name, property(name).toULongLong() + 1); +} + +void ConversationItemModel::updateRow(int rowIndex, Row replacement) { + Row &before = rows_[static_cast(rowIndex)]; + QList roles; + if (before.card.threadId != replacement.card.threadId) + roles.push_back(ThreadIdRole); + if (before.card.turnId != replacement.card.turnId) + roles.push_back(TurnIdRole); + if (before.card.itemId != replacement.card.itemId) + roles.push_back(ItemIdRole); + if (before.card.kind != replacement.card.kind) { + roles.push_back(CardKindRole); + roles.push_back(Qt::DisplayRole); + roles.push_back(Qt::AccessibleTextRole); + } + if (before.card.target != replacement.card.target) + roles.push_back(TargetIdentityRole); + if (before.sectionKey != replacement.sectionKey) + roles.push_back(TurnSectionRole); + if (before.turnRoot != replacement.turnRoot) + roles.push_back(TurnRootRole); + if (before.nested != replacement.nested) + roles.push_back(NestedCardRole); + if (before.firstInTurn != replacement.firstInTurn) + roles.push_back(FirstInTurnRole); + if (before.lastInTurn != replacement.lastInTurn) + roles.push_back(LastInTurnRole); + if (before.presented != replacement.presented) + roles.push_back(PresentedRole); + if (before.activeTurn != replacement.activeTurn) + roles.push_back(ActiveTurnRole); + if (before.card.payload != replacement.card.payload || + before.card.activeWork != replacement.card.activeWork) + roles.push_back(PresentationRole); + before = std::move(replacement); + if (roles.empty()) + roles.push_back(PresentationRole); + emit dataChanged(index(rowIndex), index(rowIndex), roles); + incrementProperty("modelDataChangeCount"); +} + +} // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationItemModel.h b/src/codex/middle/ConversationItemModel.h new file mode 100644 index 0000000..44f2836 --- /dev/null +++ b/src/codex/middle/ConversationItemModel.h @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONITEMMODEL_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONITEMMODEL_H + +#include "codex/middle/MiddleTypes.h" + +#include + +#include +#include +#include +#include + +namespace codexui::codex::middle { + +class ConversationItemModel final : public QAbstractListModel { + Q_OBJECT + +public: + enum Role { + StableKeyRole = Qt::UserRole + 1, + ThreadIdRole, + TurnIdRole, + ItemIdRole, + CardKindRole, + TargetIdentityRole, + TurnSectionRole, + TurnRootRole, + NestedCardRole, + FirstInTurnRole, + LastInTurnRole, + PresentedRole, + ActiveTurnRole, + PresentationRole, + }; + + enum class CardUpdateResult { + Missing, + Incompatible, + Unchanged, + Changed, + }; + + struct Visibility { + bool showReasoning = true; + bool showCodexUpdates = true; + + bool operator==(const Visibility &) const = default; + }; + + struct Row { + VisibleCardData card; + std::string stableKey; + std::string sectionKey; + bool turnRoot = false; + bool nested = false; + bool firstInTurn = false; + bool lastInTurn = false; + bool presented = true; + bool activeTurn = false; + + bool operator==(const Row &) const = default; + }; + + explicit ConversationItemModel(QObject *parent = nullptr); + + [[nodiscard]] int + rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, + int role = Qt::DisplayRole) const override; + [[nodiscard]] Qt::ItemFlags flags(const QModelIndex &index) const override; + [[nodiscard]] QHash roleNames() const override; + + // A different thread is one complete authority replacement and therefore a + // model reset. Same-thread order is reconciled with exact row operations. + [[nodiscard]] bool reconcile(ConversationSnapshot snapshot); + [[nodiscard]] CardUpdateResult updateCard(VisibleCardData card); + [[nodiscard]] bool setVisibility(Visibility visibility); + + [[nodiscard]] const Row *row(int row) const noexcept; + [[nodiscard]] const VisibleCardData *card(int row) const noexcept; + [[nodiscard]] QModelIndex indexForStableKey(const std::string &key) const; + [[nodiscard]] QModelIndex + indexForTarget(const nodegraph::NodeRef &target) const; + [[nodiscard]] const std::string &threadId() const noexcept { + return threadId_; + } + [[nodiscard]] std::size_t hiddenAuthoritativeItemCount() const noexcept { + return hiddenAuthoritativeItemCount_; + } + [[nodiscard]] bool hasMore() const noexcept { return hasMore_; } + +private: + [[nodiscard]] std::vector flatten(ConversationSnapshot &&snapshot) const; + [[nodiscard]] bool isPresented(const VisibleCardData &card) const noexcept; + void rebuildIndexes(); + void incrementProperty(const char *name); + void updateRow(int row, Row replacement); + + std::vector rows_; + std::unordered_map stableRows_; + std::unordered_map targetRows_; + std::string threadId_; + std::size_t hiddenAuthoritativeItemCount_ = 0; + bool hasMore_ = false; + Visibility visibility_; +}; + +} // namespace codexui::codex::middle + +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONITEMMODEL_H diff --git a/tests/codex/ConversationItemModelTest.cpp b/tests/codex/ConversationItemModelTest.cpp new file mode 100644 index 0000000..7b78988 --- /dev/null +++ b/tests/codex/ConversationItemModelTest.cpp @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationItemModel.h" +#include "codex/middle/ConversationHeightIndex.h" +#include "codex/nodegraph/NodeGraph.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +bool require(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +struct SignalLog { + struct Range { + int first = -1; + int last = -1; + }; + struct Move { + int first = -1; + int last = -1; + int destination = -1; + }; + std::vector inserted; + std::vector removed; + std::vector moved; + std::vector changed; + int resets = 0; + + explicit SignalLog(ConversationItemModel &model) { + QObject::connect(&model, &QAbstractItemModel::rowsInserted, &model, + [this](const QModelIndex &, int first, int last) { + inserted.push_back({first, last}); + }); + QObject::connect(&model, &QAbstractItemModel::rowsRemoved, &model, + [this](const QModelIndex &, int first, int last) { + removed.push_back({first, last}); + }); + QObject::connect(&model, &QAbstractItemModel::rowsMoved, &model, + [this](const QModelIndex &, int first, int last, + const QModelIndex &, int destination) { + moved.push_back({first, last, destination}); + }); + QObject::connect(&model, &QAbstractItemModel::dataChanged, &model, + [this](const QModelIndex &first, const QModelIndex &last, + const QList &) { + changed.push_back({first.row(), last.row()}); + }); + QObject::connect(&model, &QAbstractItemModel::modelReset, &model, + [this] { ++resets; }); + } + + void clear() { + inserted.clear(); + removed.clear(); + moved.clear(); + changed.clear(); + resets = 0; + } +}; + +VisibleCardData card(std::string key, nodegraph::NodeRef target, + std::string text = {}) { + VisibleCardData result; + result.key = AuthoritativeItemKey{"thread", "turn", key}; + result.kind = CardKind::AgentMessage; + result.threadId = "thread"; + result.turnId = "turn"; + result.itemId = key; + result.payload = AgentMessageData{std::move(text), true}; + result.target = std::move(target); + return result; +} + +ConversationSnapshot snapshot(std::vector cards, + std::string threadId = "thread") { + ConversationSnapshot result; + result.threadId = std::move(threadId); + if (!cards.empty()) { + TurnSection section; + section.key = "section"; + section.turnId = "turn"; + section.rootCardKey = cards.front().key; + section.cards = std::move(cards); + result.sections.push_back(std::move(section)); + } + return result; +} + +bool testStableIdentityAndExactSignals() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef first; + nodegraph::NodeRef second; + nodegraph::NodeRef third; + { + auto write = graph.write(); + first = write.upsert({nodegraph::NodeKind::Item, "turn-a/item"}); + second = write.upsert({nodegraph::NodeKind::Item, "turn-b/item"}); + third = write.upsert({nodegraph::NodeKind::Item, "turn-c/item"}); + static_cast(write.finish()); + } + + ConversationItemModel model; + SignalLog log(model); + bool result = require( + model.reconcile(snapshot({card("same-wire-id-a", first, "one"), + card("same-wire-id-b", second, "two")})), + "initial authority was not accepted"); + result &= + require(log.resets == 1 && log.inserted.empty() && model.rowCount() == 2, + "initial thread did not use one model reset"); + result &= require(model.indexForTarget(first).row() == 0 && + model.indexForTarget(second).row() == 1 && + !model.indexForTarget(third).isValid(), + "NodeRef targeting did not resolve the exact graph node"); + const qulonglong indexRebuilds = + model.property("modelIndexRebuildCount").toULongLong(); + + log.clear(); + result &= require( + !model.reconcile(snapshot({card("same-wire-id-a", first, "one"), + card("same-wire-id-b", second, "two")})) && + log.resets == 0 && log.inserted.empty() && log.removed.empty() && + log.moved.empty() && log.changed.empty(), + "identical model state emitted presentation work"); + result &= require(model.property("modelIndexRebuildCount").toULongLong() == + indexRebuilds, + "identical model state rebuilt stable indexes"); + + log.clear(); + result &= + require(model.updateCard(card("same-wire-id-b", second, "streamed")) == + ConversationItemModel::CardUpdateResult::Changed && + log.changed.size() == 1 && log.changed.front().first == 1 && + log.changed.front().last == 1, + "one streamed card did not emit one exact dataChanged range"); + result &= require(model.property("modelIndexRebuildCount").toULongLong() == + indexRebuilds, + "one streamed card traversed and rebuilt stable indexes"); + log.clear(); + result &= + require(model.updateCard(card("same-wire-id-b", second, "streamed")) == + ConversationItemModel::CardUpdateResult::Unchanged && + log.changed.empty(), + "repeated streamed card emitted presentation work"); + + log.clear(); + result &= require( + model.reconcile(snapshot({card("same-wire-id-a", first, "one"), + card("inserted", third, "three"), + card("same-wire-id-b", second, "streamed")})) && + log.inserted.size() == 1 && log.inserted.front().first == 1 && + log.inserted.front().last == 1 && log.resets == 0, + "middle insertion did not use beginInsertRows/endInsertRows"); + + log.clear(); + result &= require( + model.reconcile(snapshot({card("inserted", third, "three"), + card("same-wire-id-a", first, "one"), + card("same-wire-id-b", second, "streamed")})) && + log.moved.size() == 1 && log.moved.front().first == 1 && + log.moved.front().last == 1 && log.moved.front().destination == 0 && + log.resets == 0, + "actual reordering did not use beginMoveRows/endMoveRows"); + + log.clear(); + result &= require( + model.reconcile(snapshot({card("inserted", third, "three"), + card("same-wire-id-b", second, "streamed")})) && + log.removed.size() == 1 && log.removed.front().first == 1 && + log.removed.front().last == 1 && log.resets == 0, + "removal did not use beginRemoveRows/endRemoveRows"); + result &= require( + model.indexForTarget(second).row() == 1 && + model.indexForStableKey("item:6:thread4:turn14:same-wire-id-b") + .row() == 1, + "stable and exact target indexes were not rebuilt"); + + log.clear(); + result &= require(model.reconcile(snapshot({}, "replacement")) && + log.resets == 1 && model.rowCount() == 0, + "genuine thread replacement did not use a model reset"); + return result; +} + +bool testVisibilityAndLargeModelRemainDataOnly() { + ConversationItemModel model; + ConversationSnapshot data; + data.threadId = "large"; + TurnSection section; + section.key = "large-section"; + section.turnId = "large-turn"; + constexpr int Count = 10000; + section.cards.reserve(Count); + for (int index = 0; index < Count; ++index) { + VisibleCardData row; + row.key = AuthoritativeItemKey{"large", "large-turn", + "item-" + std::to_string(index)}; + row.kind = index % 2 == 0 ? CardKind::Reasoning : CardKind::AgentMessage; + row.threadId = "large"; + row.turnId = "large-turn"; + row.itemId = "item-" + std::to_string(index); + row.payload = row.kind == CardKind::Reasoning + ? CardPayload{ReasoningData{"summary"}} + : CardPayload{AgentMessageData{"update", false}}; + section.cards.push_back(std::move(row)); + } + section.rootCardKey = section.cards.front().key; + data.sections.push_back(std::move(section)); + bool result = + require(model.reconcile(std::move(data)) && model.rowCount() == Count, + "ten-thousand-row model was not indexed"); + SignalLog log(model); + result &= require( + model.setVisibility({false, false}) && + !model.data(model.index(0, 0), ConversationItemModel::PresentedRole) + .toBool() && + log.changed.size() == 1 && log.changed.front().first == 0 && + log.changed.front().last == Count - 1, + "visibility did not produce one precise contiguous role change"); + log.clear(); + result &= require(!model.setVisibility({false, false}) && log.changed.empty(), + "identical visibility emitted work"); + return result; +} + +bool testHeightIndexIsBoundedAndExact() { + constexpr std::size_t Count = 10000; + std::vector heights(Count); + for (std::size_t row = 0; row < Count; ++row) + heights[row] = 20 + static_cast(row % 17); + ConversationHeightIndex index; + index.assign(heights); + bool result = + require(index.size() == Count, "height index did not retain every row"); + std::vector prefix(Count + 1, 0); + for (std::size_t row = 0; row < Count; ++row) + prefix[row + 1] = prefix[row] + heights[row]; + for (std::size_t sample = 0; sample < 1000; ++sample) { + const qint64 y = prefix.back() * static_cast(sample) / 1000; + const auto expected = static_cast( + std::upper_bound(prefix.begin(), prefix.end(), y) - prefix.begin() - 1); + if (!require(index.rowAt(y) == std::min(expected, Count - 1), + "position-to-row lookup returned the wrong row") || + !require(index.lastLookupSteps() <= 15, + "position-to-row lookup exceeded logarithmic steps")) { + result = false; + break; + } + } + + const std::size_t rebuilds = index.rebuildCount(); + const qint64 totalBefore = index.totalHeight(); + result &= require(index.setHeight(5000, heights[5000] + 91) && + index.totalHeight() == totalBefore + 91 && + index.rebuildCount() == rebuilds && + index.lastUpdateSteps() <= 15, + "one height update was not exact and logarithmic"); + const std::vector appended{41, 42}; + index.insert(index.size(), appended); + result &= require(index.size() == Count + 2 && index.height(Count) == 41 && + index.height(Count + 1) == 42 && + index.rebuildCount() == rebuilds, + "tail append rebuilt retained height state"); + + const std::vector inserted{77}; + index.insert(3, inserted); + result &= + require(index.height(3) == 77 && index.rebuildCount() == rebuilds + 1, + "non-tail insertion did not rebuild exact prefix state"); + index.move(3, 1, 8); + result &= require(index.height(8) == 77, + "height movement did not retain the moved extent"); + index.remove(8, 1); + result &= require(index.size() == Count + 2, + "height removal did not restore the expected row count"); + return result; +} + +} // namespace +} // namespace codexui::codex::middle + +int main(int argc, char **argv) { + QCoreApplication application(argc, argv); + using namespace codexui::codex::middle; + bool result = testStableIdentityAndExactSignals(); + result &= testVisibilityAndLargeModelRemainDataOnly(); + result &= testHeightIndexIsBoundedAndExact(); + if (result) + std::cout << "Conversation item model tests passed\n"; + return result ? 0 : 1; +} diff --git a/tests/codex/ConversationViewBenchmark.cpp b/tests/codex/ConversationViewBenchmark.cpp index 16af187..8f0c6c8 100644 --- a/tests/codex/ConversationViewBenchmark.cpp +++ b/tests/codex/ConversationViewBenchmark.cpp @@ -39,8 +39,8 @@ VisibleCardData cardData(std::size_t index) { break; case 2: card.kind = CardKind::CommandExecution; - card.payload = CommandExecutionData{"printf benchmark", "line one\nline two", - "completed", "/tmp", 0, 4}; + card.payload = CommandExecutionData{ + "printf benchmark", "line one\nline two", "completed", "/tmp", 0, 4}; break; case 3: card.kind = CardKind::Reasoning; @@ -48,14 +48,15 @@ VisibleCardData cardData(std::size_t index) { break; case 4: card.kind = CardKind::AgentActivity; - card.payload = AgentActivityData{"worker", "completed", "completed", {}, - "Agent result " + suffix}; + card.payload = AgentActivityData{ + "worker", "completed", "completed", {}, "Agent result " + suffix}; break; case 5: card.kind = CardKind::FileChanges; - card.payload = FileChangesData{ - "completed", {{"src/example-" + suffix + ".cpp", "update", 2, 1}}, - "/tmp"}; + card.payload = + FileChangesData{"completed", + {{"src/example-" + suffix + ".cpp", "update", 2, 1}}, + "/tmp"}; break; case 6: card.kind = CardKind::Plan; @@ -65,8 +66,8 @@ VisibleCardData cardData(std::size_t index) { break; default: card.kind = CardKind::GenericActivity; - card.payload = GenericActivityData{"toolCall", {}, "completed", - "detail: benchmark " + suffix}; + card.payload = GenericActivityData{ + "toolCall", {}, "completed", "detail: benchmark " + suffix}; break; } return card; @@ -103,10 +104,9 @@ int main(int argc, char **argv) { QApplication application(argc, argv); using namespace codexui::codex::middle; - const std::size_t count = argc > 1 - ? std::max( - 1, std::strtoull(argv[1], nullptr, 10)) - : 80; + const std::size_t count = + argc > 1 ? std::max(1, std::strtoull(argv[1], nullptr, 10)) + : 80; ConversationView view; view.resize(900, 700); view.show(); @@ -124,8 +124,8 @@ int main(int argc, char **argv) { constexpr int ScrollSamples = 240; const int maximum = view.verticalScrollBar()->maximum(); for (int sample = 0; sample < ScrollSamples; ++sample) { - view.verticalScrollBar()->setValue( - maximum * sample / std::max(1, ScrollSamples - 1)); + view.verticalScrollBar()->setValue(maximum * sample / + std::max(1, ScrollSamples - 1)); QApplication::processEvents(QEventLoop::AllEvents, 2); } const qint64 scrollMicroseconds = scroll.nsecsElapsed() / 1000; From 6c4a0e1d15f67e1779f630b862876d7a8cd7e6db Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 17:39:54 +0200 Subject: [PATCH 03/39] Virtualize conversation row materialization --- CMakeLists.txt | 51 + src/codex/middle/ConversationView.cpp | 2508 +++++++---------- src/codex/middle/ConversationView.h | 181 +- .../codex/ConversationVirtualizationTest.cpp | 232 ++ 4 files changed, 1333 insertions(+), 1639 deletions(-) create mode 100644 tests/codex/ConversationVirtualizationTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f6e4e4e..6f728f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -197,6 +197,10 @@ if(BUILD_TESTING) src/codex/middle/ConversationCards.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h + src/codex/middle/ConversationHeightIndex.cpp + src/codex/middle/ConversationHeightIndex.h + src/codex/middle/ConversationItemModel.cpp + src/codex/middle/ConversationItemModel.h src/codex/middle/MiddleTypes.cpp src/codex/middle/MiddleTypes.h src/codex/ui/UiStyle.cpp @@ -340,6 +344,10 @@ if(BUILD_TESTING) src/codex/middle/ConversationCards.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h + src/codex/middle/ConversationHeightIndex.cpp + src/codex/middle/ConversationHeightIndex.h + src/codex/middle/ConversationItemModel.cpp + src/codex/middle/ConversationItemModel.h src/codex/middle/MiddleTypes.cpp src/codex/middle/MiddleTypes.h src/codex/ui/UiStyle.cpp @@ -390,6 +398,41 @@ if(BUILD_TESTING) PROPERTIES TIMEOUT 15 ) + qt_add_executable( + codexui-conversation-virtualization-test + tests/codex/ConversationVirtualizationTest.cpp + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationHeightIndex.cpp + src/codex/middle/ConversationHeightIndex.h + src/codex/middle/ConversationItemModel.cpp + src/codex/middle/ConversationItemModel.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h + ) + target_compile_features( + codexui-conversation-virtualization-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-conversation-virtualization-test PRIVATE src + ) + target_link_libraries( + codexui-conversation-virtualization-test + PRIVATE codexui-nodegraph Qt6::Widgets + ) + add_test( + NAME codexui-conversation-virtualization + COMMAND codexui-conversation-virtualization-test + ) + set_tests_properties( + codexui-conversation-virtualization + PROPERTIES TIMEOUT 20 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) + qt_add_executable( codexui-conversation-view-benchmark tests/codex/ConversationViewBenchmark.cpp @@ -397,6 +440,10 @@ if(BUILD_TESTING) src/codex/middle/ConversationCards.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h + src/codex/middle/ConversationHeightIndex.cpp + src/codex/middle/ConversationHeightIndex.h + src/codex/middle/ConversationItemModel.cpp + src/codex/middle/ConversationItemModel.h src/codex/middle/MiddleTypes.cpp src/codex/middle/MiddleTypes.h src/codex/ui/UiStyle.cpp @@ -434,6 +481,10 @@ if(BUILD_TESTING) src/codex/middle/ConversationCards.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h + src/codex/middle/ConversationHeightIndex.cpp + src/codex/middle/ConversationHeightIndex.h + src/codex/middle/ConversationItemModel.cpp + src/codex/middle/ConversationItemModel.h src/codex/middle/InspectorPane.cpp src/codex/middle/InspectorPane.h src/codex/middle/MiddleRegionWidget.cpp diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 588bb9b..027c323 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -2,39 +2,46 @@ #include "codex/middle/ConversationView.h" -#include "codex/middle/ConversationCards.h" - #include #include +#include #include -#include #include +#include #include +#include +#include #include #include #include #include #include -#include -#include +#include +#include #include -#include #include #include #include +#include #include +#include +#include #include namespace codexui::codex::middle { namespace { constexpr int CardSpacing = 8; +constexpr int HistoryButtonHeight = 32; +constexpr int NestedCardIndent = 12; constexpr int NativeScrollLineStep = 20; +constexpr int EstimatedCardHeight = 112; +constexpr int MinimumMaterializationRows = 8; -QLabel *makeEmptyLabel() { +QLabel *makeEmptyLabel(QWidget *parent) { auto *label = - new QLabel(QStringLiteral("Conversation activity appears here.")); + new QLabel(QStringLiteral("Conversation activity appears here."), parent); label->setProperty("kind", "muted"); label->setWordWrap(true); label->setMinimumWidth(0); @@ -42,75 +49,55 @@ QLabel *makeEmptyLabel() { return label; } -} // namespace - -class ConversationView::TurnSectionWidget final : public QWidget { -public: - explicit TurnSectionWidget(QWidget *parent = nullptr) : QWidget(parent) { - setAttribute(Qt::WA_StyledBackground, false); - setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); - cards = new QVBoxLayout(this); - cards->setContentsMargins(0, 0, 0, 0); - cards->setSpacing(CardSpacing); - } +void incrementProperty(QObject *object, const char *name) { + object->setProperty(name, object->property(name).toULongLong() + 1); +} - QVBoxLayout *cards = nullptr; - std::vector cardKeys; -}; +} // namespace ConversationView::ConversationView(QWidget *parent) - : QAbstractScrollArea(parent) { + : QAbstractItemView(parent), model_(new ConversationItemModel(this)) { setObjectName(QStringLiteral("conversationScroll")); setFrameShape(QFrame::NoFrame); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); setSizeAdjustPolicy(QAbstractScrollArea::AdjustIgnored); + setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + setSelectionMode(QAbstractItemView::SingleSelection); + setSelectionBehavior(QAbstractItemView::SelectRows); + setTabKeyNavigation(true); + setModel(model_); verticalScrollBar()->setSingleStep(NativeScrollLineStep); viewport()->setAutoFillBackground(false); - content_ = new QWidget(viewport()); - content_->setObjectName(QStringLiteral("conversationContent")); - content_->setAttribute(Qt::WA_StyledBackground, false); - content_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - content_->installEventFilter(this); + loadMore_ = + new QPushButton(QStringLiteral("Load more activities"), viewport()); + loadMore_->setObjectName(QStringLiteral("conversationLoadMore")); + loadMore_->setProperty("kind", "history"); + loadMore_->setFixedHeight(HistoryButtonHeight); + loadMore_->hide(); + connect(loadMore_, &QPushButton::clicked, this, [this] { + if (loadMoreAction_) + loadMoreAction_(); + }); + + empty_ = makeEmptyLabel(viewport()); + emptyMessage_ = empty_->text(); - // Keep preparatory widgets outside the visible QObject subtree as well as - // outside its layouts. Tests, accessibility walks, and presentation code - // must observe only the atomically committed surface. + // Rich rows prepared for an atomic thread/paging reveal are never parented + // into the visible or accessible viewport until their final geometry is + // known. stagingHost_ = new QWidget; stagingHost_->setObjectName(QStringLiteral("conversationStagingHost")); stagingHost_->hide(); - stagingOverlay_ = new QLabel(QStringLiteral("Loading conversation…"), - viewport()); + stagingOverlay_ = + new QLabel(QStringLiteral("Loading conversation…"), viewport()); stagingOverlay_->setObjectName(QStringLiteral("conversationStagingOverlay")); stagingOverlay_->setAlignment(Qt::AlignCenter); stagingOverlay_->setAutoFillBackground(true); stagingOverlay_->hide(); - contentLayout_ = new QVBoxLayout(content_); - contentLayout_->setContentsMargins(0, 0, 0, 0); - contentLayout_->setSpacing(CardSpacing); - contentLayout_->setAlignment(Qt::AlignTop); - - loadMore_ = new QPushButton(QStringLiteral("Load more activities"), content_); - loadMore_->setProperty("kind", "history"); - loadMore_->setFixedHeight(32); - loadMore_->hide(); - connect(loadMore_, &QPushButton::clicked, this, [this] { - if (loadMoreAction_) - loadMoreAction_(); - }); - contentLayout_->addWidget(loadMore_, 0, Qt::AlignHCenter); - - empty_ = makeEmptyLabel(); - emptyMessage_ = empty_->text(); - empty_->setParent(content_); - contentLayout_->addWidget(empty_); - trailingSpace_ = - new QSpacerItem(0, 0, QSizePolicy::Minimum, QSizePolicy::Fixed); - contentLayout_->addItem(trailingSpace_); - followAnimation_ = new QVariantAnimation(this); followAnimation_->setEasingCurve(QEasingCurve::OutCubic); connect(followAnimation_, &QVariantAnimation::valueChanged, this, @@ -119,8 +106,6 @@ ConversationView::ConversationView(QWidget *parent) followAnimation_->stop(); return; } - // Never let a retargeted animation move an already-following view - // backwards. setScrollValue( std::max(verticalScrollBar()->value(), value.toInt())); }); @@ -145,26 +130,24 @@ ConversationView::ConversationView(QWidget *parent) stopFollowingAnimation(); if (action == QAbstractSlider::SliderSingleStepSub || action == QAbstractSlider::SliderPageStepSub || - action == QAbstractSlider::SliderToMinimum) { + action == QAbstractSlider::SliderToMinimum) mode_ = Mode::Paused; - } }); connect(verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) { - positionContent(); - if (programmaticScroll_ || applying_) - return; - if (sliderDown_ || userActionPending_) { + if (!programmaticScroll_ && !applying_ && + (sliderDown_ || userActionPending_)) handleUserScrollValue(value); - } userActionPending_ = false; }); - recomputeGeometry(); + rebuildHeightIndex(); + updateScrollRange(); } ConversationView::~ConversationView() { cancelStructuralStaging(); + releaseAllCards(); delete stagingHost_; } @@ -187,26 +170,35 @@ void ConversationView::setEmptyMessage(QString message) { return; const Anchor anchor = captureAnchor(); const bool follow = mode_ == Mode::Following; - applying_ = true; - viewport()->setUpdatesEnabled(false); - const QSignalBlocker scrollSignals(verticalScrollBar()); emptyMessage_ = std::move(message); empty_->setText(emptyMessage_); - recomputeGeometry(); + updateScrollRange(); if (follow) setScrollValue(verticalScrollBar()->maximum()); else restoreAnchor(anchor); - applying_ = false; - viewport()->setUpdatesEnabled(true); + layoutMaterializedCards(); viewport()->update(); } void ConversationView::setPresentationOptions(PresentationOptions options) { if (presentationOptions_ == options) return; + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; presentationOptions_ = options; - static_cast(reconcile(snapshot_, true, true)); + const bool visibilityChanged = + model_->setVisibility({options.showReasoning, options.showCodexUpdates}); + if (!visibilityChanged) + return; + rebuildHeightIndex(); + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + updateMaterialization(false); + viewport()->update(); } bool ConversationView::cardVisible(const VisibleCardData &card) const noexcept { @@ -240,39 +232,105 @@ void ConversationView::setThread(const std::string &threadId) { bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { if (!committingStructuralStage_ && pendingStructuralSnapshot_) cancelStructuralStaging(); - return reconcile(ConversationSnapshot(snapshot), false, false); + return reconcileOwned(ConversationSnapshot(snapshot)); } -void ConversationView::reconcileStaged(ConversationSnapshot snapshot) { - if (snapshot == snapshot_ && snapshot.threadId == threadId_) { - cancelStructuralStaging(); - return; +bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { + const bool switchedThread = snapshot.threadId != threadId_; + const Anchor currentAnchor = captureAnchor(); + setThread(snapshot.threadId); + Anchor targetAnchor = currentAnchor; + if (switchedThread) { + const auto saved = threadStates_.find(snapshot.threadId); + targetAnchor = + saved == threadStates_.end() ? Anchor{} : saved->second.anchor; + } + const bool follow = mode_ == Mode::Following; + + std::unordered_map previousKinds; + previousKinds.reserve(static_cast(model_->rowCount())); + for (int row = 0; row < model_->rowCount(); ++row) { + if (const auto *value = model_->row(row)) + previousKinds.emplace(value->stableKey, value->card.kind); } - std::vector missing; - for (const TurnSection §ion : snapshot.sections) { - for (const VisibleCardData &data : section.cards) { - const std::string key = stableKey(data.key); - const auto retained = cards_.find(key); - if (retained == cards_.end() || !retained->second->canApply(data)) - missing.push_back(key); + const QScopedValueRollback applying(applying_, true); + const QSignalBlocker scrollSignals(verticalScrollBar()); + viewport()->setUpdatesEnabled(false); + stopFollowingAnimation(); + + const bool changed = model_->reconcile(std::move(snapshot)); + loadMore_->setVisible(model_->hasMore()); + empty_->setVisible(model_->rowCount() == 0); + + std::vector removeKeys; + removeKeys.reserve(materializedCards_.size()); + for (auto &[key, card] : materializedCards_) { + const QModelIndex index = model_->indexForStableKey(key); + const ConversationItemModel::Row *row = model_->row(index.row()); + if (!index.isValid() || !row || !row->presented || + !card->canApply(row->card)) { + removeKeys.push_back(key); + continue; + } + if (card->data() != row->card) { + if (card->applyPresentation(row->card) == + PresentationImpact::GeometryChanged) + heightCache_.erase(key); } + configureCardForRow(card, *row); + } + for (const std::string &key : removeKeys) { + const auto found = materializedCards_.find(key); + if (found == materializedCards_.end()) + continue; + ConversationCard *card = found->second; + materializedCards_.erase(found); + releaseCard(key, card); } - // A structure change without construction can commit directly. Even one - // rich arriving card is built in a hidden pass first so an active wheel or - // touchpad sequence gets an event-loop boundary before the cached geometry - // commit. This is normally only a few milliseconds and never exposes a - // placeholder or partially parented Turn. - if (missing.empty()) { - cancelStructuralStaging(); - static_cast(reconcile(std::move(snapshot), false, false)); - return; + rebuildHeightIndex(); + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(targetAnchor); + updateMaterialization(false); + + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(targetAnchor); + layoutMaterializedCards(); + storeCurrentThreadState(); + + std::vector acknowledged; + for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { + const auto *row = model_->row(rowIndex); + if (!row || row->card.kind != CardKind::UserMessage || !row->card.target) + continue; + const auto before = previousKinds.find(row->stableKey); + if (before != previousKinds.end() && + before->second == CardKind::LocalPrompt) + acknowledged.push_back(row->card.target); } + viewport()->setUpdatesEnabled(true); + viewport()->update(); + if (changed) + incrementProperty(this, "graphRefreshPasses"); + for (nodegraph::NodeRef &target : acknowledged) + if (promptMaterializedAction_ && + !promptMaterializedAction_(std::move(target))) + break; + return changed; +} + +void ConversationView::reconcileStaged(ConversationSnapshot snapshot) { cancelStructuralStaging(); pendingStructuralSnapshot_ = std::move(snapshot); - pendingStructuralCardKeys_ = std::move(missing); + buildPendingLocations(); + choosePendingStageRows(); pendingStructuralCardIndex_ = 0; stagingHost_->resize(std::max(0, viewport()->width()), std::max(0, viewport()->height())); @@ -281,11 +339,108 @@ void ConversationView::reconcileStaged(ConversationSnapshot snapshot) { stagingOverlay_->show(); stagingOverlay_->raise(); } - setProperty("structuralStageStarts", - property("structuralStageStarts").toULongLong() + 1); + incrementProperty(this, "structuralStageStarts"); + if (pendingStructuralCardKeys_.empty()) { + runStructuralStagePass(); + return; + } scheduleStructuralStagePass(); } +void ConversationView::buildPendingLocations() { + pendingLocations_.clear(); + if (!pendingStructuralSnapshot_) + return; + for (std::size_t sectionIndex = 0; + sectionIndex < pendingStructuralSnapshot_->sections.size(); + ++sectionIndex) { + TurnSection §ion = pendingStructuralSnapshot_->sections[sectionIndex]; + std::optional root; + if (section.rootCardKey) + root = stableKey(*section.rootCardKey); + const bool representedRoot = + root && std::ranges::any_of(section.cards, [&](const auto &card) { + return stableKey(card.key) == *root; + }); + for (std::size_t cardIndex = 0; cardIndex < section.cards.size(); + ++cardIndex) { + const std::string key = stableKey(section.cards[cardIndex].key); + const bool isRoot = representedRoot && key == *root; + pendingLocations_.emplace( + key, + PendingLocation{ + sectionIndex, cardIndex, representedRoot && !isRoot, isRoot, + isRoot && pendingStructuralSnapshot_->activeTurnId && + section.turnId == *pendingStructuralSnapshot_->activeTurnId}); + } + } +} + +void ConversationView::choosePendingStageRows() { + pendingStructuralCardKeys_.clear(); + if (!pendingStructuralSnapshot_ || pendingLocations_.empty()) + return; + + std::vector keys; + keys.reserve(pendingLocations_.size()); + for (const TurnSection §ion : pendingStructuralSnapshot_->sections) + for (const VisibleCardData &card : section.cards) + keys.push_back(stableKey(card.key)); + + const int viewportRows = + std::max(MinimumMaterializationRows, + std::max(1, viewport()->height()) / EstimatedCardHeight + 2); + const std::size_t budget = static_cast(viewportRows * 3); + std::size_t center = keys.empty() ? 0 : keys.size() - 1; + if (pendingStructuralSnapshot_->threadId == threadId_ && + mode_ == Mode::Paused) { + const Anchor anchor = captureAnchor(); + const auto found = std::ranges::find(keys, anchor.stableKey); + if (found != keys.end()) + center = static_cast(std::distance(keys.begin(), found)); + } else if (const auto saved = + threadStates_.find(pendingStructuralSnapshot_->threadId); + saved != threadStates_.end() && + saved->second.mode == Mode::Paused) { + const auto found = std::ranges::find(keys, saved->second.anchor.stableKey); + if (found != keys.end()) + center = static_cast(std::distance(keys.begin(), found)); + } + const std::size_t first = center > budget / 2 ? center - budget / 2 : 0; + const std::size_t last = std::min(keys.size(), first + budget); + for (std::size_t index = first; index < last; ++index) { + const std::string &key = keys[index]; + VisibleCardData *card = pendingCard(key); + if (!card || !cardVisible(*card)) + continue; + const auto retained = materializedCards_.find(key); + if (retained != materializedCards_.end() && + retained->second->canApply(*card)) + continue; + pendingStructuralCardKeys_.push_back(key); + } +} + +VisibleCardData *ConversationView::pendingCard(const std::string &key) { + if (!pendingStructuralSnapshot_) + return nullptr; + const auto found = pendingLocations_.find(key); + if (found == pendingLocations_.end()) + return nullptr; + const PendingLocation &location = found->second; + if (location.section >= pendingStructuralSnapshot_->sections.size()) + return nullptr; + TurnSection §ion = pendingStructuralSnapshot_->sections[location.section]; + return location.card < section.cards.size() ? §ion.cards[location.card] + : nullptr; +} + +const ConversationView::PendingLocation * +ConversationView::pendingLocation(const std::string &key) const { + const auto found = pendingLocations_.find(key); + return found == pendingLocations_.end() ? nullptr : &found->second; +} + void ConversationView::scheduleStructuralStagePass() { if (structuralStagePassScheduled_ || !pendingStructuralSnapshot_) return; @@ -296,43 +451,26 @@ void ConversationView::scheduleStructuralStagePass() { }); } -VisibleCardData *ConversationView::pendingCard(const std::string &key) { - if (!pendingStructuralSnapshot_) - return nullptr; - for (TurnSection §ion : pendingStructuralSnapshot_->sections) { - const auto found = std::ranges::find_if(section.cards, [&](const auto &card) { - return stableKey(card.key) == key; - }); - if (found != section.cards.end()) - return &*found; - } - return nullptr; -} - void ConversationView::runStructuralStagePass() { if (!pendingStructuralSnapshot_) return; - - // One rich card is the indivisible Qt unit. Yield after each constructor so - // input and already-painted surfaces remain responsive during an 80-item - // history expansion. while (pendingStructuralCardIndex_ < pendingStructuralCardKeys_.size()) { const std::string key = pendingStructuralCardKeys_[pendingStructuralCardIndex_++]; VisibleCardData *data = pendingCard(key); - if (!data) + const PendingLocation *location = pendingLocation(key); + if (!data || !location) continue; - const auto retained = cards_.find(key); - if (retained != cards_.end() && retained->second->canApply(*data)) - continue; - QElapsedTimer constructionElapsed; - constructionElapsed.start(); - ConversationCard *card = createRetainedCard(*data, stagingHost_, key); + ConversationCard *card = createCard(*data, stagingHost_, key); + card->setNestedPresentation(location->nested); + card->setAuthoritativeTurnActive(location->activeTurn); + const int width = std::max( + 0, viewport()->width() - (location->nested ? 2 * NestedCardIndent : 0)); + const int height = measureCard(card, width); + card->hide(); stagedCards_.insert_or_assign(key, card); - setProperty("lastStructuralStageCardConstructionMicros", - constructionElapsed.nsecsElapsed() / 1000); - setProperty("structuralStageCardPasses", - property("structuralStageCardPasses").toULongLong() + 1); + stagedHeights_.insert_or_assign(key, height); + incrementProperty(this, "structuralStageCardPasses"); scheduleStructuralStagePass(); return; } @@ -341,23 +479,22 @@ void ConversationView::runStructuralStagePass() { pendingStructuralSnapshot_.reset(); pendingStructuralCardKeys_.clear(); pendingStructuralCardIndex_ = 0; + pendingLocations_.clear(); const QScopedValueRollback committing(committingStructuralStage_, true); - QElapsedTimer elapsed; - elapsed.start(); - static_cast(reconcile(std::move(completed), false, false)); - setProperty("structuralStageCommitMillis", elapsed.elapsed()); + static_cast(reconcileOwned(std::move(completed))); for (auto &[key, card] : stagedCards_) { static_cast(key); delete card; } stagedCards_.clear(); + stagedHeights_.clear(); stagingOverlay_->hide(); - setProperty("structuralStageCommits", - property("structuralStageCommits").toULongLong() + 1); + incrementProperty(this, "structuralStageCommits"); } void ConversationView::cancelStructuralStaging() { pendingStructuralSnapshot_.reset(); + pendingLocations_.clear(); pendingStructuralCardKeys_.clear(); pendingStructuralCardIndex_ = 0; for (auto &[key, card] : stagedCards_) { @@ -365,87 +502,305 @@ void ConversationView::cancelStructuralStaging() { delete card; } stagedCards_.clear(); + stagedHeights_.clear(); stagingOverlay_->hide(); } std::optional -ConversationView::applyCardPresentation(const VisibleCardData &data) { - const std::string key = stableKey(data.key); - VisibleCardData *stagedData = pendingCard(key); - if (stagedData && data.threadId == pendingStructuralSnapshot_->threadId) { - if (*stagedData != data) { - const auto staged = stagedCards_.find(key); - if (staged != stagedCards_.end()) { - if (staged->second->canApply(data)) { - static_cast(staged->second->applyPresentation(data)); - } else { - delete staged->second; - stagedCards_.erase(staged); +ConversationView::applyCardPresentation(const VisibleCardData &card) { + return applyCardPresentationOwned(VisibleCardData(card)); +} + +std::optional +ConversationView::applyCardPresentation(VisibleCardData &&card) { + return applyCardPresentationOwned(std::move(card)); +} + +std::optional +ConversationView::applyCardPresentationOwned(VisibleCardData card) { + const std::string key = stableKey(card.key); + if (VisibleCardData *pending = pendingCard(key); + pending && pendingStructuralSnapshot_ && + card.threadId == pendingStructuralSnapshot_->threadId) { + const auto staged = stagedCards_.find(key); + if (staged != stagedCards_.end()) { + if (staged->second->canApply(card)) { + const PresentationImpact impact = + staged->second->applyPresentation(card); + if (impact == PresentationImpact::GeometryChanged) { + const PendingLocation *location = pendingLocation(key); + const int width = std::max( + 0, viewport()->width() - + (location && location->nested ? 2 * NestedCardIndent : 0)); + stagedHeights_.insert_or_assign(key, + measureCard(staged->second, width)); } + } else { + delete staged->second; + stagedCards_.erase(staged); + stagedHeights_.erase(key); } - *stagedData = data; } - // A not-yet-committed card has no visible Qt presentation to invalidate. - // Its newest canonical fields will appear in the atomic stage commit. - if (!cards_.contains(key)) + *pending = card; + if (!model_->indexForStableKey(key).isValid()) return PresentationImpact::None; } - if (data.threadId != threadId_) + if (card.threadId != threadId_) return std::nullopt; - const auto retained = cards_.find(key); - if (retained == cards_.end() || !retained->second->canApply(data)) + const QModelIndex index = model_->indexForStableKey(key); + const ConversationItemModel::Row *before = model_->row(index.row()); + if (!index.isValid() || !before) return std::nullopt; + if (before->card == card) + return PresentationImpact::None; - VisibleCardData *previous = nullptr; - for (TurnSection §ion : snapshot_.sections) { - const auto found = std::ranges::find_if(section.cards, [&](const auto &card) { - return stableKey(card.key) == key; - }); - if (found != section.cards.end()) { - previous = &*found; - break; - } + const bool wasPresented = before->presented; + const bool becomingAuthoritative = + before->card.kind == CardKind::LocalPrompt && + card.kind == CardKind::UserMessage && card.target; + nodegraph::NodeRef authoritativeTarget = + becomingAuthoritative ? card.target : nodegraph::NodeRef{}; + ConversationCard *visibleCard = cardForStableKey(key); + PresentationImpact impact = PresentationImpact::None; + if (visibleCard) { + if (!visibleCard->canApply(card)) + return std::nullopt; + impact = visibleCard->applyPresentation(card); } - if (!previous || cardVisible(*previous) != cardVisible(data)) + + const ConversationItemModel::CardUpdateResult result = + model_->updateCard(std::move(card)); + if (result == ConversationItemModel::CardUpdateResult::Missing || + result == ConversationItemModel::CardUpdateResult::Incompatible) return std::nullopt; - if (*previous == data) + if (result == ConversationItemModel::CardUpdateResult::Unchanged) return PresentationImpact::None; - const bool becomingAuthoritative = - previous->kind == CardKind::LocalPrompt && - data.kind == CardKind::UserMessage && data.target; - const Anchor anchor = captureAnchor(); - const bool follow = mode_ == Mode::Following; - applying_ = true; - const PresentationImpact impact = retained->second->applyPresentation(data); - *previous = data; - if (impact == PresentationImpact::GeometryChanged) { - const QSignalBlocker scrollSignals(verticalScrollBar()); - stopFollowingAnimation(); - recomputeCardGeometries({retained->second}); - if (follow) - setScrollValue(verticalScrollBar()->maximum()); - else - restoreAnchor(anchor); - } else if (impact == PresentationImpact::PaintOnly) { - settlePaintOnlyCard(retained->second); + const ConversationItemModel::Row *after = model_->row(index.row()); + const bool presentationChanged = after && after->presented != wasPresented; + if (presentationChanged) { + if (after->presented) { + static_cast( + heights_.setHeight(static_cast(index.row()), + estimatedCardHeight(after->card) + CardSpacing)); + } else { + static_cast( + heights_.setHeight(static_cast(index.row()), 0)); + if (visibleCard) { + materializedCards_.erase(key); + releaseCard(key, visibleCard); + visibleCard = nullptr; + } + } + updateScrollRange(); + updateMaterialization(true); + } else if (visibleCard && impact == PresentationImpact::GeometryChanged) { + const int height = measureCard(visibleCard, rowWidth(*after)); + static_cast(updateMeasuredHeight(index.row(), height, true)); + } else if (visibleCard && impact == PresentationImpact::PaintOnly) { + visibleCard->update(); } - applying_ = false; + + if (visibleCard) + incrementProperty(this, "targetedVisibleCardUpdates"); + else + incrementProperty(this, "targetedOffscreenCardUpdates"); + incrementProperty(this, "graphRefreshPasses"); + incrementProperty(this, "targetedCardCommits"); storeCurrentThreadState(); if (becomingAuthoritative && promptMaterializedAction_) - static_cast(promptMaterializedAction_(data.target)); - if (impact != PresentationImpact::None) { - setProperty("graphRefreshPasses", - property("graphRefreshPasses").toULongLong() + 1); - setProperty("targetedCardCommits", - property("targetedCardCommits").toULongLong() + 1); - } + static_cast( + promptMaterializedAction_(std::move(authoritativeTarget))); return impact; } -ConversationCard *ConversationView::createRetainedCard( - const VisibleCardData &data, QWidget *parent, const std::string &key) { +int ConversationView::estimatedCardHeight(const VisibleCardData &card) const { + switch (card.kind) { + case CardKind::CommandExecution: + return 156; + case CardKind::FileChanges: + case CardKind::ImageGeneration: + case CardKind::Plan: + return 136; + case CardKind::UserMessage: + case CardKind::LocalPrompt: + return 92; + default: + return EstimatedCardHeight; + } +} + +int ConversationView::rowWidth(const ConversationItemModel::Row &row) const { + return std::max(0, viewport()->width() - + (row.nested ? 2 * NestedCardIndent : 0)); +} + +void ConversationView::rebuildHeightIndex() { + std::vector extents; + extents.reserve(static_cast(model_->rowCount())); + for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->presented) { + extents.push_back(0); + continue; + } + const int width = rowWidth(*row); + int height = 0; + if (const auto staged = stagedHeights_.find(row->stableKey); + staged != stagedHeights_.end()) { + height = staged->second; + heightCache_.insert_or_assign(row->stableKey, + HeightRecord{width, height}); + } else if (const auto cached = heightCache_.find(row->stableKey); + cached != heightCache_.end() && cached->second.width == width) { + height = cached->second.height; + } else { + height = estimatedCardHeight(row->card); + } + extents.push_back(std::max(1, height) + CardSpacing); + } + heights_.assign(extents); + setProperty("conversationHeightIndexRebuilds", + static_cast(heights_.rebuildCount())); +} + +int ConversationView::leadingChromeHeight() const noexcept { + if (!model_) + return 0; + if (model_->hasMore()) + return HistoryButtonHeight + CardSpacing; + if (model_->rowCount() == 0 && empty_) + return std::max(28, empty_->sizeHint().height()) + CardSpacing; + return 0; +} + +qint64 ConversationView::naturalContentHeight() const noexcept { + return static_cast(leadingChromeHeight()) + heights_.totalHeight() + + trailingSpaceHeight_; +} + +void ConversationView::updateScrollRange() { + if (!model_ || !viewport()) + return; + const int viewportHeight = std::max(0, viewport()->height()); + const qint64 maximum64 = + std::max(0, naturalContentHeight() - viewportHeight); + const int maximum = static_cast(std::min(INT_MAX, maximum64)); + verticalScrollBar()->setPageStep(viewportHeight); + verticalScrollBar()->setRange(0, maximum); + horizontalScrollBar()->setPageStep(viewport()->width()); + horizontalScrollBar()->setRange(0, 0); + + const int leading = leadingChromeHeight(); + if (model_->hasMore() && loadMore_) { + const int width = std::min(std::max(180, loadMore_->sizeHint().width()), + std::max(0, viewport()->width())); + loadMore_->setGeometry( + (viewport()->width() - width) / 2 - horizontalScrollBar()->value(), + -verticalScrollBar()->value(), width, HistoryButtonHeight); + } + if (model_->rowCount() == 0 && empty_) { + empty_->setGeometry(0, -verticalScrollBar()->value(), + std::max(0, viewport()->width()), + std::max(0, leading - CardSpacing)); + } + if (stagingOverlay_) + stagingOverlay_->setGeometry(viewport()->rect()); +} + +QRect ConversationView::rowRect(int rowIndex) const { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->presented || rowIndex < 0 || + static_cast(rowIndex) >= heights_.size()) + return {}; + const int extent = heights_.height(static_cast(rowIndex)); + if (extent <= 0) + return {}; + const qint64 contentTop = static_cast(leadingChromeHeight()) + + heights_.top(static_cast(rowIndex)); + const qint64 viewportTop = contentTop - verticalScrollBar()->value(); + const int x = row->nested ? NestedCardIndent : 0; + return {x - horizontalScrollBar()->value(), + static_cast(std::clamp(viewportTop, INT_MIN, INT_MAX)), + rowWidth(*row), std::max(1, extent - CardSpacing)}; +} + +int ConversationView::measureCard(ConversationCard *card, int width) const { + if (!card || !card->layout()) + return 0; + width = std::max(1, width); + card->setMinimumHeight(0); + card->setMaximumHeight(QWIDGETSIZE_MAX); + card->resize(width, std::max(1, card->height())); + if (QWidget *content = + card->findChild(QStringLiteral("conversationCardContent"), + Qt::FindDirectChildrenOnly); + content && content->layout()) { + content->layout()->invalidate(); + content->layout()->setGeometry(content->contentsRect()); + content->layout()->activate(); + } + card->layout()->invalidate(); + card->layout()->setGeometry(card->contentsRect()); + card->layout()->activate(); + const int height = + card->layout()->hasHeightForWidth() + ? card->layout()->heightForWidth(width) + 2 * card->frameWidth() + : card->sizeHint().height(); + card->setFixedHeight(std::max(1, height)); + card->resize(width, std::max(1, height)); + card->layout()->setGeometry(card->contentsRect()); + card->layout()->activate(); + QCoreApplication::removePostedEvents(card, QEvent::LayoutRequest); + return std::max(1, height); +} + +bool ConversationView::updateMeasuredHeight(int rowIndex, int cardHeight, + bool preserveAnchor) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->presented) + return false; + const Anchor anchor = preserveAnchor ? captureAnchor() : Anchor{}; + const bool follow = mode_ == Mode::Following; + heightCache_.insert_or_assign(row->stableKey, + HeightRecord{rowWidth(*row), cardHeight}); + if (!heights_.setHeight(static_cast(rowIndex), + std::max(1, cardHeight) + CardSpacing)) + return false; + incrementProperty(this, "conversationLocalGeometryPasses"); + setProperty("conversationHeightIndexUpdateSteps", + static_cast(heights_.lastUpdateSteps())); + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else if (preserveAnchor) + restoreAnchor(anchor); + layoutMaterializedCards(); + return true; +} + +std::pair ConversationView::materializationRows() const { + if (model_->rowCount() == 0 || heights_.empty() || + heights_.totalHeight() <= 0) + return {-1, -1}; + const qint64 scroll = verticalScrollBar()->value(); + const qint64 viewportHeight = std::max(1, viewport()->height()); + const qint64 contentStart = + std::max(0, scroll - leadingChromeHeight() - viewportHeight); + const qint64 contentEnd = + std::min(heights_.totalHeight() - 1, + scroll - leadingChromeHeight() + 2 * viewportHeight); + if (contentEnd < contentStart) + return {-1, -1}; + const int first = static_cast(heights_.rowAt(contentStart)); + const int last = static_cast(heights_.rowAt(contentEnd)); + return {std::max(0, first), std::min(model_->rowCount() - 1, last)}; +} + +ConversationCard *ConversationView::createCard(const VisibleCardData &data, + QWidget *parent, + const std::string &key) { ConversationCard *card = createConversationCard( data, parent, !presentationOptions_.commandsInitiallyExpanded, !presentationOptions_.imagesInitiallyExpanded, @@ -454,824 +809,174 @@ ConversationCard *ConversationView::createRetainedCard( if (const auto collapsed = cardCollapsedStates_.find(key); collapsed != cardCollapsedStates_.end()) card->setCollapsed(collapsed->second); + if (const auto output = commandOutputStates_.find(key); + output != commandOutputStates_.end()) + card->restoreCommandOutputScrollState(output->second); + card->installEventFilter(this); + for (QWidget *child : card->findChildren()) + child->installEventFilter(this); connect(card, &ConversationCard::foldRequested, this, [this, key, card](bool collapsed) { - const auto retained = cards_.find(key); - if (retained != cards_.end() && retained->second == card) + const auto retained = materializedCards_.find(key); + if (retained != materializedCards_.end() && + retained->second == card) setCardCollapsed(key, card, collapsed); }); - connect(card, &ConversationCard::recoveryRequested, this, - [this, key, card] { - const auto retained = cards_.find(key); - if (retained == cards_.end() || retained->second != card || - !promptRecoveryAction_ || !card->data().target) - return; - promptRecoveryAction_(card->data().target); - }); + connect(card, &ConversationCard::recoveryRequested, this, [this, key, card] { + const auto retained = materializedCards_.find(key); + if (retained == materializedCards_.end() || retained->second != card || + !promptRecoveryAction_ || !card->data().target) + return; + promptRecoveryAction_(card->data().target); + }); return card; } -bool ConversationView::tryReconcileSingleInsertion( - ConversationSnapshot &snapshot, bool settleFollowImmediately) { - QElapsedTimer insertionElapsed; - insertionElapsed.start(); - if (snapshot.threadId != snapshot_.threadId || - snapshot.threadId != threadId_ || snapshot.hasMore != snapshot_.hasMore || - snapshot.hiddenAuthoritativeItemCount != - snapshot_.hiddenAuthoritativeItemCount || - snapshot.sections.size() < snapshot_.sections.size() || - snapshot.sections.size() > snapshot_.sections.size() + 1) - return false; - - struct Insertion { - std::size_t section = 0; - std::size_t card = 0; - bool newSection = false; - }; - std::optional insertion; - std::unordered_map previousCards; - for (const TurnSection §ion : snapshot_.sections) - for (const VisibleCardData &card : section.cards) - previousCards.emplace(stableKey(card.key), &card); - - std::size_t previousSection = 0; - for (std::size_t sectionIndex = 0; sectionIndex < snapshot.sections.size(); - ++sectionIndex) { - const TurnSection &nextSection = snapshot.sections[sectionIndex]; - if (previousSection >= snapshot_.sections.size() || - snapshot_.sections[previousSection].key != nextSection.key) { - if (insertion || nextSection.cards.size() != 1 || - (nextSection.rootCardKey && - stableKey(*nextSection.rootCardKey) != - stableKey(nextSection.cards.front().key))) - return false; - insertion = Insertion{sectionIndex, 0, true}; - continue; - } +void ConversationView::configureCardForRow( + ConversationCard *card, const ConversationItemModel::Row &row) { + if (!card) + return; + card->setProperty("turnContainer", row.turnRoot); + card->setNestedCards({}); + card->setNestedPresentation(row.nested); + card->setAuthoritativeTurnActive(row.turnRoot && row.activeTurn); +} - const TurnSection &oldSection = snapshot_.sections[previousSection++]; - if (oldSection.turnId != nextSection.turnId || - oldSection.rootCardKey != nextSection.rootCardKey || - nextSection.cards.size() < oldSection.cards.size() || - nextSection.cards.size() > oldSection.cards.size() + 1) - return false; +ConversationCard *ConversationView::materializeRow(int rowIndex) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->presented) + return nullptr; + if (ConversationCard *retained = cardForStableKey(row->stableKey)) + return retained; - std::size_t oldCardIndex = 0; - for (std::size_t cardIndex = 0; cardIndex < nextSection.cards.size(); - ++cardIndex) { - const VisibleCardData &nextCard = nextSection.cards[cardIndex]; - if (oldCardIndex < oldSection.cards.size() && - stableKey(oldSection.cards[oldCardIndex].key) == - stableKey(nextCard.key)) { - const VisibleCardData &oldCard = oldSection.cards[oldCardIndex++]; - const auto retained = cards_.find(stableKey(nextCard.key)); - if (retained == cards_.end() || - !retained->second->canApply(nextCard) || - cardVisible(oldCard) != cardVisible(nextCard)) - return false; - continue; - } - if (insertion || cards_.contains(stableKey(nextCard.key))) - return false; - insertion = Insertion{sectionIndex, cardIndex, false}; - } - if (oldCardIndex != oldSection.cards.size()) - return false; + ConversationCard *card = nullptr; + if (const auto staged = stagedCards_.find(row->stableKey); + staged != stagedCards_.end() && staged->second->canApply(row->card)) { + card = staged->second; + stagedCards_.erase(staged); + stagedHeights_.erase(row->stableKey); + } else { + card = createCard(row->card, stagingHost_, row->stableKey); + incrementProperty(this, "conversationCardConstructions"); } - if (previousSection != snapshot_.sections.size() || !insertion) - return false; + card->hide(); + card->setParent(viewport()); + configureCardForRow(card, *row); + const int height = measureCard(card, rowWidth(*row)); + heightCache_.insert_or_assign(row->stableKey, + HeightRecord{rowWidth(*row), height}); + static_cast(heights_.setHeight(static_cast(rowIndex), + height + CardSpacing)); + materializedCards_.emplace(row->stableKey, card); + card->setGeometry(rowRect(rowIndex)); + card->show(); + incrementProperty(this, "conversationRowsMaterialized"); + return card; +} - const TurnSection &insertedSectionData = - snapshot.sections[insertion->section]; - const VisibleCardData &insertedData = - insertedSectionData.cards[insertion->card]; - const std::string insertedKey = stableKey(insertedData.key); - if (previousCards.contains(insertedKey)) - return false; +void ConversationView::releaseCard(const std::string &key, + ConversationCard *card) { + if (!card) + return; + cardCollapsedStates_.insert_or_assign(key, card->isCollapsed()); + if (const auto state = card->commandOutputScrollState()) + commandOutputStates_.insert_or_assign(key, *state); + card->setViewportVisible(false); + delete card; + incrementProperty(this, "conversationRowsReleased"); +} - TurnSectionWidget *retainedSection = nullptr; - if (!insertion->newSection) { - const auto found = sections_.find(insertedSectionData.key); - if (found == sections_.end()) - return false; - retainedSection = found->second; +void ConversationView::releaseUnneededCards(int firstRow, int lastRow) { + std::unordered_set retainedKeys; + if (firstRow >= 0 && lastRow >= firstRow) { + retainedKeys.reserve(static_cast(lastRow - firstRow + 1)); + for (int rowIndex = firstRow; rowIndex <= lastRow; ++rowIndex) + if (const auto *row = model_->row(rowIndex); row && row->presented) + retainedKeys.insert(row->stableKey); } - for (const VisibleCardData &cardData : insertedSectionData.cards) { - const std::string key = stableKey(cardData.key); - if (key != insertedKey && !cards_.contains(key)) - return false; + + QWidget *focused = QApplication::focusWidget(); + std::vector removeKeys; + for (const auto &[key, card] : materializedCards_) { + const bool ownsFocus = + focused && (focused == card || card->isAncestorOf(focused)); + if (!retainedKeys.contains(key) && !ownsFocus) + removeKeys.push_back(key); } - if (insertedSectionData.rootCardKey) { - const std::string rootKey = stableKey(*insertedSectionData.rootCardKey); - if (rootKey != insertedKey && !cards_.contains(rootKey)) - return false; + for (const std::string &key : removeKeys) { + const auto found = materializedCards_.find(key); + if (found == materializedCards_.end()) + continue; + ConversationCard *card = found->second; + materializedCards_.erase(found); + releaseCard(key, card); } +} - // QWidget construction is indivisible and must stay on Qt-main. Build the - // one new rich subtree outside the visible hierarchy, then expose only its - // final parented geometry in the structural commit below. - QElapsedTimer constructionElapsed; - setProperty("lastIncrementalValidationMicros", - insertionElapsed.nsecsElapsed() / 1000); - constructionElapsed.start(); - ConversationCard *insertedCard = nullptr; - const auto staged = stagedCards_.find(insertedKey); - if (staged != stagedCards_.end() && - staged->second->canApply(insertedData)) { - insertedCard = staged->second; - stagedCards_.erase(staged); - } else { - if (staged != stagedCards_.end()) { - delete staged->second; - stagedCards_.erase(staged); - } - insertedCard = createRetainedCard(insertedData, stagingHost_, insertedKey); - } - insertedCard->hide(); - setProperty("lastIncrementalCardConstructionMicros", - constructionElapsed.nsecsElapsed() / 1000); +void ConversationView::releaseAllCards() { + for (auto &[key, card] : materializedCards_) + releaseCard(key, card); + materializedCards_.clear(); + updateMaterializationProperties(); +} - const Anchor anchor = captureAnchor(); +void ConversationView::updateMaterialization(bool preserveAnchor) { + if (materializing_) + return; + const QScopedValueRollback materializing(materializing_, true); + const Anchor anchor = preserveAnchor ? captureAnchor() : Anchor{}; const bool follow = mode_ == Mode::Following; - std::vector nextDisplayedKeys; - for (const TurnSection §ion : snapshot.sections) - for (const VisibleCardData &card : section.cards) - if (cardVisible(card)) - nextDisplayedKeys.push_back(stableKey(card.key)); - const bool appendedVisibleCard = - nextDisplayedKeys.size() == displayedCardKeys_.size() + 1 && - std::equal(displayedCardKeys_.begin(), displayedCardKeys_.end(), - nextDisplayedKeys.begin()); - - stopFollowingAnimation(); - std::vector geometryCards; - std::vector materializedPrompts; - if (std::holds_alternative(insertedData.key) && - insertedData.kind == CardKind::UserMessage && insertedData.target) - materializedPrompts.push_back(insertedData.target); - - { - const QScopedValueRollback applying(applying_, true); - const QSignalBlocker scrollSignals(verticalScrollBar()); - - // A coalesced notification may pair the insertion with field changes to - // retained cards. Apply those through their normal local path. - for (const TurnSection §ion : snapshot.sections) { - for (const VisibleCardData &cardData : section.cards) { - const std::string key = stableKey(cardData.key); - if (key == insertedKey) - continue; - const auto before = previousCards.find(key); - if (before == previousCards.end() || *before->second == cardData) - continue; - ConversationCard *card = cards_.at(key); - if (before->second->kind == CardKind::LocalPrompt && - cardData.kind == CardKind::UserMessage && cardData.target) - materializedPrompts.push_back(cardData.target); - if (card->applyPresentation(cardData) == - PresentationImpact::GeometryChanged) - geometryCards.push_back(card); - } - } - - const bool cachedSectionAppend = - insertion->newSection && insertion->section + 1 == - snapshot.sections.size() && - !displayedSectionKeys_.empty() && geometryCards.empty(); - int appendedSectionTop = 0; - if (cachedSectionAppend) { - const auto previous = sections_.find(displayedSectionKeys_.back()); - if (previous != sections_.end()) - appendedSectionTop = previous->second->geometry().bottom() + 1 + - contentLayout_->spacing(); - contentLayout_->setEnabled(false); - } - - TurnSectionWidget *section = retainedSection; - if (insertion->newSection) { - section = new TurnSectionWidget(content_); - section->setProperty("turnSectionKey", - QString::fromStdString(insertedSectionData.key)); - section->setProperty("turnId", - QString::fromStdString(insertedSectionData.turnId)); - section->resize(std::max(0, content_->width()), 0); - if (cachedSectionAppend) - section->layout()->setEnabled(false); - sections_.emplace(insertedSectionData.key, section); - contentLayout_->insertWidget(1 + static_cast(insertion->section), - section); - } - cards_.emplace(insertedKey, insertedCard); - insertedCard->setParent(section); - // Nested-card visibility is part of the owner's fold presentation. - // Establish it before setNestedCards() computes whether the container is - // visible; changing only the child afterward leaves the owner collapsed. - insertedCard->setVisible(cardVisible(insertedData)); - - std::vector orderedCards; - orderedCards.reserve(insertedSectionData.cards.size()); - for (const VisibleCardData &cardData : insertedSectionData.cards) - orderedCards.push_back(cards_.at(stableKey(cardData.key))); - ConversationCard *root = nullptr; - if (insertedSectionData.rootCardKey) - root = cards_.at(stableKey(*insertedSectionData.rootCardKey)); - QWidget *rootNested = - root ? root->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly) - : nullptr; - const int previousNestedHeight = rootNested ? rootNested->height() : 0; - const bool previousNestedVisible = rootNested && !rootNested->isHidden(); - const int previousRootHeight = root ? root->height() : 0; - const int previousSectionHeight = section->height(); - const bool cachedNestedAppend = - root && root != insertedCard && !insertion->newSection && - insertion->card + 1 == insertedSectionData.cards.size() && - insertion->section + 1 == snapshot.sections.size() && - geometryCards.empty() && rootNested && rootNested->layout(); - if (cachedNestedAppend) { - rootNested->layout()->setEnabled(false); - root->layout()->setEnabled(false); - section->layout()->setEnabled(false); - contentLayout_->setEnabled(false); - } - if (root) { - std::vector nestedCards; - nestedCards.reserve(orderedCards.size() - 1); - for (ConversationCard *card : orderedCards) { - card->setProperty("turnContainer", card == root); - if (card == root) { - card->setNestedPresentation(false); - } else { - card->setAuthoritativeTurnActive(false); - nestedCards.push_back(card); - } - } - root->setNestedCards(nestedCards); - if (section->cards->indexOf(root) != 0) - section->cards->insertWidget(0, root); - } else { - for (std::size_t index = 0; index < orderedCards.size(); ++index) { - ConversationCard *card = orderedCards[index]; - card->setProperty("turnContainer", false); - card->setNestedPresentation(false); - card->setAuthoritativeTurnActive(false); - if (section->cards->indexOf(card) != static_cast(index)) - section->cards->insertWidget(static_cast(index), card); - } - } - section->cardKeys.clear(); - section->cardKeys.reserve(insertedSectionData.cards.size()); - for (const VisibleCardData &cardData : insertedSectionData.cards) - section->cardKeys.push_back(stableKey(cardData.key)); - - const bool sectionVisible = std::ranges::any_of( - insertedSectionData.cards, - [this](const VisibleCardData &card) { return cardVisible(card); }); - section->setVisible(sectionVisible); - if (empty_->isVisible()) - empty_->hide(); - - if (snapshot.activeTurnId != snapshot_.activeTurnId) { - const auto updateActiveRoot = [this](const ConversationSnapshot &state, - bool active) { - if (!state.activeTurnId) - return; - const auto found = std::ranges::find_if( - state.sections, [&](const TurnSection &candidate) { - return candidate.turnId == *state.activeTurnId && - candidate.rootCardKey.has_value(); - }); - if (found == state.sections.end()) - return; - const auto retained = cards_.find(stableKey(*found->rootCardKey)); - if (retained != cards_.end()) - retained->second->setAuthoritativeTurnActive(active); - }; - updateActiveRoot(snapshot_, false); - updateActiveRoot(snapshot, true); - } else if (root) { - root->setAuthoritativeTurnActive( - snapshot.activeTurnId && - insertedSectionData.turnId == *snapshot.activeTurnId); - } - - geometryCards.push_back(insertedCard); - displayedSectionKeys_.clear(); - displayedSectionKeys_.reserve(snapshot.sections.size()); - for (const TurnSection &candidate : snapshot.sections) - displayedSectionKeys_.push_back(candidate.key); - displayedCardKeys_ = std::move(nextDisplayedKeys); - snapshot_ = std::move(snapshot); - QElapsedTimer geometryElapsed; - geometryElapsed.start(); - if (cachedNestedAppend) { - recomputeAppendedNestedCardGeometry( - insertedCard, root, section, previousNestedHeight, - previousNestedVisible, previousRootHeight, previousSectionHeight); - } else if (cachedSectionAppend && root == insertedCard) { - recomputeAppendedSectionGeometry(insertedCard, section, - appendedSectionTop); - } else { - if (cachedSectionAppend) - contentLayout_->setEnabled(true); - recomputeCardGeometries(geometryCards); - } - setProperty("lastIncrementalGeometryMicros", - geometryElapsed.nsecsElapsed() / 1000); - if (follow && (appendedVisibleCard || settleFollowImmediately)) + for (int pass = 0; pass < 2; ++pass) { + const auto [first, last] = materializationRows(); + const qint64 totalBefore = heights_.totalHeight(); + if (first >= 0) + for (int rowIndex = first; rowIndex <= last; ++rowIndex) + static_cast(materializeRow(rowIndex)); + if (heights_.totalHeight() == totalBefore) + break; + updateScrollRange(); + if (follow) setScrollValue(verticalScrollBar()->maximum()); - else + else if (preserveAnchor) restoreAnchor(anchor); } + const auto [first, last] = materializationRows(); + releaseUnneededCards(first, last); + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else if (preserveAnchor) + restoreAnchor(anchor); + layoutMaterializedCards(); + updateMaterializationProperties(); +} - if (follow && !appendedVisibleCard && !settleFollowImmediately) { - const int stableValue = verticalScrollBar()->value(); - if (verticalScrollBar()->maximum() > stableValue + 3) - animateToBottom(stableValue); - else - setScrollValue(verticalScrollBar()->maximum()); +void ConversationView::layoutMaterializedCards() { + const QRect visibleRect = viewport()->rect(); + for (auto &[key, card] : materializedCards_) { + const QModelIndex index = model_->indexForStableKey(key); + if (!index.isValid()) + continue; + const QRect geometry = rowRect(index.row()); + card->setGeometry(geometry); + card->setViewportVisible(geometry.intersects(visibleRect)); } - storeCurrentThreadState(); - for (nodegraph::NodeRef &prompt : materializedPrompts) - if (promptMaterializedAction_ && - !promptMaterializedAction_(std::move(prompt))) - break; - setProperty("graphRefreshPasses", - property("graphRefreshPasses").toULongLong() + 1); - setProperty("incrementalStructuralCommits", - property("incrementalStructuralCommits").toULongLong() + 1); - setProperty("lastIncrementalStructuralMicros", - insertionElapsed.nsecsElapsed() / 1000); - return true; } -bool ConversationView::reconcile(ConversationSnapshot snapshot, - bool force, bool settleFollowImmediately) { - if (!force && snapshot == snapshot_ && snapshot.threadId == threadId_) - return false; - - const bool switchedThread = snapshot.threadId != threadId_; - const auto sameStructure = [this, &snapshot] { - if (snapshot.threadId != snapshot_.threadId || - snapshot.hasMore != snapshot_.hasMore || - snapshot.hiddenAuthoritativeItemCount != - snapshot_.hiddenAuthoritativeItemCount || - snapshot.sections.size() != snapshot_.sections.size()) - return false; - for (std::size_t sectionIndex = 0; - sectionIndex < snapshot.sections.size(); ++sectionIndex) { - const TurnSection &before = snapshot_.sections[sectionIndex]; - const TurnSection &after = snapshot.sections[sectionIndex]; - if (before.key != after.key || before.turnId != after.turnId || - before.rootCardKey != after.rootCardKey || - before.cards.size() != after.cards.size()) - return false; - for (std::size_t cardIndex = 0; cardIndex < after.cards.size(); - ++cardIndex) { - const VisibleCardData &oldCard = before.cards[cardIndex]; - const VisibleCardData &newCard = after.cards[cardIndex]; - if (stableKey(oldCard.key) != stableKey(newCard.key) || - cardVisible(oldCard) != cardVisible(newCard)) - return false; - const auto retained = cards_.find(stableKey(newCard.key)); - if (retained == cards_.end() || - !retained->second->canApply(newCard)) - return false; - } - } - return true; - }; - - // The established snapshot API remains the structural authority, but most - // protocol traffic changes only presentation fields of existing cards. - // Keep those updates inside their card instead of rebuilding nesting and - // traversing every QWidget/layout in the retained history window. - if (!force && !switchedThread && sameStructure()) { - const Anchor anchor = captureAnchor(); - const bool follow = mode_ == Mode::Following; - PresentationImpact impact = PresentationImpact::None; - std::vector geometryCards; - std::vector materializedPrompts; - applying_ = true; - const QSignalBlocker scrollSignals(verticalScrollBar()); - for (std::size_t sectionIndex = 0; - sectionIndex < snapshot.sections.size(); ++sectionIndex) { - const TurnSection &before = snapshot_.sections[sectionIndex]; - const TurnSection &after = snapshot.sections[sectionIndex]; - for (std::size_t cardIndex = 0; cardIndex < after.cards.size(); - ++cardIndex) { - const VisibleCardData &oldCard = before.cards[cardIndex]; - const VisibleCardData &newCard = after.cards[cardIndex]; - if (oldCard == newCard) - continue; - ConversationCard *card = cards_.at(stableKey(newCard.key)); - if (oldCard.kind == CardKind::LocalPrompt && - newCard.kind == CardKind::UserMessage && newCard.target) - materializedPrompts.push_back(newCard.target); - const PresentationImpact cardImpact = card->applyPresentation(newCard); - if (cardImpact == PresentationImpact::GeometryChanged) - geometryCards.push_back(card); - if (static_cast(cardImpact) > static_cast(impact)) - impact = cardImpact; - } - } - if (snapshot.activeTurnId != snapshot_.activeTurnId) { - for (const TurnSection §ion : snapshot.sections) { - if (!section.rootCardKey) - continue; - const auto retained = cards_.find(stableKey(*section.rootCardKey)); - if (retained != cards_.end() && - retained->second->setAuthoritativeTurnActive( - snapshot.activeTurnId && - section.turnId == *snapshot.activeTurnId) && - static_cast(PresentationImpact::PaintOnly) > - static_cast(impact)) - impact = PresentationImpact::PaintOnly; - } - } - snapshot_ = std::move(snapshot); - if (impact == PresentationImpact::GeometryChanged) { - recomputeCardGeometries(geometryCards); - if (follow) - setScrollValue(verticalScrollBar()->maximum()); - else - restoreAnchor(anchor); - } - applying_ = false; - storeCurrentThreadState(); - if (promptMaterializedAction_) - for (nodegraph::NodeRef &prompt : materializedPrompts) - if (!promptMaterializedAction_(std::move(prompt))) - break; - if (impact != PresentationImpact::None) - setProperty("graphRefreshPasses", - property("graphRefreshPasses").toULongLong() + 1); - return impact != PresentationImpact::None; - } - - if (!force && !switchedThread && - tryReconcileSingleInsertion(snapshot, settleFollowImmediately)) - return true; - - if (switchedThread) - setThread(snapshot.threadId); - - Anchor anchor = captureAnchor(); - if (switchedThread) { - const auto saved = threadStates_.find(threadId_); - if (saved != threadStates_.end()) { - mode_ = saved->second.mode; - anchor = saved->second.anchor; - } else { - mode_ = Mode::Following; - pausedByComposerGrowth_ = false; - anchor = {}; - } - } - const bool follow = mode_ == Mode::Following; - const auto visibleOutputFootprint = [this] { - int height = 0; - for (const auto &[key, card] : cards_) { - static_cast(key); - auto *output = dynamic_cast( - card->findChild(QStringLiteral("commandOutputView"))); - if (output && output->isVisibleTo(card)) - height += output->height(); - } - return height; - }; - const int outputFootprintBefore = visibleOutputFootprint(); - - stopFollowingAnimation(); - applying_ = true; - viewport()->setUpdatesEnabled(false); - content_->setUpdatesEnabled(false); - const QSignalBlocker scrollSignals(verticalScrollBar()); - bool visualChange = switchedThread; - const bool showLoadMore = snapshot.hasMore; - if (loadMore_->isVisible() != showLoadMore) { - loadMore_->setVisible(showLoadMore); - visualChange = true; - } - if (showLoadMore) { - const std::size_t page = - snapshot.hiddenAuthoritativeItemCount == 0 - ? AuthoritativeHistoryPageSize - : std::min(AuthoritativeHistoryPageSize, - snapshot.hiddenAuthoritativeItemCount); - const QString label = QStringLiteral("Load %1 more activities") - .arg(static_cast(page)); - if (loadMore_->text() != label) { - loadMore_->setText(label); - visualChange = true; - } - const QString tooltip = snapshot.hiddenAuthoritativeItemCount == 0 - ? QStringLiteral( - "Earlier activities are available") - : QStringLiteral( - "%1 earlier activities are retained") - .arg(static_cast( - snapshot - .hiddenAuthoritativeItemCount)); - if (loadMore_->toolTip() != tooltip) - loadMore_->setToolTip(tooltip); - } - - struct DesiredSection { - TurnSectionWidget *widget = nullptr; - int position = 0; - bool insert = false; - std::vector cardKeys; - }; - struct DesiredCard { - TurnSectionWidget *section = nullptr; - const VisibleCardData *data = nullptr; - }; - std::unordered_map desiredSections; - std::unordered_map desiredCards; - std::vector desiredSectionKeys; - desiredSectionKeys.reserve(snapshot.sections.size()); - std::vector displayedKeys; - std::vector> - commandOutputRestorations; - std::vector materializedPrompts; - const auto retainCommandOutputState = [this](const std::string &key, - ConversationCard *card) { - const auto state = card ? card->commandOutputScrollState() : std::nullopt; - if (state && !state->followsLatest) - commandOutputStates_[key] = *state; - else - commandOutputStates_.erase(key); - }; - - int sectionIndex = 0; - for (const TurnSection §ionData : snapshot.sections) { - desiredSectionKeys.push_back(sectionData.key); - TurnSectionWidget *section = nullptr; - const auto existingSection = sections_.find(sectionData.key); - const bool newSection = existingSection == sections_.end(); - if (newSection) { - section = new TurnSectionWidget(content_); - section->setProperty("turnSectionKey", - QString::fromStdString(sectionData.key)); - sections_.emplace(sectionData.key, section); - visualChange = true; - } else { - section = existingSection->second; - } - section->setProperty("turnId", QString::fromStdString(sectionData.turnId)); - - DesiredSection desiredSection{section, sectionIndex++, newSection}; - desiredSection.cardKeys.reserve(sectionData.cards.size()); - int cardIndex = 0; - for (const VisibleCardData &cardData : sectionData.cards) { - desiredSection.cardKeys.push_back(stableKey(cardData.key)); - const std::string &key = desiredSection.cardKeys.back(); - if (cardVisible(cardData)) - displayedKeys.push_back(key); - desiredCards.emplace(key, DesiredCard{section, &cardData}); - ++cardIndex; - } - desiredSections.emplace(sectionData.key, std::move(desiredSection)); - } - const bool appendedVisibleCards = - displayedKeys.size() > displayedCardKeys_.size() && - std::equal(displayedCardKeys_.begin(), displayedCardKeys_.end(), - displayedKeys.begin()); - - for (std::size_t offset = displayedSectionKeys_.size(); offset > 0; - --offset) { - const std::size_t index = offset - 1; - const std::string &key = displayedSectionKeys_[index]; - const auto desired = desiredSections.find(key); - if (desired != desiredSections.end() && - desired->second.position == static_cast(index)) - continue; - delete contentLayout_->takeAt(1 + static_cast(index)); - if (desired != desiredSections.end()) - desired->second.insert = true; - visualChange = true; - } - - // A prompt card may currently own desired nested cards while itself falls - // outside the retained window. Detach those children before deleting the - // obsolete prompt so their stable widgets can move to the transparent - // section fallback instead of being destroyed with their QObject parent. - for (const auto &[key, card] : cards_) { - static_cast(key); - ConversationCard *owner = nullptr; - for (QWidget *parent = card->parentWidget(); parent; - parent = parent->parentWidget()) { - owner = dynamic_cast(parent); - if (owner) - break; - } - if (!owner) - continue; - const std::string ownerKey = - owner->property("conversationCardKey").toString().toStdString(); - const auto desiredOwner = desiredCards.find(ownerKey); - const bool ownerRetained = - desiredOwner != desiredCards.end() && - owner->canApply(*desiredOwner->second.data); - if (ownerRetained) - continue; - const auto desiredCard = desiredCards.find(key); - QWidget *safeParent = desiredCard == desiredCards.end() - ? owner->parentWidget() - : desiredCard->second.section; - card->setParent(safeParent); - } - - for (auto iterator = cards_.begin(); iterator != cards_.end();) { - const auto desired = desiredCards.find(iterator->first); - if (desired != desiredCards.end() && - iterator->second->canApply(*desired->second.data)) { - ++iterator; - continue; - } - retainCommandOutputState(iterator->first, iterator->second); - delete iterator->second; - iterator = cards_.erase(iterator); - visualChange = true; - } - - for (const TurnSection §ionData : snapshot.sections) { - DesiredSection &desiredSection = desiredSections.at(sectionData.key); - TurnSectionWidget *section = desiredSection.widget; - int cardIndex = 0; - for (const VisibleCardData &cardData : sectionData.cards) { - const std::string &key = - desiredSection.cardKeys[static_cast(cardIndex)]; - - ConversationCard *card = nullptr; - const auto existingCard = cards_.find(key); - if (existingCard != cards_.end()) { - card = existingCard->second; - if (card->data().kind == CardKind::LocalPrompt && - cardData.kind == CardKind::UserMessage && cardData.target) - materializedPrompts.push_back(cardData.target); - visualChange = card->apply(cardData) || visualChange; - } else { - const auto staged = stagedCards_.find(key); - if (staged != stagedCards_.end() && - staged->second->canApply(cardData)) { - card = staged->second; - stagedCards_.erase(staged); - card->setParent(section); - // The staging pass created this card from the same presentation and - // applyCardPresentation keeps it current while the hidden batch is - // being prepared. Reapplying every rich subtree here makes the - // atomic reveal proportional to presentation work already done. - // Reparent only; the final geometry transaction below establishes - // its committed width and height. - } else { - if (staged != stagedCards_.end()) { - delete staged->second; - stagedCards_.erase(staged); - } - card = createRetainedCard(cardData, section, key); - } - if (std::holds_alternative(cardData.key) && - cardData.kind == CardKind::UserMessage && cardData.target) - materializedPrompts.push_back(cardData.target); - if (const auto saved = commandOutputStates_.find(key); - saved != commandOutputStates_.end()) { - commandOutputRestorations.emplace_back(card, saved->second); - commandOutputStates_.erase(saved); - } - cards_.emplace(key, card); - visualChange = true; - } - - const bool visible = cardVisible(cardData); - if (card->isHidden() == visible) { - card->setVisible(visible); - visualChange = true; - } - ++cardIndex; - } - - ConversationCard *prompt = nullptr; - if (sectionData.rootCardKey) { - const auto root = cards_.find(stableKey(*sectionData.rootCardKey)); - if (root != cards_.end()) - prompt = root->second; - } - std::vector orderedCards; - orderedCards.reserve(sectionData.cards.size()); - for (const VisibleCardData &cardData : sectionData.cards) { - ConversationCard *card = cards_.at(stableKey(cardData.key)); - orderedCards.push_back(card); - } - if (prompt) { - for (ConversationCard *card : orderedCards) { - if (card != prompt && card->property("turnContainer").toBool()) - card->setNestedCards({}); - if (card != prompt) - visualChange = - card->setAuthoritativeTurnActive(false) || visualChange; - card->setProperty("turnContainer", false); - } - std::vector nestedCards; - nestedCards.reserve(orderedCards.size() - 1); - for (ConversationCard *card : orderedCards) - if (card != prompt) - nestedCards.push_back(card); - prompt->setProperty("nestedConversationCard", false); - prompt->setProperty("turnContainer", true); - prompt->setNestedCards(nestedCards); - visualChange = - prompt->setAuthoritativeTurnActive( - snapshot.activeTurnId && - sectionData.turnId == *snapshot.activeTurnId) || - visualChange; - if (section->cards->indexOf(prompt) != 0) - section->cards->insertWidget(0, prompt); - } else { - for (std::size_t position = 0; position < orderedCards.size(); ++position) { - ConversationCard *card = orderedCards[position]; - if (card->property("turnContainer").toBool()) - card->setNestedCards({}); - card->setProperty("nestedConversationCard", false); - card->setProperty("turnContainer", false); - visualChange = card->setAuthoritativeTurnActive(false) || visualChange; - card->setMinimumHeight(0); - if (section->cards->indexOf(card) != static_cast(position)) - section->cards->insertWidget(static_cast(position), card); - } - } - section->cardKeys = std::move(desiredSection.cardKeys); - const bool sectionVisible = - std::ranges::any_of(sectionData.cards, [this](const auto &card) { - return cardVisible(card); - }); - if (section->isHidden() == sectionVisible) { - section->setVisible(sectionVisible); - visualChange = true; - } - if (desiredSection.insert) - contentLayout_->insertWidget(1 + desiredSection.position, section); - } - - for (auto iterator = sections_.begin(); iterator != sections_.end();) { - if (desiredSections.contains(iterator->first)) { - ++iterator; - continue; - } - delete iterator->second; - iterator = sections_.erase(iterator); - visualChange = true; - } - displayedSectionKeys_ = std::move(desiredSectionKeys); - - const bool empty = displayedKeys.empty(); - if (empty_->isVisible() != empty) { - empty_->setVisible(empty); - visualChange = true; - } - displayedCardKeys_ = std::move(displayedKeys); - snapshot_ = std::move(snapshot); - recomputeGeometry(); - const bool outputGrew = visibleOutputFootprint() > outputFootprintBefore; - for (const auto &[card, state] : commandOutputRestorations) - card->restoreCommandOutputScrollState(state); - if (follow) { - if (switchedThread || outputGrew || appendedVisibleCards || - settleFollowImmediately) { - setScrollValue(verticalScrollBar()->maximum()); - } else { - // Reflow above the viewport must preserve the same painted card/pixel - // first. Smooth following starts only after that stable transaction. - restoreAnchor(anchor); - } - } else { - restoreAnchor(anchor); - } - applying_ = false; - content_->setUpdatesEnabled(true); - viewport()->setUpdatesEnabled(true); - viewport()->update(); +void ConversationView::updateMaterializationProperties() { + const qulonglong count = static_cast(materializedCards_.size()); + setProperty("conversationMaterializedCardCount", count); + setProperty( + "conversationMaterializedCardPeak", + std::max(property("conversationMaterializedCardPeak").toULongLong(), + count)); +} - if (follow && !switchedThread && !outputGrew && !appendedVisibleCards && - !settleFollowImmediately) { - const int stableValue = verticalScrollBar()->value(); - if (verticalScrollBar()->maximum() > stableValue + 3) - animateToBottom(stableValue); - else - setScrollValue(verticalScrollBar()->maximum()); - } - storeCurrentThreadState(); - if (promptMaterializedAction_) - for (nodegraph::NodeRef &prompt : materializedPrompts) - if (!promptMaterializedAction_(std::move(prompt))) - break; - if (visualChange) - setProperty("graphRefreshPasses", - property("graphRefreshPasses").toULongLong() + 1); - return visualChange; +ConversationCard * +ConversationView::cardForStableKey(const std::string &key) const { + const auto found = materializedCards_.find(key); + return found == materializedCards_.end() ? nullptr : found->second; } void ConversationView::setCardCollapsed(const std::string &key, @@ -1279,41 +984,22 @@ void ConversationView::setCardCollapsed(const std::string &key, bool collapsed) { if (!card || card->isCollapsed() == collapsed) return; - - const int titleTop = card->mapTo(viewport(), QPoint{}).y(); - stopFollowingAnimation(); - applying_ = true; - viewport()->setUpdatesEnabled(false); - content_->setUpdatesEnabled(false); - const QSignalBlocker scrollSignals(verticalScrollBar()); - + const QModelIndex index = model_->indexForStableKey(key); + if (!index.isValid()) + return; + Anchor anchor = captureAnchor(); + anchor.stableKey = key; + anchor.pixelOffset = rowRect(index.row()).top(); mode_ = Mode::Paused; pausedByComposerGrowth_ = false; - cardCollapsedStates_[key] = collapsed; - ConversationCard *turnContainer = - card->property("turnContainer").toBool() ? card : nullptr; - for (QWidget *parent = card->parentWidget(); !turnContainer && parent; - parent = parent->parentWidget()) - if (auto *candidate = dynamic_cast(parent); - candidate && candidate->property("turnContainer").toBool()) - turnContainer = candidate; - if (turnContainer) - turnContainer->setMinimumHeight(0); + stopFollowingAnimation(); + cardCollapsedStates_.insert_or_assign(key, collapsed); card->setCollapsed(collapsed); - recomputeGeometry(); - const int visibleHeight = - std::max(0, viewport()->height() - trailingSpaceHeight_); - const int visibleTop = - collapsed - ? titleTop - : std::clamp(titleTop, 0, - std::max(0, visibleHeight - card->height())); - setScrollValue(card->mapTo(content_, QPoint{}).y() - visibleTop); - - applying_ = false; - content_->setUpdatesEnabled(true); - viewport()->setUpdatesEnabled(true); - viewport()->update(); + const ConversationItemModel::Row *row = model_->row(index.row()); + const int height = measureCard(card, rowWidth(*row)); + static_cast(updateMeasuredHeight(index.row(), height, false)); + restoreAnchor(anchor); + layoutMaterializedCards(); storeCurrentThreadState(); } @@ -1321,31 +1007,23 @@ void ConversationView::setTrailingSpaceHeight(int height) { height = std::max(0, height); if (height == trailingSpaceHeight_) return; - const bool grew = height > trailingSpaceHeight_; const Anchor anchor = captureAnchor(); const int previousValue = verticalScrollBar()->value(); stopFollowingAnimation(); - - applying_ = true; - viewport()->setUpdatesEnabled(false); - content_->setUpdatesEnabled(false); - const QSignalBlocker scrollSignals(verticalScrollBar()); - if (grew) { pausedByComposerGrowth_ = pausedByComposerGrowth_ || mode_ == Mode::Following; mode_ = Mode::Paused; } trailingSpaceHeight_ = height; - QScrollBar *conversationScrollBar = verticalScrollBar(); - conversationScrollBar->setProperty("composerBottomInset", height); - conversationScrollBar->setStyleSheet( + verticalScrollBar()->setProperty("composerBottomInset", height); + verticalScrollBar()->setStyleSheet( height == 0 ? QString{} : QStringLiteral("QScrollBar:vertical{margin:2px 2px %1px 2px;}") .arg(height + 2)); - recomputeGeometry(); + updateScrollRange(); if (mode_ == Mode::Following) setScrollValue(verticalScrollBar()->maximum()); else if (grew && anchor.stableKey.empty()) @@ -1356,11 +1034,7 @@ void ConversationView::setTrailingSpaceHeight(int height) { mode_ = Mode::Following; pausedByComposerGrowth_ = false; } - - applying_ = false; - content_->setUpdatesEnabled(true); - viewport()->setUpdatesEnabled(true); - viewport()->update(); + layoutMaterializedCards(); storeCurrentThreadState(); } @@ -1388,102 +1062,54 @@ ConversationView::modeForThread(const std::string &threadId) const noexcept { return saved == threadStates_.end() ? Mode::Following : saved->second.mode; } -bool ConversationView::eventFilter(QObject *watched, QEvent *event) { - if (watched == content_ && event->type() == QEvent::LayoutRequest && - !applying_) { - Anchor anchor = captureAnchor(); - if (mode_ == Mode::Paused) { - const auto retained = threadStates_.find(threadId_); - if (retained != threadStates_.end() && - !retained->second.anchor.stableKey.empty()) - anchor = retained->second.anchor; - } - const bool follow = mode_ == Mode::Following; - stopFollowingAnimation(); - applying_ = true; - viewport()->setUpdatesEnabled(false); - const QSignalBlocker scrollSignals(verticalScrollBar()); - recomputeGeometry(); - restoreAnchor(anchor); - applying_ = false; - viewport()->setUpdatesEnabled(true); - viewport()->update(); - const int stableValue = verticalScrollBar()->value(); - if (follow && verticalScrollBar()->maximum() > stableValue + 3) - animateToBottom(stableValue); - else if (follow) - setScrollValue(verticalScrollBar()->maximum()); - storeCurrentThreadState(); - return true; - } - return QAbstractScrollArea::eventFilter(watched, event); -} - -void ConversationView::resizeEvent(QResizeEvent *event) { - const Anchor anchor = captureAnchor(); - const bool follow = mode_ == Mode::Following; - stopFollowingAnimation(); - applying_ = true; - viewport()->setUpdatesEnabled(false); - const QSignalBlocker scrollSignals(verticalScrollBar()); - QAbstractScrollArea::resizeEvent(event); - stagingHost_->resize(viewport()->size()); - stagingOverlay_->setGeometry(viewport()->rect()); - recomputeGeometry(); - if (follow) - setScrollValue(verticalScrollBar()->maximum()); - else - restoreAnchor(anchor); - applying_ = false; - viewport()->setUpdatesEnabled(true); - viewport()->update(); - storeCurrentThreadState(); -} - -void ConversationView::wheelEvent(QWheelEvent *event) { - if (!applyWheel(event)) - QAbstractScrollArea::wheelEvent(event); -} - ConversationView::Anchor ConversationView::captureAnchor() const { Anchor anchor; anchor.absoluteValue = verticalScrollBar()->value(); - for (const std::string &key : displayedCardKeys_) { - ConversationCard *card = cardForStableKey(key); - if (!card || !card->isVisible()) - continue; - const int viewportTop = card->mapTo(viewport(), QPoint(0, 0)).y(); - if (viewportTop + card->height() < 0) - continue; - anchor.stableKey = key; - // The contract is visual stability. Capture the actual painted offset - // instead of deriving it from content coordinates while a layout/range - // transaction may temporarily be between those coordinate systems. - anchor.pixelOffset = viewportTop; - break; - } + anchor.horizontalValue = horizontalScrollBar()->value(); + if (model_->rowCount() == 0 || heights_.empty() || + heights_.totalHeight() <= 0) + return anchor; + const qint64 contentY = + std::max(0, static_cast(verticalScrollBar()->value()) - + leadingChromeHeight()); + std::size_t rowIndex = heights_.rowAt(contentY); + while (rowIndex < heights_.size() && heights_.height(rowIndex) == 0) + ++rowIndex; + if (rowIndex >= heights_.size()) + return anchor; + const ConversationItemModel::Row *row = + model_->row(static_cast(rowIndex)); + if (!row) + return anchor; + anchor.stableKey = row->stableKey; + anchor.pixelOffset = rowRect(static_cast(rowIndex)).top(); return anchor; } void ConversationView::restoreAnchor(const Anchor &anchor) { int value = anchor.absoluteValue; if (!anchor.stableKey.empty()) { - if (ConversationCard *card = cardForStableKey(anchor.stableKey)) { - const int top = card->mapTo(content_, QPoint(0, 0)).y(); - value = top - anchor.pixelOffset; + const QModelIndex index = model_->indexForStableKey(anchor.stableKey); + if (index.isValid()) { + const qint64 top = static_cast(leadingChromeHeight()) + + heights_.top(static_cast(index.row())); + value = static_cast(std::clamp( + top - anchor.pixelOffset, verticalScrollBar()->minimum(), + verticalScrollBar()->maximum())); } } - setScrollValue(std::clamp(value, verticalScrollBar()->minimum(), - verticalScrollBar()->maximum())); + setScrollValue(value); + horizontalScrollBar()->setValue(std::clamp(anchor.horizontalValue, + horizontalScrollBar()->minimum(), + horizontalScrollBar()->maximum())); } void ConversationView::setScrollValue(int value) { value = std::clamp(value, verticalScrollBar()->minimum(), verticalScrollBar()->maximum()); - programmaticScroll_ = true; + const QScopedValueRollback programmatic(programmaticScroll_, true); verticalScrollBar()->setValue(value); - programmaticScroll_ = false; - positionContent(); + layoutMaterializedCards(); } void ConversationView::stopFollowingAnimation() { @@ -1511,451 +1137,6 @@ void ConversationView::animateToBottom(int previousValue) { followAnimation_->start(); } -void ConversationView::recomputeCardGeometries( - const std::vector &changedCards) { - if (changedCards.empty() || !content_ || !viewport()) - return; - contentLayout_->setEnabled(true); - setProperty("conversationLocalGeometryPasses", - property("conversationLocalGeometryPasses").toULongLong() + 1); - - const auto appendUnique = [](auto &values, auto *value) { - if (value && std::ranges::find(values, value) == values.end()) - values.push_back(value); - }; - std::vector sections; - std::vector turnContainers; - std::vector directCards; - for (ConversationCard *card : changedCards) { - if (!card) - continue; - TurnSectionWidget *section = nullptr; - ConversationCard *turnContainer = - card->property("turnContainer").toBool() ? card : nullptr; - for (QWidget *parent = card->parentWidget(); parent; - parent = parent->parentWidget()) { - if (!turnContainer) { - auto *candidate = dynamic_cast(parent); - if (candidate && candidate->property("turnContainer").toBool()) - turnContainer = candidate; - } - if (auto *candidate = dynamic_cast(parent)) { - section = candidate; - break; - } - } - appendUnique(sections, section); - appendUnique(turnContainers, turnContainer); - if (card != turnContainer) - appendUnique(directCards, card); - } - - for (TurnSectionWidget *section : sections) - if (section && section->layout()) - section->layout()->setEnabled(true); - for (ConversationCard *container : turnContainers) - if (container && container->layout()) - container->layout()->setEnabled(true); - - for (ConversationCard *card : directCards) { - const int width = std::max( - 0, card->parentWidget() ? card->parentWidget()->contentsRect().width() - : card->width()); - static_cast(settleCardGeometry(card, width)); - } - for (ConversationCard *container : turnContainers) { - QWidget *nested = container->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly); - if (nested && nested->layout()) { - nested->layout()->setEnabled(true); - nested->layout()->invalidate(); - nested->layout()->activate(); - const int nestedHeight = - nested->isHidden() ? 0 : nested->layout()->minimumSize().height(); - nested->setFixedHeight(nestedHeight); - nested->layout()->setGeometry(nested->contentsRect()); - nested->layout()->activate(); - } - const int width = std::max( - 0, container->parentWidget() - ? container->parentWidget()->contentsRect().width() - : container->width()); - static_cast(settleCardGeometry(container, width)); - } - - int totalDelta = 0; - for (TurnSectionWidget *section : sections) { - if (!section || !section->layout()) - continue; - const int previousHeight = section->height(); - section->setMinimumHeight(0); - section->layout()->invalidate(); - section->layout()->activate(); - const int height = section->layout()->minimumSize().height(); - section->setMinimumHeight(height); - section->resize(section->width(), height); - section->layout()->setGeometry(section->contentsRect()); - section->layout()->activate(); - totalDelta += height - previousHeight; - } - - naturalContentHeight_ = std::max(0, naturalContentHeight_ + totalDelta); - contentHeight_ = std::max(viewport()->height(), - naturalContentHeight_ + trailingSpaceHeight_); - const int width = std::max(0, viewport()->width()); - content_->resize(width, contentHeight_); - contentLayout_->setGeometry(QRect(0, 0, width, contentHeight_)); - contentLayout_->activate(); - verticalScrollBar()->setPageStep(viewport()->height()); - verticalScrollBar()->setRange( - 0, std::max(0, contentHeight_ - viewport()->height())); - positionContent(); - - // Consume only the requests generated by the affected ancestry while the - // local transaction is still marked as applying. They must not escape as a - // later complete-conversation LayoutRequest. - for (ConversationCard *card : directCards) - QCoreApplication::sendPostedEvents(card, QEvent::LayoutRequest); - for (ConversationCard *container : turnContainers) - QCoreApplication::sendPostedEvents(container, QEvent::LayoutRequest); - for (TurnSectionWidget *section : sections) - QCoreApplication::sendPostedEvents(section, QEvent::LayoutRequest); - QCoreApplication::sendPostedEvents(content_, QEvent::LayoutRequest); -} - -int ConversationView::settleCardGeometry(ConversationCard *card, int width) { - if (!card || !card->layout()) - return 0; - width = std::max(0, width); - card->setMinimumHeight(0); - card->resize(width, card->height()); - if (QWidget *cardContent = card->findChild( - QStringLiteral("conversationCardContent"), - Qt::FindDirectChildrenOnly); - cardContent && cardContent->layout()) { - cardContent->layout()->invalidate(); - cardContent->layout()->setGeometry(cardContent->contentsRect()); - cardContent->layout()->activate(); - } - card->layout()->invalidate(); - card->layout()->setGeometry(card->contentsRect()); - card->layout()->activate(); - card->updateGeometry(); - const int height = card->layout()->hasHeightForWidth() - ? card->layout()->heightForWidth(width) + - 2 * card->frameWidth() - : card->sizeHint().height(); - card->setMinimumHeight(height); - card->resize(width, height); - card->layout()->setGeometry(card->contentsRect()); - card->layout()->activate(); - return height; -} - -void ConversationView::recomputeAppendedNestedCardGeometry( - ConversationCard *card, ConversationCard *turnContainer, - TurnSectionWidget *section, int previousNestedHeight, - bool previousNestedVisible, int previousContainerHeight, - int previousSectionHeight) { - if (!card || !turnContainer || !section) - return; - setProperty("conversationCachedAppendGeometryPasses", - property("conversationCachedAppendGeometryPasses") - .toULongLong() + - 1); - - QWidget *nested = turnContainer->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly); - if (!nested || !nested->layout() || !card->layout()) { - recomputeCardGeometries({card}); - return; - } - - const int cardWidth = std::max(0, nested->contentsRect().width()); - const int cardHeight = settleCardGeometry(card, cardWidth); - - const bool nestedVisible = !nested->isHidden(); - int nestedHeight = previousNestedHeight; - if (!nestedVisible) { - nestedHeight = 0; - } else if (!card->isHidden()) { - if (previousNestedVisible) { - nestedHeight += nested->layout()->spacing() + cardHeight; - } else { - const QMargins margins = nested->layout()->contentsMargins(); - nestedHeight = margins.top() + cardHeight + margins.bottom(); - } - } - QLayout *nestedLayout = nested->layout(); - nested->setFixedHeight(std::max(0, nestedHeight)); - - int containerDelta = nestedHeight - previousNestedHeight; - if (!previousNestedVisible && nestedVisible) - containerDelta += turnContainer->layout()->spacing(); - else if (previousNestedVisible && !nestedVisible) - containerDelta -= turnContainer->layout()->spacing(); - const int containerHeight = - std::max(0, previousContainerHeight + containerDelta); - turnContainer->setMinimumHeight(containerHeight); - turnContainer->resize(turnContainer->width(), containerHeight); - - const int sectionHeight = std::max(0, previousSectionHeight + containerDelta); - section->setMinimumHeight(sectionHeight); - section->resize(section->width(), sectionHeight); - - naturalContentHeight_ = std::max(0, naturalContentHeight_ + containerDelta); - contentHeight_ = std::max(viewport()->height(), - naturalContentHeight_ + trailingSpaceHeight_); - const int width = std::max(0, viewport()->width()); - content_->resize(width, contentHeight_); - verticalScrollBar()->setPageStep(viewport()->height()); - verticalScrollBar()->setRange( - 0, std::max(0, contentHeight_ - viewport()->height())); - positionContent(); - - if (!card->isHidden()) { - const QMargins margins = nestedLayout->contentsMargins(); - const int cardTop = previousNestedVisible - ? previousNestedHeight - margins.bottom() + - nestedLayout->spacing() - : margins.top(); - card->setGeometry(margins.left(), cardTop, - std::max(0, nested->width() - margins.left() - - margins.right()), - cardHeight); - } - - for (QWidget *descendant : card->findChildren()) - QCoreApplication::removePostedEvents(descendant, QEvent::LayoutRequest); - QCoreApplication::removePostedEvents(card, QEvent::LayoutRequest); - QCoreApplication::removePostedEvents(nested, QEvent::LayoutRequest); - QCoreApplication::removePostedEvents(turnContainer, QEvent::LayoutRequest); - QCoreApplication::removePostedEvents(section, QEvent::LayoutRequest); - QCoreApplication::removePostedEvents(content_, QEvent::LayoutRequest); -} - -void ConversationView::recomputeAppendedSectionGeometry( - ConversationCard *card, TurnSectionWidget *section, int sectionTop) { - if (!card || !section) - return; - setProperty("conversationCachedSectionAppendGeometryPasses", - property("conversationCachedSectionAppendGeometryPasses") - .toULongLong() + - 1); - - const int width = std::max(0, content_->width()); - const int cardHeight = settleCardGeometry(card, width); - - section->setMinimumHeight(cardHeight); - section->setGeometry(0, sectionTop, width, cardHeight); - card->setGeometry(0, 0, width, cardHeight); - naturalContentHeight_ = - std::max(0, naturalContentHeight_ + contentLayout_->spacing() + - cardHeight); - contentHeight_ = std::max(viewport()->height(), - naturalContentHeight_ + trailingSpaceHeight_); - content_->resize(width, contentHeight_); - verticalScrollBar()->setPageStep(viewport()->height()); - verticalScrollBar()->setRange( - 0, std::max(0, contentHeight_ - viewport()->height())); - positionContent(); - - for (QWidget *descendant : card->findChildren()) - QCoreApplication::removePostedEvents(descendant, QEvent::LayoutRequest); - for (QWidget *widget : {static_cast(card), - static_cast(section), content_}) - QCoreApplication::removePostedEvents(widget, QEvent::LayoutRequest); -} - -void ConversationView::settlePaintOnlyCard(ConversationCard *card) { - if (!card) - return; - - // Text and lifecycle setters can post LayoutRequest even when the card's - // measured height is unchanged. Settle the card's internal layout in its - // existing rectangle and discard only the now-redundant requests along its - // retained ancestry. Letting one escape to content_ would invoke the full - // conversation geometry fallback for a paint-only status transition. - if (QWidget *cardContent = card->findChild( - QStringLiteral("conversationCardContent"), - Qt::FindDirectChildrenOnly); - cardContent && cardContent->layout()) { - cardContent->layout()->setGeometry(cardContent->contentsRect()); - cardContent->layout()->activate(); - QCoreApplication::removePostedEvents(cardContent, - QEvent::LayoutRequest); - } - if (card->layout()) { - card->layout()->setGeometry(card->contentsRect()); - card->layout()->activate(); - } - - for (QWidget *widget = card; widget && widget != content_; - widget = widget->parentWidget()) - QCoreApplication::removePostedEvents(widget, QEvent::LayoutRequest); - QCoreApplication::removePostedEvents(content_, QEvent::LayoutRequest); -} - -void ConversationView::recomputeGeometry() { - if (!content_ || !viewport()) - return; - contentLayout_->setEnabled(true); - setProperty("conversationGeometryPasses", - property("conversationGeometryPasses").toULongLong() + 1); - const int width = std::max(0, viewport()->width()); - trailingSpace_->changeSize(0, 0, QSizePolicy::Minimum, QSizePolicy::Fixed); - contentLayout_->invalidate(); - for (const auto &[key, section] : sections_) { - static_cast(key); - if (section->layout()) - section->layout()->setEnabled(true); - section->setMinimumHeight(0); - } - - // Give every nested layout its final width before asking for height. This - // makes wrapped labels and command output contribute to the same range - // transaction as their insertion/update. - content_->resize(width, std::max(viewport()->height(), contentHeight_)); - contentLayout_->setGeometry(content_->rect()); - for (const auto &[key, section] : sections_) { - static_cast(key); - section->layout()->activate(); - } - const auto activateCard = [](ConversationCard *card) { - if (!card) - return; - if (QWidget *cardContent = card->findChild( - QStringLiteral("conversationCardContent"), - Qt::FindDirectChildrenOnly); - cardContent && cardContent->layout()) - cardContent->layout()->activate(); - if (card->layout()) - card->layout()->activate(); - }; - for (const auto &[key, card] : cards_) { - static_cast(key); - if (card->layout()) - card->layout()->setEnabled(true); - if (QWidget *nested = card->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly); - nested && nested->layout()) - nested->layout()->setEnabled(true); - activateCard(card); - } - // Child/subagent threads may have no visible You root. Their cards live - // directly in a turn section, so settle them at the final section width - // just as deliberately as cards nested inside a normal turn container. - for (const auto &[key, card] : cards_) { - static_cast(key); - if (card->property("turnContainer").toBool() || - card->property("nestedConversationCard").toBool()) - continue; - const int cardWidth = card->parentWidget() - ? card->parentWidget()->contentsRect().width() - : card->width(); - static_cast(settleCardGeometry(card, cardWidth)); - } - // A You turn container adds one real layout depth. Settle that depth in - // dependency order so newly nested cards reach their final height inside - // this transaction instead of posting a second visible LayoutRequest. - for (const auto &[key, card] : cards_) { - static_cast(key); - if (!card->property("turnContainer").toBool()) - continue; - card->setMinimumHeight(0); - QWidget *nested = card->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly); - if (!nested || !nested->layout()) - continue; - const int cardWidth = card->parentWidget() - ? card->parentWidget()->contentsRect().width() - : card->width(); - card->resize(cardWidth, card->height()); - if (card->layout()) { - card->layout()->invalidate(); - card->layout()->setGeometry(card->contentsRect()); - card->layout()->activate(); - } - nested->layout()->activate(); - for (int index = 0; index < nested->layout()->count(); ++index) { - auto *nestedCard = dynamic_cast( - nested->layout()->itemAt(index)->widget()); - if (!nestedCard) - continue; - const int nestedWidth = nested->contentsRect().width(); - static_cast(settleCardGeometry(nestedCard, nestedWidth)); - } - nested->layout()->invalidate(); - const int nestedHeight = - nested->isHidden() ? 0 : nested->layout()->minimumSize().height(); - nested->setFixedHeight(nestedHeight); - nested->layout()->setGeometry(nested->contentsRect()); - nested->updateGeometry(); - nested->layout()->invalidate(); - nested->layout()->activate(); - static_cast(settleCardGeometry(card, cardWidth)); - } - for (const auto &[key, section] : sections_) { - static_cast(key); - section->layout()->invalidate(); - const int sectionHeight = section->layout()->minimumSize().height(); - section->setMinimumHeight(sectionHeight); - section->resize(section->width(), sectionHeight); - section->layout()->setGeometry(section->contentsRect()); - section->updateGeometry(); - section->layout()->activate(); - } - contentLayout_->invalidate(); - contentLayout_->setGeometry(content_->rect()); - contentLayout_->activate(); - - int wanted = contentLayout_->hasHeightForWidth() - ? contentLayout_->heightForWidth(width) - : contentLayout_->sizeHint().height(); - wanted = std::max(wanted, contentLayout_->minimumSize().height()); - naturalContentHeight_ = wanted; - trailingSpace_->changeSize(0, trailingSpaceHeight_, QSizePolicy::Minimum, - QSizePolicy::Fixed); - contentLayout_->invalidate(); - wanted += trailingSpaceHeight_; - contentHeight_ = std::max(viewport()->height(), wanted); - content_->resize(width, contentHeight_); - contentLayout_->setGeometry(QRect(0, 0, width, contentHeight_)); - for (const auto &[key, section] : sections_) { - static_cast(key); - section->layout()->activate(); - } - contentLayout_->activate(); - - verticalScrollBar()->setPageStep(viewport()->height()); - verticalScrollBar()->setRange( - 0, std::max(0, contentHeight_ - viewport()->height())); - positionContent(); - - for (const auto &[key, card] : cards_) { - static_cast(key); - if (QWidget *nested = card->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly)) - QCoreApplication::sendPostedEvents(nested, QEvent::LayoutRequest); - QCoreApplication::sendPostedEvents(card, QEvent::LayoutRequest); - } - for (const auto &[key, section] : sections_) { - static_cast(key); - QCoreApplication::sendPostedEvents(section, QEvent::LayoutRequest); - } - QCoreApplication::sendPostedEvents(content_, QEvent::LayoutRequest); -} - -void ConversationView::positionContent() { - if (content_) - content_->move(0, -verticalScrollBar()->value()); -} - void ConversationView::handleUserScrollValue(int value) { stopFollowingAnimation(); pausedByComposerGrowth_ = false; @@ -1971,19 +1152,12 @@ bool ConversationView::applyWheel(QWheelEvent *event) { : event->angleDelta().y(); if (intent == 0) return false; - pausedByComposerGrowth_ = false; const int oldValue = verticalScrollBar()->value(); if (intent > 0) { - // An upward wheel/touchpad gesture pauses before any subsequent layout or - // incoming frame can move the viewport. stopFollowingAnimation(); mode_ = Mode::Paused; } - // Keep Qt's native wheel/touchpad interpretation, but deliver it directly - // to the scrollbar. Calling QAbstractScrollArea::wheelEvent() here would - // redispatch through ShellWidget's application event filter, which routes - // the same gesture back into this method recursively. QScrollBar *bar = verticalScrollBar(); const QPointF local = bar->mapFromGlobal(event->globalPosition().toPoint()); QWheelEvent forwarded(local, event->globalPosition(), event->pixelDelta(), @@ -1991,20 +1165,224 @@ bool ConversationView::applyWheel(QWheelEvent *event) { event->modifiers(), event->phase(), event->inverted()); const QScopedValueRollback nativeDispatch(dispatchingNativeWheel_, true); QApplication::sendEvent(bar, &forwarded); - positionContent(); if (verticalScrollBar()->value() < oldValue) mode_ = Mode::Paused; - if (verticalScrollBar()->value() >= verticalScrollBar()->maximum() - 1) + if (isAtBottom()) mode_ = Mode::Following; + updateMaterialization(false); storeCurrentThreadState(); event->accept(); return true; } -ConversationCard * -ConversationView::cardForStableKey(const std::string &key) const { - const auto card = cards_.find(key); - return card == cards_.end() ? nullptr : card->second; +QRect ConversationView::visualRect(const QModelIndex &index) const { + if (!index.isValid() || index.model() != model_ || index.column() != 0) + return {}; + return rowRect(index.row()); +} + +void ConversationView::scrollTo(const QModelIndex &index, ScrollHint hint) { + const QRect geometry = visualRect(index); + if (geometry.isEmpty()) + return; + int target = verticalScrollBar()->value(); + if (hint == PositionAtTop) + target += geometry.top(); + else if (hint == PositionAtBottom) + target += geometry.bottom() - viewport()->height() + 1; + else if (hint == PositionAtCenter) + target += geometry.center().y() - viewport()->height() / 2; + else if (geometry.top() < 0) + target += geometry.top(); + else if (geometry.bottom() >= viewport()->height()) + target += geometry.bottom() - viewport()->height() + 1; + setScrollValue(target); + handleUserScrollValue(verticalScrollBar()->value()); + updateMaterialization(false); +} + +QModelIndex ConversationView::indexAt(const QPoint &point) const { + if (!viewport()->rect().contains(point) || heights_.empty()) + return {}; + const qint64 contentY = static_cast(point.y()) + + verticalScrollBar()->value() - leadingChromeHeight(); + if (contentY < 0 || contentY >= heights_.totalHeight()) + return {}; + const int rowIndex = static_cast(heights_.rowAt(contentY)); + const QRect geometry = rowRect(rowIndex); + return geometry.contains(point) ? model_->index(rowIndex) : QModelIndex{}; +} + +QModelIndex ConversationView::moveCursor(CursorAction cursorAction, + Qt::KeyboardModifiers modifiers) { + static_cast(modifiers); + int row = currentIndex().isValid() ? currentIndex().row() : -1; + const int count = model_->rowCount(); + int direction = 0; + switch (cursorAction) { + case MoveUp: + case MovePrevious: + direction = -1; + break; + case MoveDown: + case MoveNext: + direction = 1; + break; + case MoveHome: + row = 0; + direction = 1; + break; + case MoveEnd: + row = count - 1; + direction = -1; + break; + case MovePageUp: + case MovePageDown: { + const int y = cursorAction == MovePageUp ? 0 : viewport()->height() - 1; + const QModelIndex page = indexAt(QPoint(viewport()->width() / 2, y)); + if (page.isValid()) + row = page.row(); + direction = cursorAction == MovePageUp ? -1 : 1; + break; + } + default: + return currentIndex(); + } + if (row < 0) + row = direction < 0 ? count - 1 : 0; + while (row >= 0 && row < count) { + if (!isIndexHidden(model_->index(row))) + return model_->index(row); + row += direction; + } + return currentIndex(); +} + +int ConversationView::horizontalOffset() const { + return horizontalScrollBar()->value(); +} + +int ConversationView::verticalOffset() const { + return verticalScrollBar()->value(); +} + +bool ConversationView::isIndexHidden(const QModelIndex &index) const { + const ConversationItemModel::Row *row = model_->row(index.row()); + return !row || !row->presented; +} + +void ConversationView::setSelection( + const QRect &rect, QItemSelectionModel::SelectionFlags command) { + if (!selectionModel()) + return; + const QModelIndex topLeft = indexAt(rect.topLeft()); + const QModelIndex bottomRight = indexAt(rect.bottomRight()); + if (!topLeft.isValid() || !bottomRight.isValid()) + return; + selectionModel()->select( + QItemSelection(model_->index(std::min(topLeft.row(), bottomRight.row())), + model_->index(std::max(topLeft.row(), bottomRight.row()))), + command); +} + +QRegion ConversationView::visualRegionForSelection( + const QItemSelection &selection) const { + QRegion region; + for (const QItemSelectionRange &range : selection) + for (int row = range.top(); row <= range.bottom(); ++row) + region += visualRect(model_->index(row)); + return region; +} + +void ConversationView::updateGeometries() { + if (applying_) + return; + updateScrollRange(); + layoutMaterializedCards(); +} + +void ConversationView::scrollContentsBy(int dx, int dy) { + static_cast(dx); + static_cast(dy); + if (!materializing_) + updateMaterialization(false); + layoutMaterializedCards(); + viewport()->update(); +} + +bool ConversationView::eventFilter(QObject *watched, QEvent *event) { + auto *widget = qobject_cast(watched); + ConversationCard *card = nullptr; + for (QWidget *candidate = widget; candidate && candidate != viewport(); + candidate = candidate->parentWidget()) { + if ((card = qobject_cast(candidate))) + break; + } + if (card && event->type() == QEvent::FocusIn) { + const std::string key = + card->property("conversationAnchorKey").toString().toStdString(); + const QModelIndex index = model_->indexForStableKey(key); + if (index.isValid()) + setCurrentIndex(index); + } + if (card && event->type() == QEvent::LayoutRequest && !applying_ && + !materializing_) { + const std::string key = + card->property("conversationAnchorKey").toString().toStdString(); + const QModelIndex index = model_->indexForStableKey(key); + const ConversationItemModel::Row *row = model_->row(index.row()); + if (index.isValid() && row && cardForStableKey(key) == card) { + const int height = measureCard(card, rowWidth(*row)); + static_cast(updateMeasuredHeight(index.row(), height, true)); + return true; + } + } + return QAbstractItemView::eventFilter(watched, event); +} + +void ConversationView::paintEvent(QPaintEvent *event) { + QPainter painter(viewport()); + painter.setClipRegion(event->region()); + if (hasFocus() && currentIndex().isValid()) { + QStyleOptionFocusRect option; + option.initFrom(this); + option.rect = visualRect(currentIndex()).adjusted(1, 1, -1, -1); + option.state |= QStyle::State_KeyboardFocusChange; + style()->drawPrimitive(QStyle::PE_FrameFocusRect, &option, &painter, this); + } +} + +void ConversationView::resizeEvent(QResizeEvent *event) { + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + stopFollowingAnimation(); + QAbstractItemView::resizeEvent(event); + stagingHost_->resize(viewport()->size()); + stagingOverlay_->setGeometry(viewport()->rect()); + rebuildHeightIndex(); + for (auto &[key, card] : materializedCards_) { + const QModelIndex index = model_->indexForStableKey(key); + const ConversationItemModel::Row *row = model_->row(index.row()); + if (!index.isValid() || !row) + continue; + const int height = measureCard(card, rowWidth(*row)); + heightCache_.insert_or_assign(key, HeightRecord{rowWidth(*row), height}); + static_cast(heights_.setHeight(static_cast(index.row()), + height + CardSpacing)); + } + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + updateMaterialization(false); + viewport()->update(); + storeCurrentThreadState(); +} + +void ConversationView::wheelEvent(QWheelEvent *event) { + if (!applyWheel(event)) + QAbstractItemView::wheelEvent(event); } } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index b16a146..64385cf 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -4,11 +4,13 @@ #define CODEXUI_CODEX_MIDDLE_CONVERSATIONVIEW_H #include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationHeightIndex.h" +#include "codex/middle/ConversationItemModel.h" -#include +#include +#include #include -#include #include #include #include @@ -16,20 +18,20 @@ class QLabel; class QEvent; +class QPaintEvent; class QPushButton; -class QSpacerItem; +class QResizeEvent; class QTimer; class QVariantAnimation; -class QVBoxLayout; class QWheelEvent; namespace codexui::codex::middle { -// The conversation has one projection path and one geometry owner. Its -// content is positioned directly in QAbstractScrollArea's viewport, so every -// reconciliation can update the layout, range, and stable anchor in one -// synchronous transaction. -class ConversationView final : public QAbstractScrollArea { +// Canonical variable-height item view for the conversation. NodeGraph remains +// authoritative; this class owns only Qt indexing, cached row geometry, and +// genuinely local interaction state. QWidget count is bounded by the visible +// viewport plus one viewport of overscan on each side. +class ConversationView final : public QAbstractItemView { public: enum class Mode { Following, Paused }; @@ -56,36 +58,19 @@ class ConversationView final : public QAbstractScrollArea { return presentationOptions_; } - // Returns false for a typed projection no-op. Existing cards are mutated by - // key; first render and later updates use this same reconciliation path. - bool reconcile(const ConversationSnapshot &snapshot); - - // Structural changes which introduce many rich cards are prepared under a - // hidden Qt parent in bounded event-loop slices, then committed through the - // ordinary reconcile contract in one visible transaction. Existing cards - // remain retained throughout Load-more staging. + [[nodiscard]] bool reconcile(const ConversationSnapshot &snapshot); void reconcileStaged(ConversationSnapshot snapshot); [[nodiscard]] bool structuralStagingActive() const noexcept { return pendingStructuralSnapshot_.has_value(); } - // Applies one already-materialized card without constructing or traversing - // a complete conversation snapshot. A disengaged result requests the - // structural reconcile path because the card is absent, hidden, or changed - // identity/kind. [[nodiscard]] std::optional applyCardPresentation(const VisibleCardData &card); + [[nodiscard]] std::optional + applyCardPresentation(VisibleCardData &&card); - // Extra composer height is represented after the final card, while the - // viewport itself keeps its canonical geometry. void setTrailingSpaceHeight(int height); - - // A local admission may resume a pause caused solely by composer growth. - // Explicit user-owned scrolling remains paused. void prepareForLocalPromptAdmission(); - - // Used by the middle-region chrome and adjacent splitter handles. Nested - // scrollable controls should consume their own event before this is called. bool forwardWheelEvent(QWheelEvent *event); [[nodiscard]] Mode mode() const noexcept { return mode_; } @@ -97,9 +82,33 @@ class ConversationView final : public QAbstractScrollArea { [[nodiscard]] int trailingSpaceHeight() const noexcept { return trailingSpaceHeight_; } + [[nodiscard]] ConversationItemModel *conversationModel() const noexcept { + return model_; + } + [[nodiscard]] int materializedCardCount() const noexcept { + return static_cast(materializedCards_.size()); + } + + [[nodiscard]] QRect visualRect(const QModelIndex &index) const override; + void scrollTo(const QModelIndex &index, + ScrollHint hint = EnsureVisible) override; + [[nodiscard]] QModelIndex indexAt(const QPoint &point) const override; protected: + [[nodiscard]] QModelIndex + moveCursor(CursorAction cursorAction, + Qt::KeyboardModifiers modifiers) override; + [[nodiscard]] int horizontalOffset() const override; + [[nodiscard]] int verticalOffset() const override; + [[nodiscard]] bool isIndexHidden(const QModelIndex &index) const override; + void setSelection(const QRect &rect, + QItemSelectionModel::SelectionFlags command) override; + [[nodiscard]] QRegion + visualRegionForSelection(const QItemSelection &selection) const override; + void updateGeometries() override; + void scrollContentsBy(int dx, int dy) override; bool eventFilter(QObject *watched, QEvent *event) override; + void paintEvent(QPaintEvent *event) override; void resizeEvent(QResizeEvent *event) override; void wheelEvent(QWheelEvent *event) override; @@ -108,6 +117,7 @@ class ConversationView final : public QAbstractScrollArea { std::string stableKey; int pixelOffset = 0; int absoluteValue = 0; + int horizontalValue = 0; }; struct ThreadScrollState { @@ -116,86 +126,109 @@ class ConversationView final : public QAbstractScrollArea { bool pausedByComposerGrowth = false; }; - class TurnSectionWidget; + struct HeightRecord { + int width = 0; + int height = 0; + }; - bool reconcile(ConversationSnapshot snapshot, bool force, - bool settleFollowImmediately); - [[nodiscard]] bool tryReconcileSingleInsertion( - ConversationSnapshot &snapshot, bool settleFollowImmediately); + struct PendingLocation { + std::size_t section = 0; + std::size_t card = 0; + bool nested = false; + bool root = false; + bool activeTurn = false; + }; + + [[nodiscard]] bool reconcileOwned(ConversationSnapshot snapshot); + [[nodiscard]] std::optional + applyCardPresentationOwned(VisibleCardData card); [[nodiscard]] bool cardVisible(const VisibleCardData &card) const noexcept; void setThread(const std::string &threadId); - void setCardCollapsed(const std::string &key, ConversationCard *card, - bool collapsed); + void storeCurrentThreadState(); [[nodiscard]] Anchor captureAnchor() const; void restoreAnchor(const Anchor &anchor); - void storeCurrentThreadState(); void setScrollValue(int value); void stopFollowingAnimation(); void animateToBottom(int previousValue); - void recomputeCardGeometries( - const std::vector &changedCards); - [[nodiscard]] int settleCardGeometry(ConversationCard *card, int width); - void recomputeAppendedNestedCardGeometry( - ConversationCard *card, ConversationCard *turnContainer, - TurnSectionWidget *section, int previousNestedHeight, - bool previousNestedVisible, int previousContainerHeight, - int previousSectionHeight); - void recomputeAppendedSectionGeometry(ConversationCard *card, - TurnSectionWidget *section, - int sectionTop); - void settlePaintOnlyCard(ConversationCard *card); - void scheduleStructuralStagePass(); - void runStructuralStagePass(); - void cancelStructuralStaging(); - [[nodiscard]] ConversationCard *createRetainedCard( - const VisibleCardData &data, QWidget *parent, const std::string &key); - [[nodiscard]] VisibleCardData *pendingCard(const std::string &key); - void recomputeGeometry(); - void positionContent(); void handleUserScrollValue(int value); [[nodiscard]] bool applyWheel(QWheelEvent *event); + + void rebuildHeightIndex(); + [[nodiscard]] int estimatedCardHeight(const VisibleCardData &card) const; + [[nodiscard]] int rowWidth(const ConversationItemModel::Row &row) const; + [[nodiscard]] QRect rowRect(int row) const; + [[nodiscard]] int measureCard(ConversationCard *card, int width) const; + [[nodiscard]] bool updateMeasuredHeight(int row, int cardHeight, + bool preserveAnchor); + void updateScrollRange(); + [[nodiscard]] int leadingChromeHeight() const noexcept; + [[nodiscard]] qint64 naturalContentHeight() const noexcept; + + void updateMaterialization(bool preserveAnchor = true); + [[nodiscard]] std::pair materializationRows() const; + [[nodiscard]] ConversationCard *materializeRow(int row); + void releaseUnneededCards(int firstRow, int lastRow); + void releaseCard(const std::string &key, ConversationCard *card); + void releaseAllCards(); + void layoutMaterializedCards(); + void updateMaterializationProperties(); [[nodiscard]] ConversationCard * - cardForStableKey(const std::string &stableKey) const; + cardForStableKey(const std::string &key) const; + void configureCardForRow(ConversationCard *card, + const ConversationItemModel::Row &row); + [[nodiscard]] ConversationCard *createCard(const VisibleCardData &data, + QWidget *parent, + const std::string &key); + void setCardCollapsed(const std::string &key, ConversationCard *card, + bool collapsed); + + void buildPendingLocations(); + void choosePendingStageRows(); + [[nodiscard]] VisibleCardData *pendingCard(const std::string &key); + [[nodiscard]] const PendingLocation * + pendingLocation(const std::string &key) const; + void scheduleStructuralStagePass(); + void runStructuralStagePass(); + void cancelStructuralStaging(); - QWidget *content_ = nullptr; + ConversationItemModel *model_ = nullptr; + ConversationHeightIndex heights_; + QLabel *empty_ = nullptr; + QPushButton *loadMore_ = nullptr; QWidget *stagingHost_ = nullptr; QLabel *stagingOverlay_ = nullptr; - QVBoxLayout *contentLayout_ = nullptr; - QPushButton *loadMore_ = nullptr; - QSpacerItem *trailingSpace_ = nullptr; - QLabel *empty_ = nullptr; QVariantAnimation *followAnimation_ = nullptr; + std::function loadMoreAction_; std::function promptMaterializedAction_; std::function promptRecoveryAction_; - ConversationSnapshot snapshot_; - std::string threadId_; - std::unordered_map sections_; - std::unordered_map cards_; + std::unordered_map materializedCards_; std::unordered_map stagedCards_; - std::vector displayedSectionKeys_; - std::vector displayedCardKeys_; - std::unordered_map threadStates_; + std::unordered_map stagedHeights_; + std::unordered_map heightCache_; + std::unordered_map cardCollapsedStates_; std::unordered_map commandOutputStates_; - std::unordered_map cardCollapsedStates_; + std::unordered_map threadStates_; + PresentationOptions presentationOptions_; + std::string threadId_; + QString emptyMessage_; std::optional pendingStructuralSnapshot_; + std::unordered_map pendingLocations_; std::vector pendingStructuralCardKeys_; std::size_t pendingStructuralCardIndex_ = 0; Mode mode_ = Mode::Following; int trailingSpaceHeight_ = 0; - int naturalContentHeight_ = 0; - int contentHeight_ = 0; - QString emptyMessage_; bool applying_ = false; bool programmaticScroll_ = false; bool sliderDown_ = false; bool userActionPending_ = false; bool pausedByComposerGrowth_ = false; bool dispatchingNativeWheel_ = false; + bool materializing_ = false; bool structuralStagePassScheduled_ = false; bool committingStructuralStage_ = false; }; diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp new file mode 100644 index 0000000..54ba202 --- /dev/null +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationView.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +bool expect(bool condition, const char *message) { + if (!condition) + std::cerr << "FAILED: " << message << '\n'; + return condition; +} + +VisibleCardData message(std::size_t serial, std::string text = {}) { + const std::string suffix = std::to_string(serial); + if (text.empty()) + text = "Answer " + suffix; + return {AuthoritativeItemKey{"virtual-thread", "turn-" + suffix, + "item-" + suffix}, + CardKind::AgentMessage, + "virtual-thread", + "turn-" + suffix, + "item-" + suffix, + AgentMessageData{std::move(text), true}}; +} + +ConversationSnapshot conversation(std::size_t count, + std::size_t firstSerial = 0) { + ConversationSnapshot result; + result.threadId = "virtual-thread"; + result.sections.reserve(count); + for (std::size_t offset = 0; offset < count; ++offset) { + VisibleCardData card = message(firstSerial + offset); + TurnSection section; + section.key = "section-" + std::to_string(firstSerial + offset); + section.turnId = card.turnId; + section.cards.push_back(std::move(card)); + result.sections.push_back(std::move(section)); + } + return result; +} + +void settle(int passes = 4) { + while (passes-- > 0) + QApplication::processEvents(QEventLoop::AllEvents, 20); +} + +std::pair firstVisible(ConversationView &view) { + for (int y = 0; y < view.viewport()->height(); ++y) { + const QModelIndex index = + view.indexAt(QPoint(view.viewport()->width() / 2, y)); + if (!index.isValid()) + continue; + return {index.data(ConversationItemModel::StableKeyRole) + .toString() + .toStdString(), + view.visualRect(index).top()}; + } + return {}; +} + +ConversationCard *materializedCard(ConversationView &view, + const std::string &key) { + for (ConversationCard *card : view.findChildren()) + if (card->property("conversationAnchorKey").toString().toStdString() == key) + return card; + return nullptr; +} + +bool viewportProportionalFoundation() { + ConversationView view; + view.resize(820, 600); + view.show(); + settle(); + + ConversationSnapshot snapshot = conversation(10'000); + bool result = expect(view.reconcile(snapshot), + "ten-thousand-row authority is accepted"); + settle(); + const int initialWidgets = view.materializedCardCount(); + result &= expect(view.conversationModel()->rowCount() == 10'000, + "the item model indexes all canonical rows"); + result &= expect(initialWidgets > 0 && initialWidgets <= 48, + "initial QWidget count is bounded by the viewport"); + result &= expect(view.findChildren().size() <= 48, + "history has no placeholder or hidden QWidget per row"); + result &= expect(view.isAtBottom(), + "initial selection reveals a complete following tail"); + + const int middle = view.verticalScrollBar()->maximum() / 2; + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + view.verticalScrollBar()->setValue(middle); + settle(); + const auto anchorBefore = firstVisible(view); + result &= expect(!anchorBefore.first.empty() && + view.mode() == ConversationView::Mode::Paused, + "manual navigation establishes a painted paused anchor"); + const int beforeAppendWidgets = view.materializedCardCount(); + + ConversationSnapshot prepended = snapshot; + VisibleCardData inserted = message(10'000); + TurnSection insertedSection; + insertedSection.key = "section-10000"; + insertedSection.turnId = inserted.turnId; + insertedSection.cards.push_back(std::move(inserted)); + prepended.sections.insert(prepended.sections.begin(), + std::move(insertedSection)); + result &= expect(view.reconcile(prepended), + "a structural insertion reconciles precisely"); + settle(); + const auto anchorAfter = firstVisible(view); + result &= expect(anchorAfter == anchorBefore, + "insertion above preserves identity and exact pixel offset"); + result &= expect(view.horizontalScrollBar()->value() == 0 && + view.materializedCardCount() <= + std::max(48, beforeAppendWidgets + 4), + "structural insertion preserves both axes and widget bound"); + + const QModelIndex offscreen = view.conversationModel()->index(0); + const std::string offscreenKey = + offscreen.data(ConversationItemModel::StableKeyRole) + .toString() + .toStdString(); + result &= expect(materializedCard(view, offscreenKey) == nullptr, + "chosen offscreen row has no QWidget"); + VisibleCardData offscreenUpdate = + *view.conversationModel()->card(offscreen.row()); + std::get(offscreenUpdate.payload).text += " updated"; + const qulonglong constructionsBefore = + view.property("conversationCardConstructions").toULongLong(); + const auto offscreenImpact = + view.applyCardPresentation(std::move(offscreenUpdate)); + settle(); + result &= + expect(offscreenImpact == PresentationImpact::None && + materializedCard(view, offscreenKey) == nullptr && + view.property("conversationCardConstructions").toULongLong() == + constructionsBefore, + "offscreen delta performs no QWidget construction"); + + const auto visibleIdentity = firstVisible(view); + ConversationCard *visible = materializedCard(view, visibleIdentity.first); + const QModelIndex visibleIndex = + view.conversationModel()->indexForStableKey(visibleIdentity.first); + VisibleCardData visibleUpdate = + *view.conversationModel()->card(visibleIndex.row()); + std::get(visibleUpdate.payload).text += + std::string(240, 'x'); + const qulonglong offscreenBefore = + view.property("targetedOffscreenCardUpdates").toULongLong(); + const auto visibleImpact = + view.applyCardPresentation(std::move(visibleUpdate)); + settle(); + result &= expect( + visible && materializedCard(view, visibleIdentity.first) == visible && + visibleImpact == PresentationImpact::GeometryChanged && + view.property("targetedOffscreenCardUpdates").toULongLong() == + offscreenBefore, + "one visible stream update mutates only its retained row"); + result &= expect(firstVisible(view).second == visibleIdentity.second, + "visible height change preserves the exact painted anchor"); + return result; +} + +bool atomicPagingAndFollowingArrival() { + ConversationView view; + view.resize(820, 600); + view.show(); + settle(); + ConversationSnapshot initial = conversation(80, 80); + bool result = expect(view.reconcile(initial), "initial page is visible"); + settle(); + const int rowsBefore = view.conversationModel()->rowCount(); + const int widgetsBefore = view.materializedCardCount(); + + ConversationSnapshot loaded = conversation(160); + loaded.hasMore = true; + view.reconcileStaged(std::move(loaded)); + const bool deferred = view.structuralStagingActive(); + result &= expect( + deferred ? view.conversationModel()->rowCount() == rowsBefore && + view.materializedCardCount() == widgetsBefore + : view.conversationModel()->rowCount() == 160 && + view.materializedCardCount() <= widgetsBefore + 4, + "Load 80 either retains the old frame while preparing visible rows or " + "commits immediately when its new rows are wholly offscreen"); + QElapsedTimer deadline; + deadline.start(); + while (view.structuralStagingActive() && deadline.elapsed() < 5000) + QApplication::processEvents(QEventLoop::AllEvents, 20); + settle(); + result &= expect(!view.structuralStagingActive() && + view.conversationModel()->rowCount() == 160 && + view.materializedCardCount() <= 48, + "Load 80 commits one complete virtualized frame"); + + ConversationSnapshot appended = conversation(161); + view.reconcileStaged(std::move(appended)); + deadline.restart(); + while (view.structuralStagingActive() && deadline.elapsed() < 5000) + QApplication::processEvents(QEventLoop::AllEvents, 20); + settle(); + const QModelIndex tail = view.conversationModel()->index(160); + result &= + expect(view.isAtBottom() && tail.isValid() && + view.visualRect(tail).bottom() <= view.viewport()->height(), + "following arrival reveals its complete final card"); + return result; +} + +} // namespace +} // namespace codexui::codex::middle + +int main(int argc, char **argv) { + QApplication application(argc, argv); + using namespace codexui::codex::middle; + const bool result = + viewportProportionalFoundation() && atomicPagingAndFollowingArrival(); + if (result) + std::cout << "Conversation virtualization tests passed\n"; + return result ? EXIT_SUCCESS : EXIT_FAILURE; +} From bd2297e436571e90d6cf28778023f32e2e73b59f Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 18:04:34 +0200 Subject: [PATCH 04/39] Paint passive conversation rows with a delegate --- docs/qt-virtualized-conversation-view.md | 41 ++ src/codex/middle/ConversationCards.cpp | 20 + src/codex/middle/ConversationCards.h | 3 + src/codex/middle/ConversationView.cpp | 685 +++++++++++++++++- src/codex/middle/ConversationView.h | 21 +- src/codex/ui/UiStyle.cpp | 1 + .../codex/ConversationVirtualizationTest.cpp | 74 +- 7 files changed, 826 insertions(+), 19 deletions(-) diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md index acc31a4..ada1c2f 100644 --- a/docs/qt-virtualized-conversation-view.md +++ b/docs/qt-virtualized-conversation-view.md @@ -143,6 +143,47 @@ widget construction is involved. At that size, position lookup and one-row height update each take at most 15 Fenwick steps, and appending rows leaves the rebuild counter unchanged. +## Item-view and passive-delegate contract + +`ConversationView` is a narrowly specialized `QAbstractItemView`. The model +owns row identity and order; a Fenwick index maps content positions to +variable-height rows; the view materializes only rows whose behavior currently +requires a real control. It does not create placeholder widgets. Cached height +records contain only stable key, width, and measured height and therefore +cannot become presentation authority. + +Collapsed cards and text-only resting cards are painted by the item delegate. +Its Markdown document cache is bounded to 128 visible/recent blocks. Hover, +keyboard current-row movement, or a direct press promotes exactly that row to +the established `ConversationCard`, so selection, copying, links, tooltips, +focus, and controls continue to use their existing implementations. Local +pending prompts and expanded command, file, and image surfaces remain real +widgets because their animation, nested scrolling, file actions, and image +controls are intrinsically interactive. Scrolling a promoted editor out of the +bounded overscan stores only its fold and inner-command-scroll state before the +widget is released. + +A canonical Turn is still flat in the model, but not visually flattened. The +view paints the continuous outer You surface from the root row through the last +presented nested row. Root content and nested cards remain independently +virtualizable fragments; nested cards keep the established 12-pixel inset, +and the outer padding, section gap, and active-Turn border are part of indexed +row geometry. This preserves the visual ownership relationship without making +one potentially unbounded Turn a single QWidget. + +The first delegate measurement in the persistent Debug/Xvfb configuration is: + +| Loaded rows | Initial reveal | Retained conversation widgets | Descendant QWidgets | Peak resident memory | 240-position sweep | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 320 | 9 ms | 0 | 8 | 85,488 KiB | 250.2 ms | +| 1,280 | 16 ms | 0 | 8 | 86,332 KiB | 270.1 ms | + +This benchmark's rows are all in passive resting states. The 6.4% sweep-time +increase for four times the history contrasts with the old fourfold QWidget +population; work at each position is bounded by the viewport and the delegate's +small document cache. Rich heterogeneous qualification adds only the real +editors required by the visible interaction state. + ## Qualification counters The final implementation reports at least these inspectable values on the diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 2efea9e..624bb4b 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -1234,6 +1234,21 @@ class ConversationCard::Impl final { owner->update(); } + void setVirtualTurnRootPresentation(bool fragmented) { + if (owner->property("virtualTurnRoot").toBool() == fragmented) + return; + owner->setProperty("virtualTurnRoot", fragmented); + const QMargins margins = layout->contentsMargins(); + if (fragmented) + turnRootBottomMargin = margins.bottom(); + layout->setContentsMargins(margins.left(), margins.top(), margins.right(), + fragmented ? 0 : turnRootBottomMargin); + owner->style()->unpolish(owner); + owner->style()->polish(owner); + owner->updateGeometry(); + owner->update(); + } + void setViewportVisible(bool visible) { if (viewportVisible == visible) return; @@ -1762,6 +1777,7 @@ class ConversationCard::Impl final { QVBoxLayout *nestedLayout = nullptr; bool hasVisibleNestedCards = false; bool authoritativeTurnActive = false; + int turnRootBottomMargin = 10; }; ConversationCard::ConversationCard(const VisibleCardData &data, QWidget *parent, @@ -1797,6 +1813,10 @@ void ConversationCard::setNestedPresentation(bool nested) { impl_->setNestedConversationCard(nested); } +void ConversationCard::setVirtualTurnRootPresentation(bool fragmented) { + impl_->setVirtualTurnRootPresentation(fragmented); +} + void ConversationCard::setNestedCards( const std::vector &cards) { impl_->setNestedCards(cards); diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index 77afee0..d79fa5a 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -101,6 +101,9 @@ class ConversationCard : public QFrame { // Select the established nested-card presentation for a child, or clear it // when the card becomes a turn root or a standalone activity. void setNestedPresentation(bool nested); + // In a virtualized turn the view paints the continuous outer You surface; + // the root card keeps only its content and interaction geometry. + void setVirtualTurnRootPresentation(bool fragmented); void setNestedCards(const std::vector &cards); // ConversationView supplies the retained child widgets in canonical order. // They stay in this existing nested layout while the thread is selected. diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 027c323..13ee17b 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -3,6 +3,7 @@ #include "codex/middle/ConversationView.h" #include +#include #include #include #include @@ -10,7 +11,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -18,6 +21,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -39,6 +45,29 @@ constexpr int NativeScrollLineStep = 20; constexpr int EstimatedCardHeight = 112; constexpr int MinimumMaterializationRows = 8; +bool passiveWhenCollapsed(CardKind kind) noexcept { + return kind == CardKind::AgentActivity || kind == CardKind::Reasoning || + kind == CardKind::Plan || kind == CardKind::GenericActivity; +} + +bool eligibleForPassivePresentation(const VisibleCardData &card, + bool collapsed) noexcept { + if (card.kind == CardKind::LocalPrompt) + return false; + if (collapsed) + return true; + if (card.kind == CardKind::AgentMessage || + card.kind == CardKind::AgentActivity || + card.kind == CardKind::Reasoning || card.kind == CardKind::Plan || + card.kind == CardKind::GenericActivity) + return true; + if (card.kind == CardKind::UserMessage) { + const auto *message = std::get_if(&card.payload); + return !message || message->imagePaths.empty(); + } + return false; +} + QLabel *makeEmptyLabel(QWidget *parent) { auto *label = new QLabel(QStringLiteral("Conversation activity appears here."), parent); @@ -53,6 +82,361 @@ void incrementProperty(QObject *object, const char *name) { object->setProperty(name, object->property(name).toULongLong() + 1); } +QString text(std::string_view value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +struct PassiveBlock { + QString text; + bool markdown = false; + bool metadata = false; +}; + +struct PassivePresentation { + QString title; + QString status; + QColor background = QColor(QStringLiteral("#ffffff")); + QColor border = QColor(QStringLiteral("#d7dee8")); + QColor titleColor = QColor(QStringLiteral("#1d2633")); + std::vector blocks; + int verticalMargin = 10; +}; + +QString planText(const PlanData &plan) { + if (!plan.legacyText.empty()) + return text(plan.legacyText); + QStringList lines; + if (!plan.explanation.empty()) + lines.push_back(text(plan.explanation)); + if (!lines.empty() && !plan.steps.empty()) + lines.push_back({}); + for (const PlanStepData &step : plan.steps) { + const QString marker = step.status == "completed" ? QStringLiteral("✓") + : step.status == "inProgress" ? QStringLiteral("◉") + : QStringLiteral("○"); + lines.push_back(QStringLiteral("%1 %2").arg(marker, text(step.text))); + } + return lines.join(QLatin1Char('\n')); +} + +QString genericDetail(const GenericActivityData &activity) { + QString value = activity.displayDetail.empty() + ? QString::fromStdString(activity.raw.dump(2)) + : text(activity.displayDetail); + constexpr qsizetype MaximumCharacters = 4096; + if (value.size() <= MaximumCharacters) + return value; + value.truncate(MaximumCharacters); + return value + QStringLiteral("\n\n[Activity details truncated]"); +} + +PassivePresentation passivePresentation(const VisibleCardData &card) { + PassivePresentation result; + std::visit( + [&](const auto &payload) { + using Payload = std::decay_t; + if constexpr (std::is_same_v) { + result.title = QStringLiteral("You"); + result.background = QColor(QStringLiteral("#eff5fe")); + result.border = QColor(QStringLiteral("#b7cff9")); + result.titleColor = QColor(QStringLiteral("#415882")); + result.blocks.push_back({text(payload.text), true, false}); + } else if constexpr (std::is_same_v) { + result.title = QStringLiteral("Codex"); + result.status = payload.finalAnswer ? QStringLiteral("final answer") + : QStringLiteral("update"); + result.background = + QColor(payload.finalAnswer ? QStringLiteral("#f4f3fd") + : QStringLiteral("#f9f4ea")); + result.border = + QColor(payload.finalAnswer ? QStringLiteral("#cec7f6") + : QStringLiteral("#e1cb9d")); + result.titleColor = + QColor(payload.finalAnswer ? QStringLiteral("#59507f") + : QStringLiteral("#6b5521")); + result.verticalMargin = payload.finalAnswer ? 10 : 8; + result.blocks.push_back({text(payload.text), true, false}); + } else if constexpr (std::is_same_v) { + result.title = QStringLiteral("Command execution"); + result.status = text(payload.status); + result.blocks.push_back({text(payload.command), false, false}); + result.blocks.push_back({text(payload.output), false, false}); + } else if constexpr (std::is_same_v) { + result.title = QStringLiteral("Agent activity"); + result.status = text(payload.status); + QStringList metadata; + if (!payload.tool.empty()) + metadata.push_back(text(payload.tool)); + if (!payload.receivers.empty()) { + QStringList receivers; + for (const std::string &receiver : payload.receivers) + receivers.push_back(text(receiver)); + metadata.push_back(receivers.join(QStringLiteral(", "))); + } + if (!payload.model.empty()) + metadata.push_back(text(payload.model)); + if (!payload.childThreadId.empty()) + metadata.push_back( + QStringLiteral("thread %1").arg(text(payload.childThreadId))); + result.blocks.push_back( + {metadata.join(QStringLiteral(" | ")), false, true}); + result.blocks.push_back({text(payload.prompt), false, false}); + result.blocks.push_back({text(payload.resultText), true, false}); + } else if constexpr (std::is_same_v) { + result.title = QStringLiteral("Reasoning"); + result.blocks.push_back({text(payload.summary), true, false}); + } else if constexpr (std::is_same_v) { + result.title = QStringLiteral("File changes"); + result.status = text(payload.status); + QStringList lines; + for (const FileChangeData &change : payload.changes) { + QString line = text(change.path); + if (!change.kind.empty()) + line += QStringLiteral(" · ") + text(change.kind); + if (change.additions && change.deletions) + line += QStringLiteral(" +%1 −%2") + .arg(*change.additions) + .arg(*change.deletions); + lines.push_back(line); + } + result.blocks.push_back( + {lines.join(QLatin1Char('\n')), false, false}); + } else if constexpr (std::is_same_v) { + result.title = QStringLiteral("Plan"); + result.blocks.push_back({planText(payload), true, false}); + } else if constexpr (std::is_same_v) { + result.title = payload.status.empty() && payload.revisedPrompt.empty() + ? QStringLiteral("Image") + : QStringLiteral("Generated image"); + result.status = text(payload.status); + result.blocks.push_back({text(payload.revisedPrompt), false, false}); + } else if constexpr (std::is_same_v) { + result.title = payload.type.empty() ? QStringLiteral("Activity") + : text(payload.type); + if (!result.title.isEmpty()) + result.title[0] = result.title.front().toUpper(); + result.status = text(payload.status); + result.blocks.push_back({genericDetail(payload), false, true}); + } else if constexpr (std::is_same_v) { + result.title = QStringLiteral("You"); + result.blocks.push_back({text(payload.prompt), true, false}); + } + }, + card.payload); + std::erase_if(result.blocks, + [](const PassiveBlock &block) { return block.text.isEmpty(); }); + return result; +} + +class ConversationPassiveDelegate final : public QStyledItemDelegate { +public: + explicit ConversationPassiveDelegate(QObject *parent) + : QStyledItemDelegate(parent) {} + + QSize sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const override { + return cardSize(option, index, true); + } + + QSize cardSize(const QStyleOptionViewItem &option, const QModelIndex &index, + bool collapsed) const { + const auto *conversation = + qobject_cast(index.model()); + const ConversationItemModel::Row *row = + conversation ? conversation->row(index.row()) : nullptr; + if (!row) + return {}; + const PassivePresentation presentation = passivePresentation(row->card); + int height = 24 + 2 * presentation.verticalMargin; + if (!collapsed) { + const int bodyWidth = std::max(1, option.rect.width() - 24); + bool first = true; + for (std::size_t block = 0; block < presentation.blocks.size(); ++block) { + const PassiveBlock &value = presentation.blocks[block]; + QFont font = option.font; + if (value.metadata) + font.setPointSizeF(std::max(7.0, font.pointSizeF() - 1.0)); + height += (first ? 6 : 6) + + documentHeight(row->stableKey, block, value, bodyWidth, font); + first = false; + } + } + return {std::max(0, option.rect.width()), std::max(44, height)}; + } + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override { + paintCard(painter, option, index, true); + } + + void paintCard(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index, bool collapsed) const { + const auto *conversation = + qobject_cast(index.model()); + const ConversationItemModel::Row *row = + conversation ? conversation->row(index.row()) : nullptr; + if (!painter || !row) + return; + + const PassivePresentation presentation = passivePresentation(row->card); + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + const QRectF bounds = QRectF(option.rect).adjusted(0.5, 0.5, -0.5, -0.5); + if (option.viewItemPosition != QStyleOptionViewItem::Beginning) { + painter->setBrush(presentation.background); + painter->setPen(QPen(presentation.border, 1.0)); + painter->drawRoundedRect(bounds, 10.0, 10.0); + } + + QFont titleFont = option.font; + titleFont.setWeight(QFont::DemiBold); + painter->setFont(titleFont); + painter->setPen(presentation.titleColor); + const int top = option.rect.top() + presentation.verticalMargin; + const QRect titleRect(option.rect.left() + 12, top, + std::max(0, option.rect.width() - 88), 24); + painter->drawText(titleRect, Qt::AlignLeft | Qt::AlignVCenter, + option.fontMetrics.elidedText(presentation.title, + Qt::ElideRight, + titleRect.width())); + + if (!presentation.status.isEmpty()) { + QFont statusFont = option.font; + statusFont.setPointSizeF(std::max(7.0, statusFont.pointSizeF() - 1.0)); + painter->setFont(statusFont); + painter->setPen(QColor(QStringLiteral("#667085"))); + const QRect statusRect(option.rect.right() - 205, top, 145, 24); + painter->drawText(statusRect, Qt::AlignRight | Qt::AlignVCenter, + option.fontMetrics.elidedText(presentation.status, + Qt::ElideRight, + statusRect.width())); + } + + if (!collapsed) { + int blockTop = top + 30; + const int bodyWidth = std::max(1, option.rect.width() - 24); + for (std::size_t block = 0; block < presentation.blocks.size(); ++block) { + const PassiveBlock &value = presentation.blocks[block]; + QFont font = option.font; + if (value.metadata) + font.setPointSizeF(std::max(7.0, font.pointSizeF() - 1.0)); + const int height = + documentHeight(row->stableKey, block, value, bodyWidth, font); + paintDocument( + painter, row->stableKey, block, value, + QRect(option.rect.left() + 12, blockTop, bodyWidth, height), font); + blockTop += height + 6; + } + } + + painter->setPen(QPen(QColor(QStringLiteral("#667085")), 1.3)); + painter->setBrush(Qt::NoBrush); + const qreal copyLeft = option.rect.right() - 43.0; + painter->drawRoundedRect( + QRectF(copyLeft, option.rect.top() + 14.0, 8.0, 9.0), 1.0, 1.0); + painter->drawRoundedRect( + QRectF(copyLeft + 3.0, option.rect.top() + 17.0, 8.0, 9.0), 1.0, 1.0); + QPainterPath chevron; + if (collapsed) { + chevron.moveTo(option.rect.right() - 15.0, top + 7.0); + chevron.lineTo(option.rect.right() - 19.0, top + 12.0); + chevron.lineTo(option.rect.right() - 15.0, top + 17.0); + } else { + chevron.moveTo(option.rect.right() - 20.0, top + 9.0); + chevron.lineTo(option.rect.right() - 15.0, top + 14.0); + chevron.lineTo(option.rect.right() - 10.0, top + 9.0); + } + painter->drawPath(chevron); + if (row->card.activeWork.value_or(false)) { + painter->setPen(QPen(QColor(QStringLiteral("#98a2b3")), 2.0)); + painter->drawRoundedRect(bounds.adjusted(0.5, 0.5, -0.5, -0.5), 9.0, 9.0); + } + painter->restore(); + } + +private: + struct DocumentRecord { + QString text; + int width = 0; + bool markdown = false; + QFont font; + std::unique_ptr document; + std::uint64_t used = 0; + }; + + QTextDocument *document(const std::string &stableKey, std::size_t block, + const PassiveBlock &value, int width, + const QFont &font) const { + const std::string key = stableKey + ':' + std::to_string(block); + auto found = documents_.find(key); + if (found == documents_.end() || found->second.text != value.text || + found->second.width != width || + found->second.markdown != value.markdown || + found->second.font != font) { + if (found != documents_.end()) + documents_.erase(found); + if (documents_.size() >= 128) { + const auto oldest = + std::ranges::min_element(documents_, {}, [](const auto &entry) { + return entry.second.used; + }); + if (oldest != documents_.end()) + documents_.erase(oldest); + } + DocumentRecord record; + record.text = value.text; + record.width = width; + record.markdown = value.markdown; + record.font = font; + record.document = std::make_unique(); + record.document->setDocumentMargin(0); + record.document->setDefaultFont(font); + record.document->setDefaultStyleSheet( + QStringLiteral("a{color:#5471a6;text-decoration:none;}")); + if (value.markdown) + record.document->setMarkdown(value.text, + QTextDocument::MarkdownFeatures( + QTextDocument::MarkdownDialectGitHub) | + QTextDocument::MarkdownNoHTML); + else + record.document->setPlainText(value.text); + record.document->setTextWidth(width); + found = documents_.emplace(key, std::move(record)).first; + } + found->second.used = ++documentUse_; + return found->second.document.get(); + } + + int documentHeight(const std::string &stableKey, std::size_t block, + const PassiveBlock &value, int width, + const QFont &font) const { + return std::max( + 1, + static_cast(std::ceil( + document(stableKey, block, value, width, font)->size().height())) + + (value.markdown ? 4 : 0)); + } + + void paintDocument(QPainter *painter, const std::string &stableKey, + std::size_t block, const PassiveBlock &value, + const QRect &rect, const QFont &font) const { + QTextDocument *valueDocument = + document(stableKey, block, value, rect.width(), font); + QAbstractTextDocumentLayout::PaintContext context; + context.palette.setColor( + QPalette::Text, value.metadata ? QColor(QStringLiteral("#667085")) + : QColor(QStringLiteral("#1d2633"))); + context.clip = QRect(QPoint{}, rect.size()); + painter->save(); + painter->translate(rect.topLeft()); + valueDocument->documentLayout()->draw(painter, context); + painter->restore(); + } + + mutable std::unordered_map documents_; + mutable std::uint64_t documentUse_ = 0; +}; + } // namespace ConversationView::ConversationView(QWidget *parent) @@ -67,6 +451,8 @@ ConversationView::ConversationView(QWidget *parent) setSelectionBehavior(QAbstractItemView::SelectRows); setTabKeyNavigation(true); setModel(model_); + setItemDelegate(new ConversationPassiveDelegate(this)); + setMouseTracking(true); verticalScrollBar()->setSingleStep(NativeScrollLineStep); viewport()->setAutoFillBackground(false); @@ -191,6 +577,7 @@ void ConversationView::setPresentationOptions(PresentationOptions options) { model_->setVisibility({options.showReasoning, options.showCodexUpdates}); if (!visibilityChanged) return; + rebuildSectionRanges(); rebuildHeightIndex(); updateScrollRange(); if (follow) @@ -260,6 +647,7 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { stopFollowingAnimation(); const bool changed = model_->reconcile(std::move(snapshot)); + rebuildSectionRanges(); loadMore_->setVisible(model_->hasMore()); empty_->setVisible(model_->rowCount() == 0); @@ -413,6 +801,23 @@ void ConversationView::choosePendingStageRows() { VisibleCardData *card = pendingCard(key); if (!card || !cardVisible(*card)) continue; + bool collapsed = false; + if (const auto retained = cardCollapsedStates_.find(key); + retained != cardCollapsedStates_.end()) { + collapsed = retained->second; + } else if (card->kind == CardKind::CommandExecution) { + collapsed = !presentationOptions_.commandsInitiallyExpanded; + } else if (card->kind == CardKind::ImageGeneration) { + collapsed = !presentationOptions_.imagesInitiallyExpanded; + } else if (card->kind == CardKind::FileChanges) { + collapsed = !presentationOptions_.fileChangesInitiallyExpanded; + } else { + collapsed = card->kind != CardKind::UserMessage && + card->kind != CardKind::AgentMessage && + card->kind != CardKind::LocalPrompt; + } + if (eligibleForPassivePresentation(*card, collapsed)) + continue; const auto retained = materializedCards_.find(key); if (retained != materializedCards_.end() && retained->second->canApply(*card)) @@ -559,6 +964,8 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { const bool becomingAuthoritative = before->card.kind == CardKind::LocalPrompt && card.kind == CardKind::UserMessage && card.target; + const bool paintedInViewport = + wasPresented && rowRect(index.row()).intersects(viewport()->rect()); nodegraph::NodeRef authoritativeTarget = becomingAuthoritative ? card.target : nodegraph::NodeRef{}; ConversationCard *visibleCard = cardForStableKey(key); @@ -581,9 +988,9 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { const bool presentationChanged = after && after->presented != wasPresented; if (presentationChanged) { if (after->presented) { - static_cast( - heights_.setHeight(static_cast(index.row()), - estimatedCardHeight(after->card) + CardSpacing)); + static_cast(heights_.setHeight( + static_cast(index.row()), + estimatedCardHeight(after->card) + rowSpacing(index.row()))); } else { static_cast( heights_.setHeight(static_cast(index.row()), 0)); @@ -593,6 +1000,7 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { visibleCard = nullptr; } } + rebuildSectionRanges(); updateScrollRange(); updateMaterialization(true); } else if (visibleCard && impact == PresentationImpact::GeometryChanged) { @@ -600,9 +1008,22 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { static_cast(updateMeasuredHeight(index.row(), height, true)); } else if (visibleCard && impact == PresentationImpact::PaintOnly) { visibleCard->update(); + } else if (after && paintedInViewport && rowUsesPassiveDelegate(*after)) { + heightCache_.erase(key); + QStyleOptionViewItem option; + option.initFrom(this); + option.rect = QRect(0, 0, rowWidth(*after), 0); + const auto *delegate = + static_cast(itemDelegate()); + const int height = + delegate->cardSize(option, index, rowCollapsed(*after)).height(); + impact = updateMeasuredHeight(index.row(), height, true) + ? PresentationImpact::GeometryChanged + : PresentationImpact::PaintOnly; + viewport()->update(rowRect(index.row())); } - if (visibleCard) + if (visibleCard || paintedInViewport) incrementProperty(this, "targetedVisibleCardUpdates"); else incrementProperty(this, "targetedOffscreenCardUpdates"); @@ -616,6 +1037,22 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { } int ConversationView::estimatedCardHeight(const VisibleCardData &card) const { + const std::string key = stableKey(card.key); + bool collapsed = false; + if (const auto retained = cardCollapsedStates_.find(key); + retained != cardCollapsedStates_.end()) { + collapsed = retained->second; + } else if (card.kind == CardKind::CommandExecution) { + collapsed = !presentationOptions_.commandsInitiallyExpanded; + } else if (card.kind == CardKind::ImageGeneration) { + collapsed = !presentationOptions_.imagesInitiallyExpanded; + } else if (card.kind == CardKind::FileChanges) { + collapsed = !presentationOptions_.fileChangesInitiallyExpanded; + } else { + collapsed = passiveWhenCollapsed(card.kind); + } + if (collapsed && card.kind != CardKind::LocalPrompt) + return 44; switch (card.kind) { case CardKind::CommandExecution: return 156; @@ -636,6 +1073,59 @@ int ConversationView::rowWidth(const ConversationItemModel::Row &row) const { (row.nested ? 2 * NestedCardIndent : 0)); } +bool ConversationView::rowUsesPassiveDelegate( + const ConversationItemModel::Row &row) const { + return eligibleForPassivePresentation(row.card, rowCollapsed(row)); +} + +bool ConversationView::rowCollapsed( + const ConversationItemModel::Row &row) const { + if (const auto retained = cardCollapsedStates_.find(row.stableKey); + retained != cardCollapsedStates_.end()) + return retained->second; + if (row.card.kind == CardKind::CommandExecution) + return !presentationOptions_.commandsInitiallyExpanded; + if (row.card.kind == CardKind::ImageGeneration) + return !presentationOptions_.imagesInitiallyExpanded; + if (row.card.kind == CardKind::FileChanges) + return !presentationOptions_.fileChangesInitiallyExpanded; + return row.card.kind != CardKind::UserMessage && + row.card.kind != CardKind::AgentMessage && + row.card.kind != CardKind::LocalPrompt; +} + +void ConversationView::rebuildSectionRanges() { + sectionRanges_.clear(); + sectionRanges_.reserve(static_cast(model_->rowCount())); + for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->presented) + continue; + SectionRange &range = sectionRanges_[row->sectionKey]; + if (range.first < 0) + range.first = rowIndex; + range.last = rowIndex; + if (row->turnRoot) + range.root = rowIndex; + range.active = range.active || (row->turnRoot && row->activeTurn); + } +} + +int ConversationView::rowSpacing(int rowIndex) const { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row) + return CardSpacing; + const auto found = sectionRanges_.find(row->sectionKey); + if (found == sectionRanges_.end() || found->second.root < 0 || + found->second.last <= found->second.root) + return CardSpacing; + if (rowIndex == found->second.root) + return 14; + if (rowIndex == found->second.last) + return CardSpacing + 10; + return CardSpacing; +} + void ConversationView::rebuildHeightIndex() { std::vector extents; extents.reserve(static_cast(model_->rowCount())); @@ -658,7 +1148,7 @@ void ConversationView::rebuildHeightIndex() { } else { height = estimatedCardHeight(row->card); } - extents.push_back(std::max(1, height) + CardSpacing); + extents.push_back(std::max(1, height) + rowSpacing(rowIndex)); } heights_.assign(extents); setProperty("conversationHeightIndexRebuilds", @@ -723,7 +1213,7 @@ QRect ConversationView::rowRect(int rowIndex) const { const int x = row->nested ? NestedCardIndent : 0; return {x - horizontalScrollBar()->value(), static_cast(std::clamp(viewportTop, INT_MIN, INT_MAX)), - rowWidth(*row), std::max(1, extent - CardSpacing)}; + rowWidth(*row), std::max(1, extent - rowSpacing(rowIndex))}; } int ConversationView::measureCard(ConversationCard *card, int width) const { @@ -766,7 +1256,7 @@ bool ConversationView::updateMeasuredHeight(int rowIndex, int cardHeight, heightCache_.insert_or_assign(row->stableKey, HeightRecord{rowWidth(*row), cardHeight}); if (!heights_.setHeight(static_cast(rowIndex), - std::max(1, cardHeight) + CardSpacing)) + std::max(1, cardHeight) + rowSpacing(rowIndex))) return false; incrementProperty(this, "conversationLocalGeometryPasses"); setProperty("conversationHeightIndexUpdateSteps", @@ -836,19 +1326,41 @@ void ConversationView::configureCardForRow( ConversationCard *card, const ConversationItemModel::Row &row) { if (!card) return; + const auto section = sectionRanges_.find(row.sectionKey); + const bool fragmentedRoot = row.turnRoot && section != sectionRanges_.end() && + section->second.last > section->second.root; card->setProperty("turnContainer", row.turnRoot); card->setNestedCards({}); card->setNestedPresentation(row.nested); - card->setAuthoritativeTurnActive(row.turnRoot && row.activeTurn); + card->setVirtualTurnRootPresentation(fragmentedRoot); + card->setAuthoritativeTurnActive(row.turnRoot && row.activeTurn && + !fragmentedRoot); } -ConversationCard *ConversationView::materializeRow(int rowIndex) { +ConversationCard *ConversationView::materializeRow(int rowIndex, + bool forInteraction) { const ConversationItemModel::Row *row = model_->row(rowIndex); if (!row || !row->presented) return nullptr; if (ConversationCard *retained = cardForStableKey(row->stableKey)) return retained; + if (!forInteraction && rowUsesPassiveDelegate(*row)) { + QStyleOptionViewItem option; + option.initFrom(this); + option.rect = QRect(0, 0, rowWidth(*row), 0); + const auto *delegate = + static_cast(itemDelegate()); + const int height = + delegate->cardSize(option, model_->index(rowIndex), rowCollapsed(*row)) + .height(); + heightCache_.insert_or_assign(row->stableKey, + HeightRecord{rowWidth(*row), height}); + static_cast(heights_.setHeight(static_cast(rowIndex), + height + rowSpacing(rowIndex))); + return nullptr; + } + ConversationCard *card = nullptr; if (const auto staged = stagedCards_.find(row->stableKey); staged != stagedCards_.end() && staged->second->canApply(row->card)) { @@ -866,7 +1378,7 @@ ConversationCard *ConversationView::materializeRow(int rowIndex) { heightCache_.insert_or_assign(row->stableKey, HeightRecord{rowWidth(*row), height}); static_cast(heights_.setHeight(static_cast(rowIndex), - height + CardSpacing)); + height + rowSpacing(rowIndex))); materializedCards_.emplace(row->stableKey, card); card->setGeometry(rowRect(rowIndex)); card->show(); @@ -1340,9 +1852,160 @@ bool ConversationView::eventFilter(QObject *watched, QEvent *event) { return QAbstractItemView::eventFilter(watched, event); } +void ConversationView::currentChanged(const QModelIndex ¤t, + const QModelIndex &previous) { + QAbstractItemView::currentChanged(current, previous); + if (!current.isValid() || current.model() != model_) + return; + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + const qint64 before = heights_.totalHeight(); + static_cast(materializeRow(current.row(), true)); + if (before != heights_.totalHeight()) { + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + } + layoutMaterializedCards(); + updateMaterializationProperties(); +} + +void ConversationView::mouseMoveEvent(QMouseEvent *event) { + const QModelIndex index = indexAt(event->position().toPoint()); + if (index.isValid() && + !cardForStableKey(index.data(ConversationItemModel::StableKeyRole) + .toString() + .toStdString())) { + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + const qint64 before = heights_.totalHeight(); + static_cast(materializeRow(index.row(), true)); + if (before != heights_.totalHeight()) { + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + } + layoutMaterializedCards(); + updateMaterializationProperties(); + } + QAbstractItemView::mouseMoveEvent(event); +} + +void ConversationView::mousePressEvent(QMouseEvent *event) { + const QPoint viewportPosition = event->position().toPoint(); + const QModelIndex index = indexAt(viewportPosition); + if (!index.isValid()) { + QAbstractItemView::mousePressEvent(event); + return; + } + setCurrentIndex(index); + const ConversationItemModel::Row *row = model_->row(index.row()); + ConversationCard *card = row ? cardForStableKey(row->stableKey) : nullptr; + if (!card) { + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + const qint64 before = heights_.totalHeight(); + card = materializeRow(index.row(), true); + if (before != heights_.totalHeight()) { + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + } + layoutMaterializedCards(); + updateMaterializationProperties(); + } + if (!card) { + QAbstractItemView::mousePressEvent(event); + return; + } + + const QPoint cardPosition = card->mapFrom(viewport(), viewportPosition); + QWidget *target = card->childAt(cardPosition); + if (!target) + target = card; + const QPoint localPosition = target->mapFrom(viewport(), viewportPosition); + QMouseEvent forwarded(event->type(), QPointF(localPosition), + event->scenePosition(), event->globalPosition(), + event->button(), event->buttons(), event->modifiers(), + event->pointingDevice()); + QApplication::sendEvent(target, &forwarded); + event->setAccepted(forwarded.isAccepted()); +} + void ConversationView::paintEvent(QPaintEvent *event) { QPainter painter(viewport()); painter.setClipRegion(event->region()); + if (!heights_.empty() && heights_.totalHeight() > 0) { + const qint64 firstY = std::max(0, verticalScrollBar()->value() - + leadingChromeHeight()); + const qint64 lastY = + std::min(heights_.totalHeight() - 1, + verticalScrollBar()->value() - leadingChromeHeight() + + std::max(0, viewport()->height() - 1)); + if (lastY >= firstY) { + const int first = static_cast(heights_.rowAt(firstY)); + const int last = static_cast(heights_.rowAt(lastY)); + std::unordered_set paintedSections; + for (int rowIndex = first; rowIndex <= last; ++rowIndex) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->presented || + !paintedSections.insert(row->sectionKey).second) + continue; + const auto section = sectionRanges_.find(row->sectionKey); + if (section == sectionRanges_.end() || section->second.root < 0 || + section->second.last <= section->second.root) + continue; + const SectionRange &range = section->second; + const qreal top = static_cast(leadingChromeHeight()) + + static_cast(heights_.top( + static_cast(range.root))) - + verticalScrollBar()->value(); + const qreal bottom = + static_cast(leadingChromeHeight()) + + static_cast( + heights_.top(static_cast(range.last))) + + heights_.height(static_cast(range.last)) - + rowSpacing(range.last) + 10 - verticalScrollBar()->value(); + const QRectF surface(0.5, top + 0.5, + std::max(0, viewport()->width()) - 1.0, + std::max(1.0, bottom - top - 1.0)); + painter.setRenderHint(QPainter::Antialiasing); + painter.setBrush(QColor(QStringLiteral("#eff5fe"))); + painter.setPen(QPen(QColor(range.active ? QStringLiteral("#6f98e8") + : QStringLiteral("#b7cff9")), + range.active ? 2.0 : 1.0)); + painter.drawRoundedRect(surface, 8.0, 8.0); + } + for (int rowIndex = first; rowIndex <= last; ++rowIndex) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->presented || !rowUsesPassiveDelegate(*row) || + cardForStableKey(row->stableKey)) + continue; + QStyleOptionViewItem option; + option.initFrom(this); + option.rect = rowRect(rowIndex); + const auto section = sectionRanges_.find(row->sectionKey); + if (section != sectionRanges_.end() && + section->second.root == rowIndex && + section->second.last > section->second.root) + option.viewItemPosition = QStyleOptionViewItem::Beginning; + if (!option.rect.intersects(event->rect())) + continue; + if (selectionModel() && + selectionModel()->isSelected(model_->index(rowIndex))) + option.state |= QStyle::State_Selected; + static_cast(itemDelegate()) + ->paintCard(&painter, option, model_->index(rowIndex), + rowCollapsed(*row)); + } + } + } if (hasFocus() && currentIndex().isValid()) { QStyleOptionFocusRect option; option.initFrom(this); @@ -1368,7 +2031,7 @@ void ConversationView::resizeEvent(QResizeEvent *event) { const int height = measureCard(card, rowWidth(*row)); heightCache_.insert_or_assign(key, HeightRecord{rowWidth(*row), height}); static_cast(heights_.setHeight(static_cast(index.row()), - height + CardSpacing)); + height + rowSpacing(index.row()))); } updateScrollRange(); if (follow) diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index 64385cf..fb230a1 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -23,6 +23,7 @@ class QPushButton; class QResizeEvent; class QTimer; class QVariantAnimation; +class QMouseEvent; class QWheelEvent; namespace codexui::codex::middle { @@ -108,6 +109,10 @@ class ConversationView final : public QAbstractItemView { void updateGeometries() override; void scrollContentsBy(int dx, int dy) override; bool eventFilter(QObject *watched, QEvent *event) override; + void currentChanged(const QModelIndex ¤t, + const QModelIndex &previous) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; void paintEvent(QPaintEvent *event) override; void resizeEvent(QResizeEvent *event) override; void wheelEvent(QWheelEvent *event) override; @@ -139,6 +144,13 @@ class ConversationView final : public QAbstractItemView { bool activeTurn = false; }; + struct SectionRange { + int first = -1; + int last = -1; + int root = -1; + bool active = false; + }; + [[nodiscard]] bool reconcileOwned(ConversationSnapshot snapshot); [[nodiscard]] std::optional applyCardPresentationOwned(VisibleCardData card); @@ -154,8 +166,13 @@ class ConversationView final : public QAbstractItemView { [[nodiscard]] bool applyWheel(QWheelEvent *event); void rebuildHeightIndex(); + void rebuildSectionRanges(); [[nodiscard]] int estimatedCardHeight(const VisibleCardData &card) const; [[nodiscard]] int rowWidth(const ConversationItemModel::Row &row) const; + [[nodiscard]] bool + rowUsesPassiveDelegate(const ConversationItemModel::Row &row) const; + [[nodiscard]] bool rowCollapsed(const ConversationItemModel::Row &row) const; + [[nodiscard]] int rowSpacing(int row) const; [[nodiscard]] QRect rowRect(int row) const; [[nodiscard]] int measureCard(ConversationCard *card, int width) const; [[nodiscard]] bool updateMeasuredHeight(int row, int cardHeight, @@ -166,7 +183,8 @@ class ConversationView final : public QAbstractItemView { void updateMaterialization(bool preserveAnchor = true); [[nodiscard]] std::pair materializationRows() const; - [[nodiscard]] ConversationCard *materializeRow(int row); + [[nodiscard]] ConversationCard *materializeRow(int row, + bool forInteraction = false); void releaseUnneededCards(int firstRow, int lastRow); void releaseCard(const std::string &key, ConversationCard *card); void releaseAllCards(); @@ -207,6 +225,7 @@ class ConversationView final : public QAbstractItemView { std::unordered_map stagedCards_; std::unordered_map stagedHeights_; std::unordered_map heightCache_; + std::unordered_map sectionRanges_; std::unordered_map cardCollapsedStates_; std::unordered_map commandOutputStates_; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 25b0ab0..2bfa1aa 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -247,6 +247,7 @@ QString applicationStyleSheet() { QFrame[messageRole="user"] QLabel[kind="title"] { color: #415882; } QFrame[messageRole="user"][nestedConversationCard="true"] { background: #eaf8f7; border-color: #9bdcdb; } QFrame[messageRole="user"][nestedConversationCard="true"] QLabel[kind="title"] { color: #0d6565; } + QFrame[virtualTurnRoot="true"] { background: transparent; border: none; } QFrame[messageRole="agent"][messagePhase="final"] { background: #f4f3fd; border: 1px solid #cec7f6; border-radius: 8px; } QFrame[messageRole="agent"][messagePhase="final"] QLabel[kind="title"] { color: #59507f; } QFrame[messageRole="agent"][messagePhase="update"] { background: #f9f4ea; border: 1px solid #e1cb9d; border-radius: 8px; } diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 54ba202..9b11414 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -90,7 +91,7 @@ bool viewportProportionalFoundation() { const int initialWidgets = view.materializedCardCount(); result &= expect(view.conversationModel()->rowCount() == 10'000, "the item model indexes all canonical rows"); - result &= expect(initialWidgets > 0 && initialWidgets <= 48, + result &= expect(initialWidgets <= 48, "initial QWidget count is bounded by the viewport"); result &= expect(view.findChildren().size() <= 48, "history has no placeholder or hidden QWidget per row"); @@ -149,7 +150,6 @@ bool viewportProportionalFoundation() { "offscreen delta performs no QWidget construction"); const auto visibleIdentity = firstVisible(view); - ConversationCard *visible = materializedCard(view, visibleIdentity.first); const QModelIndex visibleIndex = view.conversationModel()->indexForStableKey(visibleIdentity.first); VisibleCardData visibleUpdate = @@ -158,15 +158,19 @@ bool viewportProportionalFoundation() { std::string(240, 'x'); const qulonglong offscreenBefore = view.property("targetedOffscreenCardUpdates").toULongLong(); + const qulonglong visibleConstructionsBefore = + view.property("conversationCardConstructions").toULongLong(); const auto visibleImpact = view.applyCardPresentation(std::move(visibleUpdate)); settle(); result &= expect( - visible && materializedCard(view, visibleIdentity.first) == visible && + materializedCard(view, visibleIdentity.first) == nullptr && visibleImpact == PresentationImpact::GeometryChanged && view.property("targetedOffscreenCardUpdates").toULongLong() == - offscreenBefore, - "one visible stream update mutates only its retained row"); + offscreenBefore && + view.property("conversationCardConstructions").toULongLong() == + visibleConstructionsBefore, + "one visible stream update invalidates only its passive delegate row"); result &= expect(firstVisible(view).second == visibleIdentity.second, "visible height change preserves the exact painted anchor"); return result; @@ -218,14 +222,70 @@ bool atomicPagingAndFollowingArrival() { return result; } +bool virtualTurnSurfaceAndInteractivePromotion() { + ConversationSnapshot snapshot; + snapshot.threadId = "turn-surface"; + VisibleCardData root{AuthoritativeItemKey{"turn-surface", "turn", "root"}, + CardKind::UserMessage, + "turn-surface", + "turn", + "root", + UserMessageData{"Question", {}}}; + VisibleCardData nested{AuthoritativeItemKey{"turn-surface", "turn", "answer"}, + CardKind::AgentMessage, + "turn-surface", + "turn", + "answer", + AgentMessageData{"Answer", true}}; + snapshot.sections.push_back( + {"turn-section", "turn", {root, nested}, root.key}); + + ConversationView view; + view.resize(820, 600); + view.show(); + bool result = + expect(view.reconcile(snapshot), "virtual Turn/You fixture reconciles"); + settle(); + const QModelIndex rootIndex = view.conversationModel()->index(0); + const QModelIndex nestedIndex = view.conversationModel()->index(1); + const QRect rootRect = view.visualRect(rootIndex); + const QRect nestedRect = view.visualRect(nestedIndex); + result &= expect(rootRect.left() == 0 && nestedRect.left() == 12 && + nestedRect.width() == rootRect.width() - 24 && + nestedRect.top() > rootRect.bottom(), + "flat rows retain the established nested turn geometry"); + const QImage painted = view.viewport()->grab().toImage(); + const int sampleY = + std::clamp(rootRect.bottom() + 3, 0, std::max(0, painted.height() - 1)); + const QColor turnSurface = painted.pixelColor(4, sampleY); + result &= expect(turnSurface.blue() > turnSurface.red(), + "the view paints the continuous blue You turn enclosure"); + + const QPoint hover = rootRect.center(); + QMouseEvent move(QEvent::MouseMove, QPointF(hover), QPointF(hover), + view.viewport()->mapToGlobal(hover), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &move); + settle(); + ConversationCard *promoted = materializedCard(view, stableKey(root.key)); + result &= expect(promoted && promoted->property("virtualTurnRoot").toBool() && + promoted->parentWidget() == view.viewport(), + "hover promotes only the interactive root fragment to a " + "real viewport editor"); + result &= expect(view.materializedCardCount() == 1, + "interactive promotion remains row-local and bounded"); + return result; +} + } // namespace } // namespace codexui::codex::middle int main(int argc, char **argv) { QApplication application(argc, argv); using namespace codexui::codex::middle; - const bool result = - viewportProportionalFoundation() && atomicPagingAndFollowingArrival(); + const bool result = viewportProportionalFoundation() && + atomicPagingAndFollowingArrival() && + virtualTurnSurfaceAndInteractivePromotion(); if (result) std::cout << "Conversation virtualization tests passed\n"; return result ? EXIT_SUCCESS : EXIT_FAILURE; From d58020d677dbb53346100a4ad7aced1904566c3e Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 18:29:48 +0200 Subject: [PATCH 05/39] Preserve virtualized conversation interactions --- src/codex/ShellWidget.cpp | 231 ++++++++------- src/codex/middle/ConversationItemModel.cpp | 72 ++++- src/codex/middle/ConversationView.cpp | 269 ++++++++++++++++-- src/codex/middle/ConversationView.h | 27 ++ tests/codex/ConversationItemModelTest.cpp | 10 +- .../codex/ConversationVirtualizationTest.cpp | 182 +++++++++++- 6 files changed, 660 insertions(+), 131 deletions(-) diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index e95a9cb..f159fe2 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -120,9 +120,10 @@ struct ThreadPaneRoute { std::vector rows; }; -ThreadPaneRoute threadPaneRoute( - const nodegraph::GraphChanged &change, const nodegraph::NodeGraph &graph, - middle::ThreadPane::SortCriterion sortCriterion) { +ThreadPaneRoute +threadPaneRoute(const nodegraph::GraphChanged &change, + const nodegraph::NodeGraph &graph, + middle::ThreadPane::SortCriterion sortCriterion) { if (change.rescanRequired || containsKind(change, {nodegraph::NodeKind::Interaction}) || std::ranges::any_of(change.removed, [](const auto &node) { @@ -136,12 +137,20 @@ ThreadPaneRoute threadPaneRoute( nodegraph::NodeKind::Thread}) ? ThreadPaneRoute{true, true, {}} : ThreadPaneRoute{}; - constexpr std::array Fields{ - "name", "title", "cwd", - "workspace", "status", "createdAt", - "updatedAt", "recencyAt", "lastActivityAt", - "localActivityAt", "localPromptActivityAt", - "pendingInteractionCount", "hydrationState", "archived"}; + constexpr std::array Fields{"name", + "title", + "cwd", + "workspace", + "status", + "createdAt", + "updatedAt", + "recencyAt", + "lastActivityAt", + "localActivityAt", + "localPromptActivityAt", + "pendingInteractionCount", + "hydrationState", + "archived"}; ThreadPaneRoute route; for (const nodegraph::NodeRef &node : change.affected) { if (!node || !read->contains(node)) @@ -171,8 +180,8 @@ ThreadPaneRoute threadPaneRoute( fieldChanged(*read, node, "updatedAt", change.revision)) || (sortCriterion == middle::ThreadPane::SortCriterion::Recency && fieldChanged(*read, node, "recencyAt", change.revision)); - if (read->structureChangedRevision(node) == change.revision || sortChanged || - fieldChanged(*read, node, "archived", change.revision)) + if (read->structureChangedRevision(node) == change.revision || + sortChanged || fieldChanged(*read, node, "archived", change.revision)) return {true, true, {}}; route.affected = true; if (std::ranges::find(route.rows, node) == route.rows.end()) @@ -244,8 +253,8 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, return; } constexpr std::array Fields{ - "historyLoadedItemCount", "historyTotalItemCount", - "historyHasMore", "hasMore", "hydrationState"}; + "historyLoadedItemCount", "historyTotalItemCount", "historyHasMore", + "hasMore", "hydrationState"}; if (read->structureChangedRevision(node) == change.revision || std::ranges::any_of(Fields, [&](std::string_view field) { return fieldChanged(*read, node, field, change.revision); @@ -274,9 +283,9 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, if (!belongs) { const std::shared_ptr state = read->state(node); - for (const std::string_view field : { - std::string_view("protocolThreadId"), - std::string_view("threadId")}) { + for (const std::string_view field : + {std::string_view("protocolThreadId"), + std::string_view("threadId")}) { if (graphString(graphField(*state, field)) == selectedId) { belongs = true; break; @@ -344,8 +353,7 @@ bool inspectorAffected(const nodegraph::GraphChanged &change, if (!read->contains(selectedThread) || read->removed(selectedThread)) return true; const std::vector agentChildren = - read->related(selectedThread, - nodegraph::RelationKind::AgentChildThread); + read->related(selectedThread, nodegraph::RelationKind::AgentChildThread); const auto relevant = [&](const nodegraph::NodeRef &node) { if (!node) return false; @@ -430,12 +438,21 @@ bool shellChromeAffected(const nodegraph::GraphChanged &change, case nodegraph::NodeKind::Thread: { if (!selectedThread || node != selectedThread) return false; - constexpr std::array Fields{ - "name", "title", "cwd", "workspace", "status", - "hydrationState", "recoveryOnly", "lastActivityAt", - "recencyAt", "updatedAt", "localActivityAt", - "localPromptActivityAt", "settingsRevision", "settings", - "latestSettingsUpdate"}; + constexpr std::array Fields{"name", + "title", + "cwd", + "workspace", + "status", + "hydrationState", + "recoveryOnly", + "lastActivityAt", + "recencyAt", + "updatedAt", + "localActivityAt", + "localPromptActivityAt", + "settingsRevision", + "settings", + "latestSettingsUpdate"}; return read->statusChangedRevision(node) == change.revision || read->structureChangedRevision(node) == change.revision || std::ranges::any_of(Fields, [&](std::string_view field) { @@ -1020,8 +1037,8 @@ struct ShellWidget::Impl final { [[nodiscard]] bool sendRuntimeAction(nodegraph::RuntimeAction action, QString rejection); [[nodiscard]] nodegraph::NodeRef activeTurn() const; - [[nodiscard]] nodegraph::NodeRef - threadById(const std::string &id, bool *busy = nullptr) const; + [[nodiscard]] nodegraph::NodeRef threadById(const std::string &id, + bool *busy = nullptr) const; [[nodiscard]] std::optional pendingRequest(const std::string &requestKey = {}, bool *busy = nullptr); void hydrateSelectedThreadIfNeeded(nodegraph::NodeRef thread); @@ -1326,13 +1343,13 @@ void ShellWidget::Impl::connectUi() { if (auto read = session.nodeGraph().tryRead()) archived = graphBool(graphField(*read->state(thread), "archived")); else { - showNotice(QStringLiteral( - "Thread state is busy; no archive action was sent.")); + showNotice( + QStringLiteral("Thread state is busy; no archive action was sent.")); return; } - nodegraph::NodeAction action{ - thread, archived ? nodegraph::NodeActionKind::Unarchive - : nodegraph::NodeActionKind::Archive}; + nodegraph::NodeAction action{thread, + archived ? nodegraph::NodeActionKind::Unarchive + : nodegraph::NodeActionKind::Archive}; static_cast(sendNodeAction( std::move(action), QStringLiteral("Archive request was not admitted; try again."))); @@ -1417,12 +1434,11 @@ void ShellWidget::Impl::connectUi() { connect(restoreSidebarButton, &QPushButton::clicked, owner, [this] { middleRegion->showSidebar(true); }); - connect(restoreInspectorButton, &QPushButton::clicked, owner, - [this] { - middleRegion->showInspector(true); - pendingInspector = true; - schedulePaneCommit(); - }); + connect(restoreInspectorButton, &QPushButton::clicked, owner, [this] { + middleRegion->showInspector(true); + pendingInspector = true; + schedulePaneCommit(); + }); connect(requestButton, &QPushButton::clicked, owner, [this] { middleRegion->showInspector(true); middleRegion->inspector().tabs()->setCurrentIndex(3); @@ -1546,9 +1562,8 @@ bool ShellWidget::Impl::refreshConversation() { return false; const std::string &threadId = boundGraphThread->id().canonical; ConversationHistoryWindow &history = conversationHistory[threadId]; - const bool following = - middleRegion->conversation().modeForThread(threadId) == - middle::ConversationView::Mode::Following; + const bool following = middleRegion->conversation().modeForThread(threadId) == + middle::ConversationView::Mode::Following; if (!following && info->authoritativeItemCount > history.lastAuthoritativeCount) { history.effective += @@ -1604,8 +1619,7 @@ bool ShellWidget::Impl::refreshInspector() { if (!info) return false; if (!info->readyForDisplay && presentedGraphThread && - presentedGraphThread != inspectorThread && - !info->hydrationFailed) + presentedGraphThread != inspectorThread && !info->hydrationFailed) return true; if (!info->readyForDisplay) inspectorThread.reset(); @@ -1626,8 +1640,8 @@ bool ShellWidget::Impl::refreshInspector() { projection = ui::InspectorProjection::Requests; break; case 4: - if (auto *infoStack = pane.findChild( - QStringLiteral("infoStack")); + if (auto *infoStack = + pane.findChild(QStringLiteral("infoStack")); infoStack && infoStack->currentIndex() != 0) projection = ui::InspectorProjection::State; break; @@ -1696,17 +1710,16 @@ void ShellWidget::Impl::commitPendingPanes() { } } if (!pendingConversation && !pendingConversationItems.empty()) { - std::vector items = - std::move(pendingConversationItems); + std::vector items = std::move(pendingConversationItems); pendingConversationItems.clear(); bool requiresStructuralReconcile = false; const auto options = middleRegion->conversation().presentationOptions(); for (const nodegraph::NodeRef &item : items) { - const auto card = uiAdapter.card( - boundGraphThread, item, - {options.showReasoning, options.showCodexUpdates}); - if (!card || - !middleRegion->conversation().applyCardPresentation(*card)) { + auto card = + uiAdapter.card(boundGraphThread, item, + {options.showReasoning, options.showCodexUpdates}); + if (!card || !middleRegion->conversation().applyCardPresentation( + std::move(*card))) { requiresStructuralReconcile = true; break; } @@ -1748,8 +1761,8 @@ void ShellWidget::Impl::commitPendingPanes() { render(); } if (retry || pendingThreadPane || !pendingThreadRows.empty() || - pendingConversation || - !pendingConversationItems.empty() || pendingInspector || pendingChrome) + pendingConversation || !pendingConversationItems.empty() || + pendingInspector || pendingChrome) schedulePaneCommit(); } @@ -1811,8 +1824,9 @@ void ShellWidget::Impl::handleGraphChanged( break; } } - const ThreadPaneRoute threads = threadPaneRoute( - change, session.nodeGraph(), middleRegion->threads().currentSortCriterion()); + const ThreadPaneRoute threads = + threadPaneRoute(change, session.nodeGraph(), + middleRegion->threads().currentSortCriterion()); if (threads.structural) { pendingThreadPane = true; pendingThreadRows.clear(); @@ -1831,11 +1845,10 @@ void ShellWidget::Impl::handleGraphChanged( pendingConversationItems.end()) pendingConversationItems.push_back(item); } - pendingInspector = - pendingInspector || - inspectorAffected(change, session.nodeGraph(), boundGraphThread, - inspectorDependency) || - stagedPresentationInvalidated; + pendingInspector = pendingInspector || + inspectorAffected(change, session.nodeGraph(), + boundGraphThread, inspectorDependency) || + stagedPresentationInvalidated; pendingChrome = pendingChrome || updateChrome; if (change.rescanRequired || containsKind(change, {nodegraph::NodeKind::Thread})) @@ -1865,17 +1878,19 @@ void ShellWidget::Impl::handleGraphChanged( if (!change.removed.empty()) commitPendingPanes(); else if (pendingThreadPane || !pendingThreadRows.empty() || - pendingConversation || - !pendingConversationItems.empty() || pendingInspector || - pendingChrome) + pendingConversation || !pendingConversationItems.empty() || + pendingInspector || pendingChrome) schedulePaneCommit(); - const bool selectedChanged = !graphPanesBound || change.rescanRequired || + const bool selectedChanged = + !graphPanesBound || change.rescanRequired || (!boundGraphThread && !selectedGraphThreadId.empty() && - std::ranges::any_of(change.affected, [this](const auto &node) { - return node && node->id().kind == nodegraph::NodeKind::Thread && - node->id().canonical == selectedGraphThreadId; - })) || + std::ranges::any_of( + change.affected, + [this](const auto &node) { + return node && node->id().kind == nodegraph::NodeKind::Thread && + node->id().canonical == selectedGraphThreadId; + })) || std::ranges::any_of(change.removed, [this](const auto &node) { return node && node->id().kind == nodegraph::NodeKind::Thread && node->id().canonical == selectedGraphThreadId; @@ -2241,8 +2256,8 @@ nodegraph::NodeRef ShellWidget::Impl::activeTurn() const { return {}; } -nodegraph::NodeRef -ShellWidget::Impl::threadById(const std::string &id, bool *busy) const { +nodegraph::NodeRef ShellWidget::Impl::threadById(const std::string &id, + bool *busy) const { if (busy) *busy = false; if (id.empty() || id == DraftThreadId) @@ -2393,22 +2408,21 @@ void ShellWidget::Impl::render() { const std::optional settingsRevision = graphInteger(graphField(*state, "settingsRevision")); if (settingsRevision && *settingsRevision >= 0) { - values.settingsRevision = - static_cast(*settingsRevision); + values.settingsRevision = static_cast(*settingsRevision); } else { - for (const std::string_view field : { - std::string_view("model"), std::string_view("effort"), - std::string_view("reasoningEffort"), - std::string_view("personality"), std::string_view("sandbox"), - std::string_view("sandboxPolicy"), - std::string_view("approvalPolicy"), - std::string_view("approvalsReviewer"), std::string_view("cwd"), - std::string_view("activePermissionProfile"), - std::string_view("serviceTier"), std::string_view("summary"), - std::string_view("collaborationMode")}) - values.settingsRevision = std::max( - values.settingsRevision, - read->fieldChangedRevision(selected, field)); + for (const std::string_view field : + {std::string_view("model"), std::string_view("effort"), + std::string_view("reasoningEffort"), + std::string_view("personality"), std::string_view("sandbox"), + std::string_view("sandboxPolicy"), + std::string_view("approvalPolicy"), + std::string_view("approvalsReviewer"), std::string_view("cwd"), + std::string_view("activePermissionProfile"), + std::string_view("serviceTier"), std::string_view("summary"), + std::string_view("collaborationMode")}) + values.settingsRevision = + std::max(values.settingsRevision, + read->fieldChangedRevision(selected, field)); } nodegraph::NodeRef turn = read->relatedAt(selected, nodegraph::RelationKind::ActiveTurn, 0); @@ -2486,10 +2500,10 @@ void ShellWidget::Impl::render() { if (!chromeChanged) return; - const bool replacementHydrating = - boundGraphThread && presentedGraphThread && - boundGraphThread != presentedGraphThread && - !values.conversationReadyForDisplay && !values.hydrationFailed; + const bool replacementHydrating = boundGraphThread && presentedGraphThread && + boundGraphThread != presentedGraphThread && + !values.conversationReadyForDisplay && + !values.hydrationFailed; attentionInteraction = values.attention ? values.attention->node : nodegraph::NodeRef{}; @@ -2499,12 +2513,11 @@ void ShellWidget::Impl::render() { ? QStringLiteral( "This thread preserves an unsent prompt. Restore it from " "the failed prompt card before continuing.") - : boundGraphThread && values.hydrationFailed - ? QStringLiteral( - "Thread loading failed. Select Reload to retry.") - : boundGraphThread && !values.conversationReadyForDisplay - ? QStringLiteral("Loading conversation…") - : boundGraphThread + : boundGraphThread && values.hydrationFailed + ? QStringLiteral("Thread loading failed. Select Reload to retry.") + : boundGraphThread && !values.conversationReadyForDisplay + ? QStringLiteral("Loading conversation…") + : boundGraphThread ? QStringLiteral("Conversation activity appears here.") : (newThreadDraft ? QStringLiteral("Send a message to create this thread.") @@ -2516,9 +2529,8 @@ void ShellWidget::Impl::render() { const QString status = text(values.status); const QString tone = values.activeTurn ? QStringLiteral("active") : QStringLiteral("neutral"); - middleRegion->setThreadHeading(text(values.title), - text(values.workspace), activity, status, - tone); + middleRegion->setThreadHeading(text(values.title), text(values.workspace), + activity, status, tone); } else { middleRegion->setThreadHeading(text(values.title), text(values.workspace)); @@ -2572,16 +2584,16 @@ void ShellWidget::Impl::renderStatus(const ShellChromeValues &status, connectAction->setEnabled(!status.connected); disconnectAction->setEnabled(status.connected); reconnectAction->setEnabled(status.connected); - const QString controllerText = - status.role == "controller" ? QStringLiteral("Release control") - : QStringLiteral("Claim control"); + const QString controllerText = status.role == "controller" + ? QStringLiteral("Release control") + : QStringLiteral("Claim control"); if (controllerButton->text() != controllerText) controllerButton->setText(controllerText); controllerButton->setEnabled(status.connected); - const QString requestText = QStringLiteral("Requests (%1)") - .arg(static_cast( - status.totalPending)); + const QString requestText = + QStringLiteral("Requests (%1)") + .arg(static_cast(status.totalPending)); if (requestButton->text() != requestText) requestButton->setText(requestText); requestButton->setVisible(status.totalPending != 0); @@ -2761,15 +2773,15 @@ bool ShellWidget::Impl::submitPrompt(QString prompt, const auto state = read->state(target); const std::string hydration = graphString(graphField(*state, "hydrationState")); - const nodegraph::NodeRef turn = read->relatedAt( - target, nodegraph::RelationKind::ActiveTurn, 0); + const nodegraph::NodeRef turn = + read->relatedAt(target, nodegraph::RelationKind::ActiveTurn, 0); const bool steeringKnownActiveTurn = turn && turn->id().kind == nodegraph::NodeKind::Turn && activeStatus(*read->state(turn)); if (!steeringKnownActiveTurn && (hydration == "loading" || hydration == "failed" || - (state->status == nodegraph::NodeStatus::NotLoaded && - hydration != "ready"))) { + (state->status == nodegraph::NodeStatus::NotLoaded && + hydration != "ready"))) { const std::string detail = graphString(graphField(*state, "hydrationError")); graphRejection = text( @@ -2797,8 +2809,7 @@ bool ShellWidget::Impl::submitPrompt(QString prompt, admitted = sendNodeAction( std::move(action), QStringLiteral("Your message was not sent; the worker queue is full.")); - } else if (visibleThreadId == DraftThreadId && - newThreadDraft) { + } else if (visibleThreadId == DraftThreadId && newThreadDraft) { nodegraph::RuntimeAction action; action.kind = nodegraph::RuntimeActionKind::CreateThread; action.correlation = creationDraftCorrelation; diff --git a/src/codex/middle/ConversationItemModel.cpp b/src/codex/middle/ConversationItemModel.cpp index 372523b..7a8f6ca 100644 --- a/src/codex/middle/ConversationItemModel.cpp +++ b/src/codex/middle/ConversationItemModel.cpp @@ -3,9 +3,12 @@ #include "codex/middle/ConversationItemModel.h" #include +#include #include #include +#include +#include #include #include @@ -38,6 +41,68 @@ QString cardLabel(CardKind kind) { return QStringLiteral("Activity"); } +QString boundedAccessibleText(std::string_view value) { + constexpr std::size_t MaximumAccessibleBytes = 8192; + const std::size_t length = std::min(value.size(), MaximumAccessibleBytes); + QString result = + QString::fromUtf8(value.data(), static_cast(length)); + if (length != value.size()) + result += QStringLiteral("…"); + return result; +} + +QString accessibleCardText(const VisibleCardData &card) { + QString detail = std::visit( + [](const auto &payload) -> QString { + using Payload = std::decay_t; + if constexpr (std::is_same_v) + return boundedAccessibleText(payload.text); + if constexpr (std::is_same_v) + return boundedAccessibleText(payload.text); + if constexpr (std::is_same_v) + return QStringLiteral("%1\n%2").arg( + boundedAccessibleText(payload.command), + boundedAccessibleText(payload.output)); + if constexpr (std::is_same_v) + return QStringLiteral("%1\n%2").arg( + boundedAccessibleText(payload.prompt), + boundedAccessibleText(payload.resultText)); + if constexpr (std::is_same_v) + return boundedAccessibleText(payload.summary); + if constexpr (std::is_same_v) { + QStringList paths; + for (const FileChangeData &change : payload.changes) + paths.push_back(boundedAccessibleText(change.path)); + return paths.join(QLatin1Char('\n')); + } + if constexpr (std::is_same_v) + return QStringLiteral("%1\n%2").arg( + boundedAccessibleText(payload.revisedPrompt), + boundedAccessibleText(payload.path)); + if constexpr (std::is_same_v) { + QStringList lines{boundedAccessibleText(payload.explanation)}; + for (const PlanStepData &step : payload.steps) + lines.push_back(boundedAccessibleText(step.text)); + if (!payload.legacyText.empty()) + lines.push_back(boundedAccessibleText(payload.legacyText)); + return lines.join(QLatin1Char('\n')); + } + if constexpr (std::is_same_v) + return boundedAccessibleText(payload.displayDetail); + if constexpr (std::is_same_v) + return boundedAccessibleText(payload.prompt); + return {}; + }, + card.payload); + constexpr qsizetype MaximumAccessibleCharacters = 8192; + if (detail.size() > MaximumAccessibleCharacters) { + detail.truncate(MaximumAccessibleCharacters); + detail += QStringLiteral("…"); + } + const QString label = cardLabel(card.kind); + return detail.isEmpty() ? label : label + QStringLiteral("\n") + detail; +} + bool compatible(const VisibleCardData &before, const VisibleCardData &after) noexcept { return before.key == after.key && @@ -60,8 +125,9 @@ QVariant ConversationItemModel::data(const QModelIndex &index, int role) const { return {}; switch (role) { case Qt::DisplayRole: - case Qt::AccessibleTextRole: return cardLabel(value->card.kind); + case Qt::AccessibleTextRole: + return accessibleCardText(value->card); case StableKeyRole: return QString::fromStdString(value->stableKey); case ThreadIdRole: @@ -426,8 +492,10 @@ void ConversationItemModel::updateRow(int rowIndex, Row replacement) { if (before.activeTurn != replacement.activeTurn) roles.push_back(ActiveTurnRole); if (before.card.payload != replacement.card.payload || - before.card.activeWork != replacement.card.activeWork) + before.card.activeWork != replacement.card.activeWork) { roles.push_back(PresentationRole); + roles.push_back(Qt::AccessibleTextRole); + } before = std::move(replacement); if (roles.empty()) roles.push_back(PresentationRole); diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 13ee17b..82b0979 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -961,6 +961,17 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { return PresentationImpact::None; const bool wasPresented = before->presented; + const Anchor presentationAnchor = captureAnchor(); + const bool followedBefore = mode_ == Mode::Following; + const std::string sectionKey = before->sectionKey; + std::optional oldSection; + if (const auto found = sectionRanges_.find(sectionKey); + found != sectionRanges_.end()) + oldSection = found->second; + QRect presentationDamage = rowRect(index.row()); + if (oldSection && oldSection->root >= 0 && oldSection->last >= 0) + presentationDamage = presentationDamage.united(rowRect(oldSection->root)) + .united(rowRect(oldSection->last)); const bool becomingAuthoritative = before->card.kind == CardKind::LocalPrompt && card.kind == CardKind::UserMessage && card.target; @@ -973,7 +984,9 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { if (visibleCard) { if (!visibleCard->canApply(card)) return std::nullopt; + captureCardInteractionState(key, visibleCard, false); impact = visibleCard->applyPresentation(card); + restoreCardInteractionState(key, visibleCard); } const ConversationItemModel::CardUpdateResult result = @@ -987,22 +1000,97 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { const ConversationItemModel::Row *after = model_->row(index.row()); const bool presentationChanged = after && after->presented != wasPresented; if (presentationChanged) { - if (after->presented) { + heightCache_.erase(key); + updateSectionRangeForPresentationChange(index.row(), wasPresented); + const auto nextSectionFound = sectionRanges_.find(sectionKey); + const SectionRange *nextSection = nextSectionFound == sectionRanges_.end() + ? nullptr + : &nextSectionFound->second; + + std::unordered_set affectedRows{index.row()}; + if (oldSection) { + affectedRows.insert(oldSection->root); + affectedRows.insert(oldSection->last); + } + if (nextSection) { + affectedRows.insert(nextSection->root); + affectedRows.insert(nextSection->last); + } + affectedRows.erase(-1); + + for (const int affectedRow : affectedRows) { + const ConversationItemModel::Row *affected = model_->row(affectedRow); + if (!affected || affected->sectionKey != sectionKey) + continue; + if (!affected->presented) { + static_cast( + heights_.setHeight(static_cast(affectedRow), 0)); + continue; + } + + const int previousExtent = + heights_.height(static_cast(affectedRow)); + int cardHeight = 0; + if (previousExtent > 0) { + cardHeight = + std::max(1, previousExtent - + rowSpacing(affectedRow, + oldSection ? &*oldSection : nullptr)); + } else if (const auto cached = heightCache_.find(affected->stableKey); + cached != heightCache_.end() && + cached->second.width == rowWidth(*affected)) { + cardHeight = cached->second.height; + } else { + cardHeight = estimatedCardHeight(affected->card); + } static_cast(heights_.setHeight( - static_cast(index.row()), - estimatedCardHeight(after->card) + rowSpacing(index.row()))); - } else { + static_cast(affectedRow), + std::max(1, cardHeight) + rowSpacing(affectedRow, nextSection))); + } + + if (!after->presented && visibleCard) { + materializedCards_.erase(key); + releaseCard(key, visibleCard); + visibleCard = nullptr; + } + + for (const int affectedRow : affectedRows) { + const ConversationItemModel::Row *affected = model_->row(affectedRow); + if (!affected || !affected->presented || !affected->turnRoot) + continue; + ConversationCard *rootCard = cardForStableKey(affected->stableKey); + if (!rootCard) + continue; + configureCardForRow(rootCard, *affected); + const int height = measureCard(rootCard, rowWidth(*affected)); + heightCache_.insert_or_assign(affected->stableKey, + HeightRecord{rowWidth(*affected), height}); static_cast( - heights_.setHeight(static_cast(index.row()), 0)); - if (visibleCard) { - materializedCards_.erase(key); - releaseCard(key, visibleCard); - visibleCard = nullptr; - } + heights_.setHeight(static_cast(affectedRow), + height + rowSpacing(affectedRow, nextSection))); } - rebuildSectionRanges(); + + impact = PresentationImpact::GeometryChanged; + incrementProperty(this, "conversationLocalGeometryPasses"); + setProperty("conversationHeightIndexUpdateSteps", + static_cast(heights_.lastUpdateSteps())); updateScrollRange(); - updateMaterialization(true); + if (followedBefore) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(presentationAnchor); + updateMaterialization(false); + if (followedBefore) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(presentationAnchor); + if (nextSection && nextSection->root >= 0 && nextSection->last >= 0) + presentationDamage = presentationDamage.united(rowRect(nextSection->root)) + .united(rowRect(nextSection->last)); + const int damageTop = + std::clamp(presentationDamage.top(), 0, viewport()->height()); + viewport()->update(QRect(0, damageTop, viewport()->width(), + viewport()->height() - damageTop)); } else if (visibleCard && impact == PresentationImpact::GeometryChanged) { const int height = measureCard(visibleCard, rowWidth(*after)); static_cast(updateMeasuredHeight(index.row(), height, true)); @@ -1109,6 +1197,57 @@ void ConversationView::rebuildSectionRanges() { range.root = rowIndex; range.active = range.active || (row->turnRoot && row->activeTurn); } + incrementProperty(this, "conversationSectionRangeRebuilds"); +} + +void ConversationView::updateSectionRangeForPresentationChange( + int rowIndex, bool wasPresented) { + const ConversationItemModel::Row *changed = model_->row(rowIndex); + if (!changed || changed->presented == wasPresented) + return; + + if (changed->presented) { + SectionRange &range = sectionRanges_[changed->sectionKey]; + if (range.first < 0 || rowIndex < range.first) + range.first = rowIndex; + if (range.last < 0 || rowIndex > range.last) + range.last = rowIndex; + if (changed->turnRoot) + range.root = rowIndex; + range.active = range.active || (changed->turnRoot && changed->activeTurn); + return; + } + + // Hiding a targeted row is uncommon (global visibility changes use the + // structural rebuild path). Recompute only its canonical turn, never the + // loaded conversation. + SectionRange replacement; + int first = rowIndex; + while (first > 0) { + const ConversationItemModel::Row *candidate = model_->row(first - 1); + if (!candidate || candidate->sectionKey != changed->sectionKey) + break; + --first; + } + for (int candidateIndex = first; candidateIndex < model_->rowCount(); + ++candidateIndex) { + const ConversationItemModel::Row *candidate = model_->row(candidateIndex); + if (!candidate || candidate->sectionKey != changed->sectionKey) + break; + if (!candidate->presented) + continue; + if (replacement.first < 0) + replacement.first = candidateIndex; + replacement.last = candidateIndex; + if (candidate->turnRoot) + replacement.root = candidateIndex; + replacement.active = + replacement.active || (candidate->turnRoot && candidate->activeTurn); + } + if (replacement.first < 0) + sectionRanges_.erase(changed->sectionKey); + else + sectionRanges_.insert_or_assign(changed->sectionKey, replacement); } int ConversationView::rowSpacing(int rowIndex) const { @@ -1116,12 +1255,17 @@ int ConversationView::rowSpacing(int rowIndex) const { if (!row) return CardSpacing; const auto found = sectionRanges_.find(row->sectionKey); - if (found == sectionRanges_.end() || found->second.root < 0 || - found->second.last <= found->second.root) + return rowSpacing(rowIndex, + found == sectionRanges_.end() ? nullptr : &found->second); +} + +int ConversationView::rowSpacing(int rowIndex, + const SectionRange *section) const { + if (!section || section->root < 0 || section->last <= section->root) return CardSpacing; - if (rowIndex == found->second.root) + if (rowIndex == section->root) return 14; - if (rowIndex == found->second.last) + if (rowIndex == section->last) return CardSpacing + 10; return CardSpacing; } @@ -1302,6 +1446,7 @@ ConversationCard *ConversationView::createCard(const VisibleCardData &data, if (const auto output = commandOutputStates_.find(key); output != commandOutputStates_.end()) card->restoreCommandOutputScrollState(output->second); + restoreCardInteractionState(key, card); card->installEventFilter(this); for (QWidget *child : card->findChildren()) child->installEventFilter(this); @@ -1382,6 +1527,7 @@ ConversationCard *ConversationView::materializeRow(int rowIndex, materializedCards_.emplace(row->stableKey, card); card->setGeometry(rowRect(rowIndex)); card->show(); + restoreCardInteractionState(row->stableKey, card); incrementProperty(this, "conversationRowsMaterialized"); return card; } @@ -1390,6 +1536,7 @@ void ConversationView::releaseCard(const std::string &key, ConversationCard *card) { if (!card) return; + captureCardInteractionState(key, card, true); cardCollapsedStates_.insert_or_assign(key, card->isCollapsed()); if (const auto state = card->commandOutputScrollState()) commandOutputStates_.insert_or_assign(key, *state); @@ -1398,6 +1545,61 @@ void ConversationView::releaseCard(const std::string &key, incrementProperty(this, "conversationRowsReleased"); } +void ConversationView::captureCardInteractionState( + const std::string &key, ConversationCard *card, + bool preserveExistingWhenEmpty) { + if (!card) + return; + CardInteractionState state; + const auto labels = card->findChildren(); + for (int ordinal = 0; ordinal < labels.size(); ++ordinal) { + QLabel *label = labels.at(ordinal); + if (!label->hasSelectedText()) + continue; + state.labels.push_back({ordinal, label->selectionStart(), + static_cast(label->selectedText().size())}); + } + const auto edits = card->findChildren(); + for (int ordinal = 0; ordinal < edits.size(); ++ordinal) { + const QTextCursor cursor = edits.at(ordinal)->textCursor(); + if (!cursor.hasSelection()) + continue; + state.edits.push_back({ordinal, cursor.position(), cursor.anchor()}); + } + if (!state.labels.empty() || !state.edits.empty()) + cardInteractionStates_.insert_or_assign(key, std::move(state)); + else if (!preserveExistingWhenEmpty) + cardInteractionStates_.erase(key); +} + +void ConversationView::restoreCardInteractionState(const std::string &key, + ConversationCard *card) { + if (!card) + return; + const auto retained = cardInteractionStates_.find(key); + if (retained == cardInteractionStates_.end()) + return; + const auto labels = card->findChildren(); + for (const LabelSelection &selection : retained->second.labels) { + if (selection.ordinal < 0 || selection.ordinal >= labels.size()) + continue; + labels.at(selection.ordinal) + ->setSelection(selection.start, selection.length); + } + const auto edits = card->findChildren(); + for (const EditSelection &selection : retained->second.edits) { + if (selection.ordinal < 0 || selection.ordinal >= edits.size()) + continue; + QTextEdit *edit = edits.at(selection.ordinal); + const int maximum = std::max(0, edit->document()->characterCount() - 1); + QTextCursor cursor(edit->document()); + cursor.setPosition(std::clamp(selection.anchor, 0, maximum)); + cursor.setPosition(std::clamp(selection.position, 0, maximum), + QTextCursor::KeepAnchor); + edit->setTextCursor(cursor); + } +} + void ConversationView::releaseUnneededCards(int firstRow, int lastRow) { std::unordered_set retainedKeys; if (firstRow >= 0 && lastRow >= firstRow) { @@ -1837,6 +2039,11 @@ bool ConversationView::eventFilter(QObject *watched, QEvent *event) { if (index.isValid()) setCurrentIndex(index); } + if (card && event->type() == QEvent::FocusOut) { + const std::string key = + card->property("conversationAnchorKey").toString().toStdString(); + captureCardInteractionState(key, card, false); + } if (card && event->type() == QEvent::LayoutRequest && !applying_ && !materializing_) { const std::string key = @@ -1873,6 +2080,18 @@ void ConversationView::currentChanged(const QModelIndex ¤t, } void ConversationView::mouseMoveEvent(QMouseEvent *event) { + if (forwardedMouseTarget_ && event->buttons() != Qt::NoButton) { + QWidget *target = forwardedMouseTarget_; + const QPoint viewportPosition = event->position().toPoint(); + const QPoint localPosition = target->mapFrom(viewport(), viewportPosition); + QMouseEvent forwarded(event->type(), QPointF(localPosition), + event->scenePosition(), event->globalPosition(), + event->button(), event->buttons(), event->modifiers(), + event->pointingDevice()); + QApplication::sendEvent(target, &forwarded); + event->setAccepted(forwarded.isAccepted()); + return; + } const QModelIndex index = indexAt(event->position().toPoint()); if (index.isValid() && !cardForStableKey(index.data(ConversationItemModel::StableKeyRole) @@ -1935,6 +2154,24 @@ void ConversationView::mousePressEvent(QMouseEvent *event) { event->button(), event->buttons(), event->modifiers(), event->pointingDevice()); QApplication::sendEvent(target, &forwarded); + forwardedMouseTarget_ = target; + event->setAccepted(forwarded.isAccepted()); +} + +void ConversationView::mouseReleaseEvent(QMouseEvent *event) { + if (!forwardedMouseTarget_) { + QAbstractItemView::mouseReleaseEvent(event); + return; + } + QWidget *target = forwardedMouseTarget_; + forwardedMouseTarget_.clear(); + const QPoint viewportPosition = event->position().toPoint(); + const QPoint localPosition = target->mapFrom(viewport(), viewportPosition); + QMouseEvent forwarded(event->type(), QPointF(localPosition), + event->scenePosition(), event->globalPosition(), + event->button(), event->buttons(), event->modifiers(), + event->pointingDevice()); + QApplication::sendEvent(target, &forwarded); event->setAccepted(forwarded.isAccepted()); } diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index fb230a1..d2bd93d 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -113,6 +113,7 @@ class ConversationView final : public QAbstractItemView { const QModelIndex &previous) override; void mouseMoveEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; void paintEvent(QPaintEvent *event) override; void resizeEvent(QResizeEvent *event) override; void wheelEvent(QWheelEvent *event) override; @@ -151,6 +152,23 @@ class ConversationView final : public QAbstractItemView { bool active = false; }; + struct LabelSelection { + int ordinal = -1; + int start = -1; + int length = 0; + }; + + struct EditSelection { + int ordinal = -1; + int position = 0; + int anchor = 0; + }; + + struct CardInteractionState { + std::vector labels; + std::vector edits; + }; + [[nodiscard]] bool reconcileOwned(ConversationSnapshot snapshot); [[nodiscard]] std::optional applyCardPresentationOwned(VisibleCardData card); @@ -167,12 +185,14 @@ class ConversationView final : public QAbstractItemView { void rebuildHeightIndex(); void rebuildSectionRanges(); + void updateSectionRangeForPresentationChange(int row, bool wasPresented); [[nodiscard]] int estimatedCardHeight(const VisibleCardData &card) const; [[nodiscard]] int rowWidth(const ConversationItemModel::Row &row) const; [[nodiscard]] bool rowUsesPassiveDelegate(const ConversationItemModel::Row &row) const; [[nodiscard]] bool rowCollapsed(const ConversationItemModel::Row &row) const; [[nodiscard]] int rowSpacing(int row) const; + [[nodiscard]] int rowSpacing(int row, const SectionRange *section) const; [[nodiscard]] QRect rowRect(int row) const; [[nodiscard]] int measureCard(ConversationCard *card, int width) const; [[nodiscard]] bool updateMeasuredHeight(int row, int cardHeight, @@ -187,6 +207,11 @@ class ConversationView final : public QAbstractItemView { bool forInteraction = false); void releaseUnneededCards(int firstRow, int lastRow); void releaseCard(const std::string &key, ConversationCard *card); + void captureCardInteractionState(const std::string &key, + ConversationCard *card, + bool preserveExistingWhenEmpty); + void restoreCardInteractionState(const std::string &key, + ConversationCard *card); void releaseAllCards(); void layoutMaterializedCards(); void updateMaterializationProperties(); @@ -226,6 +251,7 @@ class ConversationView final : public QAbstractItemView { std::unordered_map stagedHeights_; std::unordered_map heightCache_; std::unordered_map sectionRanges_; + std::unordered_map cardInteractionStates_; std::unordered_map cardCollapsedStates_; std::unordered_map commandOutputStates_; @@ -250,6 +276,7 @@ class ConversationView final : public QAbstractItemView { bool materializing_ = false; bool structuralStagePassScheduled_ = false; bool committingStructuralStage_ = false; + QPointer forwardedMouseTarget_; }; } // namespace codexui::codex::middle diff --git a/tests/codex/ConversationItemModelTest.cpp b/tests/codex/ConversationItemModelTest.cpp index 7b78988..80d9f79 100644 --- a/tests/codex/ConversationItemModelTest.cpp +++ b/tests/codex/ConversationItemModelTest.cpp @@ -37,6 +37,7 @@ struct SignalLog { std::vector removed; std::vector moved; std::vector changed; + std::vector> changedRoles; int resets = 0; explicit SignalLog(ConversationItemModel &model) { @@ -55,8 +56,9 @@ struct SignalLog { }); QObject::connect(&model, &QAbstractItemModel::dataChanged, &model, [this](const QModelIndex &first, const QModelIndex &last, - const QList &) { + const QList &roles) { changed.push_back({first.row(), last.row()}); + changedRoles.push_back(roles); }); QObject::connect(&model, &QAbstractItemModel::modelReset, &model, [this] { ++resets; }); @@ -67,6 +69,7 @@ struct SignalLog { removed.clear(); moved.clear(); changed.clear(); + changedRoles.clear(); resets = 0; } }; @@ -144,7 +147,10 @@ bool testStableIdentityAndExactSignals() { require(model.updateCard(card("same-wire-id-b", second, "streamed")) == ConversationItemModel::CardUpdateResult::Changed && log.changed.size() == 1 && log.changed.front().first == 1 && - log.changed.front().last == 1, + log.changed.front().last == 1 && + log.changedRoles.front().contains( + ConversationItemModel::PresentationRole) && + log.changedRoles.front().contains(Qt::AccessibleTextRole), "one streamed card did not emit one exact dataChanged range"); result &= require(model.property("modelIndexRebuildCount").toULongLong() == indexRebuilds, diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 9b11414..e397c20 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -4,7 +4,10 @@ #include "codex/middle/ConversationView.h" #include +#include #include +#include +#include #include #include @@ -176,6 +179,60 @@ bool viewportProportionalFoundation() { return result; } +bool targetedVisibilityChangeIsLocal() { + ConversationView view; + view.resize(820, 600); + view.setPresentationOptions( + {.showReasoning = true, .showCodexUpdates = false}); + view.show(); + ConversationSnapshot snapshot = conversation(10'000); + constexpr int TargetRow = 100; + auto &initial = std::get( + snapshot.sections[TargetRow].cards.front().payload); + initial.finalAnswer = false; + bool result = expect(view.reconcile(std::move(snapshot)), + "a long thread with one filtered update reconciles"); + settle(); + const QModelIndex index = view.conversationModel()->index(TargetRow); + result &= expect(!index.data(ConversationItemModel::PresentedRole).toBool(), + "the non-final update begins filtered"); + + VisibleCardData finalAnswer = *view.conversationModel()->card(TargetRow); + std::get(finalAnswer.payload).finalAnswer = true; + const qulonglong sectionRebuilds = + view.property("conversationSectionRangeRebuilds").toULongLong(); + const qulonglong indexRebuilds = view.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong(); + const qulonglong constructions = + view.property("conversationCardConstructions").toULongLong(); + const auto impact = view.applyCardPresentation(finalAnswer); + settle(); + result &= expect( + impact == PresentationImpact::GeometryChanged && + index.data(ConversationItemModel::PresentedRole).toBool() && + view.property("conversationSectionRangeRebuilds").toULongLong() == + sectionRebuilds && + view.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() == indexRebuilds && + view.property("conversationCardConstructions").toULongLong() == + constructions && + view.property("conversationHeightIndexUpdateSteps").toULongLong() <= + 15, + "final-answer visibility updates only its row and logarithmic height " + "index"); + + const qulonglong commits = view.property("targetedCardCommits").toULongLong(); + result &= + expect(view.applyCardPresentation(std::move(finalAnswer)) == + PresentationImpact::None && + view.property("targetedCardCommits").toULongLong() == commits, + "repeated identical final state performs zero presentation " + "work"); + return result; +} + bool atomicPagingAndFollowingArrival() { ConversationView view; view.resize(820, 600); @@ -277,6 +334,127 @@ bool virtualTurnSurfaceAndInteractivePromotion() { return result; } +bool selectionFocusAndOneGesturePromotion() { + ConversationView view; + view.resize(820, 600); + view.show(); + ConversationSnapshot snapshot = conversation(200); + bool result = + expect(view.reconcile(snapshot), "interaction-state fixture reconciles"); + settle(); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderSingleStepSub); + const auto identity = firstVisible(view); + const QModelIndex index = + view.conversationModel()->indexForStableKey(identity.first); + const QPoint hover = view.visualRect(index).center(); + QMouseEvent move(QEvent::MouseMove, QPointF(hover), QPointF(hover), + view.viewport()->mapToGlobal(hover), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &move); + settle(); + ConversationCard *card = materializedCard(view, identity.first); + QLabel *body = nullptr; + if (card) { + for (QLabel *label : card->findChildren()) + if (label->property("markdownSource").isValid()) { + body = label; + break; + } + } + result &= expect(card && body, + "hover promotes selectable Markdown to its real card"); + if (!body) + return false; + body->setSelection(0, 6); + const QString selected = body->selectedText(); + + VisibleCardData streamed = *view.conversationModel()->card(index.row()); + std::get(streamed.payload).text += " streamed suffix"; + result &= expect(view.applyCardPresentation(std::move(streamed)).has_value(), + "streaming targets the promoted row"); + settle(); + result &= expect(body->selectedText() == selected, + "Markdown selection survives in-place streaming"); + body->setFocus(Qt::OtherFocusReason); + QKeyEvent copy(QEvent::KeyPress, Qt::Key_C, Qt::ControlModifier); + QApplication::sendEvent(body, ©); + result &= expect(QApplication::clipboard()->text() == selected, + "the promoted Markdown keeps native selection copying"); + + view.setFocus(Qt::OtherFocusReason); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->minimum()); + settle(); + result &= expect(materializedCard(view, identity.first) == nullptr, + "an unfocused editor is released outside bounded overscan"); + view.scrollTo(index, QAbstractItemView::PositionAtTop); + settle(); + const QPoint restoredHover = view.visualRect(index).center(); + QMouseEvent restoredMove(QEvent::MouseMove, QPointF(restoredHover), + QPointF(restoredHover), + view.viewport()->mapToGlobal(restoredHover), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &restoredMove); + settle(); + card = materializedCard(view, identity.first); + body = nullptr; + if (card) + for (QLabel *label : card->findChildren()) + if (label->property("markdownSource").isValid()) { + body = label; + break; + } + result &= + expect(body && body->selectedText() == selected, + "selection is restored after virtualized release and return"); + result &= expect( + index.data(Qt::AccessibleTextRole).toString().contains("streamed suffix"), + "the passive model exposes current card content to accessibility"); + + ConversationSnapshot foldedSnapshot; + foldedSnapshot.threadId = "folded-promotion"; + VisibleCardData reasoning{ + AuthoritativeItemKey{"folded-promotion", "turn", "reasoning"}, + CardKind::Reasoning, + "folded-promotion", + "turn", + "reasoning", + ReasoningData{"Expanded by the same pointer gesture"}}; + foldedSnapshot.sections.push_back( + {"folded-section", "turn", {reasoning}, std::nullopt}); + ConversationView foldedView; + foldedView.resize(620, 320); + foldedView.show(); + result &= expect(foldedView.reconcile(foldedSnapshot), + "folded delegate fixture reconciles"); + settle(); + const QRect foldedRect = + foldedView.visualRect(foldedView.conversationModel()->index(0)); + const QPoint disclosurePoint(foldedRect.right() - 16, foldedRect.top() + 22); + QMouseEvent press(QEvent::MouseButtonPress, QPointF(disclosurePoint), + QPointF(disclosurePoint), + foldedView.viewport()->mapToGlobal(disclosurePoint), + Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(foldedView.viewport(), &press); + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(disclosurePoint), + QPointF(disclosurePoint), + foldedView.viewport()->mapToGlobal(disclosurePoint), + Qt::LeftButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(foldedView.viewport(), &release); + settle(); + ConversationCard *expanded = + materializedCard(foldedView, stableKey(reasoning.key)); + result &= expect(expanded && !expanded->isCollapsed(), + "a press-triggered promotion preserves one-gesture " + "disclosure activation"); + foldedView.setFocus(Qt::TabFocusReason); + foldedView.setCurrentIndex(foldedView.conversationModel()->index(0)); + result &= + expect(foldedView.hasFocus() && foldedView.currentIndex().row() == 0, + "keyboard current-row focus remains visibly owned by the " + "item view"); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -284,8 +462,10 @@ int main(int argc, char **argv) { QApplication application(argc, argv); using namespace codexui::codex::middle; const bool result = viewportProportionalFoundation() && + targetedVisibilityChangeIsLocal() && atomicPagingAndFollowingArrival() && - virtualTurnSurfaceAndInteractivePromotion(); + virtualTurnSurfaceAndInteractivePromotion() && + selectionFocusAndOneGesturePromotion(); if (result) std::cout << "Conversation virtualization tests passed\n"; return result ? EXIT_SUCCESS : EXIT_FAILURE; From 42c05ad310ce00df792ee0022a4b737b36343fe3 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 19:10:06 +0200 Subject: [PATCH 06/39] Cut over conversation workflows to virtualization --- src/codex/middle/ConversationView.cpp | 173 +++++- src/codex/middle/ConversationView.h | 2 + tests/codex/ConversationCardsTest.cpp | 524 +++++++++--------- .../codex/ConversationVirtualizationTest.cpp | 36 +- tests/codex/EstablishedUiUxTest.cpp | 27 +- tests/codex/NodeGraphConversationUiTest.cpp | 72 ++- tests/codex/ShellIntegrationTest.cpp | 136 +++-- 7 files changed, 563 insertions(+), 407 deletions(-) diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 82b0979..ca3be70 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -649,6 +649,21 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { const bool changed = model_->reconcile(std::move(snapshot)); rebuildSectionRanges(); loadMore_->setVisible(model_->hasMore()); + if (model_->hasMore()) { + const std::size_t page = + model_->hiddenAuthoritativeItemCount() == 0 + ? AuthoritativeHistoryPageSize + : std::min(AuthoritativeHistoryPageSize, + model_->hiddenAuthoritativeItemCount()); + loadMore_->setText(QStringLiteral("Load %1 more activities") + .arg(static_cast(page))); + loadMore_->setToolTip( + model_->hiddenAuthoritativeItemCount() == 0 + ? QStringLiteral("Earlier activities are available") + : QStringLiteral("%1 earlier activities are retained") + .arg(static_cast( + model_->hiddenAuthoritativeItemCount()))); + } empty_->setVisible(model_->rowCount() == 0); std::vector removeKeys; @@ -656,17 +671,17 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { for (auto &[key, card] : materializedCards_) { const QModelIndex index = model_->indexForStableKey(key); const ConversationItemModel::Row *row = model_->row(index.row()); - if (!index.isValid() || !row || !row->presented || + if (!index.isValid() || !row || !rowPresented(index.row()) || !card->canApply(row->card)) { removeKeys.push_back(key); continue; } - if (card->data() != row->card) { - if (card->applyPresentation(row->card) == - PresentationImpact::GeometryChanged) - heightCache_.erase(key); - } + if (card->data() != row->card) + static_cast(card->applyPresentation(row->card)); configureCardForRow(card, *row); + const int measuredHeight = measureCard(card, rowWidth(*row)); + heightCache_.insert_or_assign( + key, HeightRecord{rowWidth(*row), measuredHeight}); } for (const std::string &key : removeKeys) { const auto found = materializedCards_.find(key); @@ -960,7 +975,7 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { if (before->card == card) return PresentationImpact::None; - const bool wasPresented = before->presented; + const bool wasPresented = rowPresented(index.row()); const Anchor presentationAnchor = captureAnchor(); const bool followedBefore = mode_ == Mode::Following; const std::string sectionKey = before->sectionKey; @@ -998,7 +1013,8 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { return PresentationImpact::None; const ConversationItemModel::Row *after = model_->row(index.row()); - const bool presentationChanged = after && after->presented != wasPresented; + const bool presentationChanged = + after && rowPresented(index.row()) != wasPresented; if (presentationChanged) { heightCache_.erase(key); updateSectionRangeForPresentationChange(index.row(), wasPresented); @@ -1022,7 +1038,7 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { const ConversationItemModel::Row *affected = model_->row(affectedRow); if (!affected || affected->sectionKey != sectionKey) continue; - if (!affected->presented) { + if (!rowPresented(affectedRow)) { static_cast( heights_.setHeight(static_cast(affectedRow), 0)); continue; @@ -1048,7 +1064,7 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { std::max(1, cardHeight) + rowSpacing(affectedRow, nextSection))); } - if (!after->presented && visibleCard) { + if (!rowPresented(index.row()) && visibleCard) { materializedCards_.erase(key); releaseCard(key, visibleCard); visibleCard = nullptr; @@ -1056,7 +1072,7 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { for (const int affectedRow : affectedRows) { const ConversationItemModel::Row *affected = model_->row(affectedRow); - if (!affected || !affected->presented || !affected->turnRoot) + if (!affected || !rowPresented(affectedRow) || !affected->turnRoot) continue; ConversationCard *rootCard = cardForStableKey(affected->stableKey); if (!rootCard) @@ -1182,12 +1198,34 @@ bool ConversationView::rowCollapsed( row.card.kind != CardKind::LocalPrompt; } +bool ConversationView::rowPresented(int rowIndex) const { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->presented) + return false; + if (!row->nested) + return true; + const auto root = sectionRootRows_.find(row->sectionKey); + if (root == sectionRootRows_.end()) + return true; + const ConversationItemModel::Row *rootRow = model_->row(root->second); + return !rootRow || !rowCollapsed(*rootRow); +} + void ConversationView::rebuildSectionRanges() { sectionRanges_.clear(); + sectionRootRows_.clear(); sectionRanges_.reserve(static_cast(model_->rowCount())); + sectionRootRows_.reserve(static_cast(model_->rowCount())); + for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row) + continue; + if (row->turnRoot) + sectionRootRows_.insert_or_assign(row->sectionKey, rowIndex); + } for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { const ConversationItemModel::Row *row = model_->row(rowIndex); - if (!row || !row->presented) + if (!row || !rowPresented(rowIndex)) continue; SectionRange &range = sectionRanges_[row->sectionKey]; if (range.first < 0) @@ -1203,10 +1241,10 @@ void ConversationView::rebuildSectionRanges() { void ConversationView::updateSectionRangeForPresentationChange( int rowIndex, bool wasPresented) { const ConversationItemModel::Row *changed = model_->row(rowIndex); - if (!changed || changed->presented == wasPresented) + if (!changed || rowPresented(rowIndex) == wasPresented) return; - if (changed->presented) { + if (rowPresented(rowIndex)) { SectionRange &range = sectionRanges_[changed->sectionKey]; if (range.first < 0 || rowIndex < range.first) range.first = rowIndex; @@ -1234,7 +1272,7 @@ void ConversationView::updateSectionRangeForPresentationChange( const ConversationItemModel::Row *candidate = model_->row(candidateIndex); if (!candidate || candidate->sectionKey != changed->sectionKey) break; - if (!candidate->presented) + if (!rowPresented(candidateIndex)) continue; if (replacement.first < 0) replacement.first = candidateIndex; @@ -1275,7 +1313,7 @@ void ConversationView::rebuildHeightIndex() { extents.reserve(static_cast(model_->rowCount())); for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { const ConversationItemModel::Row *row = model_->row(rowIndex); - if (!row || !row->presented) { + if (!row || !rowPresented(rowIndex)) { extents.push_back(0); continue; } @@ -1345,7 +1383,7 @@ void ConversationView::updateScrollRange() { QRect ConversationView::rowRect(int rowIndex) const { const ConversationItemModel::Row *row = model_->row(rowIndex); - if (!row || !row->presented || rowIndex < 0 || + if (!row || !rowPresented(rowIndex) || rowIndex < 0 || static_cast(rowIndex) >= heights_.size()) return {}; const int extent = heights_.height(static_cast(rowIndex)); @@ -1393,7 +1431,7 @@ int ConversationView::measureCard(ConversationCard *card, int width) const { bool ConversationView::updateMeasuredHeight(int rowIndex, int cardHeight, bool preserveAnchor) { const ConversationItemModel::Row *row = model_->row(rowIndex); - if (!row || !row->presented) + if (!row || !rowPresented(rowIndex)) return false; const Anchor anchor = preserveAnchor ? captureAnchor() : Anchor{}; const bool follow = mode_ == Mode::Following; @@ -1485,7 +1523,7 @@ void ConversationView::configureCardForRow( ConversationCard *ConversationView::materializeRow(int rowIndex, bool forInteraction) { const ConversationItemModel::Row *row = model_->row(rowIndex); - if (!row || !row->presented) + if (!row || !rowPresented(rowIndex)) return nullptr; if (ConversationCard *retained = cardForStableKey(row->stableKey)) return retained; @@ -1527,6 +1565,9 @@ ConversationCard *ConversationView::materializeRow(int rowIndex, materializedCards_.emplace(row->stableKey, card); card->setGeometry(rowRect(rowIndex)); card->show(); + if (const auto output = commandOutputStates_.find(row->stableKey); + output != commandOutputStates_.end()) + card->restoreCommandOutputScrollState(output->second); restoreCardInteractionState(row->stableKey, card); incrementProperty(this, "conversationRowsMaterialized"); return card; @@ -1605,7 +1646,8 @@ void ConversationView::releaseUnneededCards(int firstRow, int lastRow) { if (firstRow >= 0 && lastRow >= firstRow) { retainedKeys.reserve(static_cast(lastRow - firstRow + 1)); for (int rowIndex = firstRow; rowIndex <= lastRow; ++rowIndex) - if (const auto *row = model_->row(rowIndex); row && row->presented) + if (const auto *row = model_->row(rowIndex); + row && rowPresented(rowIndex)) retainedKeys.insert(row->stableKey); } @@ -1710,9 +1752,89 @@ void ConversationView::setCardCollapsed(const std::string &key, cardCollapsedStates_.insert_or_assign(key, collapsed); card->setCollapsed(collapsed); const ConversationItemModel::Row *row = model_->row(index.row()); - const int height = measureCard(card, rowWidth(*row)); - static_cast(updateMeasuredHeight(index.row(), height, false)); + if (!row) + return; + + if (row->turnRoot) { + SectionRange replacement; + int first = index.row(); + while (first > 0) { + const ConversationItemModel::Row *candidate = model_->row(first - 1); + if (!candidate || candidate->sectionKey != row->sectionKey) + break; + --first; + } + int last = first; + for (; last < model_->rowCount(); ++last) { + const ConversationItemModel::Row *candidate = model_->row(last); + if (!candidate || candidate->sectionKey != row->sectionKey) + break; + if (!rowPresented(last)) + continue; + if (replacement.first < 0) + replacement.first = last; + replacement.last = last; + if (candidate->turnRoot) + replacement.root = last; + replacement.active = replacement.active || + (candidate->turnRoot && candidate->activeTurn); + } + if (replacement.first < 0) + sectionRanges_.erase(row->sectionKey); + else + sectionRanges_.insert_or_assign(row->sectionKey, replacement); + + const SectionRange *section = nullptr; + if (const auto found = sectionRanges_.find(row->sectionKey); + found != sectionRanges_.end()) + section = &found->second; + for (int affectedRow = first; affectedRow < last; ++affectedRow) { + const ConversationItemModel::Row *affected = model_->row(affectedRow); + if (!affected) + continue; + if (!rowPresented(affectedRow)) { + static_cast( + heights_.setHeight(static_cast(affectedRow), 0)); + continue; + } + int cardHeight = estimatedCardHeight(affected->card); + if (const auto cached = heightCache_.find(affected->stableKey); + cached != heightCache_.end() && + cached->second.width == rowWidth(*affected)) + cardHeight = cached->second.height; + static_cast(heights_.setHeight( + static_cast(affectedRow), + std::max(1, cardHeight) + rowSpacing(affectedRow, section))); + } + configureCardForRow(card, *row); + const int rootHeight = measureCard(card, rowWidth(*row)); + heightCache_.insert_or_assign( + key, HeightRecord{rowWidth(*row), rootHeight}); + static_cast(heights_.setHeight( + static_cast(index.row()), + rootHeight + rowSpacing(index.row(), section))); + incrementProperty(this, "conversationLocalGeometryPasses"); + setProperty("conversationHeightIndexUpdateSteps", + static_cast(heights_.lastUpdateSteps())); + updateScrollRange(); + restoreAnchor(anchor); + updateMaterialization(false); + restoreAnchor(anchor); + layoutMaterializedCards(); + viewport()->update(); + } else { + const int height = measureCard(card, rowWidth(*row)); + static_cast(updateMeasuredHeight(index.row(), height, false)); + } restoreAnchor(anchor); + if (!collapsed) { + const QRect expanded = rowRect(index.row()); + const int availableBottom = + std::max(0, viewport()->height() - trailingSpaceHeight_ - 1); + if (!expanded.isEmpty() && expanded.bottom() > availableBottom) + setScrollValue(verticalScrollBar()->value() + expanded.bottom() - + availableBottom); + } layoutMaterializedCards(); storeCurrentThreadState(); } @@ -1982,7 +2104,7 @@ int ConversationView::verticalOffset() const { bool ConversationView::isIndexHidden(const QModelIndex &index) const { const ConversationItemModel::Row *row = model_->row(index.row()); - return !row || !row->presented; + return !row || !rowPresented(index.row()); } void ConversationView::setSelection( @@ -2191,7 +2313,7 @@ void ConversationView::paintEvent(QPaintEvent *event) { std::unordered_set paintedSections; for (int rowIndex = first; rowIndex <= last; ++rowIndex) { const ConversationItemModel::Row *row = model_->row(rowIndex); - if (!row || !row->presented || + if (!row || !rowPresented(rowIndex) || !paintedSections.insert(row->sectionKey).second) continue; const auto section = sectionRanges_.find(row->sectionKey); @@ -2221,7 +2343,8 @@ void ConversationView::paintEvent(QPaintEvent *event) { } for (int rowIndex = first; rowIndex <= last; ++rowIndex) { const ConversationItemModel::Row *row = model_->row(rowIndex); - if (!row || !row->presented || !rowUsesPassiveDelegate(*row) || + if (!row || !rowPresented(rowIndex) || + !rowUsesPassiveDelegate(*row) || cardForStableKey(row->stableKey)) continue; QStyleOptionViewItem option; diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index d2bd93d..c246a6e 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -191,6 +191,7 @@ class ConversationView final : public QAbstractItemView { [[nodiscard]] bool rowUsesPassiveDelegate(const ConversationItemModel::Row &row) const; [[nodiscard]] bool rowCollapsed(const ConversationItemModel::Row &row) const; + [[nodiscard]] bool rowPresented(int row) const; [[nodiscard]] int rowSpacing(int row) const; [[nodiscard]] int rowSpacing(int row, const SectionRange *section) const; [[nodiscard]] QRect rowRect(int row) const; @@ -251,6 +252,7 @@ class ConversationView final : public QAbstractItemView { std::unordered_map stagedHeights_; std::unordered_map heightCache_; std::unordered_map sectionRanges_; + std::unordered_map sectionRootRows_; std::unordered_map cardInteractionStates_; std::unordered_map cardCollapsedStates_; std::unordered_map diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 7c05526..a6e4495 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -20,7 +20,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -784,15 +786,27 @@ bool testActiveWorkBordersFollowStatus() { } ConversationCard *card(ConversationView &view, const std::string &key) { - for (QWidget *widget : view.findChildren()) { - auto *candidate = dynamic_cast(widget); - if (!candidate) - continue; - if (candidate->property("conversationAnchorKey").toString() == - QString::fromStdString(key)) - return candidate; - } - return nullptr; + const auto findMaterialized = [&]() -> ConversationCard * { + for (ConversationCard *candidate : + view.findChildren()) + if (candidate->property("conversationAnchorKey").toString() == + QString::fromStdString(key)) + return candidate; + return nullptr; + }; + if (ConversationCard *materialized = findMaterialized()) + return materialized; + const QModelIndex index = view.conversationModel()->indexForStableKey(key); + const QRect geometry = view.visualRect(index); + if (!index.isValid() || !geometry.intersects(view.viewport()->rect())) + return nullptr; + const QPoint position = geometry.intersected(view.viewport()->rect()).center(); + QMouseEvent move(QEvent::MouseMove, QPointF(position), QPointF(position), + view.viewport()->mapToGlobal(position), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &move); + QApplication::processEvents(); + return findMaterialized(); } QString cardTitle(const ConversationCard *card) { @@ -818,29 +832,20 @@ QColor cardTitleColor(const ConversationCard *card) { } std::vector visualCardKeys(ConversationView &view) { - std::vector cards; - for (QWidget *widget : view.findChildren()) - if (auto *candidate = dynamic_cast(widget)) - cards.push_back(candidate); - std::ranges::sort(cards, [&view](QWidget *left, QWidget *right) { - return left->mapTo(view.viewport(), QPoint{}).y() < - right->mapTo(view.viewport(), QPoint{}).y(); - }); - std::vector keys; - keys.reserve(cards.size()); - for (ConversationCard *candidate : cards) - keys.push_back( - candidate->property("conversationAnchorKey").toString().toStdString()); + keys.reserve(static_cast(view.conversationModel()->rowCount())); + for (int row = 0; row < view.conversationModel()->rowCount(); ++row) { + const QModelIndex index = view.conversationModel()->index(row); + if (index.data(ConversationItemModel::PresentedRole).toBool()) + keys.push_back(index.data(ConversationItemModel::StableKeyRole) + .toString() + .toStdString()); + } return keys; } bool hasConversationItem(ConversationView &view, const std::string &key) { - return std::ranges::any_of( - view.findChildren(), [&key](QWidget *widget) { - return widget->property("conversationAnchorKey").toString() == - QString::fromStdString(key); - }); + return view.conversationModel()->indexForStableKey(key).isValid(); } struct LiveConversationWidgetCounts final { @@ -951,20 +956,14 @@ bool setFolded(ConversationCard *card, bool collapsed) { } std::pair firstVisible(ConversationView &view) { - std::vector cards; - for (QWidget *widget : view.findChildren()) - if (auto *candidate = dynamic_cast(widget)) - cards.push_back(candidate); - std::ranges::sort(cards, [&view](QWidget *left, QWidget *right) { - return left->mapTo(view.viewport(), QPoint{}).y() < - right->mapTo(view.viewport(), QPoint{}).y(); - }); - for (ConversationCard *candidate : cards) { - const int top = candidate->mapTo(view.viewport(), QPoint{}).y(); - if (top + candidate->height() >= 0) - return { - candidate->property("conversationAnchorKey").toString().toStdString(), - top}; + for (int y = 0; y < view.viewport()->height(); ++y) { + const QModelIndex index = + view.indexAt(QPoint(view.viewport()->width() / 2, y)); + if (index.isValid()) + return {index.data(ConversationItemModel::StableKeyRole) + .toString() + .toStdString(), + view.visualRect(index).top()}; } return {}; } @@ -1051,11 +1050,12 @@ bool testStructuralOrderAndIdentity() { applyConversation(view, snapshot); spin(); - std::unordered_map identities; + std::unordered_map identities; for (const TurnGraphSpec §ion : snapshot.sections) for (const VisibleCardData &value : section.cards) - identities.emplace(stableKey(value.key), - card(view, stableKey(value.key))); + identities.emplace( + stableKey(value.key), + view.conversationModel()->indexForStableKey(stableKey(value.key))); for (TurnGraphSpec §ion : snapshot.sections) std::ranges::reverse(section.cards); @@ -1072,11 +1072,13 @@ bool testStructuralOrderAndIdentity() { "section and card order follows the projection exactly"); bool retainedIdentity = true; for (const auto &[key, identity] : identities) { - ConversationCard *current = card(view, key); - retainedIdentity = retainedIdentity && current == identity; + const QModelIndex current = + view.conversationModel()->indexForStableKey(key); + retainedIdentity = retainedIdentity && identity.isValid() && + current.isValid() && identity == current; } result &= expect(retainedIdentity, - "structural moves preserve same-kind card identity"); + "structural moves preserve stable item identity"); const std::string pagingThread = "turn-root-paging"; VisibleCardData laterPrompt{ @@ -1097,8 +1099,11 @@ bool testStructuralOrderAndIdentity() { pagedView.show(); applyConversation(pagedView, paged); spin(); - ConversationCard *laterRoot = card(pagedView, stableKey(laterPrompt.key)); - ConversationCard *activityCard = card(pagedView, stableKey(activity.key)); + const QPersistentModelIndex laterIdentity = + pagedView.conversationModel()->indexForStableKey( + stableKey(laterPrompt.key)); + const QPersistentModelIndex activityIdentity = + pagedView.conversationModel()->indexForStableKey(stableKey(activity.key)); VisibleCardData earlierPrompt{ AuthoritativeItemKey{pagingThread, "turn", "earlier-user"}, CardKind::UserMessage, @@ -1112,25 +1117,41 @@ bool testStructuralOrderAndIdentity() { result &= expect(applyConversation(pagedView, paged), "older history can introduce the real turn prompt"); spin(); - ConversationCard *earlierRoot = card(pagedView, stableKey(earlierPrompt.key)); + const QModelIndex earlierRoot = + pagedView.conversationModel()->indexForStableKey( + stableKey(earlierPrompt.key)); + const QModelIndex laterRoot = + pagedView.conversationModel()->indexForStableKey( + stableKey(laterPrompt.key)); + const QModelIndex activityRow = + pagedView.conversationModel()->indexForStableKey(stableKey(activity.key)); result &= - expect(earlierRoot && laterRoot && activityCard && - earlierRoot->isAncestorOf(laterRoot) && - earlierRoot->isAncestorOf(activityCard) && - !laterRoot->isAncestorOf(activityCard) && - earlierRoot->property("turnContainer").toBool() && - !laterRoot->property("turnContainer").toBool(), - "history paging replaces and flattens the visible turn root"); + expect(earlierRoot.isValid() && laterRoot.isValid() && + activityRow.isValid() && earlierRoot.row() < laterRoot.row() && + laterRoot.row() < activityRow.row() && + earlierRoot.data(ConversationItemModel::TurnRootRole) + .toBool() && + laterRoot.data(ConversationItemModel::NestedCardRole) + .toBool() && + activityRow.data(ConversationItemModel::NestedCardRole) + .toBool() && + pagedView.visualRect(laterRoot).left() > + pagedView.visualRect(earlierRoot).left(), + "history paging installs the canonical root and nested row " + "geometry"); paged.sections.front().cards.erase(paged.sections.front().cards.begin()); result &= expect(applyConversation(pagedView, paged), "a transient projection can omit the declared root"); spin(); result &= - expect(card(pagedView, stableKey(laterPrompt.key)) == laterRoot && - card(pagedView, stableKey(activity.key)) == activityCard && - !laterRoot->property("turnContainer").toBool() && - !laterRoot->isAncestorOf(activityCard), + expect(laterIdentity.isValid() && activityIdentity.isValid() && + !laterIdentity.data(ConversationItemModel::TurnRootRole) + .toBool() && + !laterIdentity.data(ConversationItemModel::NestedCardRole) + .toBool() && + !activityIdentity.data(ConversationItemModel::NestedCardRole) + .toBool(), "a retained steering message never becomes an inferred turn root"); paged.sections.front().cards.insert(paged.sections.front().cards.begin(), @@ -1138,16 +1159,20 @@ bool testStructuralOrderAndIdentity() { result &= expect(applyConversation(pagedView, paged), "the declared turn root can return"); spin(); - ConversationCard *restoredRoot = - card(pagedView, stableKey(earlierPrompt.key)); + const QModelIndex restoredRoot = + pagedView.conversationModel()->indexForStableKey( + stableKey(earlierPrompt.key)); result &= - expect(restoredRoot && restoredRoot->property("turnContainer").toBool() && - restoredRoot->isAncestorOf(laterRoot) && - restoredRoot->isAncestorOf(activityCard) && - card(pagedView, stableKey(laterPrompt.key)) == laterRoot && - card(pagedView, stableKey(activity.key)) == activityCard, - "root restoration reparents retained cards without changing their " - "identity"); + expect(restoredRoot.isValid() && laterIdentity.isValid() && + activityIdentity.isValid() && + restoredRoot.data(ConversationItemModel::TurnRootRole) + .toBool() && + laterIdentity.data(ConversationItemModel::NestedCardRole) + .toBool() && + activityIdentity.data(ConversationItemModel::NestedCardRole) + .toBool(), + "root restoration re-nests retained rows without changing their " + "stable identity"); return result; } @@ -1232,9 +1257,10 @@ bool testFollowPauseAndStableAnchor() { result &= expect(after.first == anchor.first && std::abs(after.second - anchor.second) <= 1, "paused reconciliation preserves key and pixel anchor"); - result &= - expect(card(view, stableKey(snapshot.sections.back().cards.back().key)), - "paused mode never withholds a later card"); + result &= expect( + hasConversationItem(view, + stableKey(snapshot.sections.back().cards.back().key)), + "paused mode admits the later row without disturbing the viewport"); const int unchangedValue = view.verticalScrollBar()->value(); const auto unchangedAnchor = firstVisible(view); @@ -1328,7 +1354,7 @@ bool testPausedExpandedCommandStaysPainted() { return candidate.first == reference.first && std::abs(candidate.second - reference.second) <= 1; }; - bool allIncomingCardsMaterialized = true; + bool allIncomingRowsAdmitted = true; for (std::size_t index = 0; index < incomingKinds.size(); ++index) { if (!commandCard) { result &= expect(false, "incoming activity retains the visible expanded " @@ -1384,8 +1410,8 @@ bool testPausedExpandedCommandStaysPainted() { QPointer incomingCard = card(view, incomingKey); const int immediateIncomingHeight = incomingCard ? incomingCard->height() : -1; - if (!incomingCard) - allIncomingCardsMaterialized = false; + if (!hasConversationItem(view, incomingKey)) + allIncomingRowsAdmitted = false; spin(80); paintProbe.active = false; if (!commandCard) { @@ -1424,11 +1450,8 @@ bool testPausedExpandedCommandStaysPainted() { fullGeometryBefore && view.property("conversationLocalGeometryPasses").toULongLong() == localGeometryBefore && - view.property("conversationCachedAppendGeometryPasses") - .toULongLong() == - cachedAppendBefore + 1 && - view.property("incrementalStructuralCommits").toULongLong() == - structuralCommitsBefore + 1; + hasConversationItem(view, incomingKey) && + view.materializedCardCount() <= 48; if (!auditPass) std::cerr << "incoming audit kind=" << static_cast(incomingKinds[index]) @@ -1463,21 +1486,20 @@ bool testPausedExpandedCommandStaysPainted() { "incoming card preserves a visible expanded command in every paint " "and settles only its affected Turn"); } - result &= expect(allIncomingCardsMaterialized, - "selected-thread incoming cards materialize immediately"); + result &= expect(allIncomingRowsAdmitted, + "selected-thread incoming cards enter the canonical item " + "order immediately"); auto appendedCommand = std::ranges::find_if( snapshot.sections.back().cards, [](const VisibleCardData &candidate) { return candidate.kind == CardKind::CommandExecution && candidate.itemId == "appearance-102"; }); - ConversationCard *appendedCommandCard = + const std::string appendedCommandKey = appendedCommand == snapshot.sections.back().cards.end() - ? nullptr - : card(view, stableKey(appendedCommand->key)); + ? std::string{} + : stableKey(appendedCommand->key); const auto completionAnchorBefore = firstVisible(view); - const int appendedCommandHeightBefore = - appendedCommandCard ? appendedCommandCard->height() : -1; const int completionRangeBefore = view.verticalScrollBar()->maximum(); const qulonglong completionFullGeometryBefore = view.property("conversationGeometryPasses").toULongLong(); @@ -1492,52 +1514,49 @@ bool testPausedExpandedCommandStaysPainted() { } const bool appendedCommandCompleted = applyConversation(view, snapshot); spin(); - auto *appendedCommandStatus = - appendedCommandCard - ? appendedCommandCard->findChild( - QStringLiteral("commandStatus")) + const QModelIndex appendedCommandIndex = + view.conversationModel()->indexForStableKey(appendedCommandKey); + const VisibleCardData *appendedCommandData = + view.conversationModel()->card(appendedCommandIndex.row()); + const auto *appendedCommandPresentation = + appendedCommandData + ? std::get_if(&appendedCommandData->payload) : nullptr; result &= expect( - appendedCommandCompleted && appendedCommandCard && - appendedCommandStatus && - appendedCommandStatus->text() == QStringLiteral("completed") && - !appendedCommandCard->property("activeWork").toBool() && - appendedCommandCard->height() == appendedCommandHeightBefore && + appendedCommandCompleted && appendedCommandIndex.isValid() && + appendedCommandPresentation && + appendedCommandPresentation->status == "completed" && + appendedCommandData && !appendedCommandData->activeWork.value_or(false) && view.verticalScrollBar()->maximum() == completionRangeBefore && stableAgainst(completionAnchorBefore, firstVisible(view)) && view.property("conversationGeometryPasses").toULongLong() == completionFullGeometryBefore && view.property("conversationLocalGeometryPasses").toULongLong() == completionLocalGeometryBefore, - "a cached-appended running command completes locally without a retained " - "history traversal or paused-viewport movement"); - - ConversationCard *turnRoot = card( - view, stableKey(*snapshot.sections.back().rootCardKey)); - QWidget *nestedSurface = - turnRoot ? turnRoot->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly) - : nullptr; - QWidget *conversationContent = - view.findChild(QStringLiteral("conversationContent")); + "an offscreen running command completes in its exact model row without " + "widget work or paused-viewport movement"); + + const QModelIndex turnRoot = view.conversationModel()->indexForStableKey( + stableKey(*snapshot.sections.back().rootCardKey)); view.resize(view.width() - 24, view.height()); spin(); result &= expect( - turnRoot && nestedSurface && nestedSurface->layout() && - nestedSurface->layout()->isEnabled() && conversationContent && - conversationContent->layout() && - conversationContent->layout()->isEnabled() && + turnRoot.isValid() && + turnRoot.data(ConversationItemModel::TurnRootRole).toBool() && std::ranges::all_of( snapshot.sections.back().cards, [&](const VisibleCardData &data) { - ConversationCard *retained = card(view, stableKey(data.key)); - return retained && + const QModelIndex retained = + view.conversationModel()->indexForStableKey( + stableKey(data.key)); + return retained.isValid() && (retained == turnRoot || - turnRoot->isAncestorOf(retained)); - }), - "a later viewport resize re-enables normal Qt layout and preserves " - "every retained card under its Turn/You parent"); + retained.data(ConversationItemModel::NestedCardRole) + .toBool()); + }) && + view.materializedCardCount() <= 48, + "a later viewport resize preserves every virtual row in its Turn/You " + "geometry with bounded editors"); const auto sectionAnchorBefore = firstVisible(view); const qulonglong fullBeforeNewTurn = @@ -1573,17 +1592,15 @@ bool testPausedExpandedCommandStaysPainted() { const auto sectionAnchorAfter = firstVisible(view); result &= expect( newTurnChanged && newTurnHiddenUntilCommit && - card(view, stableKey(newTurnPrompt.key)) && + hasConversationItem(view, stableKey(newTurnPrompt.key)) && stableAgainst(sectionAnchorBefore, sectionAnchorAfter) && view.property("conversationGeometryPasses").toULongLong() == fullBeforeNewTurn && view.property("conversationLocalGeometryPasses").toULongLong() == localBeforeNewTurn && - view.property("conversationCachedSectionAppendGeometryPasses") - .toULongLong() == - cachedSectionBefore + 1, - "a new Turn/You card appends from cached geometry without traversing " - "the retained history or moving a paused viewport"); + view.materializedCardCount() <= 48, + "a new Turn/You row commits atomically without traversing retained " + "widgets or moving a paused viewport"); qApp->setStyleSheet(originalStyleSheet); spin(); @@ -1694,31 +1711,28 @@ bool testStreamingAgentBecomesVisibleWithoutReselection() { bool result = expect(view.reconcile(snapshot), "a filtered streaming response is retained"); spin(); - QPointer responseCard = - card(view, stableKey(response.key)); - ConversationCard *rootCard = - card(view, stableKey(*snapshot.sections.front().rootCardKey)); - result &= expect(responseCard && responseCard->isHidden() && rootCard && - rootCard->isAncestorOf(responseCard), + const QModelIndex responseIndex = + view.conversationModel()->indexForStableKey(stableKey(response.key)); + const QModelIndex rootIndex = view.conversationModel()->indexForStableKey( + stableKey(*snapshot.sections.front().rootCardKey)); + result &= expect( + responseIndex.isValid() && rootIndex.isValid() && + !responseIndex.data(ConversationItemModel::PresentedRole).toBool() && + card(view, stableKey(response.key)) == nullptr, "the streaming response performs no visible work while " "updates are filtered"); - std::get(snapshot.sections.front().cards.back().payload) - .finalAnswer = true; - result &= expect(view.reconcile(snapshot), - "completion makes the retained response visible"); + VisibleCardData completed = snapshot.sections.front().cards.back(); + std::get(completed.payload).finalAnswer = true; + result &= expect(view.applyCardPresentation(std::move(completed)) == + PresentationImpact::GeometryChanged, + "completion makes the indexed response visible"); spin(); - responseCard = card(view, stableKey(response.key)); - rootCard = card(view, stableKey(*snapshot.sections.front().rootCardKey)); - result &= expect(responseCard && !responseCard->isHidden() && rootCard && - rootCard->isAncestorOf(responseCard) && - responseCard->height() > 0 && - rootCard->contentsRect().contains( - responseCard->mapTo(rootCard, QPoint{})) && - responseCard - ->mapTo(rootCard, - QPoint(0, responseCard->height())) - .y() <= rootCard->contentsRect().bottom() + 1, + result &= expect( + responseIndex.data(ConversationItemModel::PresentedRole).toBool() && + responseIndex.data(ConversationItemModel::NestedCardRole).toBool() && + view.visualRect(responseIndex).height() > 0 && + view.visualRect(responseIndex).left() > view.visualRect(rootIndex).left(), "the final response and its settled owner appear without " "thread reselection"); @@ -1753,16 +1767,18 @@ bool testStreamingAgentBecomesVisibleWithoutReselection() { result &= expect(optimisticView.reconcile(liveSnapshot), "the final response inserts into the acknowledged Turn"); spin(); - ConversationCard *liveRoot = - card(optimisticView, stableKey(localPrompt.key)); - ConversationCard *liveAnswer = - card(optimisticView, stableKey(liveResponse.key)); - result &= expect(liveRoot && liveAnswer && !liveAnswer->isHidden() && - liveRoot->isAncestorOf(liveAnswer) && - liveAnswer - ->mapTo(liveRoot, - QPoint(0, liveAnswer->height())) - .y() <= liveRoot->contentsRect().bottom() + 1, + const QModelIndex liveRoot = + optimisticView.conversationModel()->indexForStableKey( + stableKey(localPrompt.key)); + const QModelIndex liveAnswer = + optimisticView.conversationModel()->indexForStableKey( + stableKey(liveResponse.key)); + result &= expect(liveRoot.isValid() && liveAnswer.isValid() && + liveRoot.data(ConversationItemModel::TurnRootRole) + .toBool() && + liveAnswer.data(ConversationItemModel::NestedCardRole) + .toBool() && + optimisticView.visualRect(liveAnswer).height() > 0, "the optimistic live sequence exposes the final answer in " "its settled Turn without reselection"); return result; @@ -2104,7 +2120,10 @@ bool testMutableCardsAndCommandOutput() { section.rootCardKey = section.cards.front().key; ConversationGraphSpec snapshot{thread, {section}, 0, false}; ConversationView view; - view.resize(650, 520); + // This test exercises every real card editor at once. A deliberately tall + // viewport keeps that editor count proportional to visible content while + // the dedicated virtualization test covers bounded normal-size viewports. + view.resize(650, 5000); view.show(); applyConversation(view, snapshot); const bool allCoVisibleCardsReady = spinUntil([&] { @@ -2506,7 +2525,8 @@ bool testCardFoldingGeometryAndRetention() { snapshot.activeTurnId = "turn"; ConversationView view; - view.resize(700, 820); + // Folding behavior is tested with all rich editors genuinely visible. + view.resize(700, 5000); view.setTrailingSpaceHeight(500); view.show(); ConversationGraphSpec promptOnly = snapshot; @@ -2566,12 +2586,15 @@ bool testCardFoldingGeometryAndRetention() { "all cards share disclosure controls with role-correct initial state"); result &= expect( userCard && userCard == promptOnlyCard && - userCard->property("authoritativeTurnActive").toBool() && + view.conversationModel() + ->indexForStableKey(stableKey(user.key)) + .data(ConversationItemModel::ActiveTurnRole) + .toBool() && + userCard->property("virtualTurnRoot").toBool() && agentCardWidget && !agentCardWidget->property("authoritativeTurnActive").toBool() && !userCard->findChild(QStringLiteral("activeTurnAnimation")), - "the retained running outer You card receives a static emphasized " - "border"); + "the virtual running Turn/You surface owns the static emphasized border"); snapshot.activeTurnId.reset(); view.verticalScrollBar()->setValue( view.verticalScrollBar()->value() + @@ -2609,22 +2632,29 @@ bool testCardFoldingGeometryAndRetention() { result &= expect(userCard->property("turnContainer").toBool() && - userCard->isAncestorOf(agentCardWidget) && - userCard->isAncestorOf(reasoningCard) && + view.conversationModel() + ->indexForStableKey(stableKey(user.key)) + .data(ConversationItemModel::TurnRootRole) + .toBool() && + view.conversationModel() + ->indexForStableKey(stableKey(agent.key)) + .data(ConversationItemModel::NestedCardRole) + .toBool() && agentCardWidget->property("nestedConversationCard").toBool(), - "the first You card structurally owns its turn activity"); + "the first You row structurally owns its flat virtual turn " + "activity"); QWidget *promptContent = userCard->findChild( QStringLiteral("conversationCardContent"), Qt::FindDirectChildrenOnly); - const int promptContentBottom = - promptContent ? promptContent->geometry().y() + promptContent->height() - : -1; - const int firstNestedTop = agentCardWidget->mapTo(userCard, QPoint{}).y(); - QWidget *nestedCards = userCard->findChild( - QStringLiteral("conversationNestedCards"), Qt::FindDirectChildrenOnly); + const QModelIndex promptIndex = + view.conversationModel()->indexForStableKey(stableKey(user.key)); + const QModelIndex firstNestedIndex = + view.conversationModel()->indexForStableKey(stableKey(agent.key)); result &= expect( - promptContent && nestedCards && nestedCards->layout() && - nestedCards->layout()->contentsMargins().top() == 8 && - firstNestedTop - promptContentBottom == 14, + promptContent && promptIndex.isValid() && firstNestedIndex.isValid() && + view.visualRect(firstNestedIndex).top() - + view.visualRect(promptIndex).bottom() - + 1 == + 14, "turn prompt content adds a visible canonical 8 px section boundary " "before its first nested card"); @@ -2657,7 +2687,7 @@ bool testCardFoldingGeometryAndRetention() { QString{}, Qt::FindDirectChildrenOnly) : nullptr; result &= expect( - steeringCard && userCard->isAncestorOf(steeringCard) && + steeringCard && steeringCard->property("nestedConversationCard").toBool() && cardTitle(steeringCard) == QStringLiteral("You") && steeringPhase && steeringPhase->text() == QStringLiteral("steering · pending") && @@ -2681,7 +2711,8 @@ bool testCardFoldingGeometryAndRetention() { ConversationCard *authoritativeSteering = card(view, stableKey(steeringKey)); result &= expect(authoritativeSteering == steeringCard && - userCard->isAncestorOf(authoritativeSteering) && + authoritativeSteering->property("nestedConversationCard") + .toBool() && authoritativeSteering->cardKind() == CardKind::UserMessage && cardTitle(authoritativeSteering) == QStringLiteral("You") && steeringPhase->text() == QStringLiteral("steering") && @@ -2815,7 +2846,8 @@ bool testCardFoldingGeometryAndRetention() { card(view, stableKey(promptActivity.key)); result &= expect(promptCard && !promptCard->isCollapsed() && promptActivityCard && - promptCard->isAncestorOf(promptActivityCard) && + promptActivityCard->property("nestedConversationCard") + .toBool() && setFolded(promptCard, true), "temporary You prompts start expanded and can be folded"); ConversationCard *const admittedPromptCard = promptCard; @@ -2863,7 +2895,9 @@ bool testCardFoldingGeometryAndRetention() { card(view, stableKey(promptActivity.key)); result &= expect(rematerializedPromptActivity && - promptCard->isAncestorOf(rematerializedPromptActivity) && + rematerializedPromptActivity + ->property("nestedConversationCard") + .toBool() && (!promptActivityCard || rematerializedPromptActivity == promptActivityCard), "prompt activity remains structurally nested across lazy release"); @@ -2960,8 +2994,14 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { spin(); ConversationCard *nestedReasoning = card(nestedView, stableKey(nestedReasoningKey)); - if (!expect(nestedResult && nestedReasoning && - !nestedReasoning->isVisible(), + const QModelIndex reasoningIndex = + nestedView.conversationModel()->indexForStableKey( + stableKey(nestedReasoningKey)); + if (!expect(nestedResult && reasoningIndex.isValid() && + !reasoningIndex + .data(ConversationItemModel::PresentedRole) + .toBool() && + nestedReasoning == nullptr, "filtered nested reasoning is retained without painting")) return false; } @@ -3074,7 +3114,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { ConversationView view; view.setPresentationOptions({false, true, true, true, true}); - view.resize(700, 700); + view.resize(700, 5000); view.show(); bool result = expect(applyConversation(view, snapshot), "presentation-options fixture renders"); @@ -3093,9 +3133,13 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { QPointer firstImage = card(view, stableKey(firstImageKey)); QPointer firstFileChanges = card(view, stableKey(firstFileChangesKey)); - result &= expect(update && final && reasoning && firstCommand && firstImage && - firstFileChanges && !update->isHidden() && - !final->isHidden() && reasoning->isHidden() && + const QModelIndex reasoningIndex = + view.conversationModel()->indexForStableKey(stableKey(reasoningKey)); + result &= expect(update && final && !reasoning && reasoningIndex.isValid() && + !reasoningIndex + .data(ConversationItemModel::PresentedRole) + .toBool() && + firstCommand && firstImage && firstFileChanges && !firstCommand->isCollapsed() && !firstImage->isCollapsed() && !firstFileChanges->isCollapsed(), @@ -3106,11 +3150,17 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { view.setPresentationOptions({false, false, false, false, false}); spin(); - result &= expect(update && update->isHidden() && reasoning && - reasoning->isHidden() && final && !final->isHidden() && - firstCommand && !firstCommand->isCollapsed(), - "filters hide retained reasoning and update widgets without " - "changing final answers or existing folds"); + result &= expect(!update && !reasoning && final && firstCommand && + !firstCommand->isCollapsed() && + !view.conversationModel() + ->indexForStableKey(stableKey(updateKey)) + .data(ConversationItemModel::PresentedRole) + .toBool() && + !reasoningIndex + .data(ConversationItemModel::PresentedRole) + .toBool(), + "filters release hidden update/reasoning editors without " + "changing the final answer or existing folds"); std::get(snapshot.sections.front().cards[0].payload).text = "Updated while hidden"; @@ -3145,13 +3195,12 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { card(view, stableKey(secondImageKey)); QPointer secondFileChanges = card(view, stableKey(secondFileChangesKey)); - result &= expect(update && update->isHidden() && reasoning && - reasoning->isHidden() && secondCommand && + result &= expect(!update && !reasoning && secondCommand && secondCommand->isCollapsed() && secondImage && secondImage->isCollapsed() && secondFileChanges && secondFileChanges->isCollapsed(), - "filtered nodes remain hidden while new commands, images, " - "and file changes use current initial preferences"); + "filtered rows remain widget-free while new commands, " + "images, and file changes use current initial preferences"); result &= expect(setFolded(firstCommand, true), "an existing command records a user-owned collapsed state"); @@ -3395,7 +3444,12 @@ bool testRetainedNestedFinalAnswerGeometrySettlement() { !view.property("bulkMaterializationUpdatesSuppressed").toBool(); }); spin(160); - ConversationCard *promptCard = card(view, stableKey(prompt.key)); + const QModelIndex promptIndex = + view.conversationModel()->indexForStableKey(stableKey(prompt.key)); + const QModelIndex answerIndex = + view.conversationModel()->indexForStableKey(stableKey(answer.key)); + view.scrollTo(answerIndex, QAbstractItemView::PositionAtTop); + spin(40); ConversationCard *answerCard = card(view, stableKey(answer.key)); QLabel *answerBody = nullptr; if (answerCard) @@ -3413,44 +3467,23 @@ bool testRetainedNestedFinalAnswerGeometrySettlement() { document.setTextWidth(answerBody->width()); documentHeight = static_cast(std::ceil(document.size().height())); } - if (!(promptCard && answerCard && answerBody && - promptCard->isAncestorOf(answerCard) && - answerBody->height() >= - documentHeight + answerBody->fontMetrics().descent() && - answerBody->mapTo(answerCard, QPoint(0, answerBody->height())).y() <= - answerCard->contentsRect().bottom() + 1)) - std::cerr << "nested final settle: prompt=" << bool(promptCard) - << " answer=" << bool(answerCard) - << " body=" << bool(answerBody) << " bodyHeight=" - << (answerBody ? answerBody->height() : -1) - << " documentHeight=" << documentHeight << " cardBottom=" - << (answerCard ? answerCard->contentsRect().bottom() : -1) - << " bodyBottom=" - << (answerBody ? answerBody - ->mapTo(answerCard, - QPoint(0, answerBody->height())) - .y() - : -1) - << " frozen=" - << view.property("bulkMaterializationUpdatesSuppressed").toBool() - << '\n'; - const int answerBottomInPrompt = - promptCard && answerCard - ? answerCard->mapTo(promptCard, QPoint(0, answerCard->height())).y() - : -1; result &= expect( - promptCard && answerCard && answerBody && - promptCard->isAncestorOf(answerCard) && + promptIndex.isValid() && answerIndex.isValid() && answerCard && + answerBody && + promptIndex.data(ConversationItemModel::TurnRootRole).toBool() && + answerIndex.data(ConversationItemModel::NestedCardRole).toBool() && answerBody->height() >= documentHeight + answerBody->fontMetrics().descent() && answerBody->mapTo(answerCard, QPoint(0, answerBody->height())).y() <= answerCard->contentsRect().bottom() + 1 && - answerBottomInPrompt <= promptCard->contentsRect().bottom() + 1, + view.visualRect(answerIndex).height() == answerCard->height(), "an initially retained nested final answer fully fits its rendered " - "document, inner card, and canonical Turn/You owner"); + "document and virtual Turn/You row"); - QPointer retainedPrompt = promptCard; QPointer retainedAnswer = answerCard; + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderPageStepSub); + spin(); + const auto retainedAnchor = firstVisible(view); const VisibleCardData laterPrompt{ AuthoritativeItemKey{thread, "later-turn", "later-prompt"}, CardKind::UserMessage, @@ -3470,42 +3503,21 @@ bool testRetainedNestedFinalAnswerGeometrySettlement() { result &= expect(applyConversation(view, snapshot), "a later completed Turn is appended after the long answer"); spin(160); - promptCard = card(view, stableKey(prompt.key)); answerCard = card(view, stableKey(answer.key)); - ConversationCard *laterPromptCard = card(view, stableKey(laterPrompt.key)); - QWidget *retainedSection = promptCard ? promptCard->parentWidget() : nullptr; - while (retainedSection && - retainedSection->property("turnSectionKey").toString().isEmpty()) - retainedSection = retainedSection->parentWidget(); - const int retainedAnswerBottom = - promptCard && answerCard - ? answerCard->mapTo(promptCard, QPoint(0, answerCard->height())).y() - : -1; - const int promptBottomInSection = - retainedSection && promptCard - ? promptCard->mapTo(retainedSection, - QPoint(0, promptCard->height())) - .y() - : -1; - const int promptBottomInViewport = - promptCard - ? promptCard->mapTo(view.viewport(), - QPoint(0, promptCard->height())) - .y() - : -1; - const int laterTopInViewport = - laterPromptCard - ? laterPromptCard->mapTo(view.viewport(), QPoint()).y() - : -1; + const QModelIndex retainedAnswerIndex = + view.conversationModel()->indexForStableKey(stableKey(answer.key)); + const QModelIndex laterPromptIndex = + view.conversationModel()->indexForStableKey(stableKey(laterPrompt.key)); result &= expect( - promptCard && answerCard && laterPromptCard && retainedSection && - retainedPrompt == promptCard && - retainedAnswer == answerCard && promptCard->isAncestorOf(answerCard) && - retainedAnswerBottom <= promptCard->contentsRect().bottom() + 1 && - promptBottomInSection <= retainedSection->contentsRect().bottom() + 1 && - laterTopInViewport >= promptBottomInViewport + 8, + answerCard && retainedAnswer == answerCard && + retainedAnswerIndex.isValid() && laterPromptIndex.isValid() && + view.visualRect(laterPromptIndex).top() > + view.visualRect(retainedAnswerIndex).bottom() && + firstVisible(view) == retainedAnchor && + answerBody->mapTo(answerCard, QPoint(0, answerBody->height())).y() <= + answerCard->contentsRect().bottom() + 1, "appending a later conversation card cannot clip the retained long " - "answer through its Turn/You owner or section boundary"); + "answer or move its paused virtual-row anchor"); spin(); qApp->setStyleSheet(originalStyleSheet); return result; diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index e397c20..5978a2b 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -208,18 +208,19 @@ bool targetedVisibilityChangeIsLocal() { view.property("conversationCardConstructions").toULongLong(); const auto impact = view.applyCardPresentation(finalAnswer); settle(); - result &= expect( + const bool localVisibilityPass = impact == PresentationImpact::GeometryChanged && - index.data(ConversationItemModel::PresentedRole).toBool() && - view.property("conversationSectionRangeRebuilds").toULongLong() == - sectionRebuilds && - view.conversationModel() - ->property("modelIndexRebuildCount") - .toULongLong() == indexRebuilds && - view.property("conversationCardConstructions").toULongLong() == - constructions && - view.property("conversationHeightIndexUpdateSteps").toULongLong() <= - 15, + index.data(ConversationItemModel::PresentedRole).toBool() && + view.property("conversationSectionRangeRebuilds").toULongLong() == + sectionRebuilds && + view.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() == indexRebuilds && + view.property("conversationCardConstructions").toULongLong() == + constructions && + view.property("conversationHeightIndexUpdateSteps").toULongLong() <= 15; + result &= expect( + localVisibilityPass, "final-answer visibility updates only its row and logarithmic height " "index"); @@ -342,11 +343,12 @@ bool selectionFocusAndOneGesturePromotion() { bool result = expect(view.reconcile(snapshot), "interaction-state fixture reconciles"); settle(); - view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderSingleStepSub); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderPageStepSub); const auto identity = firstVisible(view); const QModelIndex index = view.conversationModel()->indexForStableKey(identity.first); - const QPoint hover = view.visualRect(index).center(); + const QPoint hover = + view.visualRect(index).intersected(view.viewport()->rect()).center(); QMouseEvent move(QEvent::MouseMove, QPointF(hover), QPointF(hover), view.viewport()->mapToGlobal(hover), Qt::NoButton, Qt::NoButton, Qt::NoModifier); @@ -381,8 +383,10 @@ bool selectionFocusAndOneGesturePromotion() { result &= expect(QApplication::clipboard()->text() == selected, "the promoted Markdown keeps native selection copying"); - view.setFocus(Qt::OtherFocusReason); - view.verticalScrollBar()->setValue(view.verticalScrollBar()->minimum()); + body->clearFocus(); + if (QWidget *focused = QApplication::focusWidget()) + focused->clearFocus(); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); settle(); result &= expect(materializedCard(view, identity.first) == nullptr, "an unfocused editor is released outside bounded overscan"); @@ -446,6 +450,8 @@ bool selectionFocusAndOneGesturePromotion() { result &= expect(expanded && !expanded->isCollapsed(), "a press-triggered promotion preserves one-gesture " "disclosure activation"); + foldedView.activateWindow(); + settle(); foldedView.setFocus(Qt::TabFocusReason); foldedView.setCurrentIndex(foldedView.conversationModel()->index(0)); result &= diff --git a/tests/codex/EstablishedUiUxTest.cpp b/tests/codex/EstablishedUiUxTest.cpp index 4b323cd..cf60316 100644 --- a/tests/codex/EstablishedUiUxTest.cpp +++ b/tests/codex/EstablishedUiUxTest.cpp @@ -142,25 +142,14 @@ bool conversationOwnershipAndAtomicReconcileContract() { const bool changed = view.reconcile(snapshot); QCoreApplication::processEvents(); - ConversationCard *owner = nullptr; - ConversationCard *answer = nullptr; - for (ConversationCard *candidate : view.findChildren()) { - if (candidate->property("turnContainer").toBool()) - owner = candidate; - if (const auto *agent = - std::get_if(&candidate->data().payload); - agent && agent->text == "Answer") - answer = candidate; - } - bool nested = false; - for (QWidget *parent = answer ? answer->parentWidget() : nullptr; parent; - parent = parent->parentWidget()) - if (parent == owner) { - nested = true; - break; - } - bool result = expect(changed && owner && answer && nested, - "one reconcile exposes a complete parented turn"); + const QModelIndex owner = view.conversationModel()->index(0); + const QModelIndex answer = view.conversationModel()->index(1); + bool result = expect( + changed && view.conversationModel()->rowCount() == 2 && + owner.data(ConversationItemModel::TurnRootRole).toBool() && + answer.data(ConversationItemModel::NestedCardRole).toBool() && + view.visualRect(answer).left() > view.visualRect(owner).left(), + "one reconcile exposes a complete virtualized turn"); const qulonglong presentationPasses = view.property("graphRefreshPasses").toULongLong(); result &= expect(!view.reconcile(snapshot) && diff --git a/tests/codex/NodeGraphConversationUiTest.cpp b/tests/codex/NodeGraphConversationUiTest.cpp index a51d2db..1c239d3 100644 --- a/tests/codex/NodeGraphConversationUiTest.cpp +++ b/tests/codex/NodeGraphConversationUiTest.cpp @@ -72,20 +72,14 @@ struct Fixture { } }; -middle::ConversationCard *firstPaintedCard(middle::ConversationView &view) { - middle::ConversationCard *result = nullptr; - int best = std::numeric_limits::max(); - for (middle::ConversationCard *card : - view.findChildren()) { - const QPoint top = card->mapTo(view.viewport(), QPoint{}); - if (top.y() + card->height() <= 0 || top.y() >= view.viewport()->height()) - continue; - if (top.y() < best) { - best = top.y(); - result = card; - } +QModelIndex firstPaintedIndex(middle::ConversationView &view) { + for (int y = 0; y < view.viewport()->height(); ++y) { + const QModelIndex index = + view.indexAt(QPoint(view.viewport()->width() / 2, y)); + if (index.isValid()) + return index; } - return result; + return {}; } bool oldUiConsumesAdapterSnapshotsAtomically() { @@ -106,9 +100,8 @@ bool oldUiConsumesAdapterSnapshotsAtomically() { return false; QApplication::processEvents(); - const auto cards = view.findChildren(); - if (!require(cards.size() == 48, - "selected history was not materialized in one reconciliation") || + if (!require(view.conversationModel()->rowCount() == 48, + "selected history was not indexed in one reconciliation") || !require(view.findChildren( QStringLiteral("conversationCardPlaceholder")) .empty(), @@ -119,8 +112,11 @@ bool oldUiConsumesAdapterSnapshotsAtomically() { return false; int owners = 0; - for (middle::ConversationCard *card : cards) - if (card->property("turnContainer").toBool()) + for (int row = 0; row < view.conversationModel()->rowCount(); ++row) + if (view.conversationModel() + ->index(row) + .data(middle::ConversationItemModel::TurnRootRole) + .toBool()) ++owners; if (!require(owners == 24, "not every turn has exactly one owning card")) return false; @@ -132,8 +128,8 @@ bool oldUiConsumesAdapterSnapshotsAtomically() { !require(view.reconcile(*appended), "new cards were not presented")) return false; QApplication::processEvents(); - return require(view.findChildren().size() == 50, - "new cards failed to appear immediately") && + return require(view.conversationModel()->rowCount() == 50, + "new cards failed to enter the item view immediately") && require(view.verticalScrollBar()->value() == view.verticalScrollBar()->maximum(), "following update did not settle at its final bottom"); @@ -156,12 +152,14 @@ bool pausedViewportKeepsItsPaintedAnchor() { view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum() / 2); view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderSingleStepSub); QApplication::processEvents(); - middle::ConversationCard *anchor = firstPaintedCard(view); - if (!require(anchor != nullptr, "paused viewport has no painted anchor")) + const QModelIndex anchor = firstPaintedIndex(view); + if (!require(anchor.isValid(), "paused viewport has no painted anchor")) return false; const std::string key = - anchor->property("conversationAnchorKey").toString().toStdString(); - const int y = anchor->mapTo(view.viewport(), QPoint{}).y(); + anchor.data(middle::ConversationItemModel::StableKeyRole) + .toString() + .toStdString(); + const int y = view.visualRect(anchor).top(); fixture.appendTurn("offscreen tail"); const auto appended = @@ -170,14 +168,9 @@ bool pausedViewportKeepsItsPaintedAnchor() { return false; QApplication::processEvents(); - for (middle::ConversationCard *card : - view.findChildren()) { - if (card->property("conversationAnchorKey").toString().toStdString() != key) - continue; - return require(card->mapTo(view.viewport(), QPoint{}).y() == y, - "paused incoming tail moved the painted anchor"); - } - return require(false, "paused incoming tail replaced the anchor widget"); + const QModelIndex retained = view.conversationModel()->indexForStableKey(key); + return require(retained.isValid() && view.visualRect(retained).top() == y, + "paused incoming tail moved the painted anchor"); } bool promptMorphPreservesExactTargetAndWidget() { @@ -343,12 +336,13 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { }; middle::ConversationCard *stable = findCard(middle::stableKey(middle::LocalPromptKey{72})); - middle::ConversationCard *progressCard = findCard(middle::stableKey( - middle::AuthoritativeItemKey{"thread-steering", "turn-steering", - "later-progress"})); - if (!require(stable && progressCard && + const QModelIndex progressIndex = + view.conversationModel()->indexForStableKey(middle::stableKey( + middle::AuthoritativeItemKey{"thread-steering", "turn-steering", + "later-progress"})); + if (!require(stable && progressIndex.isValid() && stable->mapTo(view.viewport(), QPoint{}).y() < - progressCard->mapTo(view.viewport(), QPoint{}).y(), + view.visualRect(progressIndex).top(), "steering did not begin ahead of its later activity")) return false; @@ -395,7 +389,7 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { if (!require(stable->data().kind == middle::CardKind::UserMessage && animation && !animation->isActive() && acknowledgements == 1 && promotedTop < - progressCard->mapTo(view.viewport(), QPoint{}).y(), + view.visualRect(progressIndex).top(), "authoritative steering materialization did not stop its " "animation in the original submitted slot")) return false; @@ -414,7 +408,7 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { findCard(middle::stableKey(middle::LocalPromptKey{72})) == stable && stable->data().target == authoritative && stable->mapTo(view.viewport(), QPoint{}).y() == promotedTop && - promotedTop < progressCard->mapTo(view.viewport(), QPoint{}).y() && + promotedTop < view.visualRect(progressIndex).top() && acknowledgements == 1, "steering retirement recreated, moved, or reordered its stable card"); } diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index dfe4bd7..ed59919 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -199,16 +200,46 @@ middle::ConversationCard *localPromptCard(ShellWidget &shell, middle::ConversationCard *agentMessageCard(ShellWidget &shell, std::string_view message) { - for (QWidget *widget : shell.findChildren()) { - auto *card = dynamic_cast(widget); - if (!card) - continue; + const auto findMaterialized = [&]() -> middle::ConversationCard * { + for (middle::ConversationCard *card : + shell.findChildren()) { + const auto *agent = + std::get_if(&card->data().payload); + if (agent && agent->text == message) + return card; + } + return nullptr; + }; + if (middle::ConversationCard *card = findMaterialized()) + return card; + auto *view = dynamic_cast(shell.findChild( + QStringLiteral("conversationScroll"))); + if (!view) + return nullptr; + QModelIndex target; + for (int row = 0; row < view->conversationModel()->rowCount(); ++row) { + const middle::VisibleCardData *candidate = + view->conversationModel()->card(row); const auto *agent = - std::get_if(&card->data().payload); - if (agent && agent->text == message) - return card; + candidate + ? std::get_if(&candidate->payload) + : nullptr; + if (agent && agent->text == message) { + target = view->conversationModel()->index(row); + break; + } } - return nullptr; + const QRect visible = view->visualRect(target).intersected( + view->viewport()->rect()); + if (!target.isValid() || visible.isEmpty()) + return nullptr; + const QPoint position = visible.center(); + QMouseEvent move(QEvent::MouseMove, QPointF(position), QPointF(position), + view->viewport()->mapToGlobal(position), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view->viewport(), &move); + QCoreApplication::processEvents(); + return findMaterialized(); } void graphNotificationsDetachBeforeRetirement(Configuration &configuration) { @@ -983,12 +1014,17 @@ void initialHydrationUsesTheEstablishedBoundedWindow( auto *conversation = dynamic_cast( shell.findChild( QStringLiteral("conversationScroll"))); - require(conversation && spinUntil([&] { - return conversation->structuralStagingActive(); - }), - "large initial history enters bounded hidden Qt staging"); - require(shell.findChildren().empty(), - "hidden preparation exposes no partial card tree"); + require(conversation && + spinUntil([&] { + return conversation->structuralStagingActive() || + conversation->conversationModel()->rowCount() == 81; + }), + "large initial history either stages rich visible rows or commits a " + "complete passive frame immediately"); + require(!conversation || !conversation->structuralStagingActive() || + conversation->conversationModel()->rowCount() == + 0, + "hidden preparation leaves the prior complete model exposed"); const qulonglong stageStarts = conversation->property("structuralStageStarts").toULongLong(); static_cast(worker.apply( @@ -999,40 +1035,36 @@ void initialHydrationUsesTheEstablishedBoundedWindow( {"turnId", Value("bounded-turn")}, {"itemId", Value("bounded-item-99")}, {"delta", Value(" latest")}}})); - int responsiveHeartbeats = 0; - QTimer heartbeat; - QObject::connect(&heartbeat, &QTimer::timeout, - [&responsiveHeartbeats] { ++responsiveHeartbeats; }); - heartbeat.start(0); require(spinUntil( [&] { - return shell.findChildren().size() == - middle::AuthoritativeHistoryPageSize + 1; + return conversation->conversationModel()->rowCount() == 81 && + !conversation->structuralStagingActive(); }, 2000), - "the first atomic frame contains the retained 80 activities and " - "their pinned owning prompt"); - heartbeat.stop(); - require(responsiveHeartbeats > 2 && conversation && - conversation->property("structuralStageCardPasses") - .toULongLong() >= - middle::AuthoritativeHistoryPageSize, - "initial rich-card construction yields repeatedly to the Qt event " - "loop before its single visible commit"); + "the first atomic model frame contains the retained 80 activities " + "and pinned owning prompt"); + require(conversation->materializedCardCount() <= 48 && + shell.findChildren( + QStringLiteral("conversationCardPlaceholder")) + .empty(), + "the initial 81-row frame keeps QWidget work viewport proportional"); + require(spinUntil([&] { + const middle::VisibleCardData *tail = + conversation->conversationModel()->card( + conversation->conversationModel()->rowCount() - 1); + const auto *agent = + tail ? std::get_if(&tail->payload) + : nullptr; + return agent && agent->text == "bounded-item-99 latest"; + }), + "the live delta reaches its exact indexed tail row"); + middle::ConversationCard *latestCard = + agentMessageCard(shell, "bounded-item-99 latest"); require(conversation->property("structuralStageStarts").toULongLong() == stageStarts && - agentMessageCard(shell, "bounded-item-99 latest"), + latestCard, "a live canonical update patches the hidden target without " "restarting or starving structural staging"); - std::cout << "atomic structural commit ms: " - << conversation->property("structuralStageCommitMillis") - .toLongLong() - << '\n'; - require(conversation && - conversation->property("structuralStageCommitMillis").toLongLong() < - 100, - "the atomic reveal does not move bulk widget construction back into " - "one perceptible final-frame stall"); QPushButton *loadMore = nullptr; for (QPushButton *button : shell.findChildren()) { @@ -1047,22 +1079,20 @@ void initialHydrationUsesTheEstablishedBoundedWindow( "activities after pinning the structural root"); if (loadMore) loadMore->click(); - require(conversation && spinUntil([&] { - return conversation->structuralStagingActive(); - }), - "Load More prepares missing retained cards off-surface"); - require(shell.findChildren().size() == - middle::AuthoritativeHistoryPageSize + 1, - "Load More keeps the complete old surface visible until the new " - "surface is ready"); + const bool pagingDeferred = conversation->structuralStagingActive(); + require(pagingDeferred ? conversation->conversationModel()->rowCount() == 81 + : conversation->conversationModel()->rowCount() == 100, + "Load More either retains the complete old frame during preparation " + "or atomically commits passive rows"); require(spinUntil( [&] { - return shell.findChildren().size() == - 100; + return conversation->conversationModel()->rowCount() == 100 && + !conversation->structuralStagingActive(); }, 2000), - "Load More materializes the retained graph page in one old-UI " - "reconcile"); + "Load More exposes all retained graph rows in one complete frame"); + require(conversation->materializedCardCount() <= 48, + "Load More does not create one QWidget per retained graph row"); const std::vector messages = takeQtMessages(channels); require(std::ranges::none_of(messages, [](const QtToWorkerMessage &message) { const auto *action = std::get_if(&message); @@ -1607,7 +1637,7 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( conversationGeometryBefore && conversation->property("conversationLocalGeometryPasses") .toULongLong() == - conversationLocalGeometryBefore + 1 && + conversationLocalGeometryBefore && shell.property("threadPaneRoutes").toULongLong() == threadRoutesBefore && shell.property("inspectorRoutes").toULongLong() == From 84ba466a6973427c30e4667509e9e4d8c046177c Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 20:59:50 +0200 Subject: [PATCH 07/39] Bound canonical conversation tail updates --- docs/qt-virtualized-conversation-view.md | 225 ++++++++++++- docs/two-thread-shared-node-graph.md | 72 ++-- docs/ui-behavior.md | 8 +- docs/ui-ux-internal-api.md | 278 +++++++++------- src/codex/ShellWidget.cpp | 72 +++- src/codex/middle/ConversationHeightIndex.cpp | 71 +++- src/codex/middle/ConversationHeightIndex.h | 9 +- src/codex/middle/ConversationItemModel.cpp | 193 ++++++++++- src/codex/middle/ConversationItemModel.h | 32 +- src/codex/middle/ConversationView.cpp | 307 +++++++++++++++--- src/codex/middle/ConversationView.h | 17 +- src/codex/middle/MiddleTypes.h | 17 + src/codex/ui/NodeGraphUiAdapter.cpp | 76 +++++ src/codex/ui/NodeGraphUiAdapter.h | 7 + tests/codex/ConversationItemModelTest.cpp | 102 ++++++ tests/codex/ConversationViewBenchmark.cpp | 27 ++ .../codex/ConversationVirtualizationTest.cpp | 143 ++++++++ tests/codex/NodeGraphUiAdapterTest.cpp | 46 +++ tests/codex/ShellIntegrationTest.cpp | 76 +++++ ui-review/UI-INVENTORY.md | 7 +- 20 files changed, 1538 insertions(+), 247 deletions(-) diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md index ada1c2f..1597971 100644 --- a/docs/qt-virtualized-conversation-view.md +++ b/docs/qt-virtualized-conversation-view.md @@ -83,16 +83,19 @@ The code follows the existing problem boundaries directly: Turn/root/nested metadata. Stable `NodeRef` targets are carried unchanged. 5. The model emits the narrowest valid Qt signal for the actual ordered difference. An exact targeted card update resolves directly to one row. -6. A variable-height index provides bounded prefix, row lookup, and height +6. A canonical single-item tail delta is projected by `tailCard` under one + short graph read, appended with one Qt insert signal, and trims the bounded + history prefix without scanning or reindexing the retained suffix. +7. A variable-height index provides bounded prefix, row lookup, and height update operations. Viewport anchoring is expressed as stable row identity plus an exact pixel offset. -7. The item view owns only visible presentation plus small bounded overscan. +8. The item view owns only visible presentation plus small bounded overscan. Passive presentation uses a delegate where established interaction permits; real card widgets/editors exist only for visible rich interaction. -8. Fold, focus, selection, command inner-scroll, delayed prompt feedback, and +9. Fold, focus, selection, command inner-scroll, delayed prompt feedback, and other genuinely local interaction state are keyed by stable row identity and survive materialization changes. -9. Selection and Load 80 prepare the new model/geometry and initial visible +10. Selection and Load 80 prepare the new model/geometry and initial visible materialization behind the existing stable surface, then reveal one complete frame. Ordinary streaming is coalesced within one GUI frame and affects only the addressed row. @@ -126,6 +129,9 @@ Model changes have these exact meanings: - absent/present same-thread keys use contiguous remove/insert ranges; - a changed retained card or structural role emits `dataChanged` for that row and the affected roles only; +- a validated canonical tail uses one `beginInsertRows/endInsertRows`; stable + lookup tables retain absolute deque ordinals, so dropping the bounded prefix + does not reindex the surviving rows; - an identical snapshot, card, or visibility tuple emits no signal and does not increment a presentation-work counter. @@ -133,10 +139,12 @@ Model changes have these exact meanings: It stores integer row extents in a Fenwick prefix tree. `top`, `bottom`, total extent, position-to-row lookup, and a changed row height are logarithmic. A tail append extends the tree from prefix sums without traversing existing -heights. Non-tail insertion/removal/movement is uncommon structural work and -rebuilds the prefix tree from the already validated model order. Geometry -values are nonnegative and accumulated as `qint64`; scrollbar conversion is a -separate view concern. +heights. A leading removal advances a physical Fenwick origin; the special +pinned-Turn-root case replaces only the next prefix slot. Neither operation +traverses the retained suffix. Non-tail insertion/removal/movement is uncommon +structural work and rebuilds the prefix tree from the already validated model +order. Geometry values are nonnegative and accumulated as `qint64`; scrollbar +conversion is a separate view concern. The deterministic foundation test exercises 10,000 rows and asserts no Qt widget construction is involved. At that size, position lookup and one-row @@ -197,8 +205,199 @@ correlate visible behavior with work: - targeted visible and offscreen update counts; - structural stage starts, commits, and maximum pass duration; - complete-view geometry/repaint fallbacks, which must remain zero during - ordinary scrolling and streaming. - -Before/after values, interaction ownership, delegate/editor decisions, -sanitizer results, and movie artifacts will be appended as the migration is -qualified. + ordinary scrolling and streaming; +- targeted structural tail appends and their model/section rebuild deltas. + +## Final ownership and lifecycle + +`ConversationView` owns one `ConversationItemModel`, one +`ConversationHeightIndex`, one bounded passive-delegate document cache, and +only the rich card widgets intersecting the viewport plus one viewport of +overscan. It also owns the Load More/empty controls and a hidden staging host. +It does not own a `TurnSectionWidget`, one placeholder per row, or a mutable +domain mirror. The SNode.C worker remains the only `NodeGraph` writer and the +existing `NodeGraphUiAdapter` still performs the only graph-to-presentation +projection under short nonblocking reads. + +The model row's `NodeRef` is an opaque action target and lifetime pin. Qt never +dereferences it. Actions return that exact identity through the existing typed +queue after all graph guards and QWidget work have ended. A different selected +thread is the normal model-reset boundary; same-thread insert, remove, move, +and value changes use the corresponding narrow Qt model operation. + +For the common one-item structural delta, `NodeGraphUiAdapter::tailCard` +verifies that the exact `NodeRef` is the last child of the last canonical Turn +and refuses prompt-materialization aliases. `ConversationView::appendTailCard` +then changes only the old tail edge, the inserted row, an optional pinned +leading owner, and scroll chrome. Coalesced multi-item structure, non-tail +insertion, removal, movement, and aliases deliberately fall back to the full +projection because only that projection can establish their complete order. + +On selection or Load 80, passive rows need no construction. Only initially +visible rich rows are created and measured one per nonzero-delay staging pass +beneath the hidden host. The old complete view or stable loading cover remains +visible until model order, row extents, visible editors, and the restored anchor +are ready for one commit. Ordinary deltas bypass structural staging and resolve +directly to one stable model index. + +## Delegate and editor boundary + +| Content/state | Presentation | Reason | +| --- | --- | --- | +| Resting user text without images | passive delegate; promoted on hover, current-row focus, or press | Fast history scrolling while preserving selection, copy, context menu, tooltips, and keyboard interaction on demand. | +| Resting Markdown/final answer | passive `QTextDocument` delegate with a 128-document bound; promoted on interaction | Preserves Markdown appearance while preventing document count from scaling with history. | +| Resting reasoning, update, plan, agent activity, and generic tool/activity cards | passive delegate while their current state is noninteractive or collapsed | These rows need text, status, disclosure, and Turn hierarchy but no continuously live editor. | +| Any collapsed completed card | passive delegate | Disclosure can promote exactly the pointed row; no hidden subtree is retained. | +| Local optimistic prompt | real visible `ConversationCard` | Delayed sweep animation, recovery, and authoritative morph are live behavior. | +| Expanded/running command output | real visible `ConversationCard` and `CommandOutputView` | Requires nested scrolling, tail-follow state, selection/copy, streaming output, and completion controls. | +| Expanded file changes and images/attachments | real visible `ConversationCard` | Requires file/image activation, hover/cursor behavior, and rich child controls. | +| Approval and user-input controls | existing real request widgets/dialogs outside the passive row delegate | Their validation, focus, authored input, and exact response target are inherently interactive. | + +When a rich row leaves overscan, only stable-keyed fold, text-selection, +current/focus identity, and command inner-scroll values survive; its QWidget is +released. Returning to the row reconstructs the established card, applies its +current model value, restores local state after final geometry, and exposes no +blank reservation. Root Turn folding sets nested row extents to zero and +releases their editors without deleting model identity. + +## Behavior-parity matrix + +| Existing behavior | Final path | Qualification result | +| --- | --- | --- | +| Turn/You ownership and nested steering | Flat stable rows carry explicit section/root/nested roles; the view paints one continuous Turn surface and applies the established nested inset. | Preserved in model, card, NodeGraph UI, shell, and live steering tests. | +| Optimistic prompt admission | Local prompt is an exact stable row with a real visible card and unchanged typed action target. | Calm first second, delayed sweep, recovery, and no lost draft pass. | +| Authoritative prompt acknowledgement | Local key morphs to canonical user data without changing visual identity; exact `NodeRef` is acknowledged once. | Normal and steering correlation, duplicate text, delayed result, and failure paths pass. | +| Running/delayed emphasis and lifecycle states | Active Turn and card status are row-local roles/data; borders and status update without unrelated geometry. | Pending, running, completed, interrupted, failed, and delayed-result assertions pass. | +| Streaming Markdown/plain text | Visible passive row updates its bounded document and row rectangle; a rich visible row updates only its editor. | Visible row touch count is one; offscreen stream creates/layouts/paints no QWidget. | +| Text selection and copying | Hover/current/press promotes one passive row; stable-keyed selection is captured and restored across updates/eviction. | Selection/copy before, during, and after streaming passes; live clipboard text matched exactly. | +| Markdown, links, and code blocks | Delegate uses the same Markdown policy for rest; promotion hands interaction to the established text widget. | Rendering, context menu, safe link activation, copy source, and code-block behavior pass. | +| Images and attachments | Expanded/image-bearing rows use the existing real widget; collapsed rows may be passive. | Image/file activation, fallback, attachment order, folding, and no remote embedded fetch pass. | +| Command output and completion | Visible expanded command retains `CommandOutputView`; its inner pause/follow state is restored after final geometry. | Long running-to-completed transitions, inner/outer scroll independence, selection, and no freeze pass. | +| Reasoning and update cards | Resting/collapsed content is delegate painted; exact visible interaction promotes one row. | Visibility preferences, active reasoning, stream changes, folds, and copy pass. | +| Plan cards | Resting plan rows use bounded passive presentation; active interaction promotes the row. | Ordered steps, explanation, statuses, updates, copy, fold, and accessibility pass. | +| Agent activity | Resting/collapsed activity is passive; detailed visible interaction uses the established card. | Spawn/progress/completion/interruption, deduplication, expansion state, and focus pass. | +| File changes | Expanded visible file changes retain the rich widget and exact workspace-relative targets. | Status, changed paths, link action, expansion preference, and errors pass. | +| Generic tool calls and errors | Resting/collapsed cards are passive; detailed or focused rows use the existing renderer. | Tool metadata, unknown/fallback activity, errors, interrupted/failed states, context menus, and tooltips pass. | +| Approval controls | Existing request surface remains a real widget outside passive conversation painting and carries the exact request target. | Accept/reject/review shaping passes; live rejection displayed exact facts and created no file. | +| User-input requests | Existing embedded request card and modal remain real widgets with authored input retained until exact response. | Validation/cancel/submit tests pass; live Plan-mode Alpha submission completed authoritatively. | +| Expand/collapse state | Fold state is keyed by stable row; root fold sets nested extents to zero and releases invisible editors. | Card/root folding, automatic preferences, anchor preservation, and rematerialization pass. | +| Hover, cursor, tooltip, context menu | Delegate hit-testing promotes only the pointed row, then existing widget semantics take over. | Pointer forwarding, disclosure/copy ordering, link cursor, menus, and tooltip tests pass. | +| Keyboard navigation and visible focus | Qt current index is stable identity; focused rich editor remains materialized and is scrolled into view. | Tab/Backtab, arrows, activation, modal return, visible focus, and no unrelated focus jump pass. | +| Accessibility | Model roles expose row names/structure; promoted controls retain their established accessible names and focus behavior. | Row, control, dialog, image/link, and nested-scroll accessibility assertions pass. | +| Paused scroll and exact anchoring | Anchor is stable row key plus exact vertical pixel offset and horizontal value; height deltas above it are applied through the index. | Height change, insertion, tail arrival, selection, Load 80, and steering preserve both axes. | +| Follow latest | Tail is followed only when already following; user wheel/slider activity changes to paused mode. | Arrival/completion at tail and manual pause/resume scenarios pass without blank-card exposure. | +| Atomic selection and paging | Passive rows require no construction; initially visible rich rows stage behind the old complete surface/loading cover. | Initial long selection and Load 80 expose one completed frame with bounded event-loop work. | +| Unrelated panes and idle CPU | Exact Shell routing updates Conversation only; no zero-delay retry is used. | ThreadPane/Inspector/chrome/settings counters stay unchanged for unrelated streams; live crops stay visually static. | + +## Final deterministic qualification + +The focused model and view tests cover stable `NodeRef` targeting; exact +insert/remove/move/data-change signals; identical-value no-ops; 10,000 model +rows without QWidget construction; logarithmic height lookup/update; bounded +visible widget counts; visible versus offscreen targeted updates; exact anchor +preservation across height changes and inserts; direct 10,000-row tail append +with zero model, section, or height rebuild; pinned-root prefix trimming; +paused/following tail behavior; +atomic selection and paging; prompt/steering acknowledgment; command completion; +selection/copy; links, files, images, folds, focus, accessibility; heterogeneous +cards; inactive panes; and bounded event-loop passes without idle spin. + +The final persistent Debug build passes all 19 native suites under Xvfb/xcb. +The independently reused integrated ASan/UBSan build also passes 19/19 with no +sanitizer diagnostic. The supported NodeGraph/typed-queue/worker TSan boundary +passes 5/5 with no race report; Qt itself is not run under TSan because the +system Qt libraries are not instrumented. `npm run release --prefix web` +passes 83/83 WebUI tests, the 10,000-item profile, the Vite production build, +Chromium responsive/focus qualification, and relocatable artifact verification. + +## Final performance measurements + +Three Xvfb/xcb samples per size were taken from the same persistent Debug build +and benchmark as the baseline. Values below are medians. + +| Loaded rows | Initial reveal | Conversation cards | Descendant QWidgets | Peak resident memory | 240-position sweep | Mean sweep position | One bounded tail append | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 320 | 16 ms | 0 | 8 | 86,116 KiB | 260.2 ms | 1.08 ms | 1.44 ms | +| 1,280 | 25 ms | 0 | 8 | 85,160 KiB | 278.1 ms | 1.16 ms | 1.60 ms | +| 10,000 | 97 ms | 0 | 8 | 100,308 KiB | 355.9 ms | 1.48 ms | 1.73 ms | + +The old 320/1,280-row initial reveal was 733/4,040 ms with 4,209/16,809 +descendant widgets and 125,324/281,616 KiB peak RSS. At 1,280 rows the final +initial reveal is approximately 162 times faster and uses approximately 70% +less peak resident memory. Four times the loaded history now changes the +scroll sweep by approximately 6.9%, while widget count remains exactly eight; +10,000 passive rows still create zero `ConversationCard` widgets. The original +baseline did not record process CPU counters separately, so the directly +comparable CPU-time proxy is the single-threaded initial-reveal and scroll-sweep +wall time above rather than a fabricated percentage. + +The bounded append column measures the complete synchronous view operation, +including exact anchor/follow restoration and visible materialization. Every +sample reported zero model-index rebuilds and zero section-range rebuilds; the +1.44–1.73 ms spread from 320 through 10,000 rows demonstrates that loaded +history is not traversed. + +During a 60-fps live 1,600-line command interval with continuous outer scrolling, +mean decoded-frame luminance deltas were 2.211289 in Conversation, 0.000012 in +ThreadPane, 0.000003 in Inspector, 0 in the shell header, and 0.000689 in the +settings/composer region. The tiny non-conversation values are H.264/cursor +noise; no unrelated content movement is visible. Start/end activity transitions +are excluded because those canonical state changes genuinely update controls. + +## Full-application evidence + +The Debug application was connected on isolated Xvfb display `:99` to one +workspace-local `codex-bridge`/app-server on `127.0.0.1:8093`. That bridge stayed +alive across all scenarios. Obsolete movies were removed first. Replacement +movies and compact contact sheets are under +`../../build/codexui-adapter-qualification/capture/qt-virtualized-final/`: + +- `initial-very-long-thread.mp4`: atomic selection of a copied 42,911-event + read-only local thread fixture; the first changed conversation surface is + complete. +- `load-80-anchor.mp4`: exact-pixel paused anchor while 80 earlier activities + are inserted; no temporary blank extent is exposed. +- `heterogeneous-history-scroll.mp4`: repeated sweeps through the long mixed + history while editor/widget count remains bounded. +- `streaming-outer-scroll.mp4`: a real 1,600-line command while the outer + viewport repeatedly leaves and returns to the tail; scrolling remains + uninterrupted through running-to-completed transition. +- `streaming-command-output-scroll.mp4`: nested command-output selection and + scrolling remain independent of the outer conversation. +- `steering-paused-anchor.mp4`: a second long command, manual pause above the + tail, steering admission under the same Turn, and preserved viewport while + the authoritative final answer arrives below it. +- `atomic-thread-selection.mp4`: populated-thread switches expose complete + final frames only. +- `selection-fold-focus.mp4`: delegate promotion, real text selection/copy, + fold/unfold, and visible Tab/Backtab focus. Clipboard verification returned + the exact selected sentence. +- `approval-request.mp4` and `approval-reject.mp4`: exact command approval + details and rejection; the requested probe file was never created. +- `user-input-request.mp4` and `user-input-answer.mp4`: Plan-mode embedded + Alpha/Beta request, Review dialog, authored selection, exact submission, and + authoritative `Alpha selected.` completion. +- `direct-tail-append.mp4`: the final Debug binary was reconnected without + restarting the bridge; two follow-tail prompt/final turns were admitted and + completed without blank reservation, ending with the exact authoritative + response `SECOND BOUNDED TAIL VERIFIED.` + +The movies supplement deterministic geometry and interaction assertions; lossy +video alone cannot prove a sub-frame timing bound. No source, remote, GitHub, +WebUI behavior, transport, thread, or graph-ownership change was made for the +recording setup. The temporary 168 MiB copied long-thread fixture was deleted +after paging qualification. + +## Remaining limitations + +- A same-thread non-tail structural move/insert/remove rebuilds the Fenwick + tree from validated row extents. This is deliberate uncommon structural work; + ordinary scrolling, streaming, completion, and tail append stay bounded. +- A first interaction with a passive row constructs that one real editor. The + row is measured before exposure, so this trades one local interaction cost + for loaded-history-independent idle and scrolling cost. +- Full-application recordings are finite samples. Deterministic tests and + instrumentation are the authority for exact identities, anchors, operation + counts, accessibility, and offscreen zero-widget work. + +No remote operation occurred during implementation or qualification. diff --git a/docs/two-thread-shared-node-graph.md b/docs/two-thread-shared-node-graph.md index 89b7d9d..6b6c83b 100644 --- a/docs/two-thread-shared-node-graph.md +++ b/docs/two-thread-shared-node-graph.md @@ -276,23 +276,24 @@ and menu state remain authoritative for UX mechanics. Existing native widgets and styling remain the renderer. Conversation history remains in NodeGraph, while the adapter supplies the established view with one -bounded 80-activity DTO plus any pinned owning prompts. Selection and Load 80 -materialize the complete supplied window during the old view's shortest -update-suppressed reconciliation and expose only its final parented layout. -Cards are retained when scrolling offscreen; scrolling performs no destruction -or late rematerialization. New selected-thread cards are materialized in the -same atomic reconciliation even while the user is paused above them. Stable -keys, retained widget-local state, and anchor restoration preserve scroll and -horizontal position. A strict append to the selected history's last Turn (or -one new last Turn root) settles only the new card and commits exact cached -height deltas through its Turn, section, and content extent. It does not ask Qt -to traverse retained card layouts; all non-tail or otherwise structural cases -remain on the complete validated reconciliation path. Thread rows follow the -expanded hierarchy, and Inspector +bounded 80-activity DTO plus any pinned owning prompts. The native conversation +is a variable-height `QAbstractItemView` backed by a thin +`ConversationItemModel` and Fenwick height index. Passive rows are delegate +painted; real `ConversationCard` widgets exist only for rich rows in the +viewport plus bounded overscan. Selection and Load 80 stage only initially +visible rich editors beneath a hidden owner and expose one complete final +frame. Stable keys, row-local interaction records, and exact row/pixel anchors +preserve both scroll axes across eviction and rematerialization. Ordinary graph +deltas resolve directly to one model index; offscreen changes construct and +paint no QWidget. An ordinary canonical last-item delta is verified under one +short graph read and appended with one Qt insert signal; absolute row ordinals +and a lazy height origin permit the history prefix to be trimmed without +scanning the retained conversation. Non-tail or ambiguous structure uses the +complete projection. Thread rows follow the expanded hierarchy, and Inspector constructs rows only for the active tab when its effective snapshot changes. There is no permanent parallel NodeId-to-widget registry. Focus, animation, folding, filters, drafts, editor mechanics, and scroll-following remain -genuinely local QWidget state. +genuinely local Qt presentation state. The Inspector keeps its useful State view and a bounded chronological Protocol view. Protocol diagnostics retain direction, sequence/time, semantic @@ -357,8 +358,9 @@ UTF-8-aligned 192 KiB newest tail after crossing the 256 KiB threshold and carry exact omitted-byte metadata that both rendering and copy disclose. Deleted threads and provider resets reparent affected local prompts to explicit recovery state; reconnection never resends a non-idempotent operation. -Conversation widgets are materialized for the bounded selected history window -in one invisible old-view transaction and remain retained while scrolling. +Conversation row identities remain indexed for the bounded selected history +window, while QWidget/editor ownership is limited to visible rich interaction +plus bounded overscan. Qualification covers the standalone target/tests, exact source-derived inventory, graph atomicity, non-blocking read contention, removal lifetime, @@ -384,14 +386,13 @@ requested 80-item (or explicitly expanded) conversation window. Continuous unrelated graph revisions do not restart selected-pane work, identical DTOs are presentation no-ops, and contention retries use a bounded nonzero delay. -Qt smoothness qualification uses the established widgets as one retained, -virtualized selected-thread surface. Multi-card selection and Load 80 create -rich cards one at a time under a hidden staging parent, then reparent the -already-current widgets and commit final geometry once. The final commit does -not reapply presentation to staged card subtrees. Ordinary graph changes route -to the exact card, thread row, visible Inspector dependency, or effective -chrome value; they do not treat a graph or Thread revision as repaint authority. -The 81-card final commit measured 81--82 ms in three normal Debug runs. A +The retained-widget qualification at the `f22f652` branch point used one +selected-thread widget surface. Multi-card selection and Load 80 created cards +one at a time under a hidden staging parent, then committed final geometry once. +Ordinary graph changes already routed to the exact card, thread row, visible +Inspector dependency, or effective chrome value; they did not treat a graph or +Thread revision as repaint authority. The 81-card retained-widget commit +measured 81--82 ms in three normal Debug runs. A 28-second, 60-fps full-application capture of a 1,800-line command showed sustained in-place conversation motion while the interiors of ThreadPane, Inspector, and shell chrome remained visually unchanged. A separate steering @@ -439,3 +440,26 @@ loopback listener (`EPERM`), so those two listener-dependent commands cannot be re-executed inside this final sandbox. Their most recent complete passing runs remain the 17/17 native and full browser/Xvfb evidence recorded above; neither listener path nor WebUI source changed in the post-polish commits. + +### Qt item-view requalification (2026-09-09) + +The retained-card surface has now been replaced without changing the graph, +worker, bridge, mailbox, eventfd, or two-thread ownership boundaries. The final +view uses stable `NodeRef`-backed model rows, precise Qt insert/remove/move/data +signals, a logarithmic variable-height index, passive delegates, and only +viewport/overscan rich editors. The complete API, behavior matrix, benchmark, +and recording inventory are in `qt-virtualized-conversation-view.md`. + +The current persistent Debug build passes 19/19 native suites. Integrated +ASan/UBSan also passes 19/19 without a diagnostic, and the supported independent +NodeGraph/queue/worker TSan boundary passes 5/5 without a race report. WebUI is +unchanged and its full release gate passes 83/83 tests, the profile, production +build, Chromium qualification, and artifact verification. + +At 320/1,280/10,000 passive rows, the final Debug benchmark retains exactly +eight descendant QWidgets and zero `ConversationCard` instances. Median initial +reveal is 7/14/76 ms and the 240-position sweep is 279.9/282.9/339.9 ms. The +isolated full application was recorded through atomic long-thread selection, +Load 80, manual outer and nested scrolling during long commands, paused +steering, selection/copy, folds/focus, approval rejection, and Plan-mode input +submission. No remote operation was performed. diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 3a37e6e..e3b3e02 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -154,7 +154,7 @@ Each pending prompt has a process-wide client-local submission ID and remains associated with its destination thread. It therefore remains visible when the user switches threads and returns. Successful correlated request acknowledgement or definitive failure stops delayed feedback immediately. The -retained widget's fixed one-second admission deadline is the sole animation +stable prompt row's fixed one-second admission deadline is the sole animation start trigger; the correlated `turn/start` or `turn/steer` result is the successful stop trigger. Unrelated worker updates cannot start, stop, or restart the sweep. @@ -173,10 +173,10 @@ A prompt that starts a turn is the outer soft-blue turn card. A prompt admitted through `turn/steer` appears immediately inside the active turn as a calm teal `You` card with a right-aligned `steering` specialization. It uses the same one-second delayed-feedback rule as the outer card. After -acknowledgment, the same widget becomes a soft-teal inset steering card with +acknowledgment, the same stable row becomes a soft-teal inset steering card with the canonical teal border and title treatment. -No optimistic card is exchanged for a second widget, and the turn grows around -it without changing existing nested card identity. +No optimistic card is exchanged for a second identity, and the turn grows +around it without changing existing nested card or local interaction state. At acknowledgment, the retained outer You card immediately uses the stronger static blue running border. That border belongs to the card across its local- diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index 9008a65..ca34b3c 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -118,6 +118,11 @@ always means “no coherent value was available now”, never “render empty” is still parented by a Turn owned by the supplied thread. It is reserved for a targeted visible-card update and must never reconstruct identity from payload fields. A stale/detached item returns `nullopt`. +- `tailCard(thread, item, options)` additionally requires that the exact item + be the last child of the last canonical Turn and that it not participate in + prompt-materialization aliasing. It returns one `ConversationTailCard` with + section/root/nested/activity placement and current history chrome for the + bounded structural append path. Any ambiguity returns `nullopt`. - `ConversationOptions` carries only `showReasoning` and `showCodexUpdates`; it owns no filter state. - `ConversationInfo` is adapter control metadata, not a presentation model or @@ -130,6 +135,7 @@ always means “no coherent value was available now”, never “render empty” | `conversationInfo` | `thread`: required stable Thread; returns optional control facts | Wrong kind, stale generation, removal, or contention returns `nullopt`. Success does not construct card DTOs. | | `conversation` | `thread`, positive effective `itemLimit`, presentation `options`; returns optional complete snapshot | Pre: the caller has observed `conversationInfo.readyForDisplay`; this primitive projects the graph's current content and does not itself infer temporal hydration completeness. Limit is clamped to at least one. Success preserves canonical order and root ownership. Invalid target/contention returns `nullopt`. | | `card` | exact `thread` and `item`, presentation `options`; returns optional card DTO | Success requires the item still be a child of a Turn owned by the exact thread. It never searches by payload IDs. | +| `tailCard` | exact `thread` and `item`, presentation `options`; returns optional tail DTO | Success requires the exact canonical last item of the exact canonical last Turn, usable loaded-count state, and no prompt alias. The DTO is non-authoritative and owns only values needed for one Qt append. | ### `middle::ThreadPane` @@ -183,21 +189,74 @@ shell code synchronously, so all caller graph guards must already be released. | `currentSortCriterion` | no parameters; returns enum | Pure local query. | | `visiblySelectedThreadId` | no parameters; returns canonical/provisional string | Empty when no visible row is selected. This is the outbound routing source of truth. | -### `middle::ConversationView` - -`ConversationView` is the sole owner of conversation QWidgets and geometry. -It retains per-thread follow/pause anchors, collapsed-card state, nested -command-output scroll state, stable card widgets for the current retained -window, and presentation options. +### `middle::ConversationItemModel` + +`ConversationItemModel` is the thin `QAbstractListModel` indexing surface for +the selected conversation. It is owned by `ConversationView` and used only on +Qt-main. `NodeGraph` remains the sole canonical state and the SNode.C worker +remains its sole writer. The model neither reads the graph nor retains protocol +payloads, revisions, a journal, or an independently mutable domain state. + +Each row contains the last rendered `VisibleCardData`, its unchanged `NodeRef` +action token, stable key, canonical Turn section key, root/nested position, +presentation visibility, and active-Turn emphasis. `VisibleCardData` is +available to the view/delegate through the typed `card(row)` accessor rather +than copied through `QVariant`; standard roles expose only small identity, +structure, visibility, and accessibility values. + +- `reconcile(snapshot)` flattens one complete toolkit-neutral snapshot into + canonical order. A different thread is the only normal complete authority + replacement and emits `modelReset`. Same-thread differences emit contiguous + insert/remove operations, actual row moves, and row-local `dataChanged`. + Identical effective input emits no signal and increments no presentation + counter. +- `updateCard(card)` resolves the stable key once and returns `Missing`, + `Incompatible`, `Unchanged`, or `Changed`. Only `Changed` emits row-local + `dataChanged` with the affected roles. +- `appendTail(tail)` accepts only a unique card in the exact last Turn + position, changes the former tail's `LastInTurnRole`, and emits one row + insertion. `trimHistoryTo(limit)` retains an owning Turn root where needed + and removes only the bounded prefix. Stable/target maps use absolute deque + ordinals, so surviving rows are not reindexed. +- `setHistoryChrome(hidden, providerHasMore)` changes only Load More facts; + `setActiveTurn(row, active)` changes only the exact root role. +- `setVisibility(visibility)` changes only the rows whose presented role + changes. It does not delete their stable identities or mutate graph state. +- `indexForStableKey(key)` resolves view-local identity. `indexForTarget(ref)` + additionally compares the pinned node pointer identity so a stale action can + never retarget a replacement node with a similar provider ID. + +### `middle::ConversationHeightIndex` + +`ConversationHeightIndex` is a non-QObject Fenwick prefix index owned by the +view. It stores only nonnegative row extents. `top`, `bottom`, total height, +position-to-row lookup, and a changed row height are logarithmic; a tail append +extends the tree and a leading trim advances its physical origin without +traversing retained rows. The pinned-root row-one removal updates one Fenwick +slot and advances the same origin. Uncommon non-tail insert, remove, or move +operations rebuild from the already validated model order. Geometry uses +`qint64` internally and is converted to scrollbar coordinates only at the view +boundary. The index contains no card values or authority. -Thread/ownership contract: Qt-main only. The view owns all cards and Turn -sections through QObject parentage. Snapshot `NodeRef` action tokens may pin -node lifetime but are opaque; the view never dereferences them. Reconciliation -may synchronously emit only local Qt signals; graph/action callbacks run after -the widget transaction and after every graph guard has been released. +### `middle::ConversationView` -- `ConversationView(parent)` creates the established scroll surface, Load - More control, empty label, and content layout. +`ConversationView` is the canonical variable-height `QAbstractItemView` for +the conversation. It owns the thin item model, height index, bounded delegate +document cache, visible rich cards/editors, Load More and empty controls, and +genuinely local interaction state. It retains per-thread follow/pause anchors, +fold state, text selections, focus/current-row identity, nested command-output +scroll state, and presentation options by stable row key. + +Thread/ownership contract: Qt-main only. Passive historical rows have no +QWidget or placeholder. QObject parentage owns only the rich cards currently +inside the viewport plus one viewport of bounded overscan and temporary hidden +staging cards. Snapshot `NodeRef` action tokens are opaque; the view never +dereferences them. Graph/action callbacks run only after graph guards have been +released, and QWidget work never occurs while a graph or channel lock is held. + +- `ConversationView(parent)` creates the established scroll surface, its + private list model and delegate, height index, Load More control, empty label, + and hidden staging host. It creates no historical card widgets. - `setLoadMoreAction(callback)` installs the one user gesture for expanding history. The callback decides retained-graph versus provider loading. - `setPromptMaterializedAction(callback)` is a narrow additive integration @@ -213,20 +272,30 @@ the widget transaction and after every graph guard has been released. initial folding preferences using the already retained snapshot. Existing card-local fold choices remain authoritative. - `presentationOptions()` returns the current local preferences without work. -- `reconcile(snapshot)` is the single structural/render entry point. It - returns `false` and performs zero presentation work for an identical - snapshot. Otherwise it validates the full target order, suppresses exposure - during the existing synchronous commit, reuses compatible keyed widgets, - establishes every Turn/You parent, restores the anchor, and exposes one - final state before returning `true`. -- `reconcileStaged(snapshot)` preserves that same observable contract while - allowing multi-card selection and Load 80 construction to yield under the - hidden staging owner. A strict one-card tail append bypasses staging: the - new card is settled off-hierarchy, then its cached card, nested-Turn, - section, and content height deltas are committed without traversing or - remeasuring retained cards. Reorder, removal, non-tail insertion, and any - coalesced retained-card geometry change continue through full validated - reconciliation. +- `reconcile(snapshot)` applies a complete structural value through the item + model's precise signals, updates only bounded materialized editors, rebuilds + indexed geometry only when structure genuinely requires it, restores the + stable row/pixel anchor, and exposes one completed viewport state. +- `reconcileStaged(snapshot)` preserves that final-state contract for initial + selection and Load 80. Only rich rows expected in the initial viewport and + bounded overscan are constructed and measured one at a time beneath the + hidden staging host; passive rows need no construction. The old complete + surface or stable loading cover remains visible until one final commit. +- `applyCardPresentation(card)` is the ordinary exact-row path. An identical + value is a no-op. An offscreen update changes model data and cached/indexed + facts without constructing, laying out, or painting a QWidget. A visible + passive row invalidates only its row rectangle; a visible rich row applies + only to that editor and propagates only its genuine height delta. +- `appendTailCard(tail, historyActivityLimit)` is the ordinary structural fast + path after `NodeGraphUiAdapter::tailCard` validates canonical placement. It + emits one insert, performs an optional bounded prefix trim, preserves the + stable anchor or existing follow state, and never rebuilds model, section, or + height indexes. `false` requests complete structural reconciliation. +- `conversationModel()` exposes the owned model for Qt selection, + accessibility, deterministic instrumentation, and exact action targeting; + callers must not treat it as graph authority. +- `materializedCardCount()` reports the current bounded rich-widget count for + qualification; it does not count delegate-painted rows. - `setTrailingSpaceHeight(height)` represents only the composer's overlay growth below conversation content and preserves current scroll semantics. - `prepareForLocalPromptAdmission()` resumes following only when pause was @@ -246,15 +315,18 @@ the widget transaction and after every graph guard has been released. | Method | Parameters / return | Preconditions and observable effect | | --- | --- | --- | -| constructor | optional QWidget `parent` | Produces an empty following-mode view with one content owner. No cards exist. | +| constructor | optional QWidget `parent` | Produces an empty following-mode item view. No historical card widgets exist. | | `setLoadMoreAction` | replacement `void()` callback | Called once per accepted button gesture; view neither changes history count nor calls provider itself. | | `setPromptMaterializedAction` | replacement `bool(NodeRef)` callback | Called after a successful local-to-authoritative visual transition. Exact token is moved to callback. False aborts only the remaining callbacks in this reconcile. | | `setPromptRecoveryAction` | replacement `void(NodeRef)` callback | Called only from explicit recovery gesture on the current matching card. | -| `setEmptyMessage` | display `QString` value | Changes only empty-label text; current cards and `snapshot_` remain. Anchor is preserved. | -| `setPresentationOptions` | complete local options | Reconciles retained `snapshot_` with force=true; no graph query. Existing user fold choices win over initial-fold defaults. | +| `setEmptyMessage` | display `QString` value | Changes only empty-label text; model rows remain. Anchor is preserved. | +| `setPresentationOptions` | complete local options | Updates model presentation roles and visible/materialized rows without a graph query. Existing user fold choices win over initial-fold defaults. | | `presentationOptions` | returns value copy | Pure query. | -| `reconcile` | complete snapshot const reference; returns changed bool | Pre: unique section/card stable keys and correct root keys. Post: complete target exposed atomically, cards parented, scroll policy applied, snapshot retained. False guarantees no presentation pass for identical input. | -| `reconcileStaged` | owned complete snapshot | Same final-state contract as `reconcile`; multi-card construction remains hidden and sliced. A single append may commit from cached geometry only when it is the last card of the last retained Turn, or the one root of a new last Turn, and no retained card also changed geometry. | +| `reconcile` | complete snapshot const reference; returns changed bool | Pre: unique section/card stable keys and correct root keys. Post: model order, indexed geometry, bounded editors, delegate surface, and scroll policy match one complete target. False means no effective model change. | +| `reconcileStaged` | owned complete snapshot | Same final-state contract as `reconcile`; only initially visible rich editors are prepared beneath the hidden host in bounded event-loop passes before one atomic reveal. | +| `applyCardPresentation` | one exact `VisibleCardData`; returns optional local impact | Wrong thread/key/incompatible kind returns `nullopt`; identical data returns `None`; otherwise only the resolved row, its genuine section-edge geometry, and its visible editor/delegate rectangle may change. | +| `appendTailCard` | one validated `ConversationTailCard`, activity limit; returns bool | Exact canonical tail inserts directly and optionally trims the prefix without retained-history traversal. Wrong thread, duplicate/invalid placement, active staging, or zero limit returns false for complete reconciliation. | +| `conversationModel`, `materializedCardCount` | borrowed model pointer / integer count | Inspection only. The model is non-authoritative and the widget count remains viewport proportional. | | `setTrailingSpaceHeight` | nonnegative effective pixels | Post: content extent/anchor reflects composer overlay without changing viewport ownership. Repeated value is a no-op. | | `prepareForLocalPromptAdmission` | no parameters | May change pause caused only by composer growth; never overrides explicit user pause. | | `forwardWheelEvent` | live `QWheelEvent*`; returns consumed bool | Event is not owned. Nested eligible control must have declined it. | @@ -282,12 +354,13 @@ graph. Their `VisibleCardData` is the entire canonical presentation input. - `isCollapsed()` and `setCollapsed(value)` read/write user-owned fold state. - `setAuthoritativeTurnActive(value)` changes only the owner card's canonical active emphasis and returns whether paint state changed. -- `setNestedCards(cards)` establishes the owning You card as QObject/layout - parent for all represented child cards in canonical order. - `setNestedPresentation(value)` applies the established nested visual style - when a card is not itself the Turn owner. -- `setNestedItems(items)` is the generalized form used by the existing nested - layout; it does not confer application ownership. + when a card is not itself the Turn root. The item view paints the continuous + Turn/You surface and positions nested rows independently, so a visible card + never owns historical sibling rows. +- `setNestedCards`/`setNestedItems` remain narrow card compatibility methods, + but the virtualized conversation clears them and owns each visible row + directly. They are not a retained conversation layout path. - `setViewportVisible(value)` pauses purely local visual feedback when a card cannot paint; it never changes canonical status. - `commandOutputScrollState()` and `restoreCommandOutputScrollState(state)` @@ -312,9 +385,9 @@ inner wheel/follow state; restoring it must not move the outer conversation. | `canApply` | candidate DTO; returns bool | Pure compatibility check; no QWidget mutation. | | `apply` | complete candidate DTO; returns visible-change bool | Requires `canApply`; post: `data()` equals candidate and specialized controls show its values. | | `applyPresentation` | complete candidate DTO; returns impact enum | Same postcondition as `apply`; impact is local and must not be promoted blindly to pane/window invalidation. | -| collapse methods | bool setter / bool query | Fold state is user-owned and geometry changes remain inside owning Turn section. | +| collapse methods | bool setter / bool query | Fold state is user-owned; the view updates only the affected indexed row/section range and restores the exact anchor. | | `setAuthoritativeTurnActive` | bool; returns paint-change bool | Valid primarily for the root You card. No geometry change for border-only state. | -| nested-parent methods | ordered child QWidget/card pointers | Pointers must be live Qt-main objects. Post: correct QObject/layout parent and canonical order; no child is temporarily unmanaged when transaction becomes visible. | +| nested-parent methods | ordered child QWidget/card pointers | Card-internal compatibility only. `ConversationView` supplies an empty list and represents Turn ownership through model roles, indexed geometry, and delegate painting. | | viewport visibility | bool | Affects only local timers/painting, not data or identity. | | command output state methods | optional state / state const reference | Preserve inner scrollbar value/follow mode without modifying outer anchor. | @@ -539,14 +612,15 @@ it knows belongs to an unfinished authoritative hydration. - `activeTurnId` is canonical active-turn identity. A locally admitted pending new Turn is visually active until provider acknowledgement. -All cards for the selected 80-item window (and each explicitly requested next -80) are created and laid out while updates are suppressed for the shortest -existing reconciliation transaction. They are then exposed in one final -frame. Cards are retained while they remain in the selected window; scrolling -offscreen does not destroy and recreate them. New incoming cards for the -selected thread are materialized in the same transaction even when the user is -paused above them, and anchor restoration prevents vertical or horizontal -movement. +All stable card identities for the selected 80-item window (and each explicitly +requested next 80) become rows in the thin Qt model. Passive rows are measured +and painted by the bounded delegate without QWidget construction. Only rich +rows in the initial viewport plus overscan are created and laid out beneath the +hidden staging host before one final frame is exposed. Scrolling may release an +offscreen rich editor after saving stable-keyed local interaction state; no +placeholder remains. A new selected-thread row is indexed even while the user +is paused above it, but offscreen insertion performs no QWidget work and exact +anchor restoration prevents vertical or horizontal movement. `VisibleCardData::key` is visual identity. Canonical items use thread/turn/item identity; a prompt that began locally keeps its process-wide `LocalPromptKey` @@ -635,20 +709,21 @@ derived from current graph state; they never become application authority. provider call. 3. Otherwise send one exact `LoadHistory` action only when the provider reports more history. -4. Preserve the old anchor while the expanded complete snapshot is reconciled; +4. Preserve the stable row and exact pixel anchor while the expanded complete snapshot is reconciled; never expose reserved empty space followed by delayed cards. ### Admit and acknowledge a prompt 1. Capture the visibly selected exact thread and active Turn before admission. 2. Attempt one typed action. On rejection return `false` and retain the draft. -3. On admission prepare the old view's local-prompt anchor behavior and clear +3. On admission prepare the item view's local-prompt anchor behavior and clear the draft once. 4. Render the pending normal or steering You card under its canonical owner, with pending status and delayed feedback animation. 5. Unrelated items may arrive without changing that ownership or anchor. -6. When the authoritative user item arrives, keep the visual key/widget and - send one prompt-materialized acknowledgement for the exact prompt node. +6. When the authoritative user item arrives, keep the stable visual key and + local interaction state, update only that row/editor, and send one + prompt-materialized acknowledgement for the exact prompt node. 7. Stop pending feedback on the correlated successful request acknowledgement or definitive failure. Keep the settled optimistic card until authoritative item materialization; never dual-send or infer acknowledgement from matching @@ -698,69 +773,38 @@ uncontrolled connection. No remote or GitHub operation is used to maintain this document. -### Final smoothness qualification (2026-09-05) - -The final correction was exercised through the complete Debug application on -Xvfb `:98`, connected to the workspace-isolated bridge and app-server that -remained alive across the scenarios. The retained proof artifacts are under -`../../build/codexui-adapter-qualification/capture/final-smoothness/`: - -- `atomic-thread-selection.mp4` switches from a populated control thread to a - longer mixed thread. The old surface remains complete, a single stable - loading cover is shown while rich cards are staged, and the incoming thread - appears in one committed frame. No card-by-card reveal or reserved blank - extent is exposed. -- `sustained-command-streaming.mp4` records normal prompt admission and an - 1,800-line command with 10 ms output intervals through completion. The exact - command card updates in place with its running border. Of 1,680 captured - frames, 1,204 contain conversation-region motion. Interior pixel-difference - analysis found no visible motion in ThreadPane, Inspector, or shell chrome; - their maximum mean luminance deltas were respectively 0.021, 0.005, and - 0.043. -- `steering-while-scrolled-up.mp4` records a second 1,800-line command, pauses - the outer conversation above the active tail, and admits steering. The - steering You card remains under the same Turn and resolves with the final - answer below the viewport. Frames sampled before and after steering have the - same visible card positions and horizontal coordinates; the full-region - normalized pixel difference is approximately `1.0e-5`, attributable to - capture encoding rather than displacement. - -The deterministic 81-card staging test additionally verifies repeated event -loop heartbeats during hidden construction, no visible partial card tree, a -live delta applied without restarting the stage, one final reveal, and an -unchanged old surface during Load 80. Three consecutive normal offscreen runs -measured the indivisible final geometry commit at 81, 82, and 82 ms, below the -existing 100 ms selection/load boundary. This bounded delay applies only to an -explicit thread selection or Load 80 operation; ordinary card deltas use the -exact retained-card path and do not traverse the loaded history. - -The later append/completion correction is qualified separately under -`../../build/codexui-adapter-qualification/capture/scroll-lag-live/`. -`final-two-pass-all-card-live.mp4` records the complete application at 60 fps -with the then-current four presentation controls checked. It repeatedly sweeps the outer -conversation viewport across the Turn/You card, reasoning/update content, -Agent activity, expanded command output, and final cards while a new command -arrives, streams, and completes. The exact prompt/command/completion interval -starts 7.8 seconds into the movie; conversation-crop freeze detection finds no -static interval of 50 ms or longer during the following 24 seconds. - -One arriving card is now constructed under the hidden staging owner, yields -to the Qt event loop, and only then commits its cached geometry. The focused -80-card Debug benchmark measures card construction phases at approximately -0.6--2.8 ms and cached commits at approximately 2.7--6.4 ms for the ordinary -card kinds exercised by the live turn. The File Changes card's first local -style/layout settlement remains a separate approximately 10--15 ms commit; -it performs no retained-history work. The running-to-completed regression -verifies unchanged card height, scroll range, and paused anchor with zero -conversation geometry passes, including a command first inserted through the -cached append path. The recording cannot exclude a shorter single-frame hitch, -and the user still perceives one occasionally; this residual observation is -retained rather than reported as proven zero-lag behavior. - -The final Debug suite passes 17/17 native tests and the WebUI compatibility -suite passes 83/83. ASan/UBSan executes every suite without a sanitizer -diagnostic; 16/17 pass their functional criteria, while the shell suite's same -strict 100 ms wall-clock assertion measures 163 ms offscreen and 189 ms on -Xvfb under sanitizer instrumentation. The unsanitized criterion and real-app -movie pass; the sanitizer-only timing overrun is not used to relax the product -limit. +### Qt item-view qualification (2026-09-09) + +The final `QAbstractItemView` implementation was exercised through the complete +Debug application on isolated Xvfb display `:99`, connected to one +workspace-local `codex-bridge` and app-server that remained alive across every +scenario. Obsolete retained-widget recordings were removed before replacement. +Current movies and contact sheets are under +`../../build/codexui-adapter-qualification/capture/qt-virtualized-final/`. + +The recordings prove atomic selection of a copied 42,911-event thread; exact +paused anchoring while Load 80 inserts earlier rows; repeated heterogeneous +history sweeps; outer and nested scrolling during 1,600/1,800-line commands; +running-to-completed command transition; normal prompt and steering admission; +authoritative steering acknowledgement below a paused viewport; delegate +promotion, selection/copy, fold/unfold and visible focus; command approval +rejection; and Plan-mode user-input Review, selection, submission, and final +acknowledgement. The temporary copied session was deleted after the paging +recording, and the rejected approval probe created no file. + +During an active-only 60-fps interval with continuous outer scrolling, mean +decoded-frame luminance deltas were 2.211289 in Conversation, 0.000012 in +ThreadPane, 0.000003 in Inspector, 0 in the shell header, and 0.000689 in the +settings/composer region. The small non-conversation values are encoding/cursor +noise; no unrelated pane content moves. Exact identity, anchor, widget-count, +offscreen, focus, accessibility, and event-loop limits remain asserted by the +deterministic tests rather than inferred from lossy video. + +The persistent Debug and integrated ASan/UBSan builds each pass all 19 native +suites; ASan/UBSan reports no finding. The supported independent NodeGraph, +typed-queue, and worker TSan boundary passes 5/5 without a race report. WebUI is +unchanged and its release gate passes 83/83 tests, performance profiling, +production bundling, Chromium responsive/focus qualification, and relocatable +artifact verification. Full ownership, delegate/editor decisions, benchmark +tables, movie names, and remaining limitations are recorded in +`qt-virtualized-conversation-view.md`. diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index f159fe2..a1760f1 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -258,8 +258,10 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, if (read->structureChangedRevision(node) == change.revision || std::ranges::any_of(Fields, [&](std::string_view field) { return fieldChanged(*read, node, field, change.revision); - })) - route = {true, true, {}}; + })) { + route.affected = true; + route.structural = true; + } return; } if (node->id().kind == nodegraph::NodeKind::Thread) @@ -295,14 +297,13 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, if (!belongs) return; route.affected = true; - if (node->id().kind == nodegraph::NodeKind::Turn || - read->structureChangedRevision(node) == change.revision) { + if (node->id().kind == nodegraph::NodeKind::Turn) { route.structural = true; - route.items.clear(); return; } - if (!route.structural && - std::ranges::find(route.items, node) == route.items.end()) + if (read->structureChangedRevision(node) == change.revision) + route.structural = true; + if (std::ranges::find(route.items, node) == route.items.end()) route.items.push_back(node); } catch (const std::invalid_argument &) { // A queued NodeRef may have been retired by a later graph transaction. @@ -365,7 +366,8 @@ bool inspectorAffected(const nodegraph::GraphChanged &change, if (dependency == InspectorDependency::Changes) return fieldChanged(*read, node, "cwd", change.revision) || fieldChanged(*read, node, "workspace", change.revision); - return read->structureChangedRevision(node) == change.revision || + return (dependency == InspectorDependency::Agents && + read->structureChangedRevision(node) == change.revision) || fieldChanged(*read, node, "hydrationState", change.revision); } if (node->id().kind == nodegraph::NodeKind::Thread) @@ -385,8 +387,7 @@ bool inspectorAffected(const nodegraph::GraphChanged &change, return false; if (node->id().kind == nodegraph::NodeKind::Turn) return dependency == InspectorDependency::Plan && - (read->structureChangedRevision(node) == change.revision || - fieldChanged(*read, node, "plan", change.revision) || + (fieldChanged(*read, node, "plan", change.revision) || fieldChanged(*read, node, "planExplanation", change.revision)); const auto state = read->state(node); const std::string type = graphString(graphField(*state, "type")); @@ -1735,6 +1736,46 @@ void ShellWidget::Impl::commitPendingPanes() { owner->property("targetedConversationRoutes").toULongLong() + 1); } } + if (pendingConversation && pendingConversationItems.size() == 1 && + boundGraphThread && + !middleRegion->conversation().structuralStagingActive()) { + const auto options = middleRegion->conversation().presentationOptions(); + auto tail = + uiAdapter.tailCard(boundGraphThread, pendingConversationItems.front(), + {options.showReasoning, options.showCodexUpdates}); + if (tail) { + const std::string &threadId = boundGraphThread->id().canonical; + ConversationHistoryWindow nextHistory = conversationHistory[threadId]; + const bool following = + middleRegion->conversation().modeForThread(threadId) == + middle::ConversationView::Mode::Following; + if ((!following || nextHistory.effective > nextHistory.requested) && + tail->authoritativeItemCount > nextHistory.lastAuthoritativeCount) { + nextHistory.effective += + tail->authoritativeItemCount - nextHistory.lastAuthoritativeCount; + } else if (following) { + nextHistory.effective = nextHistory.requested; + } + nextHistory.lastAuthoritativeCount = tail->authoritativeItemCount; + if (middleRegion->conversation().appendTailCard(std::move(*tail), + nextHistory.effective)) { + conversationHistory.insert_or_assign(threadId, nextHistory); + pendingConversation = false; + pendingConversationItems.clear(); + ++conversationRoutes; + owner->setProperty("conversationRoutes", + static_cast(conversationRoutes)); + owner->setProperty( + "targetedConversationRoutes", + owner->property("targetedConversationRoutes").toULongLong() + 1); + owner->setProperty( + "targetedConversationStructuralAppends", + owner->property("targetedConversationStructuralAppends") + .toULongLong() + + 1); + } + } + } if (pendingConversation) { if (refreshConversation()) { pendingConversation = false; @@ -1836,9 +1877,18 @@ void ShellWidget::Impl::handleGraphChanged( pendingThreadRows.end()) pendingThreadRows.push_back(thread); } - if (stagedPresentationInvalidated || conversation.structural) { + if (stagedPresentationInvalidated) { pendingConversation = true; pendingConversationItems.clear(); + } else if (conversation.structural) { + if (!pendingConversation) { + pendingConversationItems = conversation.items; + } else if (pendingConversationItems != conversation.items) { + // More than one structural transaction was coalesced. The complete + // projection is the only safe way to establish the combined order. + pendingConversationItems.clear(); + } + pendingConversation = true; } else if (conversation.affected && !pendingConversation) { for (const nodegraph::NodeRef &item : conversation.items) if (std::ranges::find(pendingConversationItems, item) == diff --git a/src/codex/middle/ConversationHeightIndex.cpp b/src/codex/middle/ConversationHeightIndex.cpp index 1adf7ac..7deff18 100644 --- a/src/codex/middle/ConversationHeightIndex.cpp +++ b/src/codex/middle/ConversationHeightIndex.cpp @@ -15,12 +15,14 @@ int validHeight(int height) noexcept { return std::max(0, height); } void ConversationHeightIndex::clear() noexcept { heights_.clear(); tree_.assign(1, 0); + offset_ = 0; lastLookupSteps_ = 0; lastUpdateSteps_ = 0; } void ConversationHeightIndex::reset(std::size_t count, int estimatedHeight) { heights_.assign(count, validHeight(estimatedHeight)); + offset_ = 0; rebuild(); } @@ -29,21 +31,23 @@ void ConversationHeightIndex::assign(std::span heights) { heights_.reserve(heights.size()); for (int height : heights) heights_.push_back(validHeight(height)); + offset_ = 0; rebuild(); } void ConversationHeightIndex::insert(std::size_t row, std::span heights) { - row = std::min(row, heights_.size()); + row = std::min(row, size()); if (heights.empty()) return; - if (row == heights_.size()) { + if (row == size()) { heights_.reserve(heights_.size() + heights.size()); tree_.reserve(tree_.size() + heights.size()); for (int height : heights) append(validHeight(height)); return; } + normalize(); std::vector inserted; inserted.reserve(heights.size()); for (int height : heights) @@ -54,9 +58,21 @@ void ConversationHeightIndex::insert(std::size_t row, } void ConversationHeightIndex::remove(std::size_t row, std::size_t count) { - if (row >= heights_.size() || count == 0) + if (row >= size() || count == 0) return; - count = std::min(count, heights_.size() - row); + count = std::min(count, size() - row); + if (row == 0) { + offset_ += count; + lastUpdateSteps_ = 0; + return; + } + if (row == 1 && count == 1 && offset_ + 1 < heights_.size()) { + const int retainedRootHeight = heights_[offset_]; + static_cast(setHeight(1, retainedRootHeight)); + ++offset_; + return; + } + normalize(); if (row + count == heights_.size()) { heights_.resize(row); tree_.resize(row + 1); @@ -70,8 +86,9 @@ void ConversationHeightIndex::remove(std::size_t row, std::size_t count) { void ConversationHeightIndex::move(std::size_t sourceRow, std::size_t count, std::size_t destinationRow) { - if (sourceRow >= heights_.size() || count == 0) + if (sourceRow >= size() || count == 0) return; + normalize(); count = std::min(count, heights_.size() - sourceRow); destinationRow = std::min(destinationRow, heights_.size() - count); if (sourceRow == destinationRow) @@ -90,22 +107,23 @@ void ConversationHeightIndex::move(std::size_t sourceRow, std::size_t count, } int ConversationHeightIndex::height(std::size_t row) const noexcept { - return row < heights_.size() ? heights_[row] : 0; + return row < size() ? heights_[offset_ + row] : 0; } bool ConversationHeightIndex::setHeight(std::size_t row, int nextHeight) noexcept { - if (row >= heights_.size()) + if (row >= size()) return false; nextHeight = validHeight(nextHeight); - const qint64 delta = static_cast(nextHeight) - heights_[row]; + const std::size_t physicalRow = offset_ + row; + const qint64 delta = static_cast(nextHeight) - heights_[physicalRow]; if (delta == 0) { lastUpdateSteps_ = 0; return false; } - heights_[row] = nextHeight; + heights_[physicalRow] = nextHeight; lastUpdateSteps_ = 0; - for (std::size_t index = row + 1; index < tree_.size(); + for (std::size_t index = physicalRow + 1; index < tree_.size(); index += index & (~index + 1)) { tree_[index] += delta; ++lastUpdateSteps_; @@ -114,20 +132,20 @@ bool ConversationHeightIndex::setHeight(std::size_t row, } qint64 ConversationHeightIndex::top(std::size_t row) const noexcept { - return prefix(std::min(row, heights_.size())); + return prefix(std::min(row, size())); } qint64 ConversationHeightIndex::bottom(std::size_t row) const noexcept { - return row < heights_.size() ? prefix(row + 1) : totalHeight(); + return row < size() ? prefix(row + 1) : totalHeight(); } qint64 ConversationHeightIndex::totalHeight() const noexcept { - return prefix(heights_.size()); + return prefix(size()); } std::size_t ConversationHeightIndex::rowAt(qint64 contentY) const noexcept { lastLookupSteps_ = 0; - if (heights_.empty()) + if (empty()) return 0; const qint64 total = totalHeight(); if (total <= 0) @@ -139,18 +157,26 @@ std::size_t ConversationHeightIndex::rowAt(qint64 contentY) const noexcept { bit <<= 1; std::size_t index = 0; qint64 sum = 0; + const qint64 target = physicalPrefix(offset_) + contentY; for (; bit != 0; bit >>= 1) { ++lastLookupSteps_; const std::size_t next = index + bit; - if (next < tree_.size() && sum + tree_[next] <= contentY) { + if (next < tree_.size() && sum + tree_[next] <= target) { index = next; sum += tree_[next]; } } - return std::min(index, heights_.size() - 1); + const std::size_t physicalRow = std::min(index, heights_.size() - 1); + return std::min(physicalRow - offset_, size() - 1); } qint64 ConversationHeightIndex::prefix(std::size_t count) const noexcept { + count = std::min(count, size()); + return physicalPrefix(offset_ + count) - physicalPrefix(offset_); +} + +qint64 +ConversationHeightIndex::physicalPrefix(std::size_t count) const noexcept { count = std::min(count, heights_.size()); qint64 result = 0; for (std::size_t index = count; index != 0; index -= index & (~index + 1)) @@ -162,13 +188,22 @@ void ConversationHeightIndex::append(int height) { const std::size_t oldCount = heights_.size(); const std::size_t index = oldCount + 1; const std::size_t lowBit = index & (~index + 1); - const qint64 preceding = - prefix(oldCount) - prefix(index > lowBit ? index - lowBit : 0); + const qint64 preceding = physicalPrefix(oldCount) - + physicalPrefix(index > lowBit ? index - lowBit : 0); heights_.push_back(height); tree_.push_back(preceding + height); lastUpdateSteps_ = 1; } +void ConversationHeightIndex::normalize() { + if (offset_ == 0) + return; + heights_.erase(heights_.begin(), + heights_.begin() + static_cast(offset_)); + offset_ = 0; + rebuild(); +} + void ConversationHeightIndex::rebuild() { tree_.assign(heights_.size() + 1, 0); for (std::size_t index = 1; index < tree_.size(); ++index) { diff --git a/src/codex/middle/ConversationHeightIndex.h b/src/codex/middle/ConversationHeightIndex.h index 35f55c8..3a1f15e 100644 --- a/src/codex/middle/ConversationHeightIndex.h +++ b/src/codex/middle/ConversationHeightIndex.h @@ -25,8 +25,10 @@ class ConversationHeightIndex final { void move(std::size_t sourceRow, std::size_t count, std::size_t destinationRow); - [[nodiscard]] std::size_t size() const noexcept { return heights_.size(); } - [[nodiscard]] bool empty() const noexcept { return heights_.empty(); } + [[nodiscard]] std::size_t size() const noexcept { + return heights_.size() - offset_; + } + [[nodiscard]] bool empty() const noexcept { return size() == 0; } [[nodiscard]] int height(std::size_t row) const noexcept; [[nodiscard]] bool setHeight(std::size_t row, int height) noexcept; [[nodiscard]] qint64 top(std::size_t row) const noexcept; @@ -46,11 +48,14 @@ class ConversationHeightIndex final { private: [[nodiscard]] qint64 prefix(std::size_t count) const noexcept; + [[nodiscard]] qint64 physicalPrefix(std::size_t count) const noexcept; void append(int height); + void normalize(); void rebuild(); std::vector heights_; std::vector tree_{0}; + std::size_t offset_ = 0; mutable std::size_t lastLookupSteps_ = 0; std::size_t lastUpdateSteps_ = 0; std::size_t rebuildCount_ = 0; diff --git a/src/codex/middle/ConversationItemModel.cpp b/src/codex/middle/ConversationItemModel.cpp index 7a8f6ca..8d86830 100644 --- a/src/codex/middle/ConversationItemModel.cpp +++ b/src/codex/middle/ConversationItemModel.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -201,7 +202,9 @@ bool ConversationItemModel::reconcile(ConversationSnapshot snapshot) { hasMore_ = nextHasMore; if (authorityReplacement) { beginResetModel(); - rows_ = std::move(desired); + rows_.clear(); + rows_.insert(rows_.end(), std::make_move_iterator(desired.begin()), + std::make_move_iterator(desired.end())); threadId_ = nextThreadId; rebuildIndexes(); endResetModel(); @@ -310,7 +313,10 @@ ConversationItemModel::updateCard(VisibleCardData card) { const auto found = stableRows_.find(key); if (found == stableRows_.end()) return CardUpdateResult::Missing; - Row ¤t = rows_[static_cast(found->second)]; + const std::optional modelRow = logicalRow(found->second); + if (!modelRow) + return CardUpdateResult::Missing; + Row ¤t = rows_[static_cast(*modelRow)]; if (!compatible(current.card, card)) return CardUpdateResult::Incompatible; if (current.card == card) @@ -329,7 +335,8 @@ ConversationItemModel::updateCard(VisibleCardData card) { replacement.lastInTurn = current.lastInTurn; replacement.presented = isPresented(replacement.card); replacement.activeTurn = current.activeTurn; - updateRow(found->second, std::move(replacement)); + replacement.historyActivity = current.historyActivity; + updateRow(*modelRow, std::move(replacement)); if (oldTarget != newTarget) { if (oldTarget) targetRows_.erase(oldTarget); @@ -339,6 +346,146 @@ ConversationItemModel::updateCard(VisibleCardData card) { return CardUpdateResult::Changed; } +bool ConversationItemModel::appendTail(ConversationTailCard tail) { + if (tail.card.threadId != threadId_ || tail.sectionKey.empty()) + return false; + const std::string key = stableKey(tail.card.key); + if (key.empty() || stableRows_.contains(key)) + return false; + + const bool startsSection = + rows_.empty() || rows_.back().sectionKey != tail.sectionKey; + if ((!startsSection && tail.turnRoot) || (startsSection && tail.nested)) + return false; + + if (!rows_.empty() && !startsSection) { + Row &previous = rows_.back(); + previous.lastInTurn = false; + emit dataChanged(index(rowCount() - 1), index(rowCount() - 1), + {LastInTurnRole}); + incrementProperty("modelDataChangeCount"); + } + + Row row; + row.card = std::move(tail.card); + row.stableKey = key; + row.sectionKey = std::move(tail.sectionKey); + row.turnRoot = tail.turnRoot; + row.nested = tail.nested; + row.firstInTurn = startsSection; + row.lastInTurn = true; + row.presented = isPresented(row.card); + row.activeTurn = tail.turnRoot && tail.activeTurn; + row.historyActivity = tail.historyActivity; + + const int insertedRow = rowCount(); + const std::size_t ordinal = rowBase_ + rows_.size(); + beginInsertRows({}, insertedRow, insertedRow); + rows_.push_back(std::move(row)); + stableRows_.emplace(key, ordinal); + if (rows_.back().card.target) + targetRows_.emplace(rows_.back().card.target.get(), ordinal); + if (rows_.back().historyActivity) + ++historyActivityCount_; + endInsertRows(); + incrementProperty("modelInsertCount"); + incrementProperty("modelTailAppendCount"); + return true; +} + +ConversationItemModel::HistoryTrim +ConversationItemModel::trimHistoryTo(std::size_t activityLimit) { + HistoryTrim result; + if (historyActivityCount_ <= activityLimit || rows_.empty()) + return result; + + Row &first = rows_.front(); + result.sectionKey = first.sectionKey; + if (first.historyActivity && first.turnRoot && rows_.size() > 1 && + rows_[1].sectionKey == first.sectionKey) { + first.historyActivity = false; + --historyActivityCount_; + result.pinnedRoot = true; + result.sectionKey = first.sectionKey; + incrementProperty("modelHistoryRootPins"); + return result; + } + + // Pending/recovery prompts are protected independently of the history + // suffix. If one is the complete leading section, leave the window one row + // over budget until its authoritative acknowledgement or a complete + // reconciliation can place it without changing optimistic ordering. + if (!first.historyActivity && (!first.turnRoot || rows_.size() == 1 || + rows_[1].sectionKey != first.sectionKey)) + return result; + + int removeCount = 1; + int removeRow = 0; + if (!first.historyActivity && first.turnRoot && rows_.size() > 1 && + rows_[1].sectionKey == first.sectionKey) { + result.sectionKey = first.sectionKey; + if (rows_.size() > 2 && rows_[2].sectionKey == first.sectionKey) { + // Retain the pinned owner at logical row zero while dropping the oldest + // nested activity. Moving that one row across the deque prefix keeps all + // later absolute identity ordinals unchanged. + removeRow = 1; + } else { + removeCount = 2; + } + } + + for (int offset = 0; offset < removeCount; ++offset) { + const Row &removed = rows_[static_cast(removeRow + offset)]; + result.removedStableKeys.push_back(removed.stableKey); + if (removed.historyActivity) { + --historyActivityCount_; + ++result.hiddenIncrement; + } + } + result.row = removeRow; + result.count = removeCount; + + beginRemoveRows({}, removeRow, removeRow + removeCount - 1); + if (removeRow == 1) { + Row retainedRoot = std::move(rows_.front()); + eraseRowIdentity(rows_[1]); + rows_.pop_front(); + rows_.front() = std::move(retainedRoot); + ++rowBase_; + stableRows_.insert_or_assign(rows_.front().stableKey, rowBase_); + if (rows_.front().card.target) + targetRows_.insert_or_assign(rows_.front().card.target.get(), rowBase_); + } else { + for (int offset = 0; offset < removeCount; ++offset) { + eraseRowIdentity(rows_.front()); + rows_.pop_front(); + ++rowBase_; + } + } + endRemoveRows(); + incrementProperty("modelRemoveCount"); + incrementProperty("modelBoundedFrontTrimCount"); + return result; +} + +bool ConversationItemModel::setActiveTurn(int rowIndex, bool active) { + Row *value = rowIndex >= 0 && rowIndex < rowCount() + ? &rows_[static_cast(rowIndex)] + : nullptr; + if (!value || !value->turnRoot || value->activeTurn == active) + return false; + value->activeTurn = active; + emit dataChanged(index(rowIndex), index(rowIndex), {ActiveTurnRole}); + incrementProperty("modelDataChangeCount"); + return true; +} + +void ConversationItemModel::setHistoryChrome( + std::size_t hiddenAuthoritativeItemCount, bool providerHasMore) { + hiddenAuthoritativeItemCount_ = hiddenAuthoritativeItemCount; + hasMore_ = hiddenAuthoritativeItemCount != 0 || providerHasMore; +} + bool ConversationItemModel::setVisibility(Visibility visibility) { if (visibility_ == visibility) return false; @@ -385,7 +532,10 @@ ConversationItemModel::card(int rowIndex) const noexcept { QModelIndex ConversationItemModel::indexForStableKey(const std::string &key) const { const auto found = stableRows_.find(key); - return found == stableRows_.end() ? QModelIndex{} : index(found->second); + if (found == stableRows_.end()) + return {}; + const std::optional row = logicalRow(found->second); + return row ? index(*row) : QModelIndex{}; } QModelIndex @@ -395,8 +545,9 @@ ConversationItemModel::indexForTarget(const nodegraph::NodeRef &target) const { const auto found = targetRows_.find(target.get()); if (found == targetRows_.end()) return {}; - const Row *candidate = row(found->second); - return candidate && candidate->card.target == target ? index(found->second) + const std::optional modelRow = logicalRow(found->second); + const Row *candidate = modelRow ? row(*modelRow) : nullptr; + return candidate && candidate->card.target == target ? index(*modelRow) : QModelIndex{}; } @@ -422,11 +573,14 @@ ConversationItemModel::flatten(ConversationSnapshot &&snapshot) const { const bool turnRoot = representedRoot && key == *root; result.push_back(Row{std::move(card), key, section.key, turnRoot, representedRoot && !turnRoot, position == 0, - position + 1 == section.cards.size(), false, false}); + position + 1 == section.cards.size(), false, false, + false}); Row &row = result.back(); row.presented = isPresented(row.card); row.activeTurn = turnRoot && snapshot.activeTurnId && row.card.turnId == *snapshot.activeTurnId; + row.historyActivity = row.card.kind != CardKind::LocalPrompt && + !(turnRoot && section.rootPinned); } } return result; @@ -447,16 +601,35 @@ void ConversationItemModel::rebuildIndexes() { targetRows_.clear(); stableRows_.reserve(rows_.size()); targetRows_.reserve(rows_.size()); + rowBase_ = 0; + historyActivityCount_ = 0; for (std::size_t position = 0; position < rows_.size(); ++position) { Row &row = rows_[position]; - const int modelRow = static_cast(position); - stableRows_.emplace(row.stableKey, modelRow); + stableRows_.emplace(row.stableKey, position); if (row.card.target) - targetRows_.emplace(row.card.target.get(), modelRow); + targetRows_.emplace(row.card.target.get(), position); + if (row.historyActivity) + ++historyActivityCount_; } incrementProperty("modelIndexRebuildCount"); } +std::optional +ConversationItemModel::logicalRow(std::size_t ordinal) const { + if (ordinal < rowBase_ || ordinal - rowBase_ >= rows_.size()) + return std::nullopt; + const std::size_t value = ordinal - rowBase_; + if (value > static_cast(std::numeric_limits::max())) + return std::nullopt; + return static_cast(value); +} + +void ConversationItemModel::eraseRowIdentity(const Row &row) { + stableRows_.erase(row.stableKey); + if (row.card.target) + targetRows_.erase(row.card.target.get()); +} + void ConversationItemModel::incrementProperty(const char *name) { setProperty(name, property(name).toULongLong() + 1); } diff --git a/src/codex/middle/ConversationItemModel.h b/src/codex/middle/ConversationItemModel.h index 44f2836..d18847f 100644 --- a/src/codex/middle/ConversationItemModel.h +++ b/src/codex/middle/ConversationItemModel.h @@ -7,6 +7,7 @@ #include +#include #include #include #include @@ -59,10 +60,22 @@ class ConversationItemModel final : public QAbstractListModel { bool lastInTurn = false; bool presented = true; bool activeTurn = false; + // Local prompts and roots retained only as a turn owner do not consume the + // bounded authoritative history activity window. + bool historyActivity = true; bool operator==(const Row &) const = default; }; + struct HistoryTrim { + int row = -1; + int count = 0; + std::vector removedStableKeys; + bool pinnedRoot = false; + std::string sectionKey; + std::size_t hiddenIncrement = 0; + }; + explicit ConversationItemModel(QObject *parent = nullptr); [[nodiscard]] int @@ -76,6 +89,11 @@ class ConversationItemModel final : public QAbstractListModel { // model reset. Same-thread order is reconciled with exact row operations. [[nodiscard]] bool reconcile(ConversationSnapshot snapshot); [[nodiscard]] CardUpdateResult updateCard(VisibleCardData card); + [[nodiscard]] bool appendTail(ConversationTailCard tail); + [[nodiscard]] HistoryTrim trimHistoryTo(std::size_t activityLimit); + [[nodiscard]] bool setActiveTurn(int row, bool active); + void setHistoryChrome(std::size_t hiddenAuthoritativeItemCount, + bool providerHasMore); [[nodiscard]] bool setVisibility(Visibility visibility); [[nodiscard]] const Row *row(int row) const noexcept; @@ -97,10 +115,16 @@ class ConversationItemModel final : public QAbstractListModel { void rebuildIndexes(); void incrementProperty(const char *name); void updateRow(int row, Row replacement); - - std::vector rows_; - std::unordered_map stableRows_; - std::unordered_map targetRows_; + [[nodiscard]] std::optional logicalRow(std::size_t ordinal) const; + void eraseRowIdentity(const Row &row); + + std::deque rows_; + // Absolute ordinals let a bounded front trim avoid rewriting every stable + // identity in the retained suffix. + std::unordered_map stableRows_; + std::unordered_map targetRows_; + std::size_t rowBase_ = 0; + std::size_t historyActivityCount_ = 0; std::string threadId_; std::size_t hiddenAuthoritativeItemCount_ = 0; bool hasMore_ = false; diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index ca3be70..c9b1745 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -936,6 +937,203 @@ ConversationView::applyCardPresentation(VisibleCardData &&card) { return applyCardPresentationOwned(std::move(card)); } +bool ConversationView::appendTailCard(ConversationTailCard tail, + std::size_t historyActivityLimit) { + if (pendingStructuralSnapshot_ || tail.card.threadId != threadId_ || + historyActivityLimit == 0) + return false; + + const std::string appendedKey = stableKey(tail.card.key); + if (appendedKey.empty() || model_->indexForStableKey(appendedKey).isValid()) + return false; + + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + const std::size_t hiddenBefore = model_->hiddenAuthoritativeItemCount(); + const bool providerHasMore = tail.providerHasMore; + const int oldLast = model_->rowCount() - 1; + std::string oldLastKey; + std::optional oldLastSection; + int oldLastCardHeight = 0; + if (const ConversationItemModel::Row *row = model_->row(oldLast)) { + oldLastKey = row->stableKey; + if (const auto found = sectionRanges_.find(row->sectionKey); + found != sectionRanges_.end()) + oldLastSection = found->second; + const int oldExtent = heights_.height(static_cast(oldLast)); + if (oldExtent > 0) + oldLastCardHeight = std::max( + 1, oldExtent - rowSpacing(oldLast, oldLastSection ? &*oldLastSection + : nullptr)); + } + + const bool startsActiveSection = tail.turnRoot && tail.activeTurn; + const std::string appendedSection = tail.sectionKey; + const QScopedValueRollback applying(applying_, true); + const QSignalBlocker scrollSignals(verticalScrollBar()); + stopFollowingAnimation(); + + if (!model_->appendTail(std::move(tail))) + return false; + const int appendedRow = model_->rowCount() - 1; + const ConversationItemModel::Row *appended = model_->row(appendedRow); + if (!appended) + return false; + + QRect damage; + if (startsActiveSection && !activeSectionKey_.empty() && + activeSectionKey_ != appendedSection) { + const auto oldActive = sectionRanges_.find(activeSectionKey_); + if (oldActive != sectionRanges_.end()) { + oldActive->second.active = false; + if (const std::optional root = + modelSectionRow(oldActive->second.root)) { + static_cast(model_->setActiveTurn(*root, false)); + damage = damage.united(rowRect(*root)); + if (const ConversationItemModel::Row *rootRow = model_->row(*root)) + if (ConversationCard *rootCard = cardForStableKey(rootRow->stableKey)) + configureCardForRow(rootCard, *rootRow); + } + } + } + + if (appended->turnRoot) + sectionRootRows_.insert_or_assign(appended->sectionKey, + storedSectionRow(appendedRow)); + if (rowPresented(appendedRow)) { + SectionRange &range = sectionRanges_[appended->sectionKey]; + if (range.first < 0) + range.first = storedSectionRow(appendedRow); + range.last = storedSectionRow(appendedRow); + if (appended->turnRoot) + range.root = storedSectionRow(appendedRow); + range.active = range.active || appended->activeTurn; + } + if (startsActiveSection) + activeSectionKey_ = appendedSection; + + // Appending inside a represented turn changes only the previous tail's + // section edge spacing. Preserve its measured card height exactly. + if (oldLast >= 0 && oldLastCardHeight > 0) + static_cast( + heights_.setHeight(static_cast(oldLast), + oldLastCardHeight + rowSpacing(oldLast))); + + int appendedExtent = 0; + if (rowPresented(appendedRow)) { + int cardHeight = estimatedCardHeight(appended->card); + if (rowUsesPassiveDelegate(*appended)) { + QStyleOptionViewItem option; + option.initFrom(this); + option.rect = QRect(0, 0, rowWidth(*appended), 0); + const auto *delegate = + static_cast(itemDelegate()); + cardHeight = delegate + ->cardSize(option, model_->index(appendedRow), + rowCollapsed(*appended)) + .height(); + heightCache_.insert_or_assign( + appendedKey, HeightRecord{rowWidth(*appended), cardHeight}); + } + appendedExtent = std::max(1, cardHeight) + rowSpacing(appendedRow); + } + const std::array appendedHeights{appendedExtent}; + heights_.insert(heights_.size(), appendedHeights); + + std::size_t hiddenIncrement = 0; + while (true) { + const ConversationItemModel::HistoryTrim trim = + model_->trimHistoryTo(historyActivityLimit); + hiddenIncrement += trim.hiddenIncrement; + if (trim.pinnedRoot) + continue; + if (trim.count == 0) + break; + + for (const std::string &key : trim.removedStableKeys) { + const auto materialized = materializedCards_.find(key); + if (materialized == materializedCards_.end()) + continue; + ConversationCard *card = materialized->second; + materializedCards_.erase(materialized); + releaseCard(key, card); + } + heights_.remove(static_cast(trim.row), + static_cast(trim.count)); + + sectionRowOrigin_ += trim.row == 1 ? 1 : trim.count; + const ConversationItemModel::Row *newFirst = model_->row(0); + if (trim.row == 1 && newFirst && newFirst->sectionKey == trim.sectionKey) { + SectionRange &range = sectionRanges_[trim.sectionKey]; + range.first = sectionRowOrigin_; + range.root = sectionRowOrigin_; + sectionRootRows_.insert_or_assign(trim.sectionKey, sectionRowOrigin_); + } else if (newFirst && newFirst->sectionKey == trim.sectionKey) { + SectionRange &range = sectionRanges_[trim.sectionKey]; + range.first = sectionRowOrigin_; + if (range.root >= 0 && range.root < sectionRowOrigin_) { + range.root = -1; + sectionRootRows_.erase(trim.sectionKey); + } + } else { + sectionRanges_.erase(trim.sectionKey); + sectionRootRows_.erase(trim.sectionKey); + if (activeSectionKey_ == trim.sectionKey) + activeSectionKey_.clear(); + } + } + + model_->setHistoryChrome(hiddenBefore + hiddenIncrement, providerHasMore); + loadMore_->setVisible(model_->hasMore()); + if (model_->hasMore()) { + const std::size_t page = + model_->hiddenAuthoritativeItemCount() == 0 + ? AuthoritativeHistoryPageSize + : std::min(AuthoritativeHistoryPageSize, + model_->hiddenAuthoritativeItemCount()); + loadMore_->setText(QStringLiteral("Load %1 more activities") + .arg(static_cast(page))); + loadMore_->setToolTip( + model_->hiddenAuthoritativeItemCount() == 0 + ? QStringLiteral("Earlier activities are available") + : QStringLiteral("%1 earlier activities are retained") + .arg(static_cast( + model_->hiddenAuthoritativeItemCount()))); + } + empty_->setVisible(model_->rowCount() == 0); + + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + updateMaterialization(false); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + layoutMaterializedCards(); + + if (!oldLastKey.empty()) { + const QModelIndex index = model_->indexForStableKey(oldLastKey); + if (index.isValid()) + damage = damage.united(rowRect(index.row())); + } + const QModelIndex appendedIndex = model_->indexForStableKey(appendedKey); + if (appendedIndex.isValid()) + damage = damage.united(rowRect(appendedIndex.row())); + if (!damage.isEmpty()) + viewport()->update(damage.intersected(viewport()->rect())); + + incrementProperty(this, "graphRefreshPasses"); + incrementProperty(this, "targetedStructuralAppends"); + setProperty("conversationHeightIndexUpdateSteps", + static_cast(heights_.lastUpdateSteps())); + updateMaterializationProperties(); + storeCurrentThreadState(); + return true; +} + std::optional ConversationView::applyCardPresentationOwned(VisibleCardData card) { const std::string key = stableKey(card.key); @@ -984,9 +1182,13 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { found != sectionRanges_.end()) oldSection = found->second; QRect presentationDamage = rowRect(index.row()); - if (oldSection && oldSection->root >= 0 && oldSection->last >= 0) - presentationDamage = presentationDamage.united(rowRect(oldSection->root)) - .united(rowRect(oldSection->last)); + if (oldSection) { + const std::optional root = modelSectionRow(oldSection->root); + const std::optional last = modelSectionRow(oldSection->last); + if (root && last) + presentationDamage = + presentationDamage.united(rowRect(*root)).united(rowRect(*last)); + } const bool becomingAuthoritative = before->card.kind == CardKind::LocalPrompt && card.kind == CardKind::UserMessage && card.target; @@ -1025,14 +1227,17 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { std::unordered_set affectedRows{index.row()}; if (oldSection) { - affectedRows.insert(oldSection->root); - affectedRows.insert(oldSection->last); + if (const std::optional row = modelSectionRow(oldSection->root)) + affectedRows.insert(*row); + if (const std::optional row = modelSectionRow(oldSection->last)) + affectedRows.insert(*row); } if (nextSection) { - affectedRows.insert(nextSection->root); - affectedRows.insert(nextSection->last); + if (const std::optional row = modelSectionRow(nextSection->root)) + affectedRows.insert(*row); + if (const std::optional row = modelSectionRow(nextSection->last)) + affectedRows.insert(*row); } - affectedRows.erase(-1); for (const int affectedRow : affectedRows) { const ConversationItemModel::Row *affected = model_->row(affectedRow); @@ -1100,9 +1305,13 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { setScrollValue(verticalScrollBar()->maximum()); else restoreAnchor(presentationAnchor); - if (nextSection && nextSection->root >= 0 && nextSection->last >= 0) - presentationDamage = presentationDamage.united(rowRect(nextSection->root)) - .united(rowRect(nextSection->last)); + if (nextSection) { + const std::optional root = modelSectionRow(nextSection->root); + const std::optional last = modelSectionRow(nextSection->last); + if (root && last) + presentationDamage = + presentationDamage.united(rowRect(*root)).united(rowRect(*last)); + } const int damageTop = std::clamp(presentationDamage.top(), 0, viewport()->height()); viewport()->update(QRect(0, damageTop, viewport()->width(), @@ -1207,11 +1416,15 @@ bool ConversationView::rowPresented(int rowIndex) const { const auto root = sectionRootRows_.find(row->sectionKey); if (root == sectionRootRows_.end()) return true; - const ConversationItemModel::Row *rootRow = model_->row(root->second); + const std::optional rootIndex = modelSectionRow(root->second); + const ConversationItemModel::Row *rootRow = + rootIndex ? model_->row(*rootIndex) : nullptr; return !rootRow || !rowCollapsed(*rootRow); } void ConversationView::rebuildSectionRanges() { + sectionRowOrigin_ = 0; + activeSectionKey_.clear(); sectionRanges_.clear(); sectionRootRows_.clear(); sectionRanges_.reserve(static_cast(model_->rowCount())); @@ -1221,7 +1434,8 @@ void ConversationView::rebuildSectionRanges() { if (!row) continue; if (row->turnRoot) - sectionRootRows_.insert_or_assign(row->sectionKey, rowIndex); + sectionRootRows_.insert_or_assign(row->sectionKey, + storedSectionRow(rowIndex)); } for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { const ConversationItemModel::Row *row = model_->row(rowIndex); @@ -1229,11 +1443,13 @@ void ConversationView::rebuildSectionRanges() { continue; SectionRange &range = sectionRanges_[row->sectionKey]; if (range.first < 0) - range.first = rowIndex; - range.last = rowIndex; + range.first = storedSectionRow(rowIndex); + range.last = storedSectionRow(rowIndex); if (row->turnRoot) - range.root = rowIndex; + range.root = storedSectionRow(rowIndex); range.active = range.active || (row->turnRoot && row->activeTurn); + if (row->turnRoot && row->activeTurn) + activeSectionKey_ = row->sectionKey; } incrementProperty(this, "conversationSectionRangeRebuilds"); } @@ -1245,13 +1461,14 @@ void ConversationView::updateSectionRangeForPresentationChange( return; if (rowPresented(rowIndex)) { + const qint64 storedRow = storedSectionRow(rowIndex); SectionRange &range = sectionRanges_[changed->sectionKey]; - if (range.first < 0 || rowIndex < range.first) - range.first = rowIndex; - if (range.last < 0 || rowIndex > range.last) - range.last = rowIndex; + if (range.first < 0 || storedRow < range.first) + range.first = storedRow; + if (range.last < 0 || storedRow > range.last) + range.last = storedRow; if (changed->turnRoot) - range.root = rowIndex; + range.root = storedRow; range.active = range.active || (changed->turnRoot && changed->activeTurn); return; } @@ -1275,10 +1492,10 @@ void ConversationView::updateSectionRangeForPresentationChange( if (!rowPresented(candidateIndex)) continue; if (replacement.first < 0) - replacement.first = candidateIndex; - replacement.last = candidateIndex; + replacement.first = storedSectionRow(candidateIndex); + replacement.last = storedSectionRow(candidateIndex); if (candidate->turnRoot) - replacement.root = candidateIndex; + replacement.root = storedSectionRow(candidateIndex); replacement.active = replacement.active || (candidate->turnRoot && candidate->activeTurn); } @@ -1301,13 +1518,25 @@ int ConversationView::rowSpacing(int rowIndex, const SectionRange *section) const { if (!section || section->root < 0 || section->last <= section->root) return CardSpacing; - if (rowIndex == section->root) + const qint64 storedRow = storedSectionRow(rowIndex); + if (storedRow == section->root) return 14; - if (rowIndex == section->last) + if (storedRow == section->last) return CardSpacing + 10; return CardSpacing; } +qint64 ConversationView::storedSectionRow(int modelRow) const noexcept { + return sectionRowOrigin_ + modelRow; +} + +std::optional ConversationView::modelSectionRow(qint64 storedRow) const { + const qint64 modelRow = storedRow - sectionRowOrigin_; + if (modelRow < 0 || modelRow >= model_->rowCount()) + return std::nullopt; + return static_cast(modelRow); +} + void ConversationView::rebuildHeightIndex() { std::vector extents; extents.reserve(static_cast(model_->rowCount())); @@ -1772,10 +2001,10 @@ void ConversationView::setCardCollapsed(const std::string &key, if (!rowPresented(last)) continue; if (replacement.first < 0) - replacement.first = last; - replacement.last = last; + replacement.first = storedSectionRow(last); + replacement.last = storedSectionRow(last); if (candidate->turnRoot) - replacement.root = last; + replacement.root = storedSectionRow(last); replacement.active = replacement.active || (candidate->turnRoot && candidate->activeTurn); } @@ -2321,16 +2550,20 @@ void ConversationView::paintEvent(QPaintEvent *event) { section->second.last <= section->second.root) continue; const SectionRange &range = section->second; - const qreal top = static_cast(leadingChromeHeight()) + - static_cast(heights_.top( - static_cast(range.root))) - - verticalScrollBar()->value(); + const std::optional root = modelSectionRow(range.root); + const std::optional sectionLast = modelSectionRow(range.last); + if (!root || !sectionLast) + continue; + const qreal top = + static_cast(leadingChromeHeight()) + + static_cast(heights_.top(static_cast(*root))) - + verticalScrollBar()->value(); const qreal bottom = static_cast(leadingChromeHeight()) + static_cast( - heights_.top(static_cast(range.last))) + - heights_.height(static_cast(range.last)) - - rowSpacing(range.last) + 10 - verticalScrollBar()->value(); + heights_.top(static_cast(*sectionLast))) + + heights_.height(static_cast(*sectionLast)) - + rowSpacing(*sectionLast) + 10 - verticalScrollBar()->value(); const QRectF surface(0.5, top + 0.5, std::max(0, viewport()->width()) - 1.0, std::max(1.0, bottom - top - 1.0)); @@ -2352,7 +2585,7 @@ void ConversationView::paintEvent(QPaintEvent *event) { option.rect = rowRect(rowIndex); const auto section = sectionRanges_.find(row->sectionKey); if (section != sectionRanges_.end() && - section->second.root == rowIndex && + section->second.root == storedSectionRow(rowIndex) && section->second.last > section->second.root) option.viewItemPosition = QStyleOptionViewItem::Beginning; if (!option.rect.intersects(event->rect())) diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index c246a6e..c7410be 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -69,6 +69,11 @@ class ConversationView final : public QAbstractItemView { applyCardPresentation(const VisibleCardData &card); [[nodiscard]] std::optional applyCardPresentation(VisibleCardData &&card); + // Applies one canonical tail insertion without traversing retained model + // rows. Returns false when the delta is not the exact append shape, so the + // caller can use complete structural reconciliation. + [[nodiscard]] bool appendTailCard(ConversationTailCard tail, + std::size_t historyActivityLimit); void setTrailingSpaceHeight(int height); void prepareForLocalPromptAdmission(); @@ -146,9 +151,9 @@ class ConversationView final : public QAbstractItemView { }; struct SectionRange { - int first = -1; - int last = -1; - int root = -1; + qint64 first = -1; + qint64 last = -1; + qint64 root = -1; bool active = false; }; @@ -194,6 +199,8 @@ class ConversationView final : public QAbstractItemView { [[nodiscard]] bool rowPresented(int row) const; [[nodiscard]] int rowSpacing(int row) const; [[nodiscard]] int rowSpacing(int row, const SectionRange *section) const; + [[nodiscard]] qint64 storedSectionRow(int modelRow) const noexcept; + [[nodiscard]] std::optional modelSectionRow(qint64 storedRow) const; [[nodiscard]] QRect rowRect(int row) const; [[nodiscard]] int measureCard(ConversationCard *card, int width) const; [[nodiscard]] bool updateMeasuredHeight(int row, int cardHeight, @@ -252,7 +259,9 @@ class ConversationView final : public QAbstractItemView { std::unordered_map stagedHeights_; std::unordered_map heightCache_; std::unordered_map sectionRanges_; - std::unordered_map sectionRootRows_; + std::unordered_map sectionRootRows_; + qint64 sectionRowOrigin_ = 0; + std::string activeSectionKey_; std::unordered_map cardInteractionStates_; std::unordered_map cardCollapsedStates_; std::unordered_map diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 1edc0fb..ad7c6d8 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -208,10 +208,27 @@ struct TurnSection { // its actual opening prompt. Rendering must never infer ownership from the // first user message that happens to survive history paging. std::optional rootCardKey; + // True only when the root lies before the requested activity suffix and is + // retained solely to preserve the canonical Turn/You owner. + bool rootPinned = false; bool operator==(const TurnSection &) const = default; }; +// Bounded projection for the common canonical tail insertion. It carries no +// authority: the NodeRef target and all values are read from NodeGraph under +// one short lock, then consumed by Qt after the lock has been released. +struct ConversationTailCard { + VisibleCardData card; + std::string sectionKey; + bool turnRoot = false; + bool nested = false; + bool activeTurn = false; + bool historyActivity = true; + std::size_t authoritativeItemCount = 0; + bool providerHasMore = false; +}; + struct ConversationSnapshot { std::string threadId; std::vector sections; diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp index 150b5f3..a0ef26a 100644 --- a/src/codex/ui/NodeGraphUiAdapter.cpp +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -1580,6 +1580,77 @@ NodeGraphUiAdapter::card(const nodegraph::NodeRef &thread, graphString(graphField(*threadState, "cwd"))); } +std::optional +NodeGraphUiAdapter::tailCard(const nodegraph::NodeRef &thread, + const nodegraph::NodeRef &item, + ConversationOptions options) const { + static_cast(options); + if (!graph_ || !thread || !item) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read || !read->contains(thread) || !read->contains(item) || + read->removed(thread) || read->removed(item) || + thread->id().kind != nodegraph::NodeKind::Thread || + item->id().kind != nodegraph::NodeKind::Item) + return std::nullopt; + + const nodegraph::NodeRef turn = read->parent(item); + if (!turn || turn->id().kind != nodegraph::NodeKind::Turn || + read->parent(turn) != thread) + return std::nullopt; + const std::size_t turnCount = read->childCount(thread); + const std::size_t itemCount = read->childCount(turn); + if (turnCount == 0 || itemCount == 0 || + read->childAt(thread, turnCount - 1) != turn || + read->childAt(turn, itemCount - 1) != item) + return std::nullopt; + + // Prompt materialization deliberately reuses the local visual key and + // action target. The complete projection owns that uncommon alias handoff. + if (!read->related(item, nodegraph::RelationKind::PromptMaterialization) + .empty()) + return std::nullopt; + + const auto state = read->state(item); + const auto turnState = read->state(turn); + const auto threadState = read->state(thread); + if (!state || !turnState || !threadState) + return std::nullopt; + const auto authoritativeCount = + graphSize(graphField(*threadState, "historyLoadedItemCount")); + if (!authoritativeCount) + return std::nullopt; + + const std::string threadId = thread->id().canonical; + const std::string turnId = nodegraph::protocolCanonicalId(*turnState, turn); + const auto roots = read->related(turn, nodegraph::RelationKind::TurnRootItem); + const nodegraph::NodeRef root = !roots.empty() && roots.front() && + read->contains(roots.front()) && + !read->removed(roots.front()) + ? roots.front() + : nodegraph::NodeRef{}; + const bool turnRoot = root == item; + bool activeTurn = graphTurnIsActive(*turnState); + if (!activeTurn) { + const auto active = + read->related(thread, nodegraph::RelationKind::ActiveTurn); + activeTurn = std::ranges::find(active, turn) != active.end(); + } + + ConversationTailCard result; + result.card = graphCardData(item, threadId, turnId, *state, + graphString(graphField(*threadState, "cwd"))); + result.sectionKey = sectionComponent("turn:", threadId, turnId); + result.turnRoot = turnRoot; + result.nested = root && !turnRoot; + result.activeTurn = activeTurn; + result.historyActivity = + graphString(graphField(*state, "type")) != "localPrompt"; + result.authoritativeItemCount = *authoritativeCount; + result.providerHasMore = graphProviderHasMoreHistory(*threadState); + return result; +} + std::optional NodeGraphUiAdapter::conversation(const nodegraph::NodeRef &thread, std::size_t itemLimit, @@ -1754,6 +1825,9 @@ NodeGraphUiAdapter::conversation(const nodegraph::NodeRef &thread, section.key = sectionComponent("turn:", result.threadId, input.id); section.turnId = input.id; + section.rootPinned = retainedAuthoritativeCount && input.root && + !input.items.empty() && + !boundedAuthoritativeItems.contains(input.root.get()); bool rootAdded = false; std::unordered_set readyPrompts; for (const nodegraph::NodeRef &candidate : input.items) { @@ -1831,6 +1905,8 @@ NodeGraphUiAdapter::conversation(const nodegraph::NodeRef &thread, const bool root = item == input.root; if (!selected && !root) continue; + if (!selected && root) + section.rootPinned = true; append(item, root); } diff --git a/src/codex/ui/NodeGraphUiAdapter.h b/src/codex/ui/NodeGraphUiAdapter.h index bf46975..8749bfc 100644 --- a/src/codex/ui/NodeGraphUiAdapter.h +++ b/src/codex/ui/NodeGraphUiAdapter.h @@ -43,6 +43,13 @@ class NodeGraphUiAdapter final { card(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item, ConversationOptions options) const; + // Projects only a canonical last item of the selected thread. It is the + // bounded structural fast path for ordinary append; any non-tail or prompt + // alias case returns nullopt and uses complete reconciliation instead. + [[nodiscard]] std::optional + tailCard(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item, + ConversationOptions options) const; + [[nodiscard]] std::optional threads(const nodegraph::NodeRef &selectedThread) const; diff --git a/tests/codex/ConversationItemModelTest.cpp b/tests/codex/ConversationItemModelTest.cpp index 80d9f79..13d92bb 100644 --- a/tests/codex/ConversationItemModelTest.cpp +++ b/tests/codex/ConversationItemModelTest.cpp @@ -242,6 +242,78 @@ bool testVisibilityAndLargeModelRemainDataOnly() { return result; } +bool testBoundedTailAppendKeepsAbsoluteIdentityIndexes() { + ConversationItemModel model; + ConversationSnapshot data; + data.threadId = "tail-thread"; + TurnSection section; + section.key = "tail-section"; + section.turnId = "tail-turn"; + constexpr int Count = 10'000; + section.cards.reserve(Count); + for (int position = 0; position < Count; ++position) { + VisibleCardData value; + value.key = AuthoritativeItemKey{"tail-thread", "tail-turn", + "item-" + std::to_string(position)}; + value.kind = position == 0 ? CardKind::UserMessage : CardKind::AgentMessage; + value.threadId = "tail-thread"; + value.turnId = "tail-turn"; + value.itemId = "item-" + std::to_string(position); + value.payload = position == 0 + ? CardPayload{UserMessageData{"Question", {}}} + : CardPayload{AgentMessageData{"Answer", true}}; + section.cards.push_back(std::move(value)); + } + section.rootCardKey = section.cards.front().key; + data.sections.push_back(std::move(section)); + bool result = require(model.reconcile(std::move(data)), + "tail fixture was not accepted"); + SignalLog log(model); + const qulonglong rebuilds = + model.property("modelIndexRebuildCount").toULongLong(); + + const auto append = [&](int serial) { + ConversationTailCard tail; + tail.card.key = AuthoritativeItemKey{"tail-thread", "tail-turn", + "item-" + std::to_string(serial)}; + tail.card.kind = CardKind::AgentMessage; + tail.card.threadId = "tail-thread"; + tail.card.turnId = "tail-turn"; + tail.card.itemId = "item-" + std::to_string(serial); + tail.card.payload = AgentMessageData{"Tail", true}; + tail.sectionKey = "tail-section"; + tail.nested = true; + return model.appendTail(std::move(tail)); + }; + + result &= require(append(Count), "first bounded tail append failed"); + const ConversationItemModel::HistoryTrim pin = model.trimHistoryTo(Count); + result &= require( + pin.pinnedRoot && pin.count == 0 && model.rowCount() == Count + 1 && + log.inserted.size() == 1 && log.inserted.front().first == Count, + "first append did not retain the root outside the window"); + + log.clear(); + result &= require(append(Count + 1), "second bounded tail append failed"); + const ConversationItemModel::HistoryTrim trim = model.trimHistoryTo(Count); + result &= require( + trim.row == 1 && trim.count == 1 && trim.hiddenIncrement == 1 && + log.inserted.size() == 1 && log.inserted.front().first == Count + 1 && + log.removed.size() == 1 && log.removed.front().first == 1 && + log.removed.front().last == 1, + "bounded append did not emit exact tail/prefix signals"); + result &= require( + model.property("modelIndexRebuildCount").toULongLong() == rebuilds && + model.indexForStableKey("item:11:tail-thread9:tail-turn6:item-0") + .row() == 0 && + model.indexForStableKey("item:11:tail-thread9:tail-turn6:item-2") + .row() == 1 && + model.indexForStableKey("item:11:tail-thread9:tail-turn10:item-10001") + .row() == Count, + "front trim rebuilt or lost absolute stable identity indexes"); + return result; +} + bool testHeightIndexIsBoundedAndExact() { constexpr std::size_t Count = 10000; std::vector heights(Count); @@ -292,6 +364,35 @@ bool testHeightIndexIsBoundedAndExact() { index.remove(8, 1); result &= require(index.size() == Count + 2, "height removal did not restore the expected row count"); + + ConversationHeightIndex prefixIndex; + const std::vector prefixHeights{50, 20, 30, 40}; + prefixIndex.assign(prefixHeights); + const std::size_t prefixRebuilds = prefixIndex.rebuildCount(); + prefixIndex.remove(0, 1); + result &= + require(prefixIndex.size() == 3 && prefixIndex.height(0) == 20 && + prefixIndex.top(1) == 20 && prefixIndex.totalHeight() == 90 && + prefixIndex.rowAt(21) == 1 && + prefixIndex.rebuildCount() == prefixRebuilds, + "prefix removal was not exact and bounded"); + const std::vector prefixTail{55}; + prefixIndex.insert(prefixIndex.size(), prefixTail); + result &= require(prefixIndex.size() == 4 && prefixIndex.height(3) == 55 && + prefixIndex.totalHeight() == 145 && + prefixIndex.rebuildCount() == prefixRebuilds, + "tail append after prefix removal rebuilt height state"); + + ConversationHeightIndex pinnedRoot; + const std::vector pinnedHeights{60, 20, 30}; + pinnedRoot.assign(pinnedHeights); + const std::size_t pinnedRebuilds = pinnedRoot.rebuildCount(); + pinnedRoot.remove(1, 1); + result &= require(pinnedRoot.size() == 2 && pinnedRoot.height(0) == 60 && + pinnedRoot.height(1) == 30 && + pinnedRoot.totalHeight() == 90 && + pinnedRoot.rebuildCount() == pinnedRebuilds, + "pinned-root prefix trim was not exact and bounded"); return result; } @@ -303,6 +404,7 @@ int main(int argc, char **argv) { using namespace codexui::codex::middle; bool result = testStableIdentityAndExactSignals(); result &= testVisibilityAndLargeModelRemainDataOnly(); + result &= testBoundedTailAppendKeepsAbsoluteIdentityIndexes(); result &= testHeightIndexIsBoundedAndExact(); if (result) std::cout << "Conversation item model tests passed\n"; diff --git a/tests/codex/ConversationViewBenchmark.cpp b/tests/codex/ConversationViewBenchmark.cpp index 8f0c6c8..4abc6ad 100644 --- a/tests/codex/ConversationViewBenchmark.cpp +++ b/tests/codex/ConversationViewBenchmark.cpp @@ -130,6 +130,22 @@ int main(int argc, char **argv) { } const qint64 scrollMicroseconds = scroll.nsecsElapsed() / 1000; + const qulonglong modelRebuildsBefore = + view.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong(); + const qulonglong sectionRebuildsBefore = + view.property("conversationSectionRangeRebuilds").toULongLong(); + ConversationTailCard tail; + tail.card = cardData(count); + tail.sectionKey = "turn-section-" + std::to_string(count); + tail.historyActivity = true; + QElapsedTimer append; + append.start(); + const bool appendAccepted = view.appendTailCard(std::move(tail), count); + const qint64 appendMicroseconds = append.nsecsElapsed() / 1000; + QApplication::processEvents(QEventLoop::AllEvents, 20); + const auto cards = view.findChildren(); const auto widgets = view.findChildren(); std::size_t sections = 0; @@ -143,6 +159,17 @@ int main(int argc, char **argv) { {"rows", static_cast(count)}, {"initialMilliseconds", initialMilliseconds}, {"scrollSweepMicroseconds", scrollMicroseconds}, + {"tailAppendMicroseconds", appendMicroseconds}, + {"tailAppendAccepted", appendAccepted}, + {"tailAppendModelRebuilds", + static_cast(view.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() - + modelRebuildsBefore)}, + {"tailAppendSectionRebuilds", + static_cast( + view.property("conversationSectionRangeRebuilds").toULongLong() - + sectionRebuildsBefore)}, {"scrollSamples", ScrollSamples}, {"conversationCards", cards.size()}, {"turnSections", static_cast(sections)}, diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 5978a2b..868e4fd 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -179,6 +179,148 @@ bool viewportProportionalFoundation() { return result; } +bool boundedTailAppendIsViewportProportional() { + ConversationView view; + view.resize(820, 600); + view.show(); + bool result = expect(view.reconcile(conversation(10'000)), + "bounded-tail fixture reconciles"); + settle(); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum() / 2); + settle(); + const auto anchorBefore = firstVisible(view); + const int horizontalBefore = view.horizontalScrollBar()->value(); + const qulonglong modelRebuilds = view.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong(); + const qulonglong sectionRebuilds = + view.property("conversationSectionRangeRebuilds").toULongLong(); + const qulonglong heightRebuilds = + view.property("conversationHeightIndexRebuilds").toULongLong(); + const int widgetsBefore = view.materializedCardCount(); + + ConversationTailCard tail; + tail.card = message(10'000); + tail.sectionKey = "section-10000"; + tail.historyActivity = true; + const std::string tailKey = stableKey(tail.card.key); + result &= expect(view.appendTailCard(std::move(tail), 10'000), + "canonical tail append was accepted"); + settle(); + const auto anchorAfter = firstVisible(view); + result &= expect( + view.conversationModel()->rowCount() == 10'000 && + view.conversationModel()->indexForStableKey(tailKey).row() == 9'999 && + view.conversationModel()->hiddenAuthoritativeItemCount() == 1 && + view.conversationModel()->hasMore(), + "bounded tail append did not retain the exact suffix and history chrome"); + result &= expect(anchorAfter == anchorBefore && + view.horizontalScrollBar()->value() == horizontalBefore, + "bounded tail append and prefix trim did not preserve both " + "viewport axes"); + result &= expect( + view.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() == modelRebuilds && + view.property("conversationSectionRangeRebuilds").toULongLong() == + sectionRebuilds && + view.property("conversationHeightIndexRebuilds").toULongLong() == + heightRebuilds && + view.materializedCardCount() <= std::max(48, widgetsBefore + 4), + "one tail append traversed retained indexes or escaped the widget bound"); + + ConversationView following; + following.resize(820, 600); + following.show(); + result &= expect(following.reconcile(conversation(80)), + "following-tail fixture reconciles"); + settle(); + ConversationTailCard followingTail; + followingTail.card = message(80); + followingTail.sectionKey = "section-80"; + followingTail.historyActivity = true; + const std::string followingKey = stableKey(followingTail.card.key); + result &= expect(following.appendTailCard(std::move(followingTail), 80), + "following tail append was accepted"); + settle(); + const QModelIndex finalIndex = + following.conversationModel()->indexForStableKey(followingKey); + result &= expect(finalIndex.isValid() && following.isAtBottom() && + following.visualRect(finalIndex).bottom() <= + following.viewport()->height(), + "following append exposed one complete final card"); + + ConversationSnapshot rooted; + rooted.threadId = "virtual-thread"; + TurnSection rootedSection; + rootedSection.key = "rooted-section"; + rootedSection.turnId = "rooted-turn"; + for (std::size_t serial = 0; serial < 80; ++serial) { + VisibleCardData value; + value.key = AuthoritativeItemKey{"virtual-thread", "rooted-turn", + "rooted-" + std::to_string(serial)}; + value.kind = serial == 0 ? CardKind::UserMessage : CardKind::AgentMessage; + value.threadId = "virtual-thread"; + value.turnId = "rooted-turn"; + value.itemId = "rooted-" + std::to_string(serial); + value.payload = serial == 0 + ? CardPayload{UserMessageData{"Root prompt", {}}} + : CardPayload{AgentMessageData{"Nested answer", true}}; + rootedSection.cards.push_back(std::move(value)); + } + rootedSection.rootCardKey = rootedSection.cards.front().key; + rooted.sections.push_back(std::move(rootedSection)); + ConversationView rootedView; + rootedView.resize(820, 600); + rootedView.show(); + result &= expect(rootedView.reconcile(std::move(rooted)), + "rooted bounded-tail fixture reconciles"); + settle(); + rootedView.verticalScrollBar()->triggerAction( + QAbstractSlider::SliderToMinimum); + rootedView.verticalScrollBar()->setValue( + rootedView.verticalScrollBar()->maximum() / 2); + settle(); + const auto rootedAnchor = firstVisible(rootedView); + const qulonglong rootedRebuilds = rootedView.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong(); + const auto appendNested = [&rootedView](std::size_t serial) { + ConversationTailCard nestedTail; + nestedTail.card.key = AuthoritativeItemKey{ + "virtual-thread", "rooted-turn", "rooted-" + std::to_string(serial)}; + nestedTail.card.kind = CardKind::AgentMessage; + nestedTail.card.threadId = "virtual-thread"; + nestedTail.card.turnId = "rooted-turn"; + nestedTail.card.itemId = "rooted-" + std::to_string(serial); + nestedTail.card.payload = AgentMessageData{"Nested tail", true}; + nestedTail.sectionKey = "rooted-section"; + nestedTail.nested = true; + nestedTail.historyActivity = true; + return rootedView.appendTailCard(std::move(nestedTail), 80); + }; + result &= expect(appendNested(80) && appendNested(81), + "root-pinned nested tail appends were accepted"); + settle(); + result &= expect( + rootedView.conversationModel()->rowCount() == 81 && + rootedView.conversationModel() + ->indexForStableKey( + "item:14:virtual-thread11:rooted-turn8:rooted-0") + .row() == 0 && + rootedView.conversationModel() + ->indexForStableKey( + "item:14:virtual-thread11:rooted-turn8:rooted-2") + .row() == 1 && + rootedView.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() == rootedRebuilds && + firstVisible(rootedView) == rootedAnchor, + "pinned Turn root trim lost identity, rebuilt history, or moved anchor"); + return result; +} + bool targetedVisibilityChangeIsLocal() { ConversationView view; view.resize(820, 600); @@ -468,6 +610,7 @@ int main(int argc, char **argv) { QApplication application(argc, argv); using namespace codexui::codex::middle; const bool result = viewportProportionalFoundation() && + boundedTailAppendIsViewportProportional() && targetedVisibilityChangeIsLocal() && atomicPagingAndFollowingArrival() && virtualTurnSurfaceAndInteractivePromotion() && diff --git a/tests/codex/NodeGraphUiAdapterTest.cpp b/tests/codex/NodeGraphUiAdapterTest.cpp index e800989..5b4664a 100644 --- a/tests/codex/NodeGraphUiAdapterTest.cpp +++ b/tests/codex/NodeGraphUiAdapterTest.cpp @@ -144,11 +144,56 @@ bool limitsHistoryButPinsTheOwningPrompt() { "bounded projection lost its turn") && require(result->sections[0].cards.size() == 3, "root was not pinned beside retained suffix") && + require(result->sections[0].rootPinned, + "bounded projection did not identify the retained owner") && require(result->sections[0].cards.front().key == *result->sections[0].rootCardKey, "pinned root does not own the turn"); } +bool projectsOnlyTheExactCanonicalTail() { + nodegraph::NodeGraph graph; + NodeRef thread; + NodeRef turn; + NodeRef root; + NodeRef tail; + { + auto write = graph.write(); + NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{2}); + threadState.fields.emplace("historyHasMore", true); + thread = + write.upsert({NodeKind::Thread, "tail-thread"}, std::move(threadState)); + NodeState turnState; + turnState.fields.emplace("id", "tail-turn"); + turnState.status = nodegraph::NodeStatus::Running; + turn = write.upsert({NodeKind::Turn, "tail-turn"}, std::move(turnState)); + root = write.upsert({NodeKind::Item, "tail-root"}, + itemState("tail-root", "userMessage", "Question")); + tail = write.upsert({NodeKind::Item, "tail-answer"}, + itemState("tail-answer", "agentMessage", "Answer")); + write.setParent(thread, turn); + write.setParent(turn, root); + write.setParent(turn, tail); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, root); + write.relate(thread, nodegraph::RelationKind::ActiveTurn, turn); + static_cast(write.finish()); + } + + NodeGraphUiAdapter adapter(graph); + const auto projected = adapter.tailCard(thread, tail, {true, true}); + return require(projected.has_value(), + "canonical last item was not projected") && + require(projected->card.target == tail && !projected->turnRoot && + projected->nested && projected->activeTurn, + "tail projection lost exact NodeRef or turn placement") && + require(projected->authoritativeItemCount == 2 && + projected->providerHasMore, + "tail projection lost authoritative history chrome") && + require(!adapter.tailCard(thread, root, {true, true}), + "a non-tail item entered the bounded append path"); +} + bool preservesReadinessActivityAndAuthoritativeBudgetSemantics() { nodegraph::NodeGraph graph; NodeRef runtime; @@ -251,6 +296,7 @@ int main() { using namespace codexui::codex::ui; if (!projectsCanonicalTurnStructureAndRoot() || !limitsHistoryButPinsTheOwningPrompt() || + !projectsOnlyTheExactCanonicalTail() || !preservesThreadRootsAndExactChildTargets() || !preservesReadinessActivityAndAuthoritativeBudgetSemantics()) return EXIT_FAILURE; diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index ed59919..6c27b48 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -1647,6 +1647,82 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( "a selected message routes only to ConversationView and leaves thread, " "Inspector, and shell-chrome boundaries untouched"); + const int rowsBeforeTail = + conversation ? conversation->conversationModel()->rowCount() : 0; + const qulonglong structuralAppendsBefore = + shell.property("targetedConversationStructuralAppends").toULongLong(); + const qulonglong modelRebuildsBefore = + conversation ? conversation->conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() + : 0; + const qulonglong sectionRebuildsBefore = + conversation ? conversation->property("conversationSectionRangeRebuilds") + .toULongLong() + : 0; + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "item/started", + std::nullopt, + {{"threadId", Value("selected-thread")}, + {"turnId", Value("selected-turn")}, + {"item", Value(Value::Object{{"id", Value("selected-tail")}, + {"type", Value("agentMessage")}, + {"text", Value("Selected tail")}})}}})); + require(spinUntil([&] { + if (!conversation || + conversation->conversationModel()->rowCount() != + rowsBeforeTail + 1) + return false; + const middle::VisibleCardData *tail = + conversation->conversationModel()->card(rowsBeforeTail); + return tail && tail->itemId == "selected-tail"; + }), + "one canonical selected-thread tail item reaches the item view"); + require( + shell.property("targetedConversationStructuralAppends").toULongLong() == + structuralAppendsBefore + 1 && + conversation->conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() == modelRebuildsBefore && + conversation->property("conversationSectionRangeRebuilds") + .toULongLong() == sectionRebuildsBefore && + shell.property("threadPaneRoutes").toULongLong() == + threadRoutesBefore && + shell.property("inspectorRoutes").toULongLong() == + inspectorRoutesBefore && + shell.property("shellRenderCommits").toULongLong() == + shellCommitsBefore, + "one canonical tail insertion uses the bounded structural path without " + "reindexing history or waking unrelated panes"); + if (shell.property("targetedConversationStructuralAppends").toULongLong() != + structuralAppendsBefore + 1 || + conversation->conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() != modelRebuildsBefore || + conversation->property("conversationSectionRangeRebuilds") + .toULongLong() != sectionRebuildsBefore || + shell.property("threadPaneRoutes").toULongLong() != threadRoutesBefore || + shell.property("inspectorRoutes").toULongLong() != + inspectorRoutesBefore || + shell.property("shellRenderCommits").toULongLong() != shellCommitsBefore) + std::cerr + << "tail route diagnostics: appends=" << structuralAppendsBefore << "->" + << shell.property("targetedConversationStructuralAppends").toULongLong() + << " model=" << modelRebuildsBefore << "->" + << conversation->conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() + << " sections=" << sectionRebuildsBefore << "->" + << conversation->property("conversationSectionRangeRebuilds") + .toULongLong() + << " threads=" << threadRoutesBefore << "->" + << shell.property("threadPaneRoutes").toULongLong() + << " inspector=" << inspectorRoutesBefore << "->" + << shell.property("inspectorRoutes").toULongLong() + << " shell=" << shellCommitsBefore << "->" + << shell.property("shellRenderCommits").toULongLong() << '\n'; + const qulonglong targetedThreadRoutesBefore = shell.property("targetedThreadPaneRoutes").toULongLong(); const qulonglong targetedRowUpdatesBefore = diff --git a/ui-review/UI-INVENTORY.md b/ui-review/UI-INVENTORY.md index 2533939..5fe8661 100644 --- a/ui-review/UI-INVENTORY.md +++ b/ui-review/UI-INVENTORY.md @@ -96,6 +96,7 @@ scroll state, splitter sizes, tab selection, focus, and other widget mechanics. Pending prompts are graph nodes and per-thread operation ordering belongs to worker logic, not to a second Qt model. The shared `NodeGraph` is the sole current native store for protocol-derived domains; local interaction values do -not replace AISuite or app-server domain authority. Materialized rows and cards -associate through each node's optional opaque Qt attachment rather than a -permanent NodeId-to-widget registry. +not replace AISuite or app-server domain authority. Conversation rows carry +stable opaque `NodeRef` action identity through the thin Qt item model; +viewport/overscan rich editors are keyed to those rows without a permanent +NodeId-to-widget registry. From 3b7b5fa062b53a593ad1a275bccdb9008ed9051b Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 21:18:04 +0200 Subject: [PATCH 08/39] Prevent recursive conversation mouse forwarding --- src/codex/middle/ConversationView.cpp | 21 ++++- src/codex/middle/ConversationView.h | 4 + .../codex/ConversationVirtualizationTest.cpp | 78 ++++++++++++++++++- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index c9b1745..0fab7cb 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -2431,14 +2431,19 @@ void ConversationView::currentChanged(const QModelIndex ¤t, } void ConversationView::mouseMoveEvent(QMouseEvent *event) { + if (forwardingMouseEvent_) { + event->accept(); + return; + } if (forwardedMouseTarget_ && event->buttons() != Qt::NoButton) { - QWidget *target = forwardedMouseTarget_; + const QPointer target = forwardedMouseTarget_; const QPoint viewportPosition = event->position().toPoint(); const QPoint localPosition = target->mapFrom(viewport(), viewportPosition); QMouseEvent forwarded(event->type(), QPointF(localPosition), event->scenePosition(), event->globalPosition(), event->button(), event->buttons(), event->modifiers(), event->pointingDevice()); + const QScopedValueRollback forwarding(forwardingMouseEvent_, true); QApplication::sendEvent(target, &forwarded); event->setAccepted(forwarded.isAccepted()); return; @@ -2466,6 +2471,10 @@ void ConversationView::mouseMoveEvent(QMouseEvent *event) { } void ConversationView::mousePressEvent(QMouseEvent *event) { + if (forwardingMouseEvent_) { + event->accept(); + return; + } const QPoint viewportPosition = event->position().toPoint(); const QModelIndex index = indexAt(viewportPosition); if (!index.isValid()) { @@ -2504,17 +2513,22 @@ void ConversationView::mousePressEvent(QMouseEvent *event) { event->scenePosition(), event->globalPosition(), event->button(), event->buttons(), event->modifiers(), event->pointingDevice()); - QApplication::sendEvent(target, &forwarded); forwardedMouseTarget_ = target; + const QScopedValueRollback forwarding(forwardingMouseEvent_, true); + QApplication::sendEvent(target, &forwarded); event->setAccepted(forwarded.isAccepted()); } void ConversationView::mouseReleaseEvent(QMouseEvent *event) { + if (forwardingMouseEvent_) { + event->accept(); + return; + } if (!forwardedMouseTarget_) { QAbstractItemView::mouseReleaseEvent(event); return; } - QWidget *target = forwardedMouseTarget_; + const QPointer target = forwardedMouseTarget_; forwardedMouseTarget_.clear(); const QPoint viewportPosition = event->position().toPoint(); const QPoint localPosition = target->mapFrom(viewport(), viewportPosition); @@ -2522,6 +2536,7 @@ void ConversationView::mouseReleaseEvent(QMouseEvent *event) { event->scenePosition(), event->globalPosition(), event->button(), event->buttons(), event->modifiers(), event->pointingDevice()); + const QScopedValueRollback forwarding(forwardingMouseEvent_, true); QApplication::sendEvent(target, &forwarded); event->setAccepted(forwarded.isAccepted()); } diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index c7410be..26df5c6 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -287,6 +287,10 @@ class ConversationView final : public QAbstractItemView { bool materializing_ = false; bool structuralStagePassScheduled_ = false; bool committingStructuralStage_ = false; + // A synthetic event ignored by a card child can propagate back through the + // viewport. Stop that propagated event from entering the forwarding path a + // second time. + bool forwardingMouseEvent_ = false; QPointer forwardedMouseTarget_; }; diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 868e4fd..27bb3ea 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -81,6 +81,15 @@ ConversationCard *materializedCard(ConversationView &view, return nullptr; } +void sendViewportMouse(ConversationView &view, QEvent::Type type, + const QPoint &position, Qt::MouseButton button, + Qt::MouseButtons buttons) { + QMouseEvent event(type, QPointF(position), QPointF(position), + view.viewport()->mapToGlobal(position), button, buttons, + Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &event); +} + bool viewportProportionalFoundation() { ConversationView view; view.resize(820, 600); @@ -603,6 +612,72 @@ bool selectionFocusAndOneGesturePromotion() { return result; } +bool outsideTextDragDoesNotReenterTheView() { + ConversationSnapshot snapshot; + snapshot.threadId = "virtual-thread"; + VisibleCardData update = message(0, "Selectable update text"); + std::get(update.payload).finalAnswer = false; + TurnSection section; + section.key = "update-section"; + section.turnId = update.turnId; + section.cards.push_back(update); + snapshot.sections.push_back(std::move(section)); + + ConversationView view; + view.resize(820, 320); + view.show(); + bool result = expect(view.reconcile(std::move(snapshot)), + "pointer-drag update fixture reconciles"); + settle(); + const QModelIndex index = view.conversationModel()->index(0); + const QRect row = view.visualRect(index); + + // A naturally delivered press on card padding is ignored by the card and + // propagates to the item view. The view's one-gesture forwarding must not + // send that same press recursively back through the parent chain. + const QPoint padding(row.left() + 2, row.center().y()); + sendViewportMouse(view, QEvent::MouseButtonPress, padding, Qt::LeftButton, + Qt::LeftButton); + const QPoint paddingDrag(row.left() + 4, row.center().y() + 2); + sendViewportMouse(view, QEvent::MouseMove, paddingDrag, Qt::NoButton, + Qt::LeftButton); + sendViewportMouse(view, QEvent::MouseButtonRelease, paddingDrag, + Qt::LeftButton, Qt::NoButton); + settle(); + + ConversationCard *card = materializedCard(view, stableKey(update.key)); + QLabel *body = nullptr; + if (card) + for (QLabel *label : card->findChildren()) + if (label->property("markdownSource").isValid()) { + body = label; + break; + } + result &= expect(card && body && view.currentIndex() == index, + "padding press remains a bounded row interaction"); + if (!body) + return false; + + // Begin in the label's blank area after the glyphs and drag back through + // the text. This is the real press/move/release path, not setSelection(). + const QPoint start = body->mapTo( + view.viewport(), QPoint(std::max(1, body->width() - 2), + std::max(1, body->fontMetrics().height() / 2))); + const QPoint finish = + body->mapTo(view.viewport(), + QPoint(1, std::max(1, body->fontMetrics().height() / 2))); + sendViewportMouse(view, QEvent::MouseButtonPress, start, Qt::LeftButton, + Qt::LeftButton); + sendViewportMouse(view, QEvent::MouseMove, finish, Qt::NoButton, + Qt::LeftButton); + sendViewportMouse(view, QEvent::MouseButtonRelease, finish, Qt::LeftButton, + Qt::NoButton); + settle(); + result &= expect(body->hasSelectedText(), + "dragging from outside update glyphs selects text"); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -614,7 +689,8 @@ int main(int argc, char **argv) { targetedVisibilityChangeIsLocal() && atomicPagingAndFollowingArrival() && virtualTurnSurfaceAndInteractivePromotion() && - selectionFocusAndOneGesturePromotion(); + selectionFocusAndOneGesturePromotion() && + outsideTextDragDoesNotReenterTheView(); if (result) std::cout << "Conversation virtualization tests passed\n"; return result ? EXIT_SUCCESS : EXIT_FAILURE; From 3f64b7c1cd3ddb32fc12e023b517ccc2fa50d153 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Wed, 9 Sep 2026 22:13:29 +0200 Subject: [PATCH 09/39] Grow active turn border on direct tail append --- src/codex/middle/ConversationView.cpp | 24 +++++- .../codex/ConversationVirtualizationTest.cpp | 74 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 0fab7cb..f4240a4 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -955,8 +955,11 @@ bool ConversationView::appendTailCard(ConversationTailCard tail, std::string oldLastKey; std::optional oldLastSection; int oldLastCardHeight = 0; + bool fragmentsMaterializedRoot = false; if (const ConversationItemModel::Row *row = model_->row(oldLast)) { oldLastKey = row->stableKey; + fragmentsMaterializedRoot = + tail.nested && row->turnRoot && row->sectionKey == tail.sectionKey; if (const auto found = sectionRanges_.find(row->sectionKey); found != sectionRanges_.end()) oldLastSection = found->second; @@ -1012,8 +1015,27 @@ bool ConversationView::appendTailCard(ConversationTailCard tail, if (startsActiveSection) activeSectionKey_ = appendedSection; + // The first child changes the retained prompt from a complete card into the + // transparent root fragment of the section-wide Turn surface. Direct-tail + // insertion must apply that transition to the existing editor before the + // completed frame is exposed; a later full reconcile may never be needed. + if (fragmentsMaterializedRoot) { + const ConversationItemModel::Row *rootRow = model_->row(oldLast); + ConversationCard *rootCard = + rootRow ? cardForStableKey(rootRow->stableKey) : nullptr; + if (rootRow && rootCard) { + configureCardForRow(rootCard, *rootRow); + oldLastCardHeight = measureCard(rootCard, rowWidth(*rootRow)); + heightCache_.insert_or_assign( + rootRow->stableKey, + HeightRecord{rowWidth(*rootRow), oldLastCardHeight}); + damage = damage.united(rowRect(oldLast)); + } + } + // Appending inside a represented turn changes only the previous tail's - // section edge spacing. Preserve its measured card height exactly. + // section edge spacing. Preserve its measured card height exactly, after + // accounting for the root-fragment margin transition above. if (oldLast >= 0 && oldLastCardHeight > 0) static_cast( heights_.setHeight(static_cast(oldLast), diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 27bb3ea..1e42156 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -486,6 +486,79 @@ bool virtualTurnSurfaceAndInteractivePromotion() { return result; } +bool directTailGrowsTheRetainedTurnSurface() { + ConversationSnapshot snapshot; + snapshot.threadId = "tail-growth"; + snapshot.activeTurnId = "active-turn"; + VisibleCardData root{ + LocalPromptKey{771}, CardKind::LocalPrompt, "tail-growth", "active-turn", + {}, LocalPromptData{771, "Pending question", PromptState::InFlight}}; + TurnSection section; + section.key = "active-section"; + section.turnId = "active-turn"; + section.cards.push_back(root); + section.rootCardKey = root.key; + snapshot.sections.push_back(std::move(section)); + + ConversationView view; + view.resize(820, 360); + view.show(); + bool result = expect(view.reconcile(std::move(snapshot)), + "single optimistic Turn fixture reconciles"); + settle(); + ConversationCard *retained = materializedCard(view, stableKey(root.key)); + result &= expect(retained && + retained->property("authoritativeTurnActive").toBool() && + !retained->property("virtualTurnRoot").toBool(), + "the optimistic Turn owns its emphasized border on its " + "first complete frame"); + + const qulonglong constructions = + view.property("conversationCardConstructions").toULongLong(); + const qulonglong sectionRebuilds = + view.property("conversationSectionRangeRebuilds").toULongLong(); + ConversationTailCard tail; + tail.card = {AuthoritativeItemKey{"tail-growth", "active-turn", "answer"}, + CardKind::AgentMessage, + "tail-growth", + "active-turn", + "answer", + AgentMessageData{"Final answer", true}}; + tail.sectionKey = "active-section"; + tail.nested = true; + tail.activeTurn = true; + tail.historyActivity = true; + const std::string answerKey = stableKey(tail.card.key); + result &= expect(view.appendTailCard(std::move(tail), 80), + "the first nested direct-tail card appends"); + settle(); + + const QModelIndex rootIndex = + view.conversationModel()->indexForStableKey(stableKey(root.key)); + const QModelIndex answerIndex = + view.conversationModel()->indexForStableKey(answerKey); + const QRect answerRect = view.visualRect(answerIndex); + const QImage frame = view.viewport()->grab().toImage(); + const QColor grownBorder = frame.pixelColor( + 1, std::clamp(answerRect.center().y(), 0, frame.height() - 1)); + const bool grew = + retained && retained == materializedCard(view, stableKey(root.key)) && + retained->property("virtualTurnRoot").toBool() && + !retained->property("authoritativeTurnActive").toBool() && + rootIndex.data(ConversationItemModel::ActiveTurnRole).toBool() && + answerIndex.isValid() && answerRect.left() == 12 && + grownBorder.blue() > grownBorder.red() && grownBorder.red() < 183 && + view.property("conversationCardConstructions").toULongLong() == + constructions && + view.property("conversationSectionRangeRebuilds").toULongLong() == + sectionRebuilds; + result &= expect( + grew, + "direct-tail growth retains the root editor and exposes one continuous " + "emphasized Turn border without rebuilding section indexes"); + return result; +} + bool selectionFocusAndOneGesturePromotion() { ConversationView view; view.resize(820, 600); @@ -689,6 +762,7 @@ int main(int argc, char **argv) { targetedVisibilityChangeIsLocal() && atomicPagingAndFollowingArrival() && virtualTurnSurfaceAndInteractivePromotion() && + directTailGrowsTheRetainedTurnSurface() && selectionFocusAndOneGesturePromotion() && outsideTextDragDoesNotReenterTheView(); if (result) From f1a2dd6e1eaeed17e3b8165427990f77b7b66e67 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 00:41:55 +0200 Subject: [PATCH 10/39] Stop idle conversation layout loop --- src/codex/middle/ConversationView.cpp | 7 +++++-- tests/codex/ConversationCardsTest.cpp | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index f4240a4..0255581 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -2417,8 +2417,11 @@ bool ConversationView::eventFilter(QObject *watched, QEvent *event) { card->property("conversationAnchorKey").toString().toStdString(); captureCardInteractionState(key, card, false); } - if (card && event->type() == QEvent::LayoutRequest && !applying_ && - !materializing_) { + // A row's root card is the view's geometry boundary. Measuring that root + // for a descendant QLabel request can change its QTextDocument width and + // post the same descendant request again, keeping an idle view busy. + if (card && widget == card && event->type() == QEvent::LayoutRequest && + !applying_ && !materializing_) { const std::string key = card->property("conversationAnchorKey").toString().toStdString(); const QModelIndex index = model_->indexForStableKey(key); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index a6e4495..71977b4 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -1781,6 +1781,14 @@ bool testStreamingAgentBecomesVisibleWithoutReselection() { optimisticView.visualRect(liveAnswer).height() > 0, "the optimistic live sequence exposes the final answer in " "its settled Turn without reselection"); + LayoutRequestProbe idleLayoutRequests(&optimisticView); + idleLayoutRequests.start(); + spin(40); + idleLayoutRequests.active = false; + result &= expect( + idleLayoutRequests.count <= 4, + "the acknowledged prompt widget reaches layout quiescence after its " + "final answer arrives"); return result; } From 96176534061ff8259ade989db31d9526df83ee8d Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 01:24:26 +0200 Subject: [PATCH 11/39] Add delayed thread loading spinners --- docs/ui-ux-internal-api.md | 7 + docs/web-1.0-contract.md | 7 + src/codex/ShellWidget.cpp | 30 ++-- src/codex/middle/ConversationView.cpp | 154 ++++++++++++++++-- src/codex/middle/ConversationView.h | 10 +- .../codex/ConversationVirtualizationTest.cpp | 150 +++++++++++++++++ tests/codex/ShellIntegrationTest.cpp | 32 +++- web/src/app/App.tsx | 67 ++++++-- web/src/app/BrowserFrontendSession.ts | 11 +- web/src/styles.css | 6 +- web/tests/browser-session-parity.test.mjs | 28 ++++ web/tests/qualification.test.mjs | 12 +- web/tests/responsive-layout.test.mjs | 2 + 13 files changed, 463 insertions(+), 53 deletions(-) diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index ca34b3c..05a45ad 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -276,6 +276,12 @@ released, and QWidget work never occurs while a graph or channel lock is held. model's precise signals, updates only bounded materialized editors, rebuilds indexed geometry only when structure genuinely requires it, restores the stable row/pixel anchor, and exposes one completed viewport state. +- `beginThreadSelection(threadId)` immediately covers the outgoing message + viewport with the application background. If the identified selection is + still unresolved after 500 ms, the cover paints one centered 30 px neutral + gray ring with a 3 px stroke; its 33 ms animation timer exists only while + the ring is visible. A superseded thread identity cannot reveal or dismiss + the current cover. - `reconcileStaged(snapshot)` preserves that final-state contract for initial selection and Load 80. Only rich rows expected in the initial viewport and bounded overscan are constructed and measured one at a time beneath the @@ -323,6 +329,7 @@ released, and QWidget work never occurs while a graph or channel lock is held. | `setPresentationOptions` | complete local options | Updates model presentation roles and visible/materialized rows without a graph query. Existing user fold choices win over initial-fold defaults. | | `presentationOptions` | returns value copy | Pure query. | | `reconcile` | complete snapshot const reference; returns changed bool | Pre: unique section/card stable keys and correct root keys. Post: model order, indexed geometry, bounded editors, delegate surface, and scroll policy match one complete target. False means no effective model change. | +| `beginThreadSelection` | exact selected thread ID | Immediately covers only the message viewport and starts one 500 ms visual-delay timer. Repeating the same pending identity is a no-op; a new identity cancels superseded staging. | | `reconcileStaged` | owned complete snapshot | Same final-state contract as `reconcile`; only initially visible rich editors are prepared beneath the hidden host in bounded event-loop passes before one atomic reveal. | | `applyCardPresentation` | one exact `VisibleCardData`; returns optional local impact | Wrong thread/key/incompatible kind returns `nullopt`; identical data returns `None`; otherwise only the resolved row, its genuine section-edge geometry, and its visible editor/delegate rectangle may change. | | `appendTailCard` | one validated `ConversationTailCard`, activity limit; returns bool | Exact canonical tail inserts directly and optionally trims the prefix without retained-history traversal. Wrong thread, duplicate/invalid placement, active staging, or zero limit returns false for complete reconciliation. | diff --git a/docs/web-1.0-contract.md b/docs/web-1.0-contract.md index 5f76fd9..3aa38a6 100644 --- a/docs/web-1.0-contract.md +++ b/docs/web-1.0-contract.md @@ -171,6 +171,13 @@ Conversation paging pins each represented turn's complete-history root prompt as structural context outside the activity budget; steering prompts never become turn roots. +Thread selection clears only the conversation message surface immediately. +Hydration and React preparation remain identified by the latest selected +thread; superseded results cannot reveal content. Loads finishing within 500 ms +show no spinner. Longer loads show the same centered 30 px neutral-gray ring +and 3 px stroke as the native UI, and remove both the ring and its CSS animation +when the complete selected-thread frame is revealed. + The responsive shell keeps Threads, Conversation, and Inspector visible above 1160 px. At tablet widths it keeps Threads and Conversation in-flow and exposes Inspector as an accessible overlay drawer; at 760 px and below Conversation is diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index a1760f1..2f20563 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -1534,6 +1534,9 @@ void ShellWidget::Impl::runGraphBinding() { void ShellWidget::Impl::bindGraphPanes(nodegraph::NodeRef selectedThread) { if (graphPanesBound && boundGraphThread == selectedThread) return; + if (selectedThread && boundGraphThread != selectedThread) + middleRegion->conversation().beginThreadSelection( + selectedThread->id().canonical); boundGraphThread = std::move(selectedThread); graphPanesBound = true; if (auto threads = uiAdapter.threads(boundGraphThread)) @@ -1575,23 +1578,18 @@ bool ShellWidget::Impl::refreshConversation() { history.lastAuthoritativeCount = info->authoritativeItemCount; if (!info->readyForDisplay) { + if (!info->hydrationFailed) { + middleRegion->conversation().beginThreadSelection(threadId); + return true; + } middleRegion->conversation().setEmptyMessage( - info->hydrationFailed - ? QStringLiteral("Thread loading failed. Select Reload to retry.") - : QStringLiteral("Loading conversation…")); - // A cold selection may show one stable loading surface. When a complete - // conversation is already painted, retain it until the replacement is - // ready so the user never sees an empty intermediate layout. A terminal - // hydration failure is itself the final selected-thread presentation. - if (!presentedGraphThread || presentedGraphThread == boundGraphThread || - info->hydrationFailed) { - middle::ConversationSnapshot loading; - loading.threadId = threadId; - static_cast(middleRegion->conversation().reconcile(loading)); - if (presentedGraphThread != boundGraphThread) { - presentedGraphThread = boundGraphThread; - renderedChrome.reset(); - } + QStringLiteral("Thread loading failed. Select Reload to retry.")); + middle::ConversationSnapshot failed; + failed.threadId = threadId; + static_cast(middleRegion->conversation().reconcile(failed)); + if (presentedGraphThread != boundGraphThread) { + presentedGraphThread = boundGraphThread; + renderedChrome.reset(); } return true; } diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 0255581..0e72a1b 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT #include "codex/middle/ConversationView.h" +#include "codex/ui/UiStyle.h" #include #include @@ -37,6 +38,111 @@ #include namespace codexui::codex::middle { + +class ConversationLoadingOverlay final : public QWidget { +public: + static constexpr int SpinnerDelayMilliseconds = 500; + static constexpr int SpinnerAnimationMilliseconds = 33; + static constexpr int SpinnerDiameter = 30; + static constexpr int SpinnerStrokeWidth = 3; + + explicit ConversationLoadingOverlay(QWidget *parent) : QWidget(parent) { + setObjectName(QStringLiteral("conversationStagingOverlay")); + setAccessibleName(QStringLiteral("Loading conversation")); + setFocusPolicy(Qt::NoFocus); + setAttribute(Qt::WA_OpaquePaintEvent); + + spinnerDelay_.setSingleShot(true); + spinnerDelay_.setTimerType(Qt::PreciseTimer); + spinnerDelay_.setInterval(SpinnerDelayMilliseconds); + connect(&spinnerDelay_, &QTimer::timeout, this, [this] { + if (!isVisible()) + return; + spinnerVisible_ = true; + setProperty("spinnerVisible", true); + spinnerAnimation_.start(); + setProperty("spinnerAnimationActive", true); + update(spinnerRect().adjusted(-2, -2, 2, 2).toAlignedRect()); + }); + + spinnerAnimation_.setTimerType(Qt::PreciseTimer); + spinnerAnimation_.setInterval(SpinnerAnimationMilliseconds); + connect(&spinnerAnimation_, &QTimer::timeout, this, [this] { + phaseDegrees_ = (phaseDegrees_ - 18 + 360) % 360; + setProperty("spinnerAnimationTick", + property("spinnerAnimationTick").toULongLong() + 1); + update(spinnerRect().adjusted(-2, -2, 2, 2).toAlignedRect()); + }); + + setProperty("spinnerDelayMilliseconds", SpinnerDelayMilliseconds); + setProperty("spinnerDiameter", SpinnerDiameter); + setProperty("spinnerStrokeWidth", SpinnerStrokeWidth); + setProperty("spinnerVisible", false); + setProperty("spinnerAnimationActive", false); + setProperty("spinnerAnimationTick", qulonglong{0}); + hide(); + } + + void begin() { + spinnerDelay_.stop(); + spinnerAnimation_.stop(); + spinnerVisible_ = false; + phaseDegrees_ = 90; + setProperty("spinnerVisible", false); + setProperty("spinnerAnimationActive", false); + setProperty("spinnerAnimationTick", qulonglong{0}); + show(); + raise(); + update(); + spinnerDelay_.start(); + } + + void finish() { + spinnerDelay_.stop(); + spinnerAnimation_.stop(); + spinnerVisible_ = false; + setProperty("spinnerVisible", false); + setProperty("spinnerAnimationActive", false); + hide(); + } + +protected: + void paintEvent(QPaintEvent *event) override { + QPainter painter(this); + painter.setClipRegion(event->region()); + painter.fillRect(rect(), QColor(QString::fromLatin1(UiStyle::appBackground))); + if (!spinnerVisible_) + return; + + painter.setRenderHint(QPainter::Antialiasing, true); + const QRectF ring = spinnerRect(); + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(QString::fromLatin1(UiStyle::divider)), + SpinnerStrokeWidth, + Qt::SolidLine, Qt::RoundCap)); + painter.drawEllipse(ring); + painter.setPen(QPen(QColor(QString::fromLatin1(UiStyle::secondary)), + SpinnerStrokeWidth, + Qt::SolidLine, Qt::RoundCap)); + painter.drawArc(ring, phaseDegrees_ * 16, 105 * 16); + } + +private: + [[nodiscard]] QRectF spinnerRect() const { + const int paintedCenterlineDiameter = + SpinnerDiameter - SpinnerStrokeWidth; + QRectF ring(0.0, 0.0, paintedCenterlineDiameter, + paintedCenterlineDiameter); + ring.moveCenter(QRectF(rect()).center()); + return ring; + } + + QTimer spinnerDelay_; + QTimer spinnerAnimation_; + bool spinnerVisible_ = false; + int phaseDegrees_ = 90; +}; + namespace { constexpr int CardSpacing = 8; @@ -478,12 +584,7 @@ ConversationView::ConversationView(QWidget *parent) stagingHost_->setObjectName(QStringLiteral("conversationStagingHost")); stagingHost_->hide(); - stagingOverlay_ = - new QLabel(QStringLiteral("Loading conversation…"), viewport()); - stagingOverlay_->setObjectName(QStringLiteral("conversationStagingOverlay")); - stagingOverlay_->setAlignment(Qt::AlignCenter); - stagingOverlay_->setAutoFillBackground(true); - stagingOverlay_->hide(); + stagingOverlay_ = new ConversationLoadingOverlay(viewport()); followAnimation_ = new QVariantAnimation(this); followAnimation_->setEasingCurve(QEasingCurve::OutCubic); @@ -623,7 +724,19 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { return reconcileOwned(ConversationSnapshot(snapshot)); } +void ConversationView::beginThreadSelection(const std::string &threadId) { + if (threadId.empty() || + (loadingThreadId_ == threadId && stagingOverlay_->isVisible())) + return; + cancelStructuralStaging(); + loadingThreadId_ = threadId; + stagingOverlay_->setGeometry(viewport()->rect()); + stagingOverlay_->begin(); + incrementProperty(this, "threadSelectionLoadsStarted"); +} + bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { + const std::string targetThreadId = snapshot.threadId; const bool switchedThread = snapshot.threadId != threadId_; const Anchor currentAnchor = captureAnchor(); setThread(snapshot.threadId); @@ -720,6 +833,7 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { } viewport()->setUpdatesEnabled(true); + finishThreadSelection(targetThreadId); viewport()->update(); if (changed) incrementProperty(this, "graphRefreshPasses"); @@ -731,18 +845,21 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { } void ConversationView::reconcileStaged(ConversationSnapshot snapshot) { - cancelStructuralStaging(); + if (!loadingThreadId_.empty() && + snapshot.threadId != loadingThreadId_) { + incrementProperty(this, "staleThreadStagesIgnored"); + return; + } + if (snapshot.threadId != threadId_ && loadingThreadId_.empty()) + beginThreadSelection(snapshot.threadId); + else + cancelStructuralStaging(); pendingStructuralSnapshot_ = std::move(snapshot); buildPendingLocations(); choosePendingStageRows(); pendingStructuralCardIndex_ = 0; stagingHost_->resize(std::max(0, viewport()->width()), std::max(0, viewport()->height())); - if (pendingStructuralSnapshot_->threadId != threadId_) { - stagingOverlay_->setGeometry(viewport()->rect()); - stagingOverlay_->show(); - stagingOverlay_->raise(); - } incrementProperty(this, "structuralStageStarts"); if (pendingStructuralCardKeys_.empty()) { runStructuralStagePass(); @@ -909,7 +1026,6 @@ void ConversationView::runStructuralStagePass() { } stagedCards_.clear(); stagedHeights_.clear(); - stagingOverlay_->hide(); incrementProperty(this, "structuralStageCommits"); } @@ -924,7 +1040,17 @@ void ConversationView::cancelStructuralStaging() { } stagedCards_.clear(); stagedHeights_.clear(); - stagingOverlay_->hide(); +} + +void ConversationView::finishThreadSelection(const std::string &threadId) { + if (!loadingThreadId_.empty() && !threadId.empty() && + loadingThreadId_ != threadId) + return; + if (loadingThreadId_.empty() && !stagingOverlay_->isVisible()) + return; + loadingThreadId_.clear(); + stagingOverlay_->finish(); + incrementProperty(this, "threadSelectionLoadsFinished"); } std::optional diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index 26df5c6..a103d4c 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -28,6 +28,8 @@ class QWheelEvent; namespace codexui::codex::middle { +class ConversationLoadingOverlay; + // Canonical variable-height item view for the conversation. NodeGraph remains // authoritative; this class owns only Qt indexing, cached row geometry, and // genuinely local interaction state. QWidget count is bounded by the visible @@ -60,6 +62,10 @@ class ConversationView final : public QAbstractItemView { } [[nodiscard]] bool reconcile(const ConversationSnapshot &snapshot); + // Covers the outgoing message viewport immediately for a different-thread + // selection. A delayed spinner remains presentation-only; the incoming + // snapshot still commits through reconcileStaged as one complete frame. + void beginThreadSelection(const std::string &threadId); void reconcileStaged(ConversationSnapshot snapshot); [[nodiscard]] bool structuralStagingActive() const noexcept { return pendingStructuralSnapshot_.has_value(); @@ -241,13 +247,14 @@ class ConversationView final : public QAbstractItemView { void scheduleStructuralStagePass(); void runStructuralStagePass(); void cancelStructuralStaging(); + void finishThreadSelection(const std::string &threadId); ConversationItemModel *model_ = nullptr; ConversationHeightIndex heights_; QLabel *empty_ = nullptr; QPushButton *loadMore_ = nullptr; QWidget *stagingHost_ = nullptr; - QLabel *stagingOverlay_ = nullptr; + ConversationLoadingOverlay *stagingOverlay_ = nullptr; QVariantAnimation *followAnimation_ = nullptr; std::function loadMoreAction_; @@ -271,6 +278,7 @@ class ConversationView final : public QAbstractItemView { PresentationOptions presentationOptions_; std::string threadId_; QString emptyMessage_; + std::string loadingThreadId_; std::optional pendingStructuralSnapshot_; std::unordered_map pendingLocations_; std::vector pendingStructuralCardKeys_; diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 1e42156..b697a9a 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -5,11 +5,14 @@ #include #include +#include #include +#include #include #include #include #include +#include #include #include @@ -59,6 +62,33 @@ void settle(int passes = 4) { QApplication::processEvents(QEventLoop::AllEvents, 20); } +template +bool waitUntil(Predicate &&predicate, int timeoutMilliseconds) { + QElapsedTimer deadline; + deadline.start(); + while (!predicate() && deadline.elapsed() < timeoutMilliseconds) { + QApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(1); + } + return predicate(); +} + +ConversationSnapshot singleMessageConversation(const std::string &threadId, + const std::string &text) { + VisibleCardData card{ + AuthoritativeItemKey{threadId, "turn", "message"}, + CardKind::AgentMessage, + threadId, + "turn", + "message", + AgentMessageData{text, true}}; + ConversationSnapshot snapshot; + snapshot.threadId = threadId; + snapshot.sections.push_back( + {"section", "turn", {std::move(card)}, std::nullopt}); + return snapshot; +} + std::pair firstVisible(ConversationView &view) { for (int y = 0; y < view.viewport()->height(); ++y) { const QModelIndex index = @@ -431,6 +461,125 @@ bool atomicPagingAndFollowingArrival() { return result; } +bool delayedThreadSelectionSpinner() { + ConversationView view; + view.resize(820, 600); + view.show(); + const ConversationSnapshot source = + singleMessageConversation("spinner-source", "Outgoing conversation"); + bool result = expect(view.reconcile(source), + "spinner source conversation reconciles"); + settle(); + + view.beginThreadSelection("spinner-slow-target"); + settle(); + auto *overlay = view.findChild( + QStringLiteral("conversationStagingOverlay")); + result &= expect( + overlay && overlay->isVisible() && + view.viewport()->childAt(view.viewport()->rect().center()) == overlay && + !overlay->property("spinnerVisible").toBool() && + overlay->property("spinnerDelayMilliseconds").toInt() == 500 && + overlay->property("spinnerDiameter").toInt() == 30 && + overlay->property("spinnerStrokeWidth").toInt() == 3 && + view.conversationModel()->indexForStableKey( + stableKey(AuthoritativeItemKey{"spinner-source", "turn", + "message"})) + .isValid(), + "thread selection immediately covers the outgoing message viewport " + "with a blank centered loading surface"); + + QElapsedTimer early; + early.start(); + while (early.elapsed() < 350) { + QApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(1); + } + result &= expect(overlay && !overlay->property("spinnerVisible").toBool(), + "the first half-second of thread loading shows no spinner"); + result &= expect( + overlay && waitUntil( + [overlay] { + return overlay->property("spinnerVisible").toBool(); + }, + 350) && + overlay->property("spinnerAnimationActive").toBool(), + "a slower thread load starts the gray spinner after its delay"); + QRect spinnerPixels; + if (overlay) { + const QImage frame = overlay->grab().toImage(); + const QColor background(QStringLiteral("#f6f8fb")); + for (int y = 0; y < frame.height(); ++y) + for (int x = 0; x < frame.width(); ++x) + if (frame.pixelColor(x, y) != background) + spinnerPixels |= QRect(x, y, 1, 1); + } + result &= expect( + spinnerPixels.width() >= 29 && spinnerPixels.width() <= 31 && + spinnerPixels.height() >= 29 && spinnerPixels.height() <= 31 && + overlay && + (spinnerPixels.center() - overlay->rect().center()).manhattanLength() <= + 2, + "the painted gray ring is 30 pixels and centered in the message view"); + const qulonglong tick = + overlay ? overlay->property("spinnerAnimationTick").toULongLong() : 0; + result &= expect( + overlay && waitUntil( + [overlay, tick] { + return overlay->property("spinnerAnimationTick") + .toULongLong() > tick; + }, + 150), + "the visible spinner advances while loading"); + + view.reconcileStaged(singleMessageConversation("spinner-slow-target", + "Incoming conversation")); + result &= expect( + waitUntil([&view] { return !view.structuralStagingActive(); }, 500) && + overlay && !overlay->isVisible() && + !overlay->property("spinnerVisible").toBool() && + !overlay->property("spinnerAnimationActive").toBool() && + view.conversationModel() + ->indexForStableKey( + stableKey(AuthoritativeItemKey{ + "spinner-slow-target", "turn", "message"})) + .isValid(), + "the complete target frame atomically removes and stops the spinner"); + + view.beginThreadSelection("spinner-fast-target"); + view.reconcileStaged(singleMessageConversation("spinner-fast-target", + "Fast conversation")); + settle(); + result &= expect( + overlay && !overlay->isVisible() && + !overlay->property("spinnerAnimationActive").toBool(), + "a fast staged selection clears and reveals without spinner motion"); + + view.beginThreadSelection("spinner-stale-target"); + view.beginThreadSelection("spinner-final-target"); + const qulonglong ignoredBefore = + view.property("staleThreadStagesIgnored").toULongLong(); + view.reconcileStaged(singleMessageConversation("spinner-stale-target", + "Stale conversation")); + result &= expect( + view.property("staleThreadStagesIgnored").toULongLong() == + ignoredBefore + 1 && + overlay && overlay->isVisible(), + "a superseded thread stage cannot reveal or stop the current load"); + view.reconcileStaged(singleMessageConversation("spinner-final-target", + "Final conversation")); + settle(); + result &= expect( + overlay && !overlay->isVisible() && + view.conversationModel() + ->indexForStableKey( + stableKey(AuthoritativeItemKey{ + "spinner-final-target", "turn", "message"})) + .isValid(), + "the newest thread identity alone completes the loading surface"); + return result; +} + bool virtualTurnSurfaceAndInteractivePromotion() { ConversationSnapshot snapshot; snapshot.threadId = "turn-surface"; @@ -761,6 +910,7 @@ int main(int argc, char **argv) { boundedTailAppendIsViewportProportional() && targetedVisibilityChangeIsLocal() && atomicPagingAndFollowingArrival() && + delayedThreadSelectionSpinner() && virtualTurnSurfaceAndInteractivePromotion() && directTailGrowsTheRetainedTurnSurface() && selectionFocusAndOneGesturePromotion() && diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 6c27b48..2f19f38 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -1214,6 +1214,8 @@ void threadSwitchStagesTheCompleteReplacement(Configuration &configuration) { auto *list = shell.findChild(QStringLiteral("threadList")); auto *heading = shell.findChild(QStringLiteral("conversationTitle")); + auto *conversation = dynamic_cast( + shell.findChild(QStringLiteral("conversationScroll"))); require(spinUntil([&] { return threadItem(list, "staged-a") && threadItem(list, "staged-b"); @@ -1232,21 +1234,39 @@ void threadSwitchStagesTheCompleteReplacement(Configuration &configuration) { require(selectThread(list, "staged-b"), "the hydrating replacement becomes the visible row selection"); static_cast(takeQtMessages(channels)); - spin(80); - require(agentMessageCard(shell, "complete A card") == source && + auto *loading = conversation ? conversation->findChild( + QStringLiteral("conversationStagingOverlay")) + : nullptr; + spin(350); + require(conversation && loading && loading->isVisible() && + conversation->viewport()->childAt( + conversation->viewport()->rect().center()) == loading && + !loading->property("spinnerVisible").toBool() && + agentMessageCard(shell, "complete A card") == source && !agentMessageCard(shell, "partial B card") && heading && heading->text() == "Complete A", - "a hydrating replacement leaves the complete outgoing surface " - "unchanged and exposes no partial provider cards"); + "a hydrating replacement immediately covers the outgoing message " + "surface and exposes no partial provider cards or early spinner"); + require(spinUntil( + [loading] { + return loading && + loading->property("spinnerVisible").toBool() && + loading->property("spinnerAnimationActive").toBool(); + }, + 300), + "a thread still loading after half a second shows the centered " + "bounded spinner"); markThreadReady(session, worker, "staged-b"); require(spinUntil([&] { return agentMessageCard(shell, "partial B card") && !agentMessageCard(shell, "complete A card") && heading && - heading->text() == "Hydrating B"; + heading->text() == "Hydrating B" && loading && + !loading->isVisible() && + !loading->property("spinnerAnimationActive").toBool(); }), "readiness replaces the staged surface once with the complete " - "incoming conversation and matching heading"); + "incoming conversation, matching heading, and no running spinner"); } void inactiveThreadNeverReactivatesAStaleTurn(Configuration &configuration) { diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 0acc0ec..b983b41 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -21,6 +21,7 @@ import {humanizeProtocolLabel as humanize} from "./Humanize.js"; import {readBrowserStorage, writeBrowserStorage} from "./BrowserStorage.js"; const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; +export const ThreadLoadingSpinnerDelayMilliseconds = 500; export type ResponsiveMode = "desktop" | "tablet" | "mobile"; @@ -527,6 +528,13 @@ function restoreConversationAnchor(container: HTMLElement, anchor: ConversationV container.scrollTop = anchoredScrollTop(cardContentTop, anchor.pixelOffset, container.scrollHeight - container.clientHeight); } +export function ThreadLoadingSurface({spinning}: {spinning: boolean}) { + return
+ {spinning &&
; +} + function Conversation({session, revision, paneControls}: {session: BrowserFrontendSession; revision: number; paneControls?: ReactNode}) { const snapshot = session.getSnapshot(); const thread = session.model.thread(snapshot.selectedThreadId); @@ -544,6 +552,38 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte const pendingGeometry = useRef(); const folding = useRef(new Map()); const [cardStateRevision, forceCardState] = useState(0); + const displayedProjection = useRef(projectionId); + const transitionGeneration = useRef(0); + const [spinnerProjection, setSpinnerProjection] = useState(""); + const threadTransitionActive = snapshot.selectedThreadId !== "" + && (snapshot.selectedThreadLoading || displayedProjection.current !== projectionId); + useEffect(() => { + const generation = ++transitionGeneration.current; + if (!threadTransitionActive) { + displayedProjection.current = projectionId; + setSpinnerProjection(""); + return; + } + let spinnerTimer: ReturnType | undefined; + let revealFrame: number | undefined; + if (snapshot.selectedThreadLoading) { + setSpinnerProjection(current => current === projectionId ? current : ""); + spinnerTimer = setTimeout(() => { + if (transitionGeneration.current === generation) setSpinnerProjection(projectionId); + }, ThreadLoadingSpinnerDelayMilliseconds); + } else { + revealFrame = requestAnimationFrame(() => { + if (transitionGeneration.current !== generation) return; + displayedProjection.current = projectionId; + setSpinnerProjection(""); + forceCardState(value => value + 1); + }); + } + return () => { + if (spinnerTimer !== undefined) clearTimeout(spinnerTimer); + if (revealFrame !== undefined) cancelAnimationFrame(revealFrame); + }; + }, [projectionId, snapshot.selectedThreadLoading, threadTransitionActive]); const [presentation, setPresentation] = useState(storedConversationPresentation); const drafts = useRef(new Map()); const draftRevision = useRef(snapshot.newThreadDraftRevision); @@ -672,21 +712,24 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte -
{ +
{ const element = event.currentTarget; const following = element.scrollHeight - element.scrollTop - element.clientHeight < 24; viewport.updateScroll(projectionId, element.scrollTop, following, conversationAnchor(element)); }}> - {conversation.hasMore && } - {visibleSections.length === 0 &&
C

Conversation activity appears here

} - {visibleSections.map(section => { - const rootKey = section.rootCardKey ? stableKey(section.rootCardKey) : ""; - const prompt = rootKey === "" ? undefined : section.cards.find(card => stableKey(card.key) === rootKey); - const nestedCards = prompt ? section.cards.filter(card => card !== prompt) : []; - const nested = nestedCards.length > 0 ? nestedCards.map(card => renderCard(card, undefined, false, true)) : undefined; - return
- {prompt ? renderCard(prompt, nested, true) : section.cards.map(card => renderCard(card))} -
; - })} + {threadTransitionActive + ? + : <>{conversation.hasMore && } + {visibleSections.length === 0 &&
C

Conversation activity appears here

} + {visibleSections.map(section => { + const rootKey = section.rootCardKey ? stableKey(section.rootCardKey) : ""; + const prompt = rootKey === "" ? undefined : section.cards.find(card => stableKey(card.key) === rootKey); + const nestedCards = prompt ? section.cards.filter(card => card !== prompt) : []; + const nested = nestedCards.length > 0 ? nestedCards.map(card => renderCard(card, undefined, false, true)) : undefined; + return
+ {prompt ? renderCard(prompt, nested, true) : section.cards.map(card => renderCard(card))} +
; + })}}
{ diff --git a/web/src/app/BrowserFrontendSession.ts b/web/src/app/BrowserFrontendSession.ts index f5e469e..9dab994 100644 --- a/web/src/app/BrowserFrontendSession.ts +++ b/web/src/app/BrowserFrontendSession.ts @@ -52,6 +52,7 @@ const actionMethods: Readonly> = { export interface BrowserSessionSnapshot { readonly revision: number; readonly selectedThreadId: string; + readonly selectedThreadLoading: boolean; readonly newThreadIntent: boolean; readonly newThreadDraft?: NewThreadDraft; readonly newThreadDraftRevision: number; @@ -258,8 +259,9 @@ export class BrowserFrontendSession { this.optimisticThreads = this.optimisticThreads.filter(thread => thread.id !== DraftThreadId); this.newThreadDraft = undefined; } - this.selectedThreadId = threadId; this.newThreadIntent = false; this.publish(); + this.selectedThreadId = threadId; this.newThreadIntent = false; if (threadId !== "") this.ensureThreadHydrated(threadId); + this.publish(); } beginNewThread(draft: NewThreadDraft = { workspace: "", name: "", baseInstructions: "", developerInstructions: "", ephemeral: false, @@ -488,6 +490,7 @@ export class BrowserFrontendSession { if (!forced && runtime.hydration !== "notHydrated") return; runtime.hydration = "inFlight"; runtime.operationReady = false; + this.schedulePublish(); const revision = ++runtime.readRevision; const epoch = this.lifecycleEpoch; this.requestPromise("thread.read", {threadId, includeTurns: true}, () => epoch === this.lifecycleEpoch @@ -498,6 +501,7 @@ export class BrowserFrontendSession { if (response.ok && this.model.thread(threadId)) { current.hydration = "hydrated"; current.operationReady = this.model.thread(threadId)?.status !== "notLoaded"; + this.publish(); queueMicrotask(() => this.dispatchNextPrompt(threadId)); return; } @@ -763,7 +767,10 @@ export class BrowserFrontendSession { this.optimisticThreads = this.optimisticThreads.filter(thread => thread.state !== "confirmed" || !this.model.thread(thread.id)); return { - revision: this.revision, selectedThreadId: this.selectedThreadId, newThreadIntent: this.newThreadIntent, + revision: this.revision, selectedThreadId: this.selectedThreadId, + selectedThreadLoading: this.selectedThreadId !== "" + && this.runtimeByThread.get(this.selectedThreadId)?.hydration === "inFlight", + newThreadIntent: this.newThreadIntent, ...(this.newThreadDraft ? {newThreadDraft: this.newThreadDraft} : {}), newThreadDraftRevision: this.newThreadDraftRevision, optimisticThreads: this.optimisticThreads, diff --git a/web/src/styles.css b/web/src/styles.css index 32da6de..c00f7ff 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -5,6 +5,7 @@ font-synthesis: none; } * { box-sizing: border-box; } +.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); } html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; } button, input, textarea { font: inherit; } button { color: inherit; } @@ -85,8 +86,11 @@ h1, h2, h3, p { margin: 0; } .conversation-view-controls button:hover::after, .conversation-view-controls button:focus-visible::after { opacity: 1; } .conversation-view-controls button:hover { background: #f1f5fb; border-color: #b9c4d2; } .conversation-view-controls button.active { background: #e5eeff; border-color: #bfd3f9; color: #1d2633; } -.conversation-scroll { overflow: auto; padding: 22px max(24px, calc((100% - 780px) / 2)) calc(var(--composer-overlay-height) + 24px); } +.conversation-scroll { position: relative; overflow: auto; padding: 22px max(24px, calc((100% - 780px) / 2)) calc(var(--composer-overlay-height) + 24px); } .conversation-scroll::-webkit-scrollbar-track { margin-block-end: calc(var(--composer-overlay-height) + 8px); } +.conversation-loading-surface { position: absolute; z-index: 3; inset: 0 0 var(--composer-overlay-height); display: grid; place-items: center; background: #f2f5f9; } +.thread-loading-spinner { width: 30px; height: 30px; border: 3px solid #d7dee8; border-top-color: #667085; border-radius: 50%; animation: thread-loading-spin .8s linear infinite; } +@keyframes thread-loading-spin { to { transform: rotate(360deg); } } .turn-section { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; } .turn-section:last-child { margin-bottom: 0; } .turn-nested { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } diff --git a/web/tests/browser-session-parity.test.mjs b/web/tests/browser-session-parity.test.mjs index d066476..520d968 100644 --- a/web/tests/browser-session-parity.test.mjs +++ b/web/tests/browser-session-parity.test.mjs @@ -59,6 +59,34 @@ test("browser session defaults to the bridge's canonical WebSocket endpoint", () } }); +test("selected-thread loading follows the latest hydration identity", async () => { + const socket = new FakeSocket(); + const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); + session.connect(); socket.open(); await readyProvider(socket, "thread-loading"); + respond(socket, requests(socket, "thread/list").at(-1), {data: [ + {id: "thread-a", status: {type: "notLoaded"}}, + {id: "thread-b", status: {type: "notLoaded"}}, + ]}); + + session.selectThread("thread-a"); + const readA = requests(socket, "thread/read").at(-1); + assert.equal(session.getSnapshot().selectedThreadLoading, true); + session.selectThread("thread-b"); + const readB = requests(socket, "thread/read").at(-1); + assert.equal(session.getSnapshot().selectedThreadLoading, true); + + respond(socket, readA, {thread: {id: "thread-a", turns: []}}); + await Promise.resolve(); await Promise.resolve(); + assert.equal(session.getSnapshot().selectedThreadId, "thread-b"); + assert.equal(session.getSnapshot().selectedThreadLoading, true, + "a superseded hydration cannot complete the visible loading state"); + + respond(socket, readB, {thread: {id: "thread-b", turns: []}}); + await Promise.resolve(); await Promise.resolve(); + assert.equal(session.getSnapshot().selectedThreadLoading, false); + session.dispose(); +}); + test("user thread operations are single-flight and report failures", async () => { const socket = new FakeSocket(); const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); diff --git a/web/tests/qualification.test.mjs b/web/tests/qualification.test.mjs index 3143271..b1a40db 100644 --- a/web/tests/qualification.test.mjs +++ b/web/tests/qualification.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import {renderToStaticMarkup} from "react-dom/server"; import {createElement} from "react"; -import {App, NewThreadDialog, inspectorPlainState, writeCardClipboard} from "../dist/app/App.js"; +import {App, NewThreadDialog, ThreadLoadingSpinnerDelayMilliseconds, ThreadLoadingSurface, inspectorPlainState, writeCardClipboard} from "../dist/app/App.js"; import {BrowserFrontendSession} from "../dist/app/BrowserFrontendSession.js"; import {readBrowserStorage, writeBrowserStorage} from "../dist/app/BrowserStorage.js"; import {event, humanizeProtocolLabel, result} from "../dist/index.js"; @@ -63,6 +63,16 @@ test("server-rendered shell exposes keyboard and landmark semantics", () => { session.dispose(); }); +test("thread-loading surface delays only its bounded visual spinner", () => { + assert.equal(ThreadLoadingSpinnerDelayMilliseconds, 500); + const blank = renderToStaticMarkup(createElement(ThreadLoadingSurface, {spinning: false})); + const spinning = renderToStaticMarkup(createElement(ThreadLoadingSurface, {spinning: true})); + assert.match(blank, /class="conversation-loading-surface" role="status" aria-live="polite"/u); + assert.match(blank, />Loading conversation { const markup = renderToStaticMarkup(createElement(NewThreadDialog, { initialWorkspace: "/workspace", onCancel: () => {}, onContinue: () => {}, diff --git a/web/tests/responsive-layout.test.mjs b/web/tests/responsive-layout.test.mjs index 342069e..41dafbc 100644 --- a/web/tests/responsive-layout.test.mjs +++ b/web/tests/responsive-layout.test.mjs @@ -122,6 +122,8 @@ test("responsive CSS keeps the desktop grid and removes the old document-width f assert.match(css, /\.composer-dock::before\s*\{[^}]*bottom:\s*100%[^}]*height:\s*8px[^}]*background:\s*#f2f5f9/u); assert.match(css, /\.composer-dock::after\s*\{[^}]*top:\s*-1px[^}]*height:\s*1px[^}]*background:\s*#d7dee8/u); assert.match(css, /\.conversation-scroll::-webkit-scrollbar-track\s*\{[^}]*margin-block-end:\s*calc\(var\(--composer-overlay-height\) \+ 8px\)/u); + assert.match(css, /\.conversation-loading-surface\s*\{[^}]*inset:\s*0 0 var\(--composer-overlay-height\)[^}]*place-items:\s*center[^}]*background:\s*#f2f5f9/u); + assert.match(css, /\.thread-loading-spinner\s*\{[^}]*width:\s*30px[^}]*height:\s*30px[^}]*border:\s*3px solid #d7dee8[^}]*border-top-color:\s*#667085/u); assert.match(css, /\.composer textarea\s*\{[^}]*overscroll-behavior:\s*contain[^}]*background:\s*#fff/u); assert.match(css, /@media \(max-width:\s*520px\)[\s\S]*\.composer-dock\s*\{[^}]*bottom:\s*0[^}]*padding-bottom:\s*8px/u); }); From 34b97521b7b8707f363766bce8328a92002d0fbc Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 10:06:45 +0200 Subject: [PATCH 12/39] Remove obsolete nested card ownership --- docs/ui-ux-internal-api.md | 5 +- src/codex/middle/ConversationCards.cpp | 101 +------------------------ src/codex/middle/ConversationCards.h | 4 - src/codex/middle/ConversationView.cpp | 1 - tests/codex/ConversationCardsTest.cpp | 20 ++--- 5 files changed, 11 insertions(+), 120 deletions(-) diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index 05a45ad..96b83e9 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -365,9 +365,8 @@ graph. Their `VisibleCardData` is the entire canonical presentation input. when a card is not itself the Turn root. The item view paints the continuous Turn/You surface and positions nested rows independently, so a visible card never owns historical sibling rows. -- `setNestedCards`/`setNestedItems` remain narrow card compatibility methods, - but the virtualized conversation clears them and owns each visible row - directly. They are not a retained conversation layout path. +- A card never owns nested sibling widgets. The virtualized view owns each + visible row directly and paints the continuous Turn surface independently. - `setViewportVisible(value)` pauses purely local visual feedback when a card cannot paint; it never changes canonical status. - `commandOutputScrollState()` and `restoreCommandOutputScrollState(state)` diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 624bb4b..52ad581 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -45,7 +45,6 @@ #include #include #include -#include #include namespace codexui::codex::middle { @@ -1036,16 +1035,6 @@ class ConversationCard::Impl final { contentLayout->setSpacing(6); layout->addWidget(content); - nestedCards = new QWidget(owner); - nestedCards->setObjectName(QStringLiteral("conversationNestedCards")); - nestedLayout = new QVBoxLayout(nestedCards); - // Keep a visible section boundary between the complete prompt and the - // activity nested beneath it, in addition to ordinary widget spacing. - nestedLayout->setContentsMargins(0, 8, 0, 0); - nestedLayout->setSpacing(8); - nestedCards->hide(); - layout->addWidget(nestedCards); - QObject::connect(disclosure, &QToolButton::clicked, owner, [this] { emit this->owner->foldRequested(!collapsed); }); QObject::connect(copy, &QToolButton::clicked, owner, [this] { @@ -1259,81 +1248,6 @@ class ConversationCard::Impl final { owner->update(); } - void setNestedItems(const std::vector &items) { - std::vector currentItems; - currentItems.reserve(static_cast(nestedLayout->count())); - for (int index = 0; index < nestedLayout->count(); ++index) - if (QWidget *item = nestedLayout->itemAt(index)->widget()) - currentItems.push_back(item); - - const bool unchanged = currentItems == items; - const bool appendOnly = - currentItems.size() <= items.size() && - std::equal(currentItems.begin(), currentItems.end(), items.begin()); - if (unchanged) { - const bool visible = std::ranges::any_of( - items, [](const QWidget *item) { return item && !item->isHidden(); }); - if (hasVisibleNestedCards != visible) { - hasVisibleNestedCards = visible; - refreshFoldPresentation(); - } - return; - } - if (appendOnly) { - for (std::size_t index = currentItems.size(); index < items.size(); - ++index) { - QWidget *item = items[index]; - if (!item) - continue; - const bool explicitlyHidden = item->isHidden(); - nestedLayout->addWidget(item); - item->setVisible(!explicitlyHidden); - if (auto *card = dynamic_cast(item)) - card->impl_->setNestedConversationCard(true); - } - hasVisibleNestedCards = std::ranges::any_of( - items, [](const QWidget *item) { return item && !item->isHidden(); }); - refreshFoldPresentation(); - return; - } - - const std::unordered_set retained(items.begin(), items.end()); - for (int index = nestedLayout->count() - 1; index >= 0; --index) { - QWidget *item = nestedLayout->itemAt(index)->widget(); - if (!item || retained.contains(item)) - continue; - const bool explicitlyHidden = item->isHidden(); - nestedLayout->removeWidget(item); - item->setParent(owner->parentWidget()); - item->setVisible(!explicitlyHidden); - if (auto *card = dynamic_cast(item)) - card->impl_->setNestedConversationCard(false); - } - for (std::size_t index = 0; index < items.size(); ++index) { - QWidget *item = items[index]; - if (!item) - continue; - const bool explicitlyHidden = item->isHidden(); - const int position = static_cast(index); - if (nestedLayout->indexOf(item) != position) - nestedLayout->insertWidget(position, item); - item->setVisible(!explicitlyHidden); - if (auto *card = dynamic_cast(item)) - card->impl_->setNestedConversationCard(true); - } - hasVisibleNestedCards = std::ranges::any_of( - items, [](const QWidget *item) { return item && !item->isHidden(); }); - refreshFoldPresentation(); - } - - void setNestedCards(const std::vector &cards) { - std::vector items; - items.reserve(cards.size()); - for (ConversationCard *card : cards) - items.push_back(card); - setNestedItems(items); - } - [[nodiscard]] bool hasVisibleContent() const { for (int index = 0; index < contentLayout->count(); ++index) { if (QWidget *widget = contentLayout->itemAt(index)->widget(); @@ -1344,11 +1258,10 @@ class ConversationCard::Impl final { } void refreshFoldPresentation() { - const bool expandable = hasVisibleContent() || hasVisibleNestedCards; + const bool expandable = hasVisibleContent(); disclosure->setExpanded(!collapsed); disclosure->setVisible(expandable); content->setVisible(expandable && !collapsed); - nestedCards->setVisible(hasVisibleNestedCards && !collapsed); } void refreshCopyPresentation() { @@ -1773,9 +1686,6 @@ class ConversationCard::Impl final { std::optional pendingFeedbackDeadlineMs; ImageRibbon *images = nullptr; QStringList fileChangeOpenPaths; - QWidget *nestedCards = nullptr; - QVBoxLayout *nestedLayout = nullptr; - bool hasVisibleNestedCards = false; bool authoritativeTurnActive = false; int turnRootBottomMargin = 10; }; @@ -1817,15 +1727,6 @@ void ConversationCard::setVirtualTurnRootPresentation(bool fragmented) { impl_->setVirtualTurnRootPresentation(fragmented); } -void ConversationCard::setNestedCards( - const std::vector &cards) { - impl_->setNestedCards(cards); -} - -void ConversationCard::setNestedItems(const std::vector &items) { - impl_->setNestedItems(items); -} - void ConversationCard::setViewportVisible(bool visible) { impl_->setViewportVisible(visible); } diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index d79fa5a..0205947 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -104,10 +104,6 @@ class ConversationCard : public QFrame { // In a virtualized turn the view paints the continuous outer You surface; // the root card keeps only its content and interaction geometry. void setVirtualTurnRootPresentation(bool fragmented); - void setNestedCards(const std::vector &cards); - // ConversationView supplies the retained child widgets in canonical order. - // They stay in this existing nested layout while the thread is selected. - void setNestedItems(const std::vector &items); // ConversationView uses this to pause local feedback timers while a card is // not painted. void setViewportVisible(bool visible); diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 0e72a1b..ec44510 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -1890,7 +1890,6 @@ void ConversationView::configureCardForRow( const bool fragmentedRoot = row.turnRoot && section != sectionRanges_.end() && section->second.last > section->second.root; card->setProperty("turnContainer", row.turnRoot); - card->setNestedCards({}); card->setNestedPresentation(row.nested); card->setVirtualTurnRootPresentation(fragmentedRoot); card->setAuthoritativeTurnActive(row.turnRoot && row.activeTurn && diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 71977b4..734f191 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -2549,9 +2549,8 @@ bool testCardFoldingGeometryAndRetention() { QStringLiteral("conversationNestedCards"), Qt::FindDirectChildrenOnly) : nullptr; - result &= expect(promptOnlyCard && promptOnlyNestedCards && - promptOnlyNestedCards->isHidden(), - "an initial turn prompt reserves no nested-card gap"); + result &= expect(promptOnlyCard && !promptOnlyNestedCards, + "a virtualized turn prompt owns no nested-card container"); result &= expect(applyConversation(view, snapshot), "first nested activity extends the folding fixture"); const bool foldingCardsReady = spinUntil([&] { @@ -5361,20 +5360,17 @@ bool testDelayedInitialHistoryMaterializesAtomically() { ? nullptr : qobject_cast( graphAttachment(items.front())->widget.data()); - QWidget *nested = rootCard - ? rootCard->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly) - : nullptr; const int rootHeight = rootCard ? rootCard->height() : -1; - const int nestedHeight = nested ? nested->height() : -1; const int scrollMaximum = view.verticalScrollBar()->maximum(); const qulonglong geometryPasses = view.property("conversationGeometryPasses").toULongLong(); spin(80); const bool finalLayoutStable = - rootCard && nested && rootCard->property("turnContainer").toBool() && - rootCard->height() == rootHeight && nested->height() == nestedHeight && + rootCard && rootCard->property("turnContainer").toBool() && + !rootCard->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly) && + rootCard->height() == rootHeight && view.verticalScrollBar()->maximum() == scrollMaximum && view.property("conversationGeometryPasses").toULongLong() == geometryPasses; @@ -5383,7 +5379,7 @@ bool testDelayedInitialHistoryMaterializesAtomically() { emptyBindingHeld && loadingCoverVisible && hydratedWindowReady && noPartialHistoryFrame && finalLayoutStable, "history arriving after an empty selection remains invisible until all " - "retained cards have their stable final old-UI layout"); + "visible cards have their stable final virtualized layout"); } bool testPartialLiveTailWaitsForAuthoritativeInitialHistory() { From 7df7a605f28e745b4d6248863187fa9899fd196c Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 10:10:49 +0200 Subject: [PATCH 13/39] Keep conversation activity data presentation-only --- src/codex/middle/ConversationCards.cpp | 10 ----- src/codex/middle/ConversationView.cpp | 4 +- src/codex/middle/MiddleTypes.h | 3 -- tests/codex/ConversationCardsTest.cpp | 52 +++++------------------ tests/codex/ConversationViewBenchmark.cpp | 4 +- 5 files changed, 14 insertions(+), 59 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 52ad581..c4480c2 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -641,17 +641,7 @@ QString planMarkdown(const PlanData &plan) { return rows.join(QLatin1Char('\n')); } -QString boundedGenericActivity(const nlohmann::json &raw) { - QString rendered = QString::fromStdString(raw.dump(2)); - if (rendered.size() <= MaximumGenericActivityCharacters) - return rendered; - rendered.truncate(MaximumGenericActivityCharacters); - return rendered + QStringLiteral("\n\n[Activity details truncated]"); -} - QString boundedGenericActivity(const GenericActivityData &activity) { - if (activity.displayDetail.empty()) - return boundedGenericActivity(activity.raw); QString rendered = text(activity.displayDetail); if (rendered.size() <= MaximumGenericActivityCharacters) return rendered; diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index ec44510..71bbdd3 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -227,9 +227,7 @@ QString planText(const PlanData &plan) { } QString genericDetail(const GenericActivityData &activity) { - QString value = activity.displayDetail.empty() - ? QString::fromStdString(activity.raw.dump(2)) - : text(activity.displayDetail); + QString value = text(activity.displayDetail); constexpr qsizetype MaximumCharacters = 4096; if (value.size() <= MaximumCharacters) return value; diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index ad7c6d8..8369734 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -5,8 +5,6 @@ #include "codex/nodegraph/NodeGraph.h" -#include - #include #include #include @@ -157,7 +155,6 @@ struct PlanData { struct GenericActivityData { std::string type; - nlohmann::json raw = nlohmann::json::object(); std::string status; std::string displayDetail; diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 734f191..e50666a 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -301,9 +301,8 @@ VisibleCardData cardForAppearanceAudit(const std::string &threadId, payload = PlanData{"Initial plan", {{"Inspect", "inProgress"}}, {}}; break; case CardKind::GenericActivity: - payload = GenericActivityData{ - "unknownActivity", - {{"type", "unknownActivity"}, {"status", "inProgress"}}}; + payload = GenericActivityData{"unknownActivity", "inProgress", + "type: unknownActivity\nstatus: inProgress"}; break; case CardKind::LocalPrompt: payload = LocalPromptData{9000U + static_cast(index), @@ -341,32 +340,6 @@ struct ConversationGraphSpec { std::make_shared(); }; -nodegraph::Value graphValue(const nlohmann::json &value) { - if (value.is_null()) - return nullptr; - if (value.is_boolean()) - return value.get(); - if (value.is_number_unsigned()) - return value.get(); - if (value.is_number_integer()) - return value.get(); - if (value.is_number_float()) - return value.get(); - if (value.is_string()) - return value.get(); - if (value.is_array()) { - nodegraph::Value::Array result; - result.reserve(value.size()); - for (const nlohmann::json &entry : value) - result.push_back(graphValue(entry)); - return result; - } - nodegraph::Value::Object result; - for (const auto &[key, entry] : value.items()) - result.emplace(key, graphValue(entry)); - return result; -} - nodegraph::NodeStatus graphStatus(std::string_view status) { if (status == "pending" || status == "inProgress" || status == "running") return nodegraph::NodeStatus::Running; @@ -507,9 +480,6 @@ nodegraph::NodeState fixtureNodeState(const VisibleCardData &card) { } case CardKind::GenericActivity: { const auto &data = std::get(card.payload); - const nodegraph::Value raw = graphValue(data.raw); - if (const auto *object = raw.asObject()) - fields = *object; fields.insert_or_assign("type", data.type); fields.insert_or_assign("status", data.status); if (!data.displayDetail.empty()) @@ -1960,8 +1930,8 @@ bool testCardCopyControls() { false}, {{AuthoritativeItemKey{thread, "turn", "generic"}, CardKind::GenericActivity, thread, "turn", "generic", - GenericActivityData{"custom", {{"detail", "value"}}}}, - QStringLiteral("{\n \"detail\": \"value\"\n}"), + GenericActivityData{"custom", {}, "detail: value"}}, + QStringLiteral("detail: value"), false}, {{LocalPromptKey{99}, CardKind::LocalPrompt, @@ -2112,7 +2082,7 @@ bool testMutableCardsAndCommandOutput() { {}}}, {AuthoritativeItemKey{thread, "turn", "generic"}, CardKind::GenericActivity, thread, "turn", "generic", - GenericActivityData{"custom activity", {{"detail", "initial"}}}}, + GenericActivityData{"custom activity", {}, "detail: initial"}}, {LocalPromptKey{77}, CardKind::LocalPrompt, thread, @@ -2311,7 +2281,7 @@ bool testMutableCardsAndCommandOutput() { std::get(cards[6].payload).steps[1].status = "completed"; auto &generic = std::get(cards[7].payload); generic.type = "updated custom activity"; - generic.raw["detail"] = "updated"; + generic.displayDetail = "detail: updated"; std::get(cards[8].payload).state = PromptState::Failed; std::get(cards[8].payload).error = "error"; result &= expect(applyConversation(view, snapshot), @@ -2513,7 +2483,7 @@ bool testCardFoldingGeometryAndRetention() { thread, "turn", "generic", - GenericActivityData{"Unknown activity", {{"detail", "bounded"}}}}; + GenericActivityData{"Unknown activity", {}, "detail: bounded"}}; const VisibleCardData emptyReasoning{ AuthoritativeItemKey{thread, "turn", "empty-reasoning"}, CardKind::Reasoning, @@ -7352,9 +7322,9 @@ bool testGeneratedImagePresentationAndGenericBound() { "generated", "turn", "unknown", - GenericActivityData{"contextCompaction", - {{"type", "contextCompaction"}, - {"large", std::string(100000, 'x')}}}}; + GenericActivityData{"contextCompaction", {}, + "type: contextCompaction\nlarge: " + + std::string(100000, 'x')}}; ConversationCard genericCard(generic); genericCard.show(); spin(); @@ -7374,7 +7344,7 @@ bool testGeneratedImagePresentationAndGenericBound() { details && details->text().size() < 4200 && details->text().endsWith( QStringLiteral("[Activity details truncated]")), - "protocol labels are humanized without changing bounded raw details"); + "protocol labels are humanized while retaining bounded display details"); auto &genericData = std::get(generic.payload); genericData.displayDetail = "field: direct graph detail"; result &= expect(genericCard.apply(generic) && details && diff --git a/tests/codex/ConversationViewBenchmark.cpp b/tests/codex/ConversationViewBenchmark.cpp index 4abc6ad..a0e95e0 100644 --- a/tests/codex/ConversationViewBenchmark.cpp +++ b/tests/codex/ConversationViewBenchmark.cpp @@ -66,8 +66,8 @@ VisibleCardData cardData(std::size_t index) { break; default: card.kind = CardKind::GenericActivity; - card.payload = GenericActivityData{ - "toolCall", {}, "completed", "detail: benchmark " + suffix}; + card.payload = GenericActivityData{"toolCall", "completed", + "detail: benchmark " + suffix}; break; } return card; From 0c1090bd2fdb13221776430780c07d9d38bce185 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 10:13:46 +0200 Subject: [PATCH 14/39] Keep conversation visibility in the Qt model --- docs/ui-ux-internal-api.md | 19 +++++++-------- src/codex/ShellWidget.cpp | 17 ++++---------- src/codex/ui/NodeGraphUiAdapter.cpp | 23 +++--------------- src/codex/ui/NodeGraphUiAdapter.h | 17 +++++--------- tests/codex/NodeGraphConversationUiTest.cpp | 26 ++++++++++----------- tests/codex/NodeGraphUiAdapterTest.cpp | 8 +++---- 6 files changed, 39 insertions(+), 71 deletions(-) diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index 96b83e9..3180444 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -108,23 +108,20 @@ always means “no coherent value was available now”, never “render empty” needed before a potentially larger projection: authoritative item count, display readiness, hydration failure, and provider continuation. Local prompts never contribute to the authoritative count. -- `conversation(thread, itemLimit, options)` returns one complete retained +- `conversation(thread, itemLimit)` returns one complete retained `ConversationSnapshot` for a validated thread. `itemLimit` is the effective per-thread history window, never an instruction to mutate graph state. - `options` mirrors the existing presentation preferences; visibility remains - the widget's responsibility so toggling it can reuse widgets and local fold - state. -- `card(thread, item, options)` projects one validated item only when the item +- Presentation visibility remains the item model/view's responsibility so + toggling it can reuse visible editors and stable local interaction state. +- `card(thread, item)` projects one validated item only when the item is still parented by a Turn owned by the supplied thread. It is reserved for a targeted visible-card update and must never reconstruct identity from payload fields. A stale/detached item returns `nullopt`. -- `tailCard(thread, item, options)` additionally requires that the exact item +- `tailCard(thread, item)` additionally requires that the exact item be the last child of the last canonical Turn and that it not participate in prompt-materialization aliasing. It returns one `ConversationTailCard` with section/root/nested/activity placement and current history chrome for the bounded structural append path. Any ambiguity returns `nullopt`. -- `ConversationOptions` carries only `showReasoning` and - `showCodexUpdates`; it owns no filter state. - `ConversationInfo` is adapter control metadata, not a presentation model or widget snapshot. @@ -133,9 +130,9 @@ always means “no coherent value was available now”, never “render empty” | constructor | `graph`: long-lived canonical graph; no return | Pre: graph outlives adapter. Post: no read and no allocation is performed. | | `threads` | `selectedThread`: optional stable target; returns optional complete DTO | Stale/removed selection is represented as no selected ID, while valid roots still project. Contention returns `nullopt` without side effects. | | `conversationInfo` | `thread`: required stable Thread; returns optional control facts | Wrong kind, stale generation, removal, or contention returns `nullopt`. Success does not construct card DTOs. | -| `conversation` | `thread`, positive effective `itemLimit`, presentation `options`; returns optional complete snapshot | Pre: the caller has observed `conversationInfo.readyForDisplay`; this primitive projects the graph's current content and does not itself infer temporal hydration completeness. Limit is clamped to at least one. Success preserves canonical order and root ownership. Invalid target/contention returns `nullopt`. | -| `card` | exact `thread` and `item`, presentation `options`; returns optional card DTO | Success requires the item still be a child of a Turn owned by the exact thread. It never searches by payload IDs. | -| `tailCard` | exact `thread` and `item`, presentation `options`; returns optional tail DTO | Success requires the exact canonical last item of the exact canonical last Turn, usable loaded-count state, and no prompt alias. The DTO is non-authoritative and owns only values needed for one Qt append. | +| `conversation` | `thread`, positive effective `itemLimit`; returns optional complete snapshot | Pre: the caller has observed `conversationInfo.readyForDisplay`; this primitive projects the graph's current content and does not itself infer temporal hydration completeness. Limit is clamped to at least one. Success preserves canonical order and root ownership. Invalid target/contention returns `nullopt`. | +| `card` | exact `thread` and `item`; returns optional card DTO | Success requires the item still be a child of a Turn owned by the exact thread. It never searches by payload IDs. | +| `tailCard` | exact `thread` and `item`; returns optional tail DTO | Success requires the exact canonical last item of the exact canonical last Turn, usable loaded-count state, and no prompt alias. The DTO is non-authoritative and owns only values needed for one Qt append. | ### `middle::ThreadPane` diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 2f20563..cba587c 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -1594,10 +1594,8 @@ bool ShellWidget::Impl::refreshConversation() { return true; } - auto snapshot = uiAdapter.conversation( - boundGraphThread, history.effective, - {middleRegion->conversation().presentationOptions().showReasoning, - middleRegion->conversation().presentationOptions().showCodexUpdates}); + auto snapshot = + uiAdapter.conversation(boundGraphThread, history.effective); if (!snapshot) return false; middleRegion->conversation().setEmptyMessage( @@ -1712,11 +1710,8 @@ void ShellWidget::Impl::commitPendingPanes() { std::vector items = std::move(pendingConversationItems); pendingConversationItems.clear(); bool requiresStructuralReconcile = false; - const auto options = middleRegion->conversation().presentationOptions(); for (const nodegraph::NodeRef &item : items) { - auto card = - uiAdapter.card(boundGraphThread, item, - {options.showReasoning, options.showCodexUpdates}); + auto card = uiAdapter.card(boundGraphThread, item); if (!card || !middleRegion->conversation().applyCardPresentation( std::move(*card))) { requiresStructuralReconcile = true; @@ -1737,10 +1732,8 @@ void ShellWidget::Impl::commitPendingPanes() { if (pendingConversation && pendingConversationItems.size() == 1 && boundGraphThread && !middleRegion->conversation().structuralStagingActive()) { - const auto options = middleRegion->conversation().presentationOptions(); - auto tail = - uiAdapter.tailCard(boundGraphThread, pendingConversationItems.front(), - {options.showReasoning, options.showCodexUpdates}); + auto tail = uiAdapter.tailCard(boundGraphThread, + pendingConversationItems.front()); if (tail) { const std::string &threadId = boundGraphThread->id().canonical; ConversationHistoryWindow nextHistory = conversationHistory[threadId]; diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp index a0ef26a..9be34b4 100644 --- a/src/codex/ui/NodeGraphUiAdapter.cpp +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -443,17 +443,6 @@ CardKind graphCardKind(const nodegraph::NodeState &state) { return CardKind::GenericActivity; } -bool graphCardVisible(const nodegraph::NodeState &state, - const NodeGraphUiAdapter::ConversationOptions &options) { - const CardKind kind = graphCardKind(state); - if (kind == CardKind::Reasoning) - return options.showReasoning; - if (kind != CardKind::AgentMessage) - return true; - return graphString(graphField(state, "phase")) == "final_answer" || - options.showCodexUpdates; -} - VisibleCardData graphCardData(const nodegraph::NodeRef &item, std::string threadId, std::string turnId, const nodegraph::NodeState &state, @@ -1556,9 +1545,7 @@ NodeGraphUiAdapter::inspector( std::optional NodeGraphUiAdapter::card(const nodegraph::NodeRef &thread, - const nodegraph::NodeRef &item, - ConversationOptions options) const { - static_cast(options); + const nodegraph::NodeRef &item) const { if (!graph_ || !thread || !item) return std::nullopt; auto read = graph_->tryRead(); @@ -1582,9 +1569,7 @@ NodeGraphUiAdapter::card(const nodegraph::NodeRef &thread, std::optional NodeGraphUiAdapter::tailCard(const nodegraph::NodeRef &thread, - const nodegraph::NodeRef &item, - ConversationOptions options) const { - static_cast(options); + const nodegraph::NodeRef &item) const { if (!graph_ || !thread || !item) return std::nullopt; auto read = graph_->tryRead(); @@ -1653,9 +1638,7 @@ NodeGraphUiAdapter::tailCard(const nodegraph::NodeRef &thread, std::optional NodeGraphUiAdapter::conversation(const nodegraph::NodeRef &thread, - std::size_t itemLimit, - ConversationOptions options) const { - static_cast(options); + std::size_t itemLimit) const { if (!graph_ || !thread) return std::nullopt; auto read = graph_->tryRead(); diff --git a/src/codex/ui/NodeGraphUiAdapter.h b/src/codex/ui/NodeGraphUiAdapter.h index 8749bfc..f8bc9b2 100644 --- a/src/codex/ui/NodeGraphUiAdapter.h +++ b/src/codex/ui/NodeGraphUiAdapter.h @@ -18,11 +18,6 @@ namespace codexui::codex::ui { // values, and releases the graph before any QWidget code runs. class NodeGraphUiAdapter final { public: - struct ConversationOptions { - bool showReasoning = true; - bool showCodexUpdates = true; - }; - struct ConversationInfo { std::size_t authoritativeItemCount = 0; bool readyForDisplay = false; @@ -33,22 +28,22 @@ class NodeGraphUiAdapter final { explicit NodeGraphUiAdapter(const nodegraph::NodeGraph &graph) noexcept; [[nodiscard]] std::optional - conversation(const nodegraph::NodeRef &thread, std::size_t itemLimit, - ConversationOptions options) const; + conversation(const nodegraph::NodeRef &thread, + std::size_t itemLimit) const; [[nodiscard]] std::optional conversationInfo(const nodegraph::NodeRef &thread) const; [[nodiscard]] std::optional - card(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item, - ConversationOptions options) const; + card(const nodegraph::NodeRef &thread, + const nodegraph::NodeRef &item) const; // Projects only a canonical last item of the selected thread. It is the // bounded structural fast path for ordinary append; any non-tail or prompt // alias case returns nullopt and uses complete reconciliation instead. [[nodiscard]] std::optional - tailCard(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item, - ConversationOptions options) const; + tailCard(const nodegraph::NodeRef &thread, + const nodegraph::NodeRef &item) const; [[nodiscard]] std::optional threads(const nodegraph::NodeRef &selectedThread) const; diff --git a/tests/codex/NodeGraphConversationUiTest.cpp b/tests/codex/NodeGraphConversationUiTest.cpp index 1c239d3..ecf2cf9 100644 --- a/tests/codex/NodeGraphConversationUiTest.cpp +++ b/tests/codex/NodeGraphConversationUiTest.cpp @@ -94,7 +94,7 @@ bool oldUiConsumesAdapterSnapshotsAtomically() { QApplication::processEvents(); const auto initial = - adapter.conversation(fixture.thread, 80, {true, true}); + adapter.conversation(fixture.thread, 80); if (!require(initial.has_value(), "initial adapter read failed") || !require(view.reconcile(*initial), "initial UI reconciliation was empty")) return false; @@ -123,7 +123,7 @@ bool oldUiConsumesAdapterSnapshotsAtomically() { fixture.appendTurn("new following answer"); const auto appended = - adapter.conversation(fixture.thread, 80, {true, true}); + adapter.conversation(fixture.thread, 80); if (!require(appended.has_value(), "appended adapter read failed") || !require(view.reconcile(*appended), "new cards were not presented")) return false; @@ -144,7 +144,7 @@ bool pausedViewportKeepsItsPaintedAnchor() { view.resize(760, 520); view.show(); const auto initial = - adapter.conversation(fixture.thread, 80, {true, true}); + adapter.conversation(fixture.thread, 80); if (!initial || !view.reconcile(*initial)) return false; QApplication::processEvents(); @@ -163,7 +163,7 @@ bool pausedViewportKeepsItsPaintedAnchor() { fixture.appendTurn("offscreen tail"); const auto appended = - adapter.conversation(fixture.thread, 80, {true, true}); + adapter.conversation(fixture.thread, 80); if (!appended || !view.reconcile(*appended)) return false; QApplication::processEvents(); @@ -207,7 +207,7 @@ bool promptMorphPreservesExactTargetAndWidget() { acknowledged = std::move(target); return true; }); - auto snapshot = adapter.conversation(thread, 80, {true, true}); + auto snapshot = adapter.conversation(thread, 80); if (!snapshot || !view.reconcile(*snapshot)) return false; QApplication::processEvents(); @@ -232,7 +232,7 @@ bool promptMorphPreservesExactTargetAndWidget() { write.relate(turn, nodegraph::RelationKind::TurnRootItem, authoritative); static_cast(write.finish()); } - snapshot = adapter.conversation(thread, 80, {true, true}); + snapshot = adapter.conversation(thread, 80); if (!snapshot || !view.reconcile(*snapshot)) return false; QApplication::processEvents(); @@ -256,7 +256,7 @@ bool promptMorphPreservesExactTargetAndWidget() { write.remove(prompt); static_cast(write.finish()); } - snapshot = adapter.conversation(thread, 80, {true, true}); + snapshot = adapter.conversation(thread, 80); if (!require(snapshot.has_value(), "local retirement did not project the authoritative card")) return false; @@ -322,7 +322,7 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { ++acknowledgements; return target == steering; }); - auto snapshot = adapter.conversation(thread, 80, {true, true}); + auto snapshot = adapter.conversation(thread, 80); if (!snapshot || !view.reconcile(*snapshot)) return false; QApplication::processEvents(); @@ -352,7 +352,7 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { write.setField(steering, "showPendingAnimation", false); static_cast(write.finish()); } - snapshot = adapter.conversation(thread, 80, {true, true}); + snapshot = adapter.conversation(thread, 80); if (!snapshot) return false; static_cast(view.reconcile(*snapshot)); @@ -380,7 +380,7 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { turn, std::array{root, steering, authoritative, progress}); static_cast(write.finish()); } - snapshot = adapter.conversation(thread, 80, {true, true}); + snapshot = adapter.conversation(thread, 80); if (!snapshot) return false; static_cast(view.reconcile(*snapshot)); @@ -399,7 +399,7 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { write.remove(steering); static_cast(write.finish()); } - snapshot = adapter.conversation(thread, 80, {true, true}); + snapshot = adapter.conversation(thread, 80); if (!snapshot) return false; static_cast(view.reconcile(*snapshot)); @@ -438,7 +438,7 @@ bool fileChangesUseCanonicalWorkspace() { } ui::NodeGraphUiAdapter adapter(graph); - auto snapshot = adapter.conversation(thread, 80, {true, true}); + auto snapshot = adapter.conversation(thread, 80); if (!require(snapshot && snapshot->sections.size() == 1 && snapshot->sections.front().cards.size() == 1, "file changes were not projected from the owning thread")) @@ -454,7 +454,7 @@ bool fileChangesUseCanonicalWorkspace() { write.setField(changes, "cwd", "/workspace/item"); static_cast(write.finish()); } - auto projected = adapter.card(thread, changes, {true, true}); + auto projected = adapter.card(thread, changes); const auto *specific = projected ? std::get_if(&projected->payload) diff --git a/tests/codex/NodeGraphUiAdapterTest.cpp b/tests/codex/NodeGraphUiAdapterTest.cpp index 5b4664a..ba7ed02 100644 --- a/tests/codex/NodeGraphUiAdapterTest.cpp +++ b/tests/codex/NodeGraphUiAdapterTest.cpp @@ -76,7 +76,7 @@ bool projectsCanonicalTurnStructureAndRoot() { } NodeGraphUiAdapter adapter(graph); - const auto result = adapter.conversation(thread, 80, {true, true}); + const auto result = adapter.conversation(thread, 80); if (!require(result.has_value(), "adapter projection was unavailable") || !require(result->threadId == "thread-1", "wrong projected thread") || !require(result->sections.size() == 2, "wrong turn count") || @@ -135,7 +135,7 @@ bool limitsHistoryButPinsTheOwningPrompt() { } NodeGraphUiAdapter adapter(graph); - const auto result = adapter.conversation(thread, 2, {true, true}); + const auto result = adapter.conversation(thread, 2); return require(result.has_value(), "bounded projection unavailable") && require(result->hasMore, "bounded projection lost Load More") && require(result->hiddenAuthoritativeItemCount == 3, @@ -181,7 +181,7 @@ bool projectsOnlyTheExactCanonicalTail() { } NodeGraphUiAdapter adapter(graph); - const auto projected = adapter.tailCard(thread, tail, {true, true}); + const auto projected = adapter.tailCard(thread, tail); return require(projected.has_value(), "canonical last item was not projected") && require(projected->card.target == tail && !projected->turnRoot && @@ -190,7 +190,7 @@ bool projectsOnlyTheExactCanonicalTail() { require(projected->authoritativeItemCount == 2 && projected->providerHasMore, "tail projection lost authoritative history chrome") && - require(!adapter.tailCard(thread, root, {true, true}), + require(!adapter.tailCard(thread, root), "a non-tail item entered the bounded append path"); } From 0d2195064f0471340df569b2b884f35d878db94e Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 10:20:10 +0200 Subject: [PATCH 15/39] Share pure conversation presentation values --- CMakeLists.txt | 12 ++ docs/ui-ux-internal-api.md | 4 +- src/codex/middle/ConversationCards.cpp | 86 ++------------ src/codex/middle/ConversationPresentation.cpp | 109 ++++++++++++++++++ src/codex/middle/ConversationPresentation.h | 26 +++++ src/codex/middle/ConversationView.cpp | 78 +++---------- 6 files changed, 176 insertions(+), 139 deletions(-) create mode 100644 src/codex/middle/ConversationPresentation.cpp create mode 100644 src/codex/middle/ConversationPresentation.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f728f6..a10e4f1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,8 @@ set( src/codex/middle/ComposerPane.h src/codex/middle/ConversationCards.cpp src/codex/middle/ConversationCards.h + src/codex/middle/ConversationPresentation.cpp + src/codex/middle/ConversationPresentation.h src/codex/middle/ConversationHeightIndex.cpp src/codex/middle/ConversationHeightIndex.h src/codex/middle/ConversationItemModel.cpp @@ -195,6 +197,8 @@ if(BUILD_TESTING) src/codex/ui/NodeGraphUiAdapter.h src/codex/middle/ConversationCards.cpp src/codex/middle/ConversationCards.h + src/codex/middle/ConversationPresentation.cpp + src/codex/middle/ConversationPresentation.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h src/codex/middle/ConversationHeightIndex.cpp @@ -342,6 +346,8 @@ if(BUILD_TESTING) tests/codex/ConversationCardsTest.cpp src/codex/middle/ConversationCards.cpp src/codex/middle/ConversationCards.h + src/codex/middle/ConversationPresentation.cpp + src/codex/middle/ConversationPresentation.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h src/codex/middle/ConversationHeightIndex.cpp @@ -403,6 +409,8 @@ if(BUILD_TESTING) tests/codex/ConversationVirtualizationTest.cpp src/codex/middle/ConversationCards.cpp src/codex/middle/ConversationCards.h + src/codex/middle/ConversationPresentation.cpp + src/codex/middle/ConversationPresentation.h src/codex/middle/ConversationHeightIndex.cpp src/codex/middle/ConversationHeightIndex.h src/codex/middle/ConversationItemModel.cpp @@ -438,6 +446,8 @@ if(BUILD_TESTING) tests/codex/ConversationViewBenchmark.cpp src/codex/middle/ConversationCards.cpp src/codex/middle/ConversationCards.h + src/codex/middle/ConversationPresentation.cpp + src/codex/middle/ConversationPresentation.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h src/codex/middle/ConversationHeightIndex.cpp @@ -479,6 +489,8 @@ if(BUILD_TESTING) src/codex/middle/ComposerPane.h src/codex/middle/ConversationCards.cpp src/codex/middle/ConversationCards.h + src/codex/middle/ConversationPresentation.cpp + src/codex/middle/ConversationPresentation.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h src/codex/middle/ConversationHeightIndex.cpp diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index 3180444..eae38ea 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -340,6 +340,9 @@ released, and QWidget work never occurs while a graph or channel lock is held. Cards remain the established specialized renderers. They do not read the graph. Their `VisibleCardData` is the entire canonical presentation input. +`ConversationPresentation` supplies only pure status, plan, agent-metadata, +file-change, and generic-activity display values shared with the passive +delegate. It owns no renderer selection, geometry, interaction, or state. - `ConversationCard(data, parent, commandInitiallyCollapsed, imageInitiallyCollapsed, fileChangesInitiallyCollapsed)` creates exactly @@ -390,7 +393,6 @@ inner wheel/follow state; restoring it must not move the outer conversation. | `applyPresentation` | complete candidate DTO; returns impact enum | Same postcondition as `apply`; impact is local and must not be promoted blindly to pane/window invalidation. | | collapse methods | bool setter / bool query | Fold state is user-owned; the view updates only the affected indexed row/section range and restores the exact anchor. | | `setAuthoritativeTurnActive` | bool; returns paint-change bool | Valid primarily for the root You card. No geometry change for border-only state. | -| nested-parent methods | ordered child QWidget/card pointers | Card-internal compatibility only. `ConversationView` supplies an empty list and represents Turn ownership through model roles, indexed geometry, and delegate painting. | | viewport visibility | bool | Affects only local timers/painting, not data or identity. | | command output state methods | optional state / state const reference | Preserve inner scrollbar value/follow mode without modifying outer anchor. | diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index c4480c2..46c480e 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -2,6 +2,8 @@ #include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationPresentation.h" + #include "codex/UiStatus.h" #include "codex/ui/UiStyle.h" @@ -57,7 +59,6 @@ constexpr int PendingAnimationIntervalMilliseconds = 32; constexpr qint64 PendingHalfCycleMilliseconds = 850; constexpr int ThumbnailMaximumWidth = 280; constexpr int ThumbnailMaximumHeight = 180; -constexpr qsizetype MaximumGenericActivityCharacters = 4096; constexpr int CardHeaderActionSpacing = 4; constexpr int CopyMorphDurationMilliseconds = 160; constexpr int CopyCheckHoldMilliseconds = 500; @@ -487,8 +488,8 @@ bool setVisibleMarkdown(QLabel *label, const QString &markdown) { QString displayStatus(const QString &status) { const QByteArray encoded = status.toUtf8(); - return text(codexui::codex::displayStatus(std::string_view( - encoded.constData(), static_cast(encoded.size())))); + return presentation::statusLabel(std::string_view( + encoded.constData(), static_cast(encoded.size()))); } QString statusTone(const QString &status) { @@ -524,27 +525,6 @@ QString commandMetadata(const CommandExecutionData &command) { return metadata.join(QStringLiteral(" | ")); } -QString agentMetadata(const AgentActivityData &activity) { - QStringList metadata; - if (!activity.tool.empty()) - metadata << text(activity.tool); - if (activity.status.empty() && !activity.kind.empty()) - metadata << displayStatus(text(activity.kind)); - if (!activity.receivers.empty()) - metadata << textList(activity.receivers).join(QStringLiteral(", ")); - if (!activity.model.empty()) - metadata << text(activity.model); - if (!activity.reasoningEffort.empty()) - metadata << text(activity.reasoningEffort); - if (!activity.childThreadId.empty()) - metadata << QStringLiteral("thread %1").arg(text(activity.childThreadId)); - if (!activity.agentPath.empty()) - metadata << text(activity.agentPath); - if (!activity.senderThreadId.empty()) - metadata << QStringLiteral("sender %1").arg(text(activity.senderThreadId)); - return metadata.join(QStringLiteral(" | ")); -} - QString displayChangeKind(std::string_view kind) { if (kind.empty()) return QStringLiteral("Changed"); @@ -566,22 +546,6 @@ QString joinedCopyText(QStringList parts) { return parts.join(QStringLiteral("\n\n")); } -QString fileChangesText(const FileChangesData &data) { - QStringList rows; - for (const FileChangeData &change : data.changes) { - if (change.path.empty()) - continue; - QString row = QStringLiteral("%1 · %2") - .arg(text(change.path), displayChangeKind(change.kind)); - if (change.additions && change.deletions) - row += QStringLiteral(" +%1 −%2") - .arg(*change.additions) - .arg(*change.deletions); - rows << row; - } - return rows.join(QLatin1Char('\n')); -} - QString fileChangesHtml(const FileChangesData &data, QStringList &openPaths) { openPaths.clear(); QStringList rows; @@ -624,31 +588,6 @@ std::optional totalDiffCounts(const FileChangesData &data) { return available ? std::optional{total} : std::nullopt; } -QString planMarkdown(const PlanData &plan) { - if (!plan.legacyText.empty()) - return text(plan.legacyText); - QStringList rows; - if (!plan.explanation.empty()) - rows << text(plan.explanation); - if (!plan.steps.empty() && !rows.empty()) - rows << QString{}; - for (const PlanStepData &step : plan.steps) { - const QString marker = step.status == "completed" ? QStringLiteral("✓") - : step.status == "inProgress" ? QStringLiteral("◉") - : QStringLiteral("○"); - rows << QStringLiteral("%1 %2 ").arg(marker, text(step.text)); - } - return rows.join(QLatin1Char('\n')); -} - -QString boundedGenericActivity(const GenericActivityData &activity) { - QString rendered = text(activity.displayDetail); - if (rendered.size() <= MaximumGenericActivityCharacters) - return rendered; - rendered.truncate(MaximumGenericActivityCharacters); - return rendered + QStringLiteral("\n\n[Activity details truncated]"); -} - CardCopyContent cardCopyContent(const VisibleCardData &card) { return std::visit( [](const auto &payload) -> CardCopyContent { @@ -673,15 +612,15 @@ CardCopyContent cardCopyContent(const VisibleCardData &card) { } else if constexpr (std::is_same_v) { return {text(payload.summary), true}; } else if constexpr (std::is_same_v) { - return {fileChangesText(payload), false}; + return {presentation::fileChangesText(payload), false}; } else if constexpr (std::is_same_v) { - return {planMarkdown(payload), true}; + return {presentation::planMarkdown(payload), true}; } else if constexpr (std::is_same_v) { return { joinedCopyText({text(payload.revisedPrompt), text(payload.path)}), false}; } else if constexpr (std::is_same_v) { - return {boundedGenericActivity(payload), false}; + return {presentation::boundedGenericActivityDetail(payload), false}; } else { return payload.prompt.empty() ? CardCopyContent{textList(payload.imagePaths) @@ -1405,7 +1344,7 @@ class ConversationCard::Impl final { void updateComposition(const AgentActivityData &activity) { showStatus(text(activity.status), QStringLiteral("agentActivityStatus")); - setVisibleText(metadata, agentMetadata(activity)); + setVisibleText(metadata, presentation::agentMetadata(activity)); setVisibleText(body, text(activity.prompt)); setVisibleMarkdown(detail, text(activity.resultText)); } @@ -1471,7 +1410,7 @@ class ConversationCard::Impl final { } void updateComposition(const PlanData &plan) { - setVisibleMarkdown(body, planMarkdown(plan)); + setVisibleMarkdown(body, presentation::planMarkdown(plan)); } void createComposition(const ImageGenerationData &image) { @@ -1504,11 +1443,10 @@ class ConversationCard::Impl final { } void updateComposition(const GenericActivityData &activity) { - title->setText(activity.type.empty() - ? QStringLiteral("Activity") - : UiStyle::humanizeLabel(text(activity.type))); + title->setText(presentation::genericActivityTitle(activity)); showStatus(text(activity.status), QStringLiteral("genericActivityStatus")); - metadata->setText(boundedGenericActivity(activity)); + metadata->setText( + presentation::boundedGenericActivityDetail(activity)); metadata->setObjectName(QStringLiteral("genericActivityMetadata")); metadata->show(); } diff --git a/src/codex/middle/ConversationPresentation.cpp b/src/codex/middle/ConversationPresentation.cpp new file mode 100644 index 0000000..4480380 --- /dev/null +++ b/src/codex/middle/ConversationPresentation.cpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationPresentation.h" + +#include "codex/UiStatus.h" +#include "codex/ui/UiStyle.h" + +#include + +#include +#include + +namespace codexui::codex::middle::presentation { +namespace { + +constexpr qsizetype MaximumGenericActivityCharacters = 4096; + +QString text(std::string_view value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +QStringList textList(const std::vector &values) { + QStringList result; + result.reserve(static_cast(values.size())); + for (const std::string &value : values) + result.push_back(text(value)); + return result; +} + +QString displayChangeKind(std::string_view kind) { + if (kind.empty()) + return QStringLiteral("Changed"); + return UiStyle::humanizeLabel(text(kind)); +} + +} // namespace + +QString statusLabel(std::string_view status) { + return text(codexui::codex::displayStatus(status)); +} + +QString planMarkdown(const PlanData &plan) { + if (!plan.legacyText.empty()) + return text(plan.legacyText); + QStringList rows; + if (!plan.explanation.empty()) + rows << text(plan.explanation); + if (!plan.steps.empty() && !rows.empty()) + rows << QString{}; + for (const PlanStepData &step : plan.steps) { + const QString marker = step.status == "completed" ? QStringLiteral("✓") + : step.status == "inProgress" ? QStringLiteral("◉") + : QStringLiteral("○"); + rows << QStringLiteral("%1 %2 ").arg(marker, text(step.text)); + } + return rows.join(QLatin1Char('\n')); +} + +QString agentMetadata(const AgentActivityData &activity) { + QStringList metadata; + if (!activity.tool.empty()) + metadata << text(activity.tool); + if (activity.status.empty() && !activity.kind.empty()) + metadata << statusLabel(activity.kind); + if (!activity.receivers.empty()) + metadata << textList(activity.receivers).join(QStringLiteral(", ")); + if (!activity.model.empty()) + metadata << text(activity.model); + if (!activity.reasoningEffort.empty()) + metadata << text(activity.reasoningEffort); + if (!activity.childThreadId.empty()) + metadata << QStringLiteral("thread %1").arg(text(activity.childThreadId)); + if (!activity.agentPath.empty()) + metadata << text(activity.agentPath); + if (!activity.senderThreadId.empty()) + metadata << QStringLiteral("sender %1").arg(text(activity.senderThreadId)); + return metadata.join(QStringLiteral(" | ")); +} + +QString fileChangesText(const FileChangesData &changes) { + QStringList rows; + for (const FileChangeData &change : changes.changes) { + if (change.path.empty()) + continue; + QString row = QStringLiteral("%1 · %2") + .arg(text(change.path), displayChangeKind(change.kind)); + if (change.additions && change.deletions) + row += QStringLiteral(" +%1 −%2") + .arg(*change.additions) + .arg(*change.deletions); + rows << row; + } + return rows.join(QLatin1Char('\n')); +} + +QString genericActivityTitle(const GenericActivityData &activity) { + return activity.type.empty() ? QStringLiteral("Activity") + : UiStyle::humanizeLabel(text(activity.type)); +} + +QString boundedGenericActivityDetail(const GenericActivityData &activity) { + QString rendered = text(activity.displayDetail); + if (rendered.size() <= MaximumGenericActivityCharacters) + return rendered; + rendered.truncate(MaximumGenericActivityCharacters); + return rendered + QStringLiteral("\n\n[Activity details truncated]"); +} + +} // namespace codexui::codex::middle::presentation diff --git a/src/codex/middle/ConversationPresentation.h b/src/codex/middle/ConversationPresentation.h new file mode 100644 index 0000000..356a83c --- /dev/null +++ b/src/codex/middle/ConversationPresentation.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONPRESENTATION_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONPRESENTATION_H + +#include "codex/middle/MiddleTypes.h" + +#include + +#include + +namespace codexui::codex::middle::presentation { + +// Pure display-value helpers shared by the passive delegate and the rich card +// editor. They own no state and do not decide which renderer a row uses. +[[nodiscard]] QString statusLabel(std::string_view status); +[[nodiscard]] QString planMarkdown(const PlanData &plan); +[[nodiscard]] QString agentMetadata(const AgentActivityData &activity); +[[nodiscard]] QString fileChangesText(const FileChangesData &changes); +[[nodiscard]] QString genericActivityTitle(const GenericActivityData &activity); +[[nodiscard]] QString +boundedGenericActivityDetail(const GenericActivityData &activity); + +} // namespace codexui::codex::middle::presentation + +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONPRESENTATION_H diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 71bbdd3..b55b068 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT #include "codex/middle/ConversationView.h" +#include "codex/middle/ConversationPresentation.h" #include "codex/ui/UiStyle.h" #include @@ -209,32 +210,6 @@ struct PassivePresentation { int verticalMargin = 10; }; -QString planText(const PlanData &plan) { - if (!plan.legacyText.empty()) - return text(plan.legacyText); - QStringList lines; - if (!plan.explanation.empty()) - lines.push_back(text(plan.explanation)); - if (!lines.empty() && !plan.steps.empty()) - lines.push_back({}); - for (const PlanStepData &step : plan.steps) { - const QString marker = step.status == "completed" ? QStringLiteral("✓") - : step.status == "inProgress" ? QStringLiteral("◉") - : QStringLiteral("○"); - lines.push_back(QStringLiteral("%1 %2").arg(marker, text(step.text))); - } - return lines.join(QLatin1Char('\n')); -} - -QString genericDetail(const GenericActivityData &activity) { - QString value = text(activity.displayDetail); - constexpr qsizetype MaximumCharacters = 4096; - if (value.size() <= MaximumCharacters) - return value; - value.truncate(MaximumCharacters); - return value + QStringLiteral("\n\n[Activity details truncated]"); -} - PassivePresentation passivePresentation(const VisibleCardData &card) { PassivePresentation result; std::visit( @@ -263,28 +238,14 @@ PassivePresentation passivePresentation(const VisibleCardData &card) { result.blocks.push_back({text(payload.text), true, false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Command execution"); - result.status = text(payload.status); + result.status = presentation::statusLabel(payload.status); result.blocks.push_back({text(payload.command), false, false}); result.blocks.push_back({text(payload.output), false, false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Agent activity"); - result.status = text(payload.status); - QStringList metadata; - if (!payload.tool.empty()) - metadata.push_back(text(payload.tool)); - if (!payload.receivers.empty()) { - QStringList receivers; - for (const std::string &receiver : payload.receivers) - receivers.push_back(text(receiver)); - metadata.push_back(receivers.join(QStringLiteral(", "))); - } - if (!payload.model.empty()) - metadata.push_back(text(payload.model)); - if (!payload.childThreadId.empty()) - metadata.push_back( - QStringLiteral("thread %1").arg(text(payload.childThreadId))); + result.status = presentation::statusLabel(payload.status); result.blocks.push_back( - {metadata.join(QStringLiteral(" | ")), false, true}); + {presentation::agentMetadata(payload), false, true}); result.blocks.push_back({text(payload.prompt), false, false}); result.blocks.push_back({text(payload.resultText), true, false}); } else if constexpr (std::is_same_v) { @@ -292,36 +253,25 @@ PassivePresentation passivePresentation(const VisibleCardData &card) { result.blocks.push_back({text(payload.summary), true, false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("File changes"); - result.status = text(payload.status); - QStringList lines; - for (const FileChangeData &change : payload.changes) { - QString line = text(change.path); - if (!change.kind.empty()) - line += QStringLiteral(" · ") + text(change.kind); - if (change.additions && change.deletions) - line += QStringLiteral(" +%1 −%2") - .arg(*change.additions) - .arg(*change.deletions); - lines.push_back(line); - } + result.status = presentation::statusLabel(payload.status); result.blocks.push_back( - {lines.join(QLatin1Char('\n')), false, false}); + {presentation::fileChangesText(payload), false, false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Plan"); - result.blocks.push_back({planText(payload), true, false}); + result.blocks.push_back( + {presentation::planMarkdown(payload), true, false}); } else if constexpr (std::is_same_v) { result.title = payload.status.empty() && payload.revisedPrompt.empty() ? QStringLiteral("Image") : QStringLiteral("Generated image"); - result.status = text(payload.status); + result.status = presentation::statusLabel(payload.status); result.blocks.push_back({text(payload.revisedPrompt), false, false}); } else if constexpr (std::is_same_v) { - result.title = payload.type.empty() ? QStringLiteral("Activity") - : text(payload.type); - if (!result.title.isEmpty()) - result.title[0] = result.title.front().toUpper(); - result.status = text(payload.status); - result.blocks.push_back({genericDetail(payload), false, true}); + result.title = presentation::genericActivityTitle(payload); + result.status = presentation::statusLabel(payload.status); + result.blocks.push_back( + {presentation::boundedGenericActivityDetail(payload), false, + true}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("You"); result.blocks.push_back({text(payload.prompt), true, false}); From 29a93b09e3abf9529282349e4e0a47b6363fbff7 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 10:33:25 +0200 Subject: [PATCH 16/39] Add explicit conversation model operations --- docs/ui-ux-internal-api.md | 15 +- src/codex/middle/ConversationItemModel.cpp | 373 +++++++++++++++++++-- src/codex/middle/ConversationItemModel.h | 31 +- src/codex/middle/MiddleTypes.h | 13 +- tests/codex/ConversationItemModelTest.cpp | 96 ++++-- 5 files changed, 480 insertions(+), 48 deletions(-) diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index eae38ea..7ed2865 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -201,10 +201,17 @@ available to the view/delegate through the typed `card(row)` accessor rather than copied through `QVariant`; standard roles expose only small identity, structure, visibility, and accessibility values. -- `reconcile(snapshot)` flattens one complete toolkit-neutral snapshot into - canonical order. A different thread is the only normal complete authority - replacement and emits `modelReset`. Same-thread differences emit contiguous - insert/remove operations, actual row moves, and row-local `dataChanged`. +- `replaceConversation(snapshot)` is the explicit complete-authority operation + for a different thread or genuine rescan and emits `modelReset` only when + effective state differs. `prependHistoryPage(snapshot)` accepts only a + same-thread ordered superset, inserts its missing ranges, and never resets or + moves retained rows. +- `insertCard(row, placement)`, `removeTarget(ref)`, and + `moveTarget(ref, destination, placement)` are the exact structural + operations. They reject duplicate/stale/ambiguous targets and emit only the + matching insert, remove, move, and affected structural-role changes. +- `reconcile(snapshot)` remains a temporary same-thread compatibility fallback + while integration routes are migrated to those explicit operations. Identical effective input emits no signal and increments no presentation counter. - `updateCard(card)` resolves the stable key once and returns `Missing`, diff --git a/src/codex/middle/ConversationItemModel.cpp b/src/codex/middle/ConversationItemModel.cpp index 8d86830..262740e 100644 --- a/src/codex/middle/ConversationItemModel.cpp +++ b/src/codex/middle/ConversationItemModel.cpp @@ -184,34 +184,124 @@ QHash ConversationItemModel::roleNames() const { {ActiveTurnRole, "activeTurn"}, {PresentationRole, "presentation"}}; } -bool ConversationItemModel::reconcile(ConversationSnapshot snapshot) { - const bool authorityReplacement = snapshot.threadId != threadId_; +bool ConversationItemModel::replaceConversation(ConversationSnapshot snapshot) { const std::string nextThreadId = snapshot.threadId; const std::size_t nextHiddenCount = snapshot.hiddenAuthoritativeItemCount; const bool nextHasMore = snapshot.hasMore; std::vector desired = flatten(std::move(snapshot)); - std::unordered_set unique; - unique.reserve(desired.size()); - for (const Row &row : desired) - if (!unique.insert(row.stableKey).second) - return false; + if (!rowsAreUnique(desired)) + return false; - const bool chromeChanged = nextHiddenCount != hiddenAuthoritativeItemCount_ || - nextHasMore != hasMore_; + const bool identical = + nextThreadId == threadId_ && + nextHiddenCount == hiddenAuthoritativeItemCount_ && + nextHasMore == hasMore_ && desired.size() == rows_.size() && + std::equal(rows_.begin(), rows_.end(), desired.begin()); + if (identical) + return false; + + beginResetModel(); + rows_.clear(); + rows_.insert(rows_.end(), std::make_move_iterator(desired.begin()), + std::make_move_iterator(desired.end())); + threadId_ = nextThreadId; hiddenAuthoritativeItemCount_ = nextHiddenCount; hasMore_ = nextHasMore; - if (authorityReplacement) { - beginResetModel(); - rows_.clear(); - rows_.insert(rows_.end(), std::make_move_iterator(desired.begin()), - std::make_move_iterator(desired.end())); - threadId_ = nextThreadId; + rebuildIndexes(); + endResetModel(); + incrementProperty("modelResetCount"); + incrementProperty("modelReplacementCount"); + return true; +} + +bool ConversationItemModel::prependHistoryPage(ConversationSnapshot snapshot) { + if (snapshot.threadId != threadId_) + return false; + const std::size_t nextHiddenCount = snapshot.hiddenAuthoritativeItemCount; + const bool nextHasMore = snapshot.hasMore; + std::vector desired = flatten(std::move(snapshot)); + if (!rowsAreUnique(desired) || desired.size() < rows_.size()) + return false; + + std::size_t retained = 0; + for (const Row &candidate : desired) { + if (retained < rows_.size() && + candidate.stableKey == rows_[retained].stableKey) { + ++retained; + continue; + } + if (stableRows_.contains(candidate.stableKey)) + return false; + } + if (retained != rows_.size()) + return false; + + bool changed = nextHiddenCount != hiddenAuthoritativeItemCount_ || + nextHasMore != hasMore_; + bool indexesDirty = false; + std::size_t desiredPosition = 0; + std::size_t modelPosition = 0; + while (desiredPosition < desired.size()) { + if (modelPosition < rows_.size() && + desired[desiredPosition].stableKey == rows_[modelPosition].stableKey) { + if (rows_[modelPosition] != desired[desiredPosition]) { + indexesDirty = indexesDirty || rows_[modelPosition].card.target != + desired[desiredPosition].card.target; + updateRow(static_cast(modelPosition), + std::move(desired[desiredPosition])); + changed = true; + } + ++desiredPosition; + ++modelPosition; + continue; + } + + const std::size_t firstDesired = desiredPosition; + while ( + desiredPosition < desired.size() && + !(modelPosition < rows_.size() && + desired[desiredPosition].stableKey == rows_[modelPosition].stableKey)) + ++desiredPosition; + const std::size_t count = desiredPosition - firstDesired; + const int firstRow = static_cast(modelPosition); + const int lastRow = static_cast(modelPosition + count - 1); + beginInsertRows({}, firstRow, lastRow); + rows_.insert( + rows_.begin() + static_cast(modelPosition), + std::make_move_iterator(desired.begin() + + static_cast(firstDesired)), + std::make_move_iterator(desired.begin() + + static_cast(desiredPosition))); rebuildIndexes(); - endResetModel(); - incrementProperty("modelResetCount"); - return true; + endInsertRows(); + incrementProperty("modelInsertCount"); + changed = true; + indexesDirty = false; + modelPosition += count; } + hiddenAuthoritativeItemCount_ = nextHiddenCount; + hasMore_ = nextHasMore; + if (indexesDirty) + rebuildIndexes(); + if (changed) + incrementProperty("modelHistoryPrependCount"); + return changed; +} + +bool ConversationItemModel::reconcile(ConversationSnapshot snapshot) { + if (snapshot.threadId != threadId_) + return replaceConversation(std::move(snapshot)); + const std::size_t nextHiddenCount = snapshot.hiddenAuthoritativeItemCount; + const bool nextHasMore = snapshot.hasMore; + std::vector desired = flatten(std::move(snapshot)); + if (!rowsAreUnique(desired)) + return false; + + const bool chromeChanged = nextHiddenCount != hiddenAuthoritativeItemCount_ || + nextHasMore != hasMore_; + hiddenAuthoritativeItemCount_ = nextHiddenCount; + hasMore_ = nextHasMore; std::unordered_set desiredKeys; desiredKeys.reserve(desired.size()); for (const Row &row : desired) @@ -346,6 +436,146 @@ ConversationItemModel::updateCard(VisibleCardData card) { return CardUpdateResult::Changed; } +ConversationItemModel::StructuralChangeResult +ConversationItemModel::insertCard(int rowIndex, + ConversationRowPlacement placement) { + Row candidate = rowFromPlacement(std::move(placement)); + if (candidate.card.threadId != threadId_ || candidate.stableKey.empty() || + !sectionPlacementIsValid(rowIndex, candidate)) + return StructuralChangeResult::Invalid; + if (stableRows_.contains(candidate.stableKey) || + (candidate.card.target && + targetRows_.contains(candidate.card.target.get()))) + return StructuralChangeResult::Duplicate; + + const bool previousInSection = + rowIndex > 0 && + rows_[static_cast(rowIndex - 1)].sectionKey == + candidate.sectionKey; + const bool nextInSection = + rowIndex < rowCount() && + rows_[static_cast(rowIndex)].sectionKey == + candidate.sectionKey; + candidate.firstInTurn = !previousInSection; + candidate.lastInTurn = !nextInSection; + + beginInsertRows({}, rowIndex, rowIndex); + rows_.insert(rows_.begin() + static_cast(rowIndex), + std::move(candidate)); + rebuildIndexes(); + endInsertRows(); + incrementProperty("modelInsertCount"); + incrementProperty("modelExactInsertCount"); + refreshSectionStructure(rows_[static_cast(rowIndex)].sectionKey); + return StructuralChangeResult::Changed; +} + +ConversationItemModel::StructuralChangeResult +ConversationItemModel::removeTarget(const nodegraph::NodeRef &target) { + const QModelIndex targetIndex = indexForTarget(target); + if (!targetIndex.isValid()) + return StructuralChangeResult::Missing; + const int rowIndex = targetIndex.row(); + const std::string sectionKey = + rows_[static_cast(rowIndex)].sectionKey; + + beginRemoveRows({}, rowIndex, rowIndex); + rows_.erase(rows_.begin() + static_cast(rowIndex)); + rebuildIndexes(); + endRemoveRows(); + incrementProperty("modelRemoveCount"); + incrementProperty("modelExactRemoveCount"); + refreshSectionStructure(sectionKey); + return StructuralChangeResult::Changed; +} + +ConversationItemModel::StructuralChangeResult +ConversationItemModel::moveTarget(const nodegraph::NodeRef &target, + int destinationRow, + ConversationRowPlacement placement) { + const QModelIndex targetIndex = indexForTarget(target); + if (!targetIndex.isValid()) + return StructuralChangeResult::Missing; + if (destinationRow < 0 || destinationRow >= rowCount() || + placement.card.threadId != threadId_ || placement.card.target != target || + placement.sectionKey.empty()) + return StructuralChangeResult::Invalid; + + const int sourceRow = targetIndex.row(); + const Row ¤t = rows_[static_cast(sourceRow)]; + Row replacement = rowFromPlacement(std::move(placement)); + if (replacement.stableKey != current.stableKey || + !compatible(current.card, replacement.card) || + (replacement.turnRoot && replacement.nested)) + return StructuralChangeResult::Invalid; + + std::vector sectionOrder; + std::vector rootOrder; + sectionOrder.reserve(rows_.size()); + rootOrder.reserve(rows_.size()); + for (int rowIndex = 0; rowIndex < rowCount(); ++rowIndex) { + if (rowIndex == sourceRow) + continue; + if (static_cast(sectionOrder.size()) == destinationRow) { + sectionOrder.push_back(replacement.sectionKey); + rootOrder.push_back(replacement.turnRoot); + } + sectionOrder.push_back( + rows_[static_cast(rowIndex)].sectionKey); + rootOrder.push_back(rows_[static_cast(rowIndex)].turnRoot); + } + if (static_cast(sectionOrder.size()) == destinationRow) { + sectionOrder.push_back(replacement.sectionKey); + rootOrder.push_back(replacement.turnRoot); + } + std::unordered_set completedSections; + std::string previousSection; + for (std::size_t position = 0; position < sectionOrder.size(); ++position) { + const std::string §ion = sectionOrder[position]; + if (section == previousSection) { + if (rootOrder[position]) + return StructuralChangeResult::Invalid; + continue; + } + if (!previousSection.empty()) + completedSections.insert(previousSection); + if (completedSections.contains(section)) + return StructuralChangeResult::Invalid; + previousSection = section; + } + + const std::string oldSection = current.sectionKey; + if (sourceRow != destinationRow) { + const int destinationChild = + destinationRow > sourceRow ? destinationRow + 1 : destinationRow; + beginMoveRows({}, sourceRow, sourceRow, {}, destinationChild); + Row moved = std::move(rows_[static_cast(sourceRow)]); + rows_.erase(rows_.begin() + static_cast(sourceRow)); + rows_.insert(rows_.begin() + static_cast(destinationRow), + std::move(moved)); + rebuildIndexes(); + endMoveRows(); + incrementProperty("modelMoveCount"); + } + + Row &moved = rows_[static_cast(destinationRow)]; + replacement.firstInTurn = moved.firstInTurn; + replacement.lastInTurn = moved.lastInTurn; + const bool presentationChanged = moved != replacement; + if (presentationChanged) + updateRow(destinationRow, std::move(replacement)); + if (sourceRow == destinationRow && !presentationChanged) + return StructuralChangeResult::Unchanged; + + rebuildIndexes(); + refreshSectionStructure(oldSection); + if (rows_[static_cast(destinationRow)].sectionKey != oldSection) + refreshSectionStructure( + rows_[static_cast(destinationRow)].sectionKey); + incrementProperty("modelExactMoveCount"); + return StructuralChangeResult::Changed; +} + bool ConversationItemModel::appendTail(ConversationTailCard tail) { if (tail.card.threadId != threadId_ || tail.sectionKey.empty()) return false; @@ -586,6 +816,111 @@ ConversationItemModel::flatten(ConversationSnapshot &&snapshot) const { return result; } +bool ConversationItemModel::rowsAreUnique(const std::vector &rows) const { + std::unordered_set unique; + unique.reserve(rows.size()); + for (const Row &row : rows) + if (row.stableKey.empty() || !unique.insert(row.stableKey).second) + return false; + return true; +} + +ConversationItemModel::Row ConversationItemModel::rowFromPlacement( + ConversationRowPlacement placement) const { + Row result; + result.card = std::move(placement.card); + result.stableKey = stableKey(result.card.key); + result.sectionKey = std::move(placement.sectionKey); + result.turnRoot = placement.turnRoot; + result.nested = placement.nested; + result.presented = isPresented(result.card); + result.activeTurn = placement.turnRoot && placement.activeTurn; + result.historyActivity = placement.historyActivity; + return result; +} + +bool ConversationItemModel::sectionPlacementIsValid( + int rowIndex, const Row &candidate) const { + if (rowIndex < 0 || rowIndex > rowCount() || candidate.sectionKey.empty() || + (candidate.turnRoot && candidate.nested)) + return false; + + bool sectionSeen = false; + bool sectionClosed = false; + bool rootSeen = false; + for (int position = 0; position <= rowCount(); ++position) { + const Row *row = + position == rowIndex + ? &candidate + : this->row(position < rowIndex ? position : position - 1); + if (!row) + continue; + if (row->sectionKey != candidate.sectionKey) { + if (sectionSeen) + sectionClosed = true; + continue; + } + if (sectionClosed) + return false; + if (row->turnRoot) { + if (rootSeen || sectionSeen) + return false; + rootSeen = true; + } + sectionSeen = true; + } + return true; +} + +void ConversationItemModel::refreshSectionStructure( + const std::string §ionKey) { + if (sectionKey.empty()) + return; + int first = -1; + int last = -1; + int root = -1; + for (int rowIndex = 0; rowIndex < rowCount(); ++rowIndex) { + const Row &row = rows_[static_cast(rowIndex)]; + if (row.sectionKey != sectionKey) + continue; + if (first < 0) + first = rowIndex; + last = rowIndex; + if (row.turnRoot) + root = rowIndex; + } + if (first < 0) + return; + + for (int rowIndex = first; rowIndex <= last; ++rowIndex) { + Row &row = rows_[static_cast(rowIndex)]; + QList roles; + const bool firstInTurn = rowIndex == first; + const bool lastInTurn = rowIndex == last; + const bool nested = root >= 0 && rowIndex != root; + if (row.firstInTurn != firstInTurn) { + row.firstInTurn = firstInTurn; + roles.push_back(FirstInTurnRole); + } + if (row.lastInTurn != lastInTurn) { + row.lastInTurn = lastInTurn; + roles.push_back(LastInTurnRole); + } + if (row.nested != nested) { + row.nested = nested; + roles.push_back(NestedCardRole); + } + if (row.activeTurn && !row.turnRoot) { + row.activeTurn = false; + roles.push_back(ActiveTurnRole); + } + if (roles.empty()) + continue; + emit dataChanged(index(rowIndex), index(rowIndex), roles); + incrementProperty("modelDataChangeCount"); + } +} + bool ConversationItemModel::isPresented( const VisibleCardData &card) const noexcept { if (card.kind == CardKind::Reasoning) @@ -671,7 +1006,7 @@ void ConversationItemModel::updateRow(int rowIndex, Row replacement) { } before = std::move(replacement); if (roles.empty()) - roles.push_back(PresentationRole); + return; emit dataChanged(index(rowIndex), index(rowIndex), roles); incrementProperty("modelDataChangeCount"); } diff --git a/src/codex/middle/ConversationItemModel.h b/src/codex/middle/ConversationItemModel.h index d18847f..f9a585a 100644 --- a/src/codex/middle/ConversationItemModel.h +++ b/src/codex/middle/ConversationItemModel.h @@ -43,6 +43,14 @@ class ConversationItemModel final : public QAbstractListModel { Changed, }; + enum class StructuralChangeResult { + Missing, + Invalid, + Duplicate, + Unchanged, + Changed, + }; + struct Visibility { bool showReasoning = true; bool showCodexUpdates = true; @@ -85,10 +93,24 @@ class ConversationItemModel final : public QAbstractListModel { [[nodiscard]] Qt::ItemFlags flags(const QModelIndex &index) const override; [[nodiscard]] QHash roleNames() const override; - // A different thread is one complete authority replacement and therefore a - // model reset. Same-thread order is reconciled with exact row operations. + // Complete replacement is explicit and is reserved for a different thread + // or a genuine full rescan where no narrower operation is correct. + [[nodiscard]] bool replaceConversation(ConversationSnapshot snapshot); + // A history page is a same-thread superset that retains every existing row + // in order. It inserts only the missing ranges and updates changed row facts. + [[nodiscard]] bool prependHistoryPage(ConversationSnapshot snapshot); + // Compatibility reconciliation remains while integration routes are moved + // to the explicit operations below. [[nodiscard]] bool reconcile(ConversationSnapshot snapshot); [[nodiscard]] CardUpdateResult updateCard(VisibleCardData card); + [[nodiscard]] StructuralChangeResult + insertCard(int row, ConversationRowPlacement placement); + [[nodiscard]] StructuralChangeResult + removeTarget(const nodegraph::NodeRef &target); + // destinationRow is the row's final logical position after the move. + [[nodiscard]] StructuralChangeResult + moveTarget(const nodegraph::NodeRef &target, int destinationRow, + ConversationRowPlacement placement); [[nodiscard]] bool appendTail(ConversationTailCard tail); [[nodiscard]] HistoryTrim trimHistoryTo(std::size_t activityLimit); [[nodiscard]] bool setActiveTurn(int row, bool active); @@ -111,6 +133,11 @@ class ConversationItemModel final : public QAbstractListModel { private: [[nodiscard]] std::vector flatten(ConversationSnapshot &&snapshot) const; + [[nodiscard]] bool rowsAreUnique(const std::vector &rows) const; + [[nodiscard]] Row rowFromPlacement(ConversationRowPlacement placement) const; + [[nodiscard]] bool sectionPlacementIsValid(int row, + const Row &candidate) const; + void refreshSectionStructure(const std::string §ionKey); [[nodiscard]] bool isPresented(const VisibleCardData &card) const noexcept; void rebuildIndexes(); void incrementProperty(const char *name); diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 8369734..3489094 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -212,16 +212,21 @@ struct TurnSection { bool operator==(const TurnSection &) const = default; }; -// Bounded projection for the common canonical tail insertion. It carries no -// authority: the NodeRef target and all values are read from NodeGraph under -// one short lock, then consumed by Qt after the lock has been released. -struct ConversationTailCard { +// Exact placement facts for one conversation row. They carry no authority: +// the NodeRef target and all values are read from NodeGraph under one short +// lock, then consumed by Qt after the lock has been released. +struct ConversationRowPlacement { VisibleCardData card; std::string sectionKey; bool turnRoot = false; bool nested = false; bool activeTurn = false; bool historyActivity = true; +}; + +// Bounded projection for the common canonical tail insertion, with the two +// thread-history facts needed to update the retained window chrome. +struct ConversationTailCard : ConversationRowPlacement { std::size_t authoritativeItemCount = 0; bool providerHasMore = false; }; diff --git a/tests/codex/ConversationItemModelTest.cpp b/tests/codex/ConversationItemModelTest.cpp index 13d92bb..97987d8 100644 --- a/tests/codex/ConversationItemModelTest.cpp +++ b/tests/codex/ConversationItemModelTest.cpp @@ -102,6 +102,16 @@ ConversationSnapshot snapshot(std::vector cards, return result; } +ConversationRowPlacement placement(VisibleCardData value, + bool turnRoot = false) { + ConversationRowPlacement result; + result.card = std::move(value); + result.sectionKey = "section"; + result.turnRoot = turnRoot; + result.nested = !turnRoot; + return result; +} + bool testStableIdentityAndExactSignals() { nodegraph::NodeGraph graph; nodegraph::NodeRef first; @@ -117,10 +127,10 @@ bool testStableIdentityAndExactSignals() { ConversationItemModel model; SignalLog log(model); - bool result = require( - model.reconcile(snapshot({card("same-wire-id-a", first, "one"), - card("same-wire-id-b", second, "two")})), - "initial authority was not accepted"); + bool result = require(model.replaceConversation( + snapshot({card("same-wire-id-a", first, "one"), + card("same-wire-id-b", second, "two")})), + "initial authority was not accepted"); result &= require(log.resets == 1 && log.inserted.empty() && model.rowCount() == 2, "initial thread did not use one model reset"); @@ -164,30 +174,29 @@ bool testStableIdentityAndExactSignals() { log.clear(); result &= require( - model.reconcile(snapshot({card("same-wire-id-a", first, "one"), - card("inserted", third, "three"), - card("same-wire-id-b", second, "streamed")})) && + model.insertCard(1, placement(card("inserted", third, "three"))) == + ConversationItemModel::StructuralChangeResult::Changed && log.inserted.size() == 1 && log.inserted.front().first == 1 && log.inserted.front().last == 1 && log.resets == 0, "middle insertion did not use beginInsertRows/endInsertRows"); log.clear(); result &= require( - model.reconcile(snapshot({card("inserted", third, "three"), - card("same-wire-id-a", first, "one"), - card("same-wire-id-b", second, "streamed")})) && - log.moved.size() == 1 && log.moved.front().first == 1 && - log.moved.front().last == 1 && log.moved.front().destination == 0 && + model.moveTarget(second, 1, + placement(card("same-wire-id-b", second, "streamed"))) == + ConversationItemModel::StructuralChangeResult::Changed && + log.moved.size() == 1 && log.moved.front().first == 2 && + log.moved.front().last == 2 && log.moved.front().destination == 1 && log.resets == 0, "actual reordering did not use beginMoveRows/endMoveRows"); log.clear(); - result &= require( - model.reconcile(snapshot({card("inserted", third, "three"), - card("same-wire-id-b", second, "streamed")})) && - log.removed.size() == 1 && log.removed.front().first == 1 && - log.removed.front().last == 1 && log.resets == 0, - "removal did not use beginRemoveRows/endRemoveRows"); + result &= + require(model.removeTarget(third) == + ConversationItemModel::StructuralChangeResult::Changed && + log.removed.size() == 1 && log.removed.front().first == 2 && + log.removed.front().last == 2 && log.resets == 0, + "removal did not use beginRemoveRows/endRemoveRows"); result &= require( model.indexForTarget(second).row() == 1 && model.indexForStableKey("item:6:thread4:turn14:same-wire-id-b") @@ -195,12 +204,60 @@ bool testStableIdentityAndExactSignals() { "stable and exact target indexes were not rebuilt"); log.clear(); - result &= require(model.reconcile(snapshot({}, "replacement")) && + result &= require(model.replaceConversation(snapshot({}, "replacement")) && log.resets == 1 && model.rowCount() == 0, "genuine thread replacement did not use a model reset"); return result; } +bool testHistoryPageInsertsOnlyMissingRanges() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef root; + nodegraph::NodeRef middle; + nodegraph::NodeRef tail; + { + auto write = graph.write(); + root = write.upsert({nodegraph::NodeKind::Item, "page/root"}); + middle = write.upsert({nodegraph::NodeKind::Item, "page/middle"}); + tail = write.upsert({nodegraph::NodeKind::Item, "page/tail"}); + static_cast(write.finish()); + } + + ConversationSnapshot initial = + snapshot({card("root", root, "root"), card("tail", tail, "tail")}); + initial.hiddenAuthoritativeItemCount = 1; + initial.hasMore = true; + initial.sections.front().rootPinned = true; + ConversationItemModel model; + bool result = require(model.replaceConversation(std::move(initial)), + "history page fixture was not accepted"); + SignalLog log(model); + + ConversationSnapshot expanded = + snapshot({card("root", root, "root"), card("middle", middle, "middle"), + card("tail", tail, "tail")}); + result &= require( + model.prependHistoryPage(std::move(expanded)) && log.resets == 0 && + log.inserted.size() == 1 && log.inserted.front().first == 1 && + log.inserted.front().last == 1 && model.rowCount() == 3 && + model.indexForTarget(root).row() == 0 && + model.indexForTarget(middle).row() == 1 && + model.indexForTarget(tail).row() == 2, + "history expansion did not insert only the missing range after its " + "pinned root"); + + log.clear(); + ConversationSnapshot reordered = + snapshot({card("root", root, "root"), card("tail", tail, "tail"), + card("middle", middle, "middle")}); + result &= require(!model.prependHistoryPage(std::move(reordered)) && + log.inserted.empty() && log.moved.empty() && + log.removed.empty() && log.changed.empty(), + "a reordered target was incorrectly accepted as a " + "history-page insertion"); + return result; +} + bool testVisibilityAndLargeModelRemainDataOnly() { ConversationItemModel model; ConversationSnapshot data; @@ -403,6 +460,7 @@ int main(int argc, char **argv) { QCoreApplication application(argc, argv); using namespace codexui::codex::middle; bool result = testStableIdentityAndExactSignals(); + result &= testHistoryPageInsertsOnlyMissingRanges(); result &= testVisibilityAndLargeModelRemainDataOnly(); result &= testBoundedTailAppendKeepsAbsoluteIdentityIndexes(); result &= testHeightIndexIsBoundedAndExact(); From 95800b23861f3579b39373722ad7d2fe2586bb68 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 10:50:34 +0200 Subject: [PATCH 17/39] Target prompt materialization to one row --- docs/ui-ux-internal-api.md | 7 +- src/codex/ShellWidget.cpp | 43 ++++++++ src/codex/ui/NodeGraphUiAdapter.cpp | 50 +++++++++ src/codex/ui/NodeGraphUiAdapter.h | 7 ++ tests/codex/NodeGraphConversationUiTest.cpp | 21 ++-- tests/codex/NodeGraphUiAdapterTest.cpp | 41 ++++++++ tests/codex/ShellIntegrationTest.cpp | 106 +++++++++++++++++++- 7 files changed, 263 insertions(+), 12 deletions(-) diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index 7ed2865..4fd1dc5 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -55,7 +55,7 @@ concrete graph-cutover requirement. | initial conversation selection | Never exposes part of an authoritative replacement. The first content frame has complete cards, final parentage, final width/height, and final anchor. When switching populated threads, the outgoing surface stays stable until the incoming final snapshot is ready. | Compatible. Provider fragments are blocked, the outgoing conversation/heading/Inspector remain staged, and readiness replaces them once with the complete bounded window. | | conversation history window | Starts at 80 authoritative items. Pinned opening prompts do not consume the budget. Load More adds 80. While paused, new authoritative tail items expand the effective window; following resets it to the requested window. | Compatible after replacing the unbounded adapter request with per-thread requested/effective counters and excluding local prompts from the authoritative count. | | card DTO and rendering | Typed payloads preserve the old card kinds, text, metadata, status, images, truncation disclosure, plan, diff counts, and unknown fallback. Presentation options are applied by `ConversationView`, not by protocol logic. | Compatible. Generic detail is a safe bounded rendering string because the graph deliberately does not retain raw payloads for UI convenience. | -| prompt materialization | An admitted local card keeps its `LocalPromptKey` while the authoritative user item arrives; the same widget changes type in place and preserves owner, anchor, focus, and local fold state. | Compatible through a narrow additive callback carrying the exact prompt `NodeRef`; widgets do not inspect graph state. | +| prompt materialization | An admitted local card keeps its `LocalPromptKey` while the authoritative user item arrives; the same widget changes type in place and preserves owner, anchor, focus, and local fold state. | Compatible through the exact adapter projection and one targeted model `dataChanged`; acknowledgement carries the related prompt `NodeRef` and performs no complete conversation reconciliation. | | prompt recovery | A definite/uncertain failed prompt remains visible and restores text/attachments only by explicit user action, without overwriting an existing draft. | Compatible through a narrow additive recovery callback carrying the exact prompt `NodeRef`. | | `setEmptyMessage` | Changes only the empty-state text and preserves the current anchor/follow behavior. It does not authorize clearing an existing conversation. | Compatible. Hydration staging decides whether an empty snapshot may be reconciled. | | presentation options | Reasoning/Codex-update visibility and initial command/image/file-change folding remain local UI preferences; changing them reuses current card widgets and state. | Compatible. The adapter does not reinterpret these preferences. | @@ -117,6 +117,10 @@ always means “no coherent value was available now”, never “render empty” is still parented by a Turn owned by the supplied thread. It is reserved for a targeted visible-card update and must never reconstruct identity from payload fields. A stale/detached item returns `nullopt`. +- `promptMaterialization(thread, item)` accepts only an authoritative user item + related to one current local prompt in `awaitingMaterialization`. It returns + the authoritative presentation under that prompt's `LocalPromptKey` and + exact prompt `NodeRef`, allowing one row-local morph and acknowledgement. - `tailCard(thread, item)` additionally requires that the exact item be the last child of the last canonical Turn and that it not participate in prompt-materialization aliasing. It returns one `ConversationTailCard` with @@ -132,6 +136,7 @@ always means “no coherent value was available now”, never “render empty” | `conversationInfo` | `thread`: required stable Thread; returns optional control facts | Wrong kind, stale generation, removal, or contention returns `nullopt`. Success does not construct card DTOs. | | `conversation` | `thread`, positive effective `itemLimit`; returns optional complete snapshot | Pre: the caller has observed `conversationInfo.readyForDisplay`; this primitive projects the graph's current content and does not itself infer temporal hydration completeness. Limit is clamped to at least one. Success preserves canonical order and root ownership. Invalid target/contention returns `nullopt`. | | `card` | exact `thread` and `item`; returns optional card DTO | Success requires the item still be a child of a Turn owned by the exact thread. It never searches by payload IDs. | +| `promptMaterialization` | exact `thread` and authoritative `item`; returns optional card DTO | Success requires one live related local prompt owned by the thread with a valid submission ID and awaiting-materialization state. No relation inference or payload-ID search is permitted. | | `tailCard` | exact `thread` and `item`; returns optional tail DTO | Success requires the exact canonical last item of the exact canonical last Turn, usable loaded-count state, and no prompt alias. The DTO is non-authoritative and owns only values needed for one Qt append. | ### `middle::ThreadPane` diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index cba587c..2062bbd 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -1729,6 +1729,49 @@ void ShellWidget::Impl::commitPendingPanes() { owner->property("targetedConversationRoutes").toULongLong() + 1); } } + if (pendingConversation && !pendingConversationItems.empty() && + boundGraphThread && + !middleRegion->conversation().structuralStagingActive()) { + std::optional materialization; + nodegraph::NodeRef authoritativeItem; + bool ambiguous = false; + for (const nodegraph::NodeRef &item : pendingConversationItems) { + auto candidate = uiAdapter.promptMaterialization(boundGraphThread, item); + if (!candidate) + continue; + if (materialization) { + ambiguous = true; + break; + } + materialization = std::move(*candidate); + authoritativeItem = item; + } + const bool exactMaterialization = + materialization && !ambiguous && + std::ranges::all_of(pendingConversationItems, + [&](const nodegraph::NodeRef &item) { + return item == authoritativeItem || + item == materialization->target; + }); + if (exactMaterialization && + middleRegion->conversation() + .applyCardPresentation(std::move(*materialization)) + .has_value()) { + pendingConversation = false; + pendingConversationItems.clear(); + ++conversationRoutes; + owner->setProperty("conversationRoutes", + static_cast(conversationRoutes)); + owner->setProperty( + "targetedConversationRoutes", + owner->property("targetedConversationRoutes").toULongLong() + 1); + owner->setProperty( + "targetedConversationPromptMaterializations", + owner->property("targetedConversationPromptMaterializations") + .toULongLong() + + 1); + } + } if (pendingConversation && pendingConversationItems.size() == 1 && boundGraphThread && !middleRegion->conversation().structuralStagingActive()) { diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp index 9be34b4..beb00af 100644 --- a/src/codex/ui/NodeGraphUiAdapter.cpp +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -1567,6 +1567,56 @@ NodeGraphUiAdapter::card(const nodegraph::NodeRef &thread, graphString(graphField(*threadState, "cwd"))); } +std::optional NodeGraphUiAdapter::promptMaterialization( + const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const { + if (!graph_ || !thread || !item) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read || !read->contains(thread) || !read->contains(item) || + read->removed(thread) || read->removed(item) || + thread->id().kind != nodegraph::NodeKind::Thread || + item->id().kind != nodegraph::NodeKind::Item) + return std::nullopt; + + const nodegraph::NodeRef turn = read->parent(item); + if (!turn || turn->id().kind != nodegraph::NodeKind::Turn || + read->parent(turn) != thread) + return std::nullopt; + const auto state = read->state(item); + const auto turnState = read->state(turn); + const auto threadState = read->state(thread); + if (!state || !turnState || !threadState || + graphCardKind(*state) != CardKind::UserMessage) + return std::nullopt; + + for (const nodegraph::NodeRef &prompt : + read->related(item, nodegraph::RelationKind::PromptMaterialization)) { + if (!prompt || !read->contains(prompt) || read->removed(prompt) || + prompt->id().kind != nodegraph::NodeKind::Item) + continue; + const auto promptState = read->state(prompt); + const nodegraph::NodeRef promptTurn = read->parent(prompt); + if (!promptState || !promptTurn || read->parent(promptTurn) != thread || + graphString(graphField(*promptState, "type")) != "localPrompt" || + graphString(graphField(*promptState, "dispatchState")) != + "awaitingMaterialization") + continue; + const auto submissionId = + graphInteger(graphField(*promptState, "submissionId")); + if (!submissionId || *submissionId < 0) + continue; + + VisibleCardData result = graphCardData( + item, thread->id().canonical, + nodegraph::protocolCanonicalId(*turnState, turn), *state, + graphString(graphField(*threadState, "cwd"))); + result.key = LocalPromptKey{static_cast(*submissionId)}; + result.target = prompt; + return result; + } + return std::nullopt; +} + std::optional NodeGraphUiAdapter::tailCard(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const { diff --git a/src/codex/ui/NodeGraphUiAdapter.h b/src/codex/ui/NodeGraphUiAdapter.h index f8bc9b2..6dc8c76 100644 --- a/src/codex/ui/NodeGraphUiAdapter.h +++ b/src/codex/ui/NodeGraphUiAdapter.h @@ -38,6 +38,13 @@ class NodeGraphUiAdapter final { card(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const; + // Projects an authoritative user item only when it exactly materializes a + // still-current local prompt. The returned card retains the LocalPromptKey + // and prompt NodeRef so Qt can morph and acknowledge that one stable row. + [[nodiscard]] std::optional + promptMaterialization(const nodegraph::NodeRef &thread, + const nodegraph::NodeRef &item) const; + // Projects only a canonical last item of the selected thread. It is the // bounded structural fast path for ordinary append; any non-tail or prompt // alias case returns nullopt and uses complete reconciliation instead. diff --git a/tests/codex/NodeGraphConversationUiTest.cpp b/tests/codex/NodeGraphConversationUiTest.cpp index ecf2cf9..cde07eb 100644 --- a/tests/codex/NodeGraphConversationUiTest.cpp +++ b/tests/codex/NodeGraphConversationUiTest.cpp @@ -232,8 +232,10 @@ bool promptMorphPreservesExactTargetAndWidget() { write.relate(turn, nodegraph::RelationKind::TurnRootItem, authoritative); static_cast(write.finish()); } - snapshot = adapter.conversation(thread, 80); - if (!snapshot || !view.reconcile(*snapshot)) + const auto materialized = + adapter.promptMaterialization(thread, authoritative); + if (!materialized || + !view.applyCardPresentation(*materialized).has_value()) return false; QApplication::processEvents(); const auto after = view.findChildren(); @@ -245,7 +247,7 @@ bool promptMorphPreservesExactTargetAndWidget() { "prompt morph discarded its exact NodeRef target")) return false; - static_cast(view.reconcile(*snapshot)); + static_cast(view.applyCardPresentation(*materialized)); if (!require(acknowledgements == 1, "unchanged prompt projection acknowledged twice")) return false; @@ -352,10 +354,10 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { write.setField(steering, "showPendingAnimation", false); static_cast(write.finish()); } - snapshot = adapter.conversation(thread, 80); - if (!snapshot) + const auto acknowledgedPrompt = adapter.card(thread, steering); + if (!acknowledgedPrompt) return false; - static_cast(view.reconcile(*snapshot)); + static_cast(view.applyCardPresentation(*acknowledgedPrompt)); QApplication::processEvents(); QTimer *animation = stable->findChild( QStringLiteral("pendingAnimationTimer")); @@ -380,10 +382,11 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { turn, std::array{root, steering, authoritative, progress}); static_cast(write.finish()); } - snapshot = adapter.conversation(thread, 80); - if (!snapshot) + const auto materialized = + adapter.promptMaterialization(thread, authoritative); + if (!materialized) return false; - static_cast(view.reconcile(*snapshot)); + static_cast(view.applyCardPresentation(*materialized)); QApplication::processEvents(); const int promotedTop = stable->mapTo(view.viewport(), QPoint{}).y(); if (!require(stable->data().kind == middle::CardKind::UserMessage && diff --git a/tests/codex/NodeGraphUiAdapterTest.cpp b/tests/codex/NodeGraphUiAdapterTest.cpp index ba7ed02..beed2c3 100644 --- a/tests/codex/NodeGraphUiAdapterTest.cpp +++ b/tests/codex/NodeGraphUiAdapterTest.cpp @@ -194,6 +194,46 @@ bool projectsOnlyTheExactCanonicalTail() { "a non-tail item entered the bounded append path"); } +bool projectsExactPromptMaterialization() { + nodegraph::NodeGraph graph; + NodeRef thread; + NodeRef turn; + NodeRef prompt; + NodeRef authoritative; + { + auto write = graph.write(); + thread = write.upsert({NodeKind::Thread, "prompt-thread"}); + turn = write.upsert({NodeKind::Turn, "prompt-turn"}); + write.setField(turn, "id", "prompt-turn"); + NodeState local = itemState("local-prompt", "localPrompt", "hello"); + local.fields.emplace("submissionId", std::uint64_t{91}); + local.fields.emplace("dispatchState", "awaitingMaterialization"); + prompt = write.upsert({NodeKind::Item, "local-prompt"}, std::move(local)); + authoritative = write.upsert( + {NodeKind::Item, "provider-prompt"}, + itemState("provider-prompt", "userMessage", "hello")); + write.setParent(thread, turn); + write.setParent(turn, prompt); + write.setParent(turn, authoritative); + write.relate(authoritative, + nodegraph::RelationKind::PromptMaterialization, prompt); + static_cast(write.finish()); + } + + NodeGraphUiAdapter adapter(graph); + const auto result = adapter.promptMaterialization(thread, authoritative); + return require(result.has_value(), + "exact prompt materialization was not projected") && + require(result->key == middle::CardKey{middle::LocalPromptKey{91}}, + "prompt materialization changed the stable local key") && + require(result->kind == middle::CardKind::UserMessage && + result->target == prompt, + "prompt materialization lost its authoritative presentation " + "or exact acknowledgement target") && + require(!adapter.promptMaterialization(thread, prompt), + "a local prompt was accepted as its own materialization"); +} + bool preservesReadinessActivityAndAuthoritativeBudgetSemantics() { nodegraph::NodeGraph graph; NodeRef runtime; @@ -297,6 +337,7 @@ int main() { if (!projectsCanonicalTurnStructureAndRoot() || !limitsHistoryButPinsTheOwningPrompt() || !projectsOnlyTheExactCanonicalTail() || + !projectsExactPromptMaterialization() || !preservesThreadRootsAndExactChildTargets() || !preservesReadinessActivityAndAuthoritativeBudgetSemantics()) return EXIT_FAILURE; diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 2f19f38..e4868f4 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -1535,6 +1535,7 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( shell.findChild(QStringLiteral("inspector"))); NodeRef selectedItem; + NodeRef selectedTurn; NodeRef backgroundItem; NodeRef selectedThread; { @@ -1546,15 +1547,17 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( scopedTurnNodeId("selected-thread", "selected-turn"), "selected-item")) : NodeRef{}; + selectedTurn = read ? read->parent(selectedItem) : NodeRef{}; backgroundItem = read ? read->find(scopedItemNodeId( scopedTurnNodeId("background-thread", "background-turn"), "background-item")) : NodeRef{}; } - require(selectedThread && selectedItem && backgroundItem, + require(selectedThread && selectedTurn && selectedItem && backgroundItem, "the test resolves the selected thread and both scoped items"); - if (!selectedThread || !selectedItem || !backgroundItem || !selectedCard) + if (!selectedThread || !selectedTurn || !selectedItem || !backgroundItem || + !selectedCard) return; const qulonglong threadRoutesBefore = @@ -1780,6 +1783,105 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( "a non-sort thread field patches only its row without topology or " "conversation geometry work"); + static_cast(takeQtMessages(channels)); + NodeRef localPrompt; + GraphChange localPromptChange; + { + auto write = graph.write(); + NodeState local; + local.status = NodeStatus::Pending; + local.fields = {{"id", Value("local-materialization")}, + {"type", Value("localPrompt")}, + {"text", Value("Materialize me")}, + {"submissionId", Value(std::uint64_t{808})}, + {"dispatchState", Value("awaitingMaterialization")}, + {"local", Value(true)}}; + localPrompt = write.upsert({NodeKind::Item, "local-materialization"}, + std::move(local)); + write.setParent(selectedTurn, localPrompt); + write.relate(selectedThread, RelationKind::PendingPrompt, localPrompt); + localPromptChange = write.finish(); + } + require(messageAdmitted( + channels.sendGraphChanged(std::move(localPromptChange))), + "the local materialization row is admitted to Qt"); + const std::string localKey = + middle::stableKey(middle::LocalPromptKey{808}); + require(spinUntil([&] { + return conversation->conversationModel() + ->indexForStableKey(localKey) + .isValid(); + }), + "the local prompt reaches its stable conversation row"); + middle::ConversationCard *localCard = nullptr; + for (middle::ConversationCard *card : + conversation->findChildren()) + if (card->property("conversationAnchorKey").toString().toStdString() == + localKey) + localCard = card; + const int materializationRowsBefore = + conversation->conversationModel()->rowCount(); + const qulonglong promptRoutesBefore = + shell.property("targetedConversationPromptMaterializations") + .toULongLong(); + const qulonglong promptSectionRebuildsBefore = + conversation->property("conversationSectionRangeRebuilds").toULongLong(); + + NodeRef authoritativePrompt; + GraphChange materializationChange; + { + auto write = graph.write(); + NodeState authoritative; + authoritative.status = NodeStatus::Completed; + authoritative.fields = { + {"id", Value("provider-materialization")}, + {"type", Value("userMessage")}, + {"text", Value("Materialize me")}, + {"localSubmissionId", Value(std::uint64_t{808})}}; + authoritativePrompt = + write.upsert({NodeKind::Item, "provider-materialization"}, + std::move(authoritative)); + write.setParent(selectedTurn, authoritativePrompt); + write.relate(authoritativePrompt, RelationKind::PromptMaterialization, + localPrompt); + materializationChange = write.finish(); + } + require(messageAdmitted( + channels.sendGraphChanged(std::move(materializationChange))), + "the authoritative prompt materialization is admitted to Qt"); + require(spinUntil([&] { + const QModelIndex index = conversation->conversationModel() + ->indexForStableKey(localKey); + const middle::VisibleCardData *card = + conversation->conversationModel()->card(index.row()); + return index.isValid() && card && + card->kind == middle::CardKind::UserMessage && + card->target == localPrompt; + }), + "the authoritative prompt morphs the exact local row"); + const std::vector materializationActions = + takeQtMessages(channels); + const bool exactAcknowledgement = + std::ranges::count_if(materializationActions, [&](const auto &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::PromptMaterialized && + action->target == localPrompt; + }) == 1; + require( + exactAcknowledgement && + conversation->conversationModel()->rowCount() == + materializationRowsBefore && + shell.property("targetedConversationPromptMaterializations") + .toULongLong() == + promptRoutesBefore + 1 && + conversation->property("conversationSectionRangeRebuilds") + .toULongLong() == promptSectionRebuildsBefore && + (!localCard || + localCard->property("conversationAnchorKey").toString() + .toStdString() == localKey), + "prompt materialization targets one stable row and exact acknowledgement " + "without structural reconciliation"); + QPointer removedWidget = selectedCard; GraphChange removal; { From 141e00078f05f5beb7145d2297c0556a3a2002a3 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 11:24:40 +0200 Subject: [PATCH 18/39] Route exact conversation structure deltas --- docs/ui-ux-internal-api.md | 18 +- src/codex/ShellWidget.cpp | 228 ++++++++++++++++-- src/codex/middle/ConversationItemModel.cpp | 6 +- src/codex/middle/ConversationView.cpp | 184 +++++++++++++- src/codex/middle/ConversationView.h | 12 +- src/codex/middle/MiddleTypes.h | 17 ++ src/codex/ui/NodeGraphUiAdapter.cpp | 173 ++++++++++++- src/codex/ui/NodeGraphUiAdapter.h | 12 +- .../codex/ConversationVirtualizationTest.cpp | 85 +++++++ tests/codex/NodeGraphConversationUiTest.cpp | 9 +- tests/codex/NodeGraphUiAdapterTest.cpp | 67 ++++- tests/codex/ShellIntegrationTest.cpp | 123 +++++++++- 12 files changed, 891 insertions(+), 43 deletions(-) diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index 4fd1dc5..db5ad72 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -119,8 +119,13 @@ always means “no coherent value was available now”, never “render empty” payload fields. A stale/detached item returns `nullopt`. - `promptMaterialization(thread, item)` accepts only an authoritative user item related to one current local prompt in `awaitingMaterialization`. It returns - the authoritative presentation under that prompt's `LocalPromptKey` and - exact prompt `NodeRef`, allowing one row-local morph and acknowledgement. + the authoritative presentation under that prompt's `LocalPromptKey`, the + authoritative Item `NodeRef` for row ownership, and the separate exact + prompt `NodeRef` for acknowledgement. +- `rowChange(thread, item)` projects one live selected-thread Item, its exact + section/root/nesting facts, and the immediate canonical card keys on either + side. It is the non-snapshot input for an exact middle insertion or move; + it retains no ordering state after the read guard is released. - `tailCard(thread, item)` additionally requires that the exact item be the last child of the last canonical Turn and that it not participate in prompt-materialization aliasing. It returns one `ConversationTailCard` with @@ -137,6 +142,7 @@ always means “no coherent value was available now”, never “render empty” | `conversation` | `thread`, positive effective `itemLimit`; returns optional complete snapshot | Pre: the caller has observed `conversationInfo.readyForDisplay`; this primitive projects the graph's current content and does not itself infer temporal hydration completeness. Limit is clamped to at least one. Success preserves canonical order and root ownership. Invalid target/contention returns `nullopt`. | | `card` | exact `thread` and `item`; returns optional card DTO | Success requires the item still be a child of a Turn owned by the exact thread. It never searches by payload IDs. | | `promptMaterialization` | exact `thread` and authoritative `item`; returns optional card DTO | Success requires one live related local prompt owned by the thread with a valid submission ID and awaiting-materialization state. No relation inference or payload-ID search is permitted. | +| `rowChange` | exact `thread` and live `item`; returns optional row-change DTO | Success requires current Turn ownership by the exact thread. Immediate neighbor keys reflect canonical graph order with a materializing local prompt suppressed behind its authoritative row. | | `tailCard` | exact `thread` and `item`; returns optional tail DTO | Success requires the exact canonical last item of the exact canonical last Turn, usable loaded-count state, and no prompt alias. The DTO is non-authoritative and owns only values needed for one Qt append. | ### `middle::ThreadPane` @@ -301,6 +307,14 @@ released, and QWidget work never occurs while a graph or channel lock is held. facts without constructing, laying out, or painting a QWidget. A visible passive row invalidates only its row rectangle; a visible rich row applies only to that editor and propagates only its genuine height delta. +- `applyPromptMaterialization(value)` morphs one local-keyed row, transfers its + model ownership to the authoritative Item `NodeRef`, and then acknowledges + the separate prompt `NodeRef`. Prompt retirement cannot remove the row. +- `applyRowChange(value)` resolves the projected neighbor keys against the + current bounded model and emits only the required insert, move, or structural + row update. Coalesced sibling changes are applied in canonical neighbor order. +- `removeCardTarget(ref)` removes only the row currently indexed by that exact + Item `NodeRef`; unrelated and already-transferred prompt removals are no-ops. - `appendTailCard(tail, historyActivityLimit)` is the ordinary structural fast path after `NodeGraphUiAdapter::tailCard` validates canonical placement. It emits one insert, performs an optional bounded prefix trim, preserves the diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 2062bbd..d074a3c 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -226,14 +226,6 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, return {true, true, {}}; if (!selectedThread) return {}; - // Removal intentionally erases ancestry and addressing fields before Qt is - // notified. Conservatively reconcile the selected conversation so no - // retired card reference can survive acknowledgement. - if (std::ranges::any_of(change.removed, [](const auto &node) { - return node && (node->id().kind == nodegraph::NodeKind::Turn || - node->id().kind == nodegraph::NodeKind::Item); - })) - return {true, true, {}}; constexpr std::size_t MaximumFilteredNodes = 64; if (change.affected.size() + change.removed.size() > MaximumFilteredNodes) return {true, true, {}}; @@ -244,6 +236,10 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, const std::string &selectedId = selectedThread->id().canonical; ConversationRoute route; + const auto addItem = [&](const nodegraph::NodeRef &item) { + if (item && std::ranges::find(route.items, item) == route.items.end()) + route.items.push_back(item); + }; const auto routeNode = [&](const nodegraph::NodeRef &node) { if (!node) return; @@ -294,17 +290,30 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, } } } - if (!belongs) + if (!belongs) { + if (node->id().kind == nodegraph::NodeKind::Item && + read->structureChangedRevision(node) == change.revision) { + // A row moved out of the selected thread has already lost its old + // ancestry. The Qt model's exact NodeRef index determines whether + // there is a selected row to remove. + route.affected = true; + route.structural = true; + addItem(node); + } return; + } route.affected = true; if (node->id().kind == nodegraph::NodeKind::Turn) { route.structural = true; + const auto roots = + read->related(node, nodegraph::RelationKind::TurnRootItem); + if (!roots.empty()) + addItem(roots.front()); return; } if (read->structureChangedRevision(node) == change.revision) route.structural = true; - if (std::ranges::find(route.items, node) == route.items.end()) - route.items.push_back(node); + addItem(node); } catch (const std::invalid_argument &) { // A queued NodeRef may have been retired by a later graph transaction. // Conservatively refresh rather than risk missing a selected update. @@ -314,8 +323,22 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, for (const nodegraph::NodeRef &node : change.affected) routeNode(node); - for (const nodegraph::NodeRef &node : change.removed) - routeNode(node); + for (const nodegraph::NodeRef &node : change.removed) { + if (!node) + continue; + if (node == selectedThread) + return {true, true, {}}; + if (node->id().kind == nodegraph::NodeKind::Item) { + route.affected = true; + route.structural = true; + addItem(node); + } else if (node->id().kind == nodegraph::NodeKind::Turn) { + // Its descendants are separately affected or removed by NodeGraph. + // The exact Item identities below decide whether selected rows exist. + route.affected = true; + route.structural = true; + } + } return route; } @@ -1732,7 +1755,7 @@ void ShellWidget::Impl::commitPendingPanes() { if (pendingConversation && !pendingConversationItems.empty() && boundGraphThread && !middleRegion->conversation().structuralStagingActive()) { - std::optional materialization; + std::optional materialization; nodegraph::NodeRef authoritativeItem; bool ambiguous = false; for (const nodegraph::NodeRef &item : pendingConversationItems) { @@ -1751,11 +1774,11 @@ void ShellWidget::Impl::commitPendingPanes() { std::ranges::all_of(pendingConversationItems, [&](const nodegraph::NodeRef &item) { return item == authoritativeItem || - item == materialization->target; + item == materialization->prompt; }); if (exactMaterialization && middleRegion->conversation() - .applyCardPresentation(std::move(*materialization)) + .applyPromptMaterialization(std::move(*materialization)) .has_value()) { pendingConversation = false; pendingConversationItems.clear(); @@ -1810,6 +1833,179 @@ void ShellWidget::Impl::commitPendingPanes() { } } } + if (pendingConversation && !pendingConversationItems.empty() && + boundGraphThread && + !middleRegion->conversation().structuralStagingActive()) { + bool exact = true; + bool touched = false; + std::vector unresolved; + unresolved.reserve(pendingConversationItems.size()); + std::vector rowChanges; + rowChanges.reserve(pendingConversationItems.size()); + + // Live projections run first so prompt retirement can transfer the stable + // row to its authoritative NodeRef before the removed prompt is examined. + for (const nodegraph::NodeRef &item : pendingConversationItems) { + auto change = uiAdapter.rowChange(boundGraphThread, item); + if (!change) { + unresolved.push_back(item); + continue; + } + rowChanges.push_back(std::move(*change)); + } + + std::vector postponedRemovals; + postponedRemovals.reserve(unresolved.size()); + for (const nodegraph::NodeRef &item : unresolved) { + const QModelIndex index = middleRegion->conversation() + .conversationModel() + ->indexForTarget(item); + const middle::ConversationItemModel::Row *row = + middleRegion->conversation().conversationModel()->row(index.row()); + if (!index.isValid() || !row) + continue; + const bool replacementPending = + std::ranges::any_of(rowChanges, [&](const auto &change) { + return middle::stableKey(change.placement.card.key) == + row->stableKey && + change.placement.card.target != item; + }); + if (replacementPending) { + postponedRemovals.push_back(item); + continue; + } + if (!middleRegion->conversation().removeCardTarget(item)) { + exact = false; + break; + } + touched = true; + } + + // replaceChildren can name every changed sibling in an implementation + // order that differs from the final provider order. Apply the small delta + // batch in the neighbor order supplied by NodeGraph so already-correct + // rows do not oscillate through redundant Qt moves. + std::unordered_map changedRows; + changedRows.reserve(rowChanges.size()); + for (std::size_t index = 0; index < rowChanges.size(); ++index) + changedRows.emplace( + middle::stableKey(rowChanges[index].placement.card.key), index); + std::vector> following(rowChanges.size()); + std::vector predecessors(rowChanges.size(), 0); + const auto relateOrder = [&](std::size_t before, std::size_t after) { + if (before == after || + std::ranges::find(following[before], after) != + following[before].end()) + return; + following[before].push_back(after); + ++predecessors[after]; + }; + for (std::size_t index = 0; index < rowChanges.size(); ++index) { + if (rowChanges[index].previousCardKey) { + const auto previous = changedRows.find( + middle::stableKey(*rowChanges[index].previousCardKey)); + if (previous != changedRows.end()) + relateOrder(previous->second, index); + } + if (rowChanges[index].nextCardKey) { + const auto next = changedRows.find( + middle::stableKey(*rowChanges[index].nextCardKey)); + if (next != changedRows.end()) + relateOrder(index, next->second); + } + } + std::vector orderedRows; + orderedRows.reserve(rowChanges.size()); + std::vector emitted(rowChanges.size(), false); + while (exact && orderedRows.size() < rowChanges.size()) { + std::optional ready; + for (std::size_t index = 0; index < rowChanges.size(); ++index) { + if (!emitted[index] && predecessors[index] == 0) { + ready = index; + break; + } + } + if (!ready) { + exact = false; + break; + } + const std::size_t index = *ready; + emitted[index] = true; + orderedRows.push_back(index); + for (const std::size_t next : following[index]) + --predecessors[next]; + } + + for (const std::size_t rowIndex : orderedRows) { + if (!exact) + break; + middle::ConversationRowChange &change = rowChanges[rowIndex]; + const std::string key = middle::stableKey(change.placement.card.key); + const bool represented = + middleRegion->conversation() + .conversationModel() + ->indexForTarget(change.placement.card.target) + .isValid() || + middleRegion->conversation() + .conversationModel() + ->indexForStableKey(key) + .isValid(); + const bool adjacent = + (change.previousCardKey && + middleRegion->conversation() + .conversationModel() + ->indexForStableKey(middle::stableKey(*change.previousCardKey)) + .isValid()) || + (change.nextCardKey && + middleRegion->conversation() + .conversationModel() + ->indexForStableKey(middle::stableKey(*change.nextCardKey)) + .isValid()) || + middleRegion->conversation().conversationModel()->rowCount() == 0; + if (!represented && !adjacent) + continue; + if (!middleRegion->conversation().applyRowChange(std::move(change))) { + exact = false; + break; + } + touched = true; + } + if (exact) { + for (const nodegraph::NodeRef &item : postponedRemovals) { + if (!middleRegion->conversation() + .conversationModel() + ->indexForTarget(item) + .isValid()) + continue; + if (!middleRegion->conversation().removeCardTarget(item)) { + exact = false; + break; + } + touched = true; + } + } + if (exact) { + pendingConversation = false; + pendingConversationItems.clear(); + ++conversationRoutes; + owner->setProperty("conversationRoutes", + static_cast(conversationRoutes)); + owner->setProperty( + "targetedConversationRoutes", + owner->property("targetedConversationRoutes").toULongLong() + 1); + owner->setProperty( + "targetedConversationStructuralDeltas", + owner->property("targetedConversationStructuralDeltas") + .toULongLong() + + 1); + if (!touched) + owner->setProperty( + "targetedConversationStructuralNoops", + owner->property("targetedConversationStructuralNoops") + .toULongLong() + + 1); + } + } if (pendingConversation) { if (refreshConversation()) { pendingConversation = false; diff --git a/src/codex/middle/ConversationItemModel.cpp b/src/codex/middle/ConversationItemModel.cpp index 262740e..e5235ea 100644 --- a/src/codex/middle/ConversationItemModel.cpp +++ b/src/codex/middle/ConversationItemModel.cpp @@ -545,7 +545,8 @@ ConversationItemModel::moveTarget(const nodegraph::NodeRef &target, } const std::string oldSection = current.sectionKey; - if (sourceRow != destinationRow) { + const bool movedRows = sourceRow != destinationRow; + if (movedRows) { const int destinationChild = destinationRow > sourceRow ? destinationRow + 1 : destinationRow; beginMoveRows({}, sourceRow, sourceRow, {}, destinationChild); @@ -572,7 +573,8 @@ ConversationItemModel::moveTarget(const nodegraph::NodeRef &target, if (rows_[static_cast(destinationRow)].sectionKey != oldSection) refreshSectionStructure( rows_[static_cast(destinationRow)].sectionKey); - incrementProperty("modelExactMoveCount"); + incrementProperty(movedRows ? "modelExactMoveCount" + : "modelExactPlacementUpdateCount"); return StructuralChangeResult::Changed; } diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index b55b068..4f284bd 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -1011,6 +1011,178 @@ ConversationView::applyCardPresentation(VisibleCardData &&card) { return applyCardPresentationOwned(std::move(card)); } +std::optional ConversationView::applyPromptMaterialization( + PromptMaterialization materialization) { + if (!materialization.prompt) + return std::nullopt; + return applyCardPresentationOwned(std::move(materialization.card), + std::move(materialization.prompt)); +} + +bool ConversationView::applyRowChange(ConversationRowChange change) { + if (pendingStructuralSnapshot_ || + change.placement.card.threadId != threadId_) + return false; + const std::string key = stableKey(change.placement.card.key); + if (key.empty()) + return false; + + QModelIndex source = + model_->indexForTarget(change.placement.card.target); + const QModelIndex stableSource = model_->indexForStableKey(key); + if (!source.isValid() && stableSource.isValid()) { + const ConversationItemModel::Row *row = model_->row(stableSource.row()); + if (!row) + return false; + const auto retargeted = model_->updateCard(change.placement.card); + if (retargeted == ConversationItemModel::CardUpdateResult::Missing || + retargeted == ConversationItemModel::CardUpdateResult::Incompatible) + return false; + source = model_->indexForTarget(change.placement.card.target); + } + + const int sourceRow = source.isValid() ? source.row() : -1; + int destinationRow = -1; + if (change.previousCardKey) { + const QModelIndex previous = + model_->indexForStableKey(stableKey(*change.previousCardKey)); + if (previous.isValid() && previous.row() != sourceRow) { + destinationRow = previous.row() + 1; + if (sourceRow >= 0 && sourceRow < destinationRow) + --destinationRow; + } + } else { + // The adapter saw the canonical beginning, rather than merely failing to + // resolve a predecessor. Applying a coalesced reorder from front to back + // therefore leaves the already-correct prefix stable. + destinationRow = 0; + } + if (destinationRow < 0 && change.nextCardKey) { + const QModelIndex next = + model_->indexForStableKey(stableKey(*change.nextCardKey)); + if (next.isValid() && next.row() != sourceRow) { + destinationRow = next.row(); + if (sourceRow >= 0 && sourceRow < destinationRow) + --destinationRow; + } + } + if (destinationRow < 0) { + if (sourceRow >= 0) + destinationRow = sourceRow; + else if (model_->rowCount() == 0) + destinationRow = 0; + else + return false; + } + + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + const QScopedValueRollback applying(applying_, true); + const QSignalBlocker scrollSignals(verticalScrollBar()); + stopFollowingAnimation(); + + ConversationItemModel::StructuralChangeResult result; + if (sourceRow >= 0) { + nodegraph::NodeRef target = change.placement.card.target; + result = model_->moveTarget(target, destinationRow, + std::move(change.placement)); + } else { + result = + model_->insertCard(destinationRow, std::move(change.placement)); + } + if (result == ConversationItemModel::StructuralChangeResult::Missing || + result == ConversationItemModel::StructuralChangeResult::Invalid || + result == ConversationItemModel::StructuralChangeResult::Duplicate) + return false; + if (result == ConversationItemModel::StructuralChangeResult::Unchanged) + return true; + + finishExactStructureChange(anchor, follow); + incrementProperty(this, "targetedStructuralRowChanges"); + return true; +} + +bool ConversationView::removeCardTarget(const nodegraph::NodeRef &target) { + if (pendingStructuralSnapshot_) + return false; + const QModelIndex index = model_->indexForTarget(target); + const ConversationItemModel::Row *row = model_->row(index.row()); + if (!index.isValid() || !row) + return false; + const std::string key = row->stableKey; + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + const QScopedValueRollback applying(applying_, true); + const QSignalBlocker scrollSignals(verticalScrollBar()); + stopFollowingAnimation(); + + if (model_->removeTarget(target) != + ConversationItemModel::StructuralChangeResult::Changed) + return false; + if (const auto found = materializedCards_.find(key); + found != materializedCards_.end()) { + ConversationCard *card = found->second; + materializedCards_.erase(found); + releaseCard(key, card); + } + heightCache_.erase(key); + cardCollapsedStates_.erase(key); + cardInteractionStates_.erase(key); + finishExactStructureChange(anchor, follow); + incrementProperty(this, "targetedStructuralRemovals"); + return true; +} + +void ConversationView::finishExactStructureChange(const Anchor &anchor, + bool follow) { + rebuildSectionRanges(); + std::vector released; + for (auto &[key, card] : materializedCards_) { + const QModelIndex index = model_->indexForStableKey(key); + const ConversationItemModel::Row *row = model_->row(index.row()); + if (!index.isValid() || !row || !rowPresented(index.row()) || + !card->canApply(row->card)) { + released.push_back(key); + continue; + } + if (card->data() != row->card) { + captureCardInteractionState(key, card, false); + static_cast(card->applyPresentation(row->card)); + restoreCardInteractionState(key, card); + } + configureCardForRow(card, *row); + const int height = measureCard(card, rowWidth(*row)); + heightCache_.insert_or_assign( + key, HeightRecord{rowWidth(*row), height}); + } + for (const std::string &key : released) { + const auto found = materializedCards_.find(key); + if (found == materializedCards_.end()) + continue; + ConversationCard *card = found->second; + materializedCards_.erase(found); + releaseCard(key, card); + } + + empty_->setVisible(model_->rowCount() == 0); + rebuildHeightIndex(); + updateScrollRange(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + updateMaterialization(false); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + layoutMaterializedCards(); + viewport()->update(); + incrementProperty(this, "graphRefreshPasses"); + updateMaterializationProperties(); + storeCurrentThreadState(); +} + bool ConversationView::appendTailCard(ConversationTailCard tail, std::size_t historyActivityLimit) { if (pendingStructuralSnapshot_ || tail.card.threadId != threadId_ || @@ -1231,7 +1403,8 @@ bool ConversationView::appendTailCard(ConversationTailCard tail, } std::optional -ConversationView::applyCardPresentationOwned(VisibleCardData card) { +ConversationView::applyCardPresentationOwned( + VisibleCardData card, nodegraph::NodeRef materializedPrompt) { const std::string key = stableKey(card.key); if (VisibleCardData *pending = pendingCard(key); pending && pendingStructuralSnapshot_ && @@ -1287,11 +1460,12 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { } const bool becomingAuthoritative = before->card.kind == CardKind::LocalPrompt && - card.kind == CardKind::UserMessage && card.target; + card.kind == CardKind::UserMessage && materializedPrompt; const bool paintedInViewport = wasPresented && rowRect(index.row()).intersects(viewport()->rect()); - nodegraph::NodeRef authoritativeTarget = - becomingAuthoritative ? card.target : nodegraph::NodeRef{}; + nodegraph::NodeRef acknowledgementTarget = + becomingAuthoritative ? std::move(materializedPrompt) + : nodegraph::NodeRef{}; ConversationCard *visibleCard = cardForStableKey(key); PresentationImpact impact = PresentationImpact::None; if (visibleCard) { @@ -1441,7 +1615,7 @@ ConversationView::applyCardPresentationOwned(VisibleCardData card) { storeCurrentThreadState(); if (becomingAuthoritative && promptMaterializedAction_) static_cast( - promptMaterializedAction_(std::move(authoritativeTarget))); + promptMaterializedAction_(std::move(acknowledgementTarget))); return impact; } diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index a103d4c..abee7cd 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -75,6 +75,14 @@ class ConversationView final : public QAbstractItemView { applyCardPresentation(const VisibleCardData &card); [[nodiscard]] std::optional applyCardPresentation(VisibleCardData &&card); + [[nodiscard]] std::optional + applyPromptMaterialization(PromptMaterialization materialization); + // Applies one exact non-tail row insertion or movement. Canonical neighbor + // keys determine the final model row; no complete snapshot is consulted. + [[nodiscard]] bool applyRowChange(ConversationRowChange change); + // Removes only the row whose current identity is the exact target NodeRef. + // A missing target is not treated as a structural authority replacement. + [[nodiscard]] bool removeCardTarget(const nodegraph::NodeRef &target); // Applies one canonical tail insertion without traversing retained model // rows. Returns false when the delta is not the exact append shape, so the // caller can use complete structural reconciliation. @@ -182,7 +190,9 @@ class ConversationView final : public QAbstractItemView { [[nodiscard]] bool reconcileOwned(ConversationSnapshot snapshot); [[nodiscard]] std::optional - applyCardPresentationOwned(VisibleCardData card); + applyCardPresentationOwned(VisibleCardData card, + nodegraph::NodeRef materializedPrompt = {}); + void finishExactStructureChange(const Anchor &anchor, bool follow); [[nodiscard]] bool cardVisible(const VisibleCardData &card) const noexcept; void setThread(const std::string &threadId); void storeCurrentThreadState(); diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 3489094..6629080 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -224,6 +224,23 @@ struct ConversationRowPlacement { bool historyActivity = true; }; +// One canonical row plus its immediate presented neighbors. The neighboring +// keys are positioning facts only; NodeGraph remains the source of both the +// row values and their order. +struct ConversationRowChange { + ConversationRowPlacement placement; + std::optional previousCardKey; + std::optional nextCardKey; +}; + +// Prompt acknowledgement and authoritative row ownership are two different +// identities during materialization. Keeping them explicit lets the Qt row +// adopt the authoritative Item NodeRef before the local prompt is retired. +struct PromptMaterialization { + VisibleCardData card; + nodegraph::NodeRef prompt; +}; + // Bounded projection for the common canonical tail insertion, with the two // thread-history facts needed to update the retained window chrome. struct ConversationTailCard : ConversationRowPlacement { diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp index beb00af..9053b85 100644 --- a/src/codex/ui/NodeGraphUiAdapter.cpp +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -1567,7 +1567,7 @@ NodeGraphUiAdapter::card(const nodegraph::NodeRef &thread, graphString(graphField(*threadState, "cwd"))); } -std::optional NodeGraphUiAdapter::promptMaterialization( +std::optional NodeGraphUiAdapter::promptMaterialization( const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const { if (!graph_ || !thread || !item) return std::nullopt; @@ -1606,17 +1606,180 @@ std::optional NodeGraphUiAdapter::promptMaterialization( if (!submissionId || *submissionId < 0) continue; - VisibleCardData result = graphCardData( + VisibleCardData card = graphCardData( item, thread->id().canonical, nodegraph::protocolCanonicalId(*turnState, turn), *state, graphString(graphField(*threadState, "cwd"))); - result.key = LocalPromptKey{static_cast(*submissionId)}; - result.target = prompt; - return result; + card.key = LocalPromptKey{static_cast(*submissionId)}; + return PromptMaterialization{std::move(card), prompt}; } return std::nullopt; } +std::optional +NodeGraphUiAdapter::rowChange(const nodegraph::NodeRef &thread, + const nodegraph::NodeRef &item) const { + if (!graph_ || !thread || !item) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read || !read->contains(thread) || !read->contains(item) || + read->removed(thread) || read->removed(item) || + thread->id().kind != nodegraph::NodeKind::Thread || + item->id().kind != nodegraph::NodeKind::Item) + return std::nullopt; + + const nodegraph::NodeRef turn = read->parent(item); + if (!turn || turn->id().kind != nodegraph::NodeKind::Turn || + read->parent(turn) != thread) + return std::nullopt; + const auto itemState = read->state(item); + const auto turnState = read->state(turn); + const auto threadState = read->state(thread); + if (!itemState || !turnState || !threadState) + return std::nullopt; + + const std::string threadId = thread->id().canonical; + const auto turnId = [&](const nodegraph::NodeRef &owner) { + const auto state = owner ? read->state(owner) : nullptr; + return state ? nodegraph::protocolCanonicalId(*state, owner) + : std::string{}; + }; + const auto readyPrompts = [&](const nodegraph::NodeRef &owner) { + std::unordered_set result; + const std::size_t count = read->childCount(owner); + for (std::size_t index = 0; index < count; ++index) { + const nodegraph::NodeRef candidate = read->childAt(owner, index); + if (!candidate || !read->contains(candidate) || read->removed(candidate)) + continue; + for (const nodegraph::NodeRef &prompt : read->related( + candidate, nodegraph::RelationKind::PromptMaterialization)) { + if (!prompt || !read->contains(prompt) || read->removed(prompt)) + continue; + const auto state = read->state(prompt); + if (state && graphString(graphField(*state, "type")) == "localPrompt" && + graphString(graphField(*state, "dispatchState")) == + "awaitingMaterialization") + result.insert(prompt.get()); + } + } + return result; + }; + const auto projectedKey = + [&](const nodegraph::NodeRef &candidate, + const nodegraph::NodeRef &owner, + const std::unordered_set &hiddenPrompts) + -> std::optional { + if (!candidate || !read->contains(candidate) || read->removed(candidate) || + candidate->id().kind != nodegraph::NodeKind::Item) + return std::nullopt; + const auto state = read->state(candidate); + if (!state) + return std::nullopt; + const std::string type = graphString(graphField(*state, "type")); + if (type == "localPrompt") { + if (hiddenPrompts.contains(candidate.get())) + return std::nullopt; + const std::int64_t rawId = + graphInteger(graphField(*state, "submissionId")).value_or(0); + return LocalPromptKey{rawId < 0 ? 0 + : static_cast(rawId)}; + } + if (graphCardKind(*state) == CardKind::UserMessage) { + const auto submission = + graphInteger(graphField(*state, "localSubmissionId")); + if (submission && *submission >= 0) + return LocalPromptKey{static_cast(*submission)}; + } + return AuthoritativeItemKey{ + threadId, turnId(owner), + nodegraph::protocolCanonicalId(*state, candidate)}; + }; + + const auto hiddenInTurn = readyPrompts(turn); + const std::optional itemKey = + projectedKey(item, turn, hiddenInTurn); + if (!itemKey) + return std::nullopt; + + std::optional previous; + std::optional next; + const std::size_t itemCount = read->childCount(turn); + std::size_t itemIndex = itemCount; + for (std::size_t index = 0; index < itemCount; ++index) { + if (read->childAt(turn, index) == item) { + itemIndex = index; + break; + } + } + if (itemIndex == itemCount) + return std::nullopt; + for (std::size_t offset = itemIndex; offset > 0 && !previous; --offset) + previous = projectedKey(read->childAt(turn, offset - 1), turn, + hiddenInTurn); + for (std::size_t index = itemIndex + 1; index < itemCount && !next; ++index) + next = projectedKey(read->childAt(turn, index), turn, hiddenInTurn); + + const std::size_t turnCount = read->childCount(thread); + std::size_t turnIndex = turnCount; + for (std::size_t index = 0; index < turnCount; ++index) { + if (read->childAt(thread, index) == turn) { + turnIndex = index; + break; + } + } + if (turnIndex == turnCount) + return std::nullopt; + for (std::size_t offset = turnIndex; offset > 0 && !previous; --offset) { + const nodegraph::NodeRef owner = read->childAt(thread, offset - 1); + if (!owner || owner->id().kind != nodegraph::NodeKind::Turn) + continue; + const auto hidden = readyPrompts(owner); + for (std::size_t child = read->childCount(owner); + child > 0 && !previous; --child) + previous = + projectedKey(read->childAt(owner, child - 1), owner, hidden); + } + for (std::size_t ownerIndex = turnIndex + 1; + ownerIndex < turnCount && !next; ++ownerIndex) { + const nodegraph::NodeRef owner = read->childAt(thread, ownerIndex); + if (!owner || owner->id().kind != nodegraph::NodeKind::Turn) + continue; + const auto hidden = readyPrompts(owner); + for (std::size_t child = 0; + child < read->childCount(owner) && !next; ++child) + next = projectedKey(read->childAt(owner, child), owner, hidden); + } + + const auto roots = + read->related(turn, nodegraph::RelationKind::TurnRootItem); + const nodegraph::NodeRef root = + !roots.empty() && roots.front() && read->contains(roots.front()) && + !read->removed(roots.front()) + ? roots.front() + : nodegraph::NodeRef{}; + bool activeTurn = graphTurnIsActive(*turnState); + if (!activeTurn) { + const auto active = + read->related(thread, nodegraph::RelationKind::ActiveTurn); + activeTurn = std::ranges::find(active, turn) != active.end(); + } + + ConversationRowChange result; + result.placement.card = graphCardData( + item, threadId, turnId(turn), *itemState, + graphString(graphField(*threadState, "cwd"))); + result.placement.sectionKey = + sectionComponent("turn:", threadId, turnId(turn)); + result.placement.turnRoot = root == item; + result.placement.nested = root && root != item; + result.placement.activeTurn = activeTurn; + result.placement.historyActivity = + graphString(graphField(*itemState, "type")) != "localPrompt"; + result.previousCardKey = std::move(previous); + result.nextCardKey = std::move(next); + return result; +} + std::optional NodeGraphUiAdapter::tailCard(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const { diff --git a/src/codex/ui/NodeGraphUiAdapter.h b/src/codex/ui/NodeGraphUiAdapter.h index 6dc8c76..52a60c3 100644 --- a/src/codex/ui/NodeGraphUiAdapter.h +++ b/src/codex/ui/NodeGraphUiAdapter.h @@ -40,11 +40,19 @@ class NodeGraphUiAdapter final { // Projects an authoritative user item only when it exactly materializes a // still-current local prompt. The returned card retains the LocalPromptKey - // and prompt NodeRef so Qt can morph and acknowledge that one stable row. - [[nodiscard]] std::optional + // and authoritative Item identity; the separate prompt identity is used + // only to acknowledge that one stable row transition. + [[nodiscard]] std::optional promptMaterialization(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const; + // Projects one live Item together with its immediate canonical row + // neighbors. This is the bounded structural adapter for non-tail insertion + // and actual movement; it never returns a complete conversation snapshot. + [[nodiscard]] std::optional + rowChange(const nodegraph::NodeRef &thread, + const nodegraph::NodeRef &item) const; + // Projects only a canonical last item of the selected thread. It is the // bounded structural fast path for ordinary append; any non-tail or prompt // alias case returns nullopt and uses complete reconciliation instead. diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index b697a9a..b7ef51f 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -218,6 +218,90 @@ bool viewportProportionalFoundation() { return result; } +bool exactStructuralRowsPreserveTheViewport() { + nodegraph::NodeGraph graph; + std::vector targets; + nodegraph::NodeRef insertedTarget; + { + auto write = graph.write(); + for (int row = 0; row < 24; ++row) + targets.push_back(write.upsert( + {nodegraph::NodeKind::Item, "exact-row-" + std::to_string(row)})); + insertedTarget = + write.upsert({nodegraph::NodeKind::Item, "exact-row-inserted"}); + static_cast(write.finish()); + } + + ConversationSnapshot snapshot = conversation(targets.size()); + for (std::size_t row = 0; row < targets.size(); ++row) + snapshot.sections[row].cards.front().target = targets[row]; + ConversationView view; + view.resize(820, 420); + view.show(); + bool result = expect(view.reconcile(std::move(snapshot)), + "exact structural row fixture reconciles"); + settle(); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum() / 2); + settle(); + + const auto anchorBeforeInsert = firstVisible(view); + VisibleCardData inserted = message(1000, "Inserted in the middle"); + inserted.target = insertedTarget; + ConversationRowChange insertion; + insertion.placement = + {inserted, "section-inserted", false, false, false, true}; + insertion.previousCardKey = message(7).key; + insertion.nextCardKey = message(8).key; + const qulonglong resetsBefore = + view.conversationModel()->property("modelResetCount").toULongLong(); + result &= expect(view.applyRowChange(std::move(insertion)) && + view.conversationModel() + ->indexForTarget(insertedTarget) + .row() == 8 && + firstVisible(view) == anchorBeforeInsert, + "a middle insertion preserves the exact pixel anchor"); + + ConversationRowChange movement; + movement.placement = + {message(2), "section-2", false, false, false, true}; + movement.placement.card.target = targets[2]; + movement.previousCardKey = + view.conversationModel() + ->row(view.conversationModel()->rowCount() - 1) + ->card.key; + const auto anchorBeforeMove = firstVisible(view); + const bool moved = view.applyRowChange(std::move(movement)); + const auto anchorAfterMove = firstVisible(view); + result &= expect(moved && + view.conversationModel()->indexForTarget(targets[2]) + .row() == + view.conversationModel()->rowCount() - 1 && + anchorAfterMove == anchorBeforeMove, + "an exact row move preserves the viewport anchor"); + + const auto anchorBeforeRemoval = firstVisible(view); + result &= expect( + view.removeCardTarget(targets.front()) && + !view.conversationModel()->indexForTarget(targets.front()).isValid() && + firstVisible(view) == anchorBeforeRemoval && + view.conversationModel()->property("modelResetCount").toULongLong() == + resetsBefore && + view.conversationModel() + ->property("modelExactInsertCount") + .toULongLong() == 1 && + view.conversationModel() + ->property("modelExactMoveCount") + .toULongLong() == 1 && + view.conversationModel() + ->property("modelExactRemoveCount") + .toULongLong() == 1 && + view.materializedCardCount() <= 48, + "exact structural operations use narrow model signals and bounded " + "widgets without a model reset"); + return result; +} + bool boundedTailAppendIsViewportProportional() { ConversationView view; view.resize(820, 600); @@ -907,6 +991,7 @@ int main(int argc, char **argv) { QApplication application(argc, argv); using namespace codexui::codex::middle; const bool result = viewportProportionalFoundation() && + exactStructuralRowsPreserveTheViewport() && boundedTailAppendIsViewportProportional() && targetedVisibilityChangeIsLocal() && atomicPagingAndFollowingArrival() && diff --git a/tests/codex/NodeGraphConversationUiTest.cpp b/tests/codex/NodeGraphConversationUiTest.cpp index cde07eb..d24cc55 100644 --- a/tests/codex/NodeGraphConversationUiTest.cpp +++ b/tests/codex/NodeGraphConversationUiTest.cpp @@ -235,7 +235,7 @@ bool promptMorphPreservesExactTargetAndWidget() { const auto materialized = adapter.promptMaterialization(thread, authoritative); if (!materialized || - !view.applyCardPresentation(*materialized).has_value()) + !view.applyPromptMaterialization(*materialized).has_value()) return false; QApplication::processEvents(); const auto after = view.findChildren(); @@ -246,8 +246,11 @@ bool promptMorphPreservesExactTargetAndWidget() { !require(acknowledged == prompt, "prompt morph discarded its exact NodeRef target")) return false; + if (!require(after.front()->data().target == authoritative, + "prompt morph did not adopt its authoritative NodeRef")) + return false; - static_cast(view.applyCardPresentation(*materialized)); + static_cast(view.applyPromptMaterialization(*materialized)); if (!require(acknowledgements == 1, "unchanged prompt projection acknowledged twice")) return false; @@ -386,7 +389,7 @@ bool steeringMorphKeepsItsSlotThroughRetirement() { adapter.promptMaterialization(thread, authoritative); if (!materialized) return false; - static_cast(view.applyCardPresentation(*materialized)); + static_cast(view.applyPromptMaterialization(*materialized)); QApplication::processEvents(); const int promotedTop = stable->mapTo(view.viewport(), QPoint{}).y(); if (!require(stable->data().kind == middle::CardKind::UserMessage && diff --git a/tests/codex/NodeGraphUiAdapterTest.cpp b/tests/codex/NodeGraphUiAdapterTest.cpp index beed2c3..c6710c7 100644 --- a/tests/codex/NodeGraphUiAdapterTest.cpp +++ b/tests/codex/NodeGraphUiAdapterTest.cpp @@ -224,16 +224,72 @@ bool projectsExactPromptMaterialization() { const auto result = adapter.promptMaterialization(thread, authoritative); return require(result.has_value(), "exact prompt materialization was not projected") && - require(result->key == middle::CardKey{middle::LocalPromptKey{91}}, + require(result->card.key == + middle::CardKey{middle::LocalPromptKey{91}}, "prompt materialization changed the stable local key") && - require(result->kind == middle::CardKind::UserMessage && - result->target == prompt, - "prompt materialization lost its authoritative presentation " - "or exact acknowledgement target") && + require(result->card.kind == middle::CardKind::UserMessage && + result->card.target == authoritative && + result->prompt == prompt, + "prompt materialization conflated authoritative row and " + "exact acknowledgement identities") && require(!adapter.promptMaterialization(thread, prompt), "a local prompt was accepted as its own materialization"); } +bool projectsExactRowPlacementAndNeighbors() { + nodegraph::NodeGraph graph; + NodeRef thread; + NodeRef turn; + NodeRef first; + NodeRef moved; + NodeRef last; + { + auto write = graph.write(); + thread = write.upsert({NodeKind::Thread, "row-thread"}); + turn = write.upsert({NodeKind::Turn, "row-turn"}); + write.setField(turn, "id", "row-turn"); + first = write.upsert({NodeKind::Item, "row-first"}, + itemState("first", "userMessage", "first")); + moved = write.upsert({NodeKind::Item, "row-moved"}, + itemState("moved", "agentMessage", "moved")); + last = write.upsert({NodeKind::Item, "row-last"}, + itemState("last", "agentMessage", "last")); + write.setParent(thread, turn); + write.setParent(turn, first); + write.setParent(turn, moved); + write.setParent(turn, last); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, first); + static_cast(write.finish()); + } + + NodeGraphUiAdapter adapter(graph); + auto placement = adapter.rowChange(thread, moved); + const middle::CardKey firstKey = middle::AuthoritativeItemKey{ + "row-thread", "row-turn", "row-first"}; + const middle::CardKey lastKey = middle::AuthoritativeItemKey{ + "row-thread", "row-turn", "row-last"}; + bool result = + require(placement && placement->placement.card.target == moved && + placement->placement.sectionKey == + "turn:10:row-thread8:row-turn" && + placement->placement.nested && + !placement->placement.turnRoot && + placement->previousCardKey == firstKey && + placement->nextCardKey == lastKey, + "one row projection lost its exact placement or neighbors"); + + { + auto write = graph.write(); + write.replaceChildren(turn, std::array{first, last, moved}); + static_cast(write.finish()); + } + placement = adapter.rowChange(thread, moved); + result &= require(placement && placement->previousCardKey == lastKey && + !placement->nextCardKey, + "a canonical move did not update the exact row neighbors"); + return result; +} + bool preservesReadinessActivityAndAuthoritativeBudgetSemantics() { nodegraph::NodeGraph graph; NodeRef runtime; @@ -338,6 +394,7 @@ int main() { !limitsHistoryButPinsTheOwningPrompt() || !projectsOnlyTheExactCanonicalTail() || !projectsExactPromptMaterialization() || + !projectsExactRowPlacementAndNeighbors() || !preservesThreadRootsAndExactChildTargets() || !preservesReadinessActivityAndAuthoritativeBudgetSemantics()) return EXIT_FAILURE; diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index e4868f4..7499751 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -1856,7 +1856,7 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( conversation->conversationModel()->card(index.row()); return index.isValid() && card && card->kind == middle::CardKind::UserMessage && - card->target == localPrompt; + card->target == authoritativePrompt; }), "the authoritative prompt morphs the exact local row"); const std::vector materializationActions = @@ -1882,7 +1882,119 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( "prompt materialization targets one stable row and exact acknowledgement " "without structural reconciliation"); + const qulonglong retirementResetsBefore = + conversation->conversationModel()->property("modelResetCount") + .toULongLong(); + const qulonglong retirementRemovalsBefore = + conversation->conversationModel()->property("modelExactRemoveCount") + .toULongLong(); + const qulonglong retirementRoutesBefore = + shell.property("targetedConversationStructuralDeltas").toULongLong(); + GraphChange promptRetirement; + { + auto write = graph.write(); + write.remove(localPrompt); + promptRetirement = write.finish(); + } + require(messageAdmitted( + channels.sendGraphChanged(std::move(promptRetirement))), + "the acknowledged prompt retirement is admitted to Qt"); + require(spinUntil([&] { + const QModelIndex index = conversation->conversationModel() + ->indexForStableKey(localKey); + const middle::VisibleCardData *card = + conversation->conversationModel()->card(index.row()); + return index.isValid() && card && + card->target == authoritativePrompt && + shell.property("targetedConversationStructuralDeltas") + .toULongLong() == retirementRoutesBefore + 1; + }), + "retiring the prompt preserves its authoritative row as an exact " + "structural no-op"); + require(conversation->conversationModel() + ->property("modelResetCount") + .toULongLong() == retirementResetsBefore && + conversation->conversationModel() + ->property("modelExactRemoveCount") + .toULongLong() == retirementRemovalsBefore, + "prompt retirement neither resets nor removes the authoritative " + "conversation row"); + + NodeRef insertedItem; + const qulonglong exactInsertsBefore = + conversation->conversationModel()->property("modelExactInsertCount") + .toULongLong(); + const qulonglong exactMovesBefore = + conversation->conversationModel()->property("modelExactMoveCount") + .toULongLong(); + const qulonglong structuralResetsBefore = + conversation->conversationModel()->property("modelResetCount") + .toULongLong(); + GraphChange middleInsertion; + { + auto write = graph.write(); + NodeState inserted; + inserted.status = NodeStatus::Running; + inserted.fields = {{"id", Value("middle-structural-item")}, + {"type", Value("agentMessage")}, + {"text", Value("Middle structural item")}}; + insertedItem = write.upsert( + {NodeKind::Item, "middle-structural-item"}, std::move(inserted)); + write.setParent(selectedTurn, insertedItem); + write.replaceChildren( + selectedTurn, + std::array{selectedItem, insertedItem, + authoritativePrompt}); + middleInsertion = write.finish(); + } + require(messageAdmitted( + channels.sendGraphChanged(std::move(middleInsertion))), + "a canonical middle insertion is admitted to Qt"); + require(spinUntil([&] { + return conversation->conversationModel() + ->indexForTarget(insertedItem) + .row() == 1 && + conversation->conversationModel() + ->property("modelExactInsertCount") + .toULongLong() == exactInsertsBefore + 1 && + conversation->conversationModel() + ->property("modelExactMoveCount") + .toULongLong() == exactMovesBefore; + }), + "the graph middle insertion reaches its exact model row"); + + GraphChange rowMove; + { + auto write = graph.write(); + write.replaceChildren( + selectedTurn, + std::array{selectedItem, authoritativePrompt, + insertedItem}); + rowMove = write.finish(); + } + require(messageAdmitted(channels.sendGraphChanged(std::move(rowMove))), + "a canonical row reorder is admitted to Qt"); + require(spinUntil([&] { + return conversation->conversationModel() + ->indexForTarget(insertedItem) + .row() == 2 && + conversation->conversationModel() + ->property("modelExactMoveCount") + .toULongLong() == exactMovesBefore + 1; + }), + "the graph reorder reaches the exact moved model row"); + require(conversation->conversationModel() + ->property("modelResetCount") + .toULongLong() == structuralResetsBefore, + "middle insertion and movement use no conversation model reset"); + QPointer removedWidget = selectedCard; + const qulonglong exactRemovalsBefore = + conversation->conversationModel()->property("modelExactRemoveCount") + .toULongLong(); + const qulonglong removalResetsBefore = + conversation->conversationModel()->property("modelResetCount") + .toULongLong(); GraphChange removal; { auto write = graph.write(); @@ -1900,10 +2012,17 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( "the background notification carrying a removed ref is admitted"); require(spinUntil([&] { return selectedItem->uiAttachment() == nullptr && - removedWidget.isNull(); + removedWidget.isNull() && + conversation->conversationModel() + ->property("modelExactRemoveCount") + .toULongLong() == exactRemovalsBefore + 1; }), "removed refs always detach matching selected widgets even when " "the change needs no structural refresh"); + require(conversation->conversationModel() + ->property("modelResetCount") + .toULongLong() == removalResetsBefore, + "an exact selected-row removal does not reset the conversation"); } void optimisticDraftUsesOneTypedCreateAction(Configuration &configuration) { From 32ec5e11d1c46e85790b99283bc4c9cf6190b27b Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 11:36:22 +0200 Subject: [PATCH 19/39] Move conversation staging state into the view --- docs/two-thread-shared-node-graph.md | 6 +- docs/ui-ux-internal-api.md | 28 +++-- src/codex/ShellWidget.cpp | 115 ++++++------------ src/codex/middle/ConversationItemModel.h | 3 + src/codex/middle/ConversationView.cpp | 73 ++++++++++- src/codex/middle/ConversationView.h | 30 ++++- tests/codex/ConversationViewBenchmark.cpp | 3 +- .../codex/ConversationVirtualizationTest.cpp | 80 ++++++++---- 8 files changed, 223 insertions(+), 115 deletions(-) diff --git a/docs/two-thread-shared-node-graph.md b/docs/two-thread-shared-node-graph.md index 6b6c83b..f71f8d6 100644 --- a/docs/two-thread-shared-node-graph.md +++ b/docs/two-thread-shared-node-graph.md @@ -289,7 +289,11 @@ paint no QWidget. An ordinary canonical last-item delta is verified under one short graph read and appended with one Qt insert signal; absolute row ordinals and a lazy height origin permit the history prefix to be trimmed without scanning the retained conversation. Non-tail or ambiguous structure uses the -complete projection. Thread rows follow the expanded hierarchy, and Inspector +exact row placement/removal APIs; only authority replacement, paging, and an +explicit rescan use a complete projection. The view owns per-thread history +windows and publishes one post-stage completion boundary so Shell reveals +matching chrome and Inspector data only with the complete conversation frame. +Thread rows follow the expanded hierarchy, and Inspector constructs rows only for the active tab when its effective snapshot changes. There is no permanent parallel NodeId-to-widget registry. Focus, animation, folding, filters, drafts, editor mechanics, and scroll-following remain diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index db5ad72..234bafb 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -258,9 +258,10 @@ boundary. The index contains no card values or authority. `ConversationView` is the canonical variable-height `QAbstractItemView` for the conversation. It owns the thin item model, height index, bounded delegate document cache, visible rich cards/editors, Load More and empty controls, and -genuinely local interaction state. It retains per-thread follow/pause anchors, -fold state, text selections, focus/current-row identity, nested command-output -scroll state, and presentation options by stable row key. +genuinely local interaction state. It retains per-thread history windows, +follow/pause anchors, fold state, text selections, focus/current-row identity, +nested command-output scroll state, and presentation options by stable row +key. Shell retains only the selected canonical graph target. Thread/ownership contract: Qt-main only. Passive historical rows have no QWidget or placeholder. QObject parentage owns only the rich cards currently @@ -273,7 +274,8 @@ released, and QWidget work never occurs while a graph or channel lock is held. private list model and delegate, height index, Load More control, empty label, and hidden staging host. It creates no historical card widgets. - `setLoadMoreAction(callback)` installs the one user gesture for expanding - history. The callback decides retained-graph versus provider loading. + history after the view has advanced its own requested/effective window. The + callback decides whether to project or send the exact provider action. - `setPromptMaterializedAction(callback)` is a narrow additive integration hook. After a local card has successfully morphed to its authoritative user card and after all QWidget work, it returns the exact prompt `NodeRef` for @@ -281,6 +283,9 @@ released, and QWidget work never occurs while a graph or channel lock is held. pass; the widget never retries automatically. - `setPromptRecoveryAction(callback)` is a narrow additive hook fired only by explicit recovery on the exact failed local-prompt token. +- `setPresentationCommittedAction(callback)` fires once only after a selected + thread's staged model, geometry, editors, cover, and spinner have committed. + Shell uses that boundary to reveal matching heading and Inspector values. - `setEmptyMessage(message)` changes empty text only, preserving anchor and follow behavior. It must not clear cards. - `setPresentationOptions(options)` updates reasoning/update visibility and @@ -315,11 +320,17 @@ released, and QWidget work never occurs while a graph or channel lock is held. row update. Coalesced sibling changes are applied in canonical neighbor order. - `removeCardTarget(ref)` removes only the row currently indexed by that exact Item `NodeRef`; unrelated and already-transferred prompt removals are no-ops. -- `appendTailCard(tail, historyActivityLimit)` is the ordinary structural fast +- `appendTailCard(tail)` is the ordinary structural fast path after `NodeGraphUiAdapter::tailCard` validates canonical placement. It emits one insert, performs an optional bounded prefix trim, preserves the stable anchor or existing follow state, and never rebuilds model, section, or height indexes. `false` requests complete structural reconciliation. +- `historyLimitForThread`, `requestNextHistoryPage`, and + `forgetThreadPresentation` own the requested/effective 80-row window and its + lifecycle beside that thread's anchor/follow state. Canonical counts are + inputs; these methods create no domain authority and issue no provider call. +- `presentedThreadId()` identifies the complete model frame currently exposed + (or covered during replacement), never the merely selected graph target. - `conversationModel()` exposes the owned model for Qt selection, accessibility, deterministic instrumentation, and exact action targeting; callers must not treat it as graph authority. @@ -345,9 +356,10 @@ released, and QWidget work never occurs while a graph or channel lock is held. | Method | Parameters / return | Preconditions and observable effect | | --- | --- | --- | | constructor | optional QWidget `parent` | Produces an empty following-mode item view. No historical card widgets exist. | -| `setLoadMoreAction` | replacement `void()` callback | Called once per accepted button gesture; view neither changes history count nor calls provider itself. | +| `setLoadMoreAction` | replacement `void()` callback | Called once per accepted button gesture after the view advances its local history window; the callback may project retained rows or request the provider. | | `setPromptMaterializedAction` | replacement `bool(NodeRef)` callback | Called after a successful local-to-authoritative visual transition. Exact token is moved to callback. False aborts only the remaining callbacks in this reconcile. | | `setPromptRecoveryAction` | replacement `void(NodeRef)` callback | Called only from explicit recovery gesture on the current matching card. | +| `setPresentationCommittedAction` | replacement `void(threadId)` callback | Called after the exact selected staged frame is complete and visible. Superseded stages never call it. | | `setEmptyMessage` | display `QString` value | Changes only empty-label text; model rows remain. Anchor is preserved. | | `setPresentationOptions` | complete local options | Updates model presentation roles and visible/materialized rows without a graph query. Existing user fold choices win over initial-fold defaults. | | `presentationOptions` | returns value copy | Pure query. | @@ -355,7 +367,9 @@ released, and QWidget work never occurs while a graph or channel lock is held. | `beginThreadSelection` | exact selected thread ID | Immediately covers only the message viewport and starts one 500 ms visual-delay timer. Repeating the same pending identity is a no-op; a new identity cancels superseded staging. | | `reconcileStaged` | owned complete snapshot | Same final-state contract as `reconcile`; only initially visible rich editors are prepared beneath the hidden host in bounded event-loop passes before one atomic reveal. | | `applyCardPresentation` | one exact `VisibleCardData`; returns optional local impact | Wrong thread/key/incompatible kind returns `nullopt`; identical data returns `None`; otherwise only the resolved row, its genuine section-edge geometry, and its visible editor/delegate rectangle may change. | -| `appendTailCard` | one validated `ConversationTailCard`, activity limit; returns bool | Exact canonical tail inserts directly and optionally trims the prefix without retained-history traversal. Wrong thread, duplicate/invalid placement, active staging, or zero limit returns false for complete reconciliation. | +| `appendTailCard` | one validated `ConversationTailCard`; returns bool | Exact canonical tail updates the view-owned history window, inserts directly, and optionally trims the prefix without retained-history traversal. Wrong thread, duplicate/invalid placement, or active staging returns false for complete reconciliation. | +| `historyLimitForThread`, `requestNextHistoryPage` | thread ID plus current canonical history facts | Update only per-thread presentation-window counters and return the effective projection limit/provider-request decision. | +| `forgetThreadPresentation`, `presentedThreadId` | retired thread ID / pure current-frame query | Releases per-thread window/anchor state or reports the complete frame currently owned by the model. | | `conversationModel`, `materializedCardCount` | borrowed model pointer / integer count | Inspection only. The model is non-authoritative and the widget count remains viewport proportional. | | `setTrailingSpaceHeight` | nonnegative effective pixels | Post: content extent/anchor reflects composer overlay without changing viewport ownership. Repeated value is a no-op. | | `prepareForLocalPromptAdmission` | no parameters | May change pause caused only by composer growth; never overrides explicit user pause. | diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index d074a3c..cc5136e 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -995,12 +995,6 @@ QFrame *statusDot() { } // namespace struct ShellWidget::Impl final { - struct ConversationHistoryWindow { - std::size_t requested = middle::AuthoritativeHistoryPageSize; - std::size_t effective = middle::AuthoritativeHistoryPageSize; - std::size_t lastAuthoritativeCount = 0; - }; - Impl(ShellWidget *owner, FrontendSession &session) : owner(owner), session(session), uiAdapter(session.nodeGraph()), alive(std::make_shared(true)) { @@ -1089,12 +1083,6 @@ struct ShellWidget::Impl final { bool creationInFlight = false; std::string selectedGraphThreadId; nodegraph::NodeRef boundGraphThread; - // The established view stages a newly selected hydration behind the last - // complete conversation. This is UI coordination state, not another model: - // the actual cards and their snapshot remain owned by ConversationView. - nodegraph::NodeRef presentedGraphThread; - std::unordered_map - conversationHistory; nodegraph::NodeRef attentionInteraction; std::map> retainedRenames; @@ -1416,16 +1404,14 @@ void ShellWidget::Impl::connectUi() { "Conversation state is busy; no history request was sent.")); return; } - ConversationHistoryWindow &history = - conversationHistory[boundGraphThread->id().canonical]; - const bool retainedHistoryAvailable = - history.effective < info->authoritativeItemCount; - history.requested += middle::AuthoritativeHistoryPageSize; - history.effective += middle::AuthoritativeHistoryPageSize; + const middle::ConversationView::HistoryPageRequest request = + middleRegion->conversation().requestNextHistoryPage( + boundGraphThread->id().canonical, + info->authoritativeItemCount, info->providerHasMore); pendingConversation = true; pendingConversationItems.clear(); commitPendingPanes(); - if (retainedHistoryAvailable || !info->providerHasMore) + if (!request.requestProvider) return; nodegraph::NodeAction action{boundGraphThread, nodegraph::NodeActionKind::LoadHistory}; @@ -1442,6 +1428,15 @@ void ShellWidget::Impl::connectUi() { }); middleRegion->conversation().setPromptRecoveryAction( [this](nodegraph::NodeRef prompt) { recoverPrompt(prompt); }); + middleRegion->conversation().setPresentationCommittedAction( + [this](const std::string &threadId) { + if (!boundGraphThread || boundGraphThread->id().canonical != threadId) + return; + renderedChrome.reset(); + pendingInspector = true; + pendingChrome = true; + schedulePaneCommit(); + }); middleRegion->inspector().setRequestActions( [this](const std::string &id) { reviewPending(id); }, [this](const std::string &id) { acceptPending(id); }, @@ -1580,7 +1575,6 @@ void ShellWidget::Impl::bindGraphPanes(nodegraph::NodeRef selectedThread) { bool ShellWidget::Impl::refreshConversation() { if (!boundGraphThread) { static_cast(middleRegion->conversation().reconcile({})); - presentedGraphThread.reset(); return true; } @@ -1588,17 +1582,9 @@ bool ShellWidget::Impl::refreshConversation() { if (!info) return false; const std::string &threadId = boundGraphThread->id().canonical; - ConversationHistoryWindow &history = conversationHistory[threadId]; - const bool following = middleRegion->conversation().modeForThread(threadId) == - middle::ConversationView::Mode::Following; - if (!following && - info->authoritativeItemCount > history.lastAuthoritativeCount) { - history.effective += - info->authoritativeItemCount - history.lastAuthoritativeCount; - } else if (following) { - history.effective = history.requested; - } - history.lastAuthoritativeCount = info->authoritativeItemCount; + const std::size_t historyLimit = + middleRegion->conversation().historyLimitForThread( + threadId, info->authoritativeItemCount); if (!info->readyForDisplay) { if (!info->hydrationFailed) { @@ -1610,25 +1596,15 @@ bool ShellWidget::Impl::refreshConversation() { middle::ConversationSnapshot failed; failed.threadId = threadId; static_cast(middleRegion->conversation().reconcile(failed)); - if (presentedGraphThread != boundGraphThread) { - presentedGraphThread = boundGraphThread; - renderedChrome.reset(); - } return true; } - auto snapshot = - uiAdapter.conversation(boundGraphThread, history.effective); + auto snapshot = uiAdapter.conversation(boundGraphThread, historyLimit); if (!snapshot) return false; middleRegion->conversation().setEmptyMessage( QStringLiteral("No materialized activity.")); middleRegion->conversation().reconcileStaged(std::move(*snapshot)); - if (presentedGraphThread != boundGraphThread) { - presentedGraphThread = boundGraphThread; - renderedChrome.reset(); - scheduleRender(); - } return true; } @@ -1638,8 +1614,11 @@ bool ShellWidget::Impl::refreshInspector() { const auto info = uiAdapter.conversationInfo(inspectorThread); if (!info) return false; - if (!info->readyForDisplay && presentedGraphThread && - presentedGraphThread != inspectorThread && !info->hydrationFailed) + const std::string &presentedThreadId = + middleRegion->conversation().presentedThreadId(); + if (!presentedThreadId.empty() && + presentedThreadId != inspectorThread->id().canonical && + !info->hydrationFailed) return true; if (!info->readyForDisplay) inspectorThread.reset(); @@ -1801,22 +1780,7 @@ void ShellWidget::Impl::commitPendingPanes() { auto tail = uiAdapter.tailCard(boundGraphThread, pendingConversationItems.front()); if (tail) { - const std::string &threadId = boundGraphThread->id().canonical; - ConversationHistoryWindow nextHistory = conversationHistory[threadId]; - const bool following = - middleRegion->conversation().modeForThread(threadId) == - middle::ConversationView::Mode::Following; - if ((!following || nextHistory.effective > nextHistory.requested) && - tail->authoritativeItemCount > nextHistory.lastAuthoritativeCount) { - nextHistory.effective += - tail->authoritativeItemCount - nextHistory.lastAuthoritativeCount; - } else if (following) { - nextHistory.effective = nextHistory.requested; - } - nextHistory.lastAuthoritativeCount = tail->authoritativeItemCount; - if (middleRegion->conversation().appendTailCard(std::move(*tail), - nextHistory.effective)) { - conversationHistory.insert_or_assign(threadId, nextHistory); + if (middleRegion->conversation().appendTailCard(std::move(*tail))) { pendingConversation = false; pendingConversationItems.clear(); ++conversationRoutes; @@ -2046,7 +2010,8 @@ void ShellWidget::Impl::handleGraphChanged( if (!removed) continue; if (removed->id().kind == nodegraph::NodeKind::Thread) - conversationHistory.erase(removed->id().canonical); + middleRegion->conversation().forgetThreadPresentation( + removed->id().canonical); if (removed->id().kind == nodegraph::NodeKind::Interaction) retainedInteractionResponses.erase(removed->id().canonical); retainedRenames.erase(removed.get()); @@ -2056,9 +2021,6 @@ void ShellWidget::Impl::handleGraphChanged( change.removed.end() ? boundGraphThread : nodegraph::NodeRef{}; - const bool stagedPresentationInvalidated = - presentedGraphThread && presentedGraphThread != boundGraphThread && - !change.removed.empty(); const bool providerReset = removedBoundThread && std::ranges::any_of(change.affected, [](const auto &node) { @@ -2107,10 +2069,7 @@ void ShellWidget::Impl::handleGraphChanged( pendingThreadRows.end()) pendingThreadRows.push_back(thread); } - if (stagedPresentationInvalidated) { - pendingConversation = true; - pendingConversationItems.clear(); - } else if (conversation.structural) { + if (conversation.structural) { if (!pendingConversation) { pendingConversationItems = conversation.items; } else if (pendingConversationItems != conversation.items) { @@ -2127,8 +2086,7 @@ void ShellWidget::Impl::handleGraphChanged( } pendingInspector = pendingInspector || inspectorAffected(change, session.nodeGraph(), - boundGraphThread, inspectorDependency) || - stagedPresentationInvalidated; + boundGraphThread, inspectorDependency); pendingChrome = pendingChrome || updateChrome; if (change.rescanRequired || containsKind(change, {nodegraph::NodeKind::Thread})) @@ -2144,13 +2102,6 @@ void ShellWidget::Impl::handleGraphChanged( if (!providerReset) selectedGraphThreadId.clear(); bindGraphPanes({}); - } else if (stagedPresentationInvalidated) { - // Removal notifications must release every card-held NodeRef before the - // worker retirement acknowledgement. Fall back to the selected thread's - // stable loading surface rather than retaining the outgoing snapshot. - presentedGraphThread.reset(); - renderedChrome.reset(); - pendingChrome = true; } // Retirement must not outlive presentation references. Ordinary state @@ -2780,10 +2731,12 @@ void ShellWidget::Impl::render() { if (!chromeChanged) return; - const bool replacementHydrating = boundGraphThread && presentedGraphThread && - boundGraphThread != presentedGraphThread && - !values.conversationReadyForDisplay && - !values.hydrationFailed; + const std::string &presentedThreadId = + middleRegion->conversation().presentedThreadId(); + const bool replacementHydrating = + boundGraphThread && !presentedThreadId.empty() && + boundGraphThread->id().canonical != presentedThreadId && + !values.hydrationFailed; attentionInteraction = values.attention ? values.attention->node : nodegraph::NodeRef{}; diff --git a/src/codex/middle/ConversationItemModel.h b/src/codex/middle/ConversationItemModel.h index f9a585a..525c4ba 100644 --- a/src/codex/middle/ConversationItemModel.h +++ b/src/codex/middle/ConversationItemModel.h @@ -129,6 +129,9 @@ class ConversationItemModel final : public QAbstractListModel { [[nodiscard]] std::size_t hiddenAuthoritativeItemCount() const noexcept { return hiddenAuthoritativeItemCount_; } + [[nodiscard]] std::size_t historyActivityCount() const noexcept { + return historyActivityCount_; + } [[nodiscard]] bool hasMore() const noexcept { return hasMore_; } private: diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 4f284bd..28a26ce 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -601,6 +601,11 @@ void ConversationView::setPromptRecoveryAction( promptRecoveryAction_ = std::move(action); } +void ConversationView::setPresentationCommittedAction( + std::function action) { + presentationCommittedAction_ = std::move(action); +} + void ConversationView::setEmptyMessage(QString message) { if (message == emptyMessage_) return; @@ -709,6 +714,17 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { stopFollowingAnimation(); const bool changed = model_->reconcile(std::move(snapshot)); + if (!targetThreadId.empty()) { + HistoryWindow &history = historyWindows_[targetThreadId]; + const std::size_t represented = model_->historyActivityCount(); + if (represented > history.effective) { + history.requested = represented; + history.effective = represented; + } + history.lastAuthoritativeCount = + std::max(history.lastAuthoritativeCount, + represented + model_->hiddenAuthoritativeItemCount()); + } rebuildSectionRanges(); loadMore_->setVisible(model_->hasMore()); if (model_->hasMore()) { @@ -999,6 +1015,8 @@ void ConversationView::finishThreadSelection(const std::string &threadId) { loadingThreadId_.clear(); stagingOverlay_->finish(); incrementProperty(this, "threadSelectionLoadsFinished"); + if (presentationCommittedAction_) + presentationCommittedAction_(threadId); } std::optional @@ -1183,8 +1201,58 @@ void ConversationView::finishExactStructureChange(const Anchor &anchor, storeCurrentThreadState(); } -bool ConversationView::appendTailCard(ConversationTailCard tail, - std::size_t historyActivityLimit) { +std::size_t ConversationView::historyLimitForThread( + const std::string &threadId, std::size_t authoritativeItemCount) { + HistoryWindow &history = historyWindows_[threadId]; + const bool following = modeForThread(threadId) == Mode::Following; + if (!following && + authoritativeItemCount > history.lastAuthoritativeCount) { + history.effective += + authoritativeItemCount - history.lastAuthoritativeCount; + } else if (following) { + history.effective = history.requested; + } + history.lastAuthoritativeCount = authoritativeItemCount; + return history.effective; +} + +ConversationView::HistoryPageRequest ConversationView::requestNextHistoryPage( + const std::string &threadId, std::size_t authoritativeItemCount, + bool providerHasMore) { + HistoryWindow &history = historyWindows_[threadId]; + const bool retainedHistoryAvailable = + history.effective < authoritativeItemCount; + history.requested += AuthoritativeHistoryPageSize; + history.effective += AuthoritativeHistoryPageSize; + return {history.effective, + !retainedHistoryAvailable && providerHasMore}; +} + +void ConversationView::forgetThreadPresentation(const std::string &threadId) { + historyWindows_.erase(threadId); + threadStates_.erase(threadId); +} + +bool ConversationView::appendTailCard(ConversationTailCard tail) { + const std::string threadId = tail.card.threadId; + HistoryWindow nextHistory = historyWindows_[threadId]; + std::size_t authoritativeItemCount = tail.authoritativeItemCount; + if (authoritativeItemCount == 0) { + authoritativeItemCount = std::max( + nextHistory.lastAuthoritativeCount + 1, + model_->historyActivityCount() + + model_->hiddenAuthoritativeItemCount() + 1); + } + const bool following = modeForThread(threadId) == Mode::Following; + if ((!following || nextHistory.effective > nextHistory.requested) && + authoritativeItemCount > nextHistory.lastAuthoritativeCount) { + nextHistory.effective += + authoritativeItemCount - nextHistory.lastAuthoritativeCount; + } else if (following) { + nextHistory.effective = nextHistory.requested; + } + nextHistory.lastAuthoritativeCount = authoritativeItemCount; + const std::size_t historyActivityLimit = nextHistory.effective; if (pendingStructuralSnapshot_ || tail.card.threadId != threadId_ || historyActivityLimit == 0) return false; @@ -1224,6 +1292,7 @@ bool ConversationView::appendTailCard(ConversationTailCard tail, if (!model_->appendTail(std::move(tail))) return false; + historyWindows_.insert_or_assign(threadId, nextHistory); const int appendedRow = model_->rowCount() - 1; const ConversationItemModel::Row *appended = model_->row(appendedRow); if (!appended) diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index abee7cd..ec92544 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -48,6 +48,11 @@ class ConversationView final : public QAbstractItemView { bool operator==(const PresentationOptions &) const = default; }; + struct HistoryPageRequest { + std::size_t effectiveLimit = AuthoritativeHistoryPageSize; + bool requestProvider = false; + }; + explicit ConversationView(QWidget *parent = nullptr); ~ConversationView() override; @@ -55,6 +60,8 @@ class ConversationView final : public QAbstractItemView { void setPromptMaterializedAction(std::function action); void setPromptRecoveryAction(std::function action); + void setPresentationCommittedAction( + std::function action); void setEmptyMessage(QString message); void setPresentationOptions(PresentationOptions options); [[nodiscard]] PresentationOptions presentationOptions() const noexcept { @@ -86,8 +93,19 @@ class ConversationView final : public QAbstractItemView { // Applies one canonical tail insertion without traversing retained model // rows. Returns false when the delta is not the exact append shape, so the // caller can use complete structural reconciliation. - [[nodiscard]] bool appendTailCard(ConversationTailCard tail, - std::size_t historyActivityLimit); + [[nodiscard]] bool appendTailCard(ConversationTailCard tail); + + // History-window and staged-presentation state belong to the item view. + // Shell supplies current canonical counts but retains no presentation copy. + [[nodiscard]] std::size_t historyLimitForThread( + const std::string &threadId, std::size_t authoritativeItemCount); + [[nodiscard]] HistoryPageRequest requestNextHistoryPage( + const std::string &threadId, std::size_t authoritativeItemCount, + bool providerHasMore); + void forgetThreadPresentation(const std::string &threadId); + [[nodiscard]] const std::string &presentedThreadId() const noexcept { + return threadId_; + } void setTrailingSpaceHeight(int height); void prepareForLocalPromptAdmission(); @@ -151,6 +169,12 @@ class ConversationView final : public QAbstractItemView { bool pausedByComposerGrowth = false; }; + struct HistoryWindow { + std::size_t requested = AuthoritativeHistoryPageSize; + std::size_t effective = AuthoritativeHistoryPageSize; + std::size_t lastAuthoritativeCount = 0; + }; + struct HeightRecord { int width = 0; int height = 0; @@ -270,6 +294,7 @@ class ConversationView final : public QAbstractItemView { std::function loadMoreAction_; std::function promptMaterializedAction_; std::function promptRecoveryAction_; + std::function presentationCommittedAction_; std::unordered_map materializedCards_; std::unordered_map stagedCards_; @@ -284,6 +309,7 @@ class ConversationView final : public QAbstractItemView { std::unordered_map commandOutputStates_; std::unordered_map threadStates_; + std::unordered_map historyWindows_; PresentationOptions presentationOptions_; std::string threadId_; diff --git a/tests/codex/ConversationViewBenchmark.cpp b/tests/codex/ConversationViewBenchmark.cpp index a0e95e0..7e55c36 100644 --- a/tests/codex/ConversationViewBenchmark.cpp +++ b/tests/codex/ConversationViewBenchmark.cpp @@ -140,9 +140,10 @@ int main(int argc, char **argv) { tail.card = cardData(count); tail.sectionKey = "turn-section-" + std::to_string(count); tail.historyActivity = true; + tail.authoritativeItemCount = count + 1; QElapsedTimer append; append.start(); - const bool appendAccepted = view.appendTailCard(std::move(tail), count); + const bool appendAccepted = view.appendTailCard(std::move(tail)); const qint64 appendMicroseconds = append.nsecsElapsed() / 1000; QApplication::processEvents(QEventLoop::AllEvents, 20); diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index b7ef51f..7f7c201 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -327,20 +327,20 @@ bool boundedTailAppendIsViewportProportional() { tail.card = message(10'000); tail.sectionKey = "section-10000"; tail.historyActivity = true; + tail.authoritativeItemCount = 10'001; const std::string tailKey = stableKey(tail.card.key); - result &= expect(view.appendTailCard(std::move(tail), 10'000), + result &= expect(view.appendTailCard(std::move(tail)), "canonical tail append was accepted"); settle(); const auto anchorAfter = firstVisible(view); result &= expect( - view.conversationModel()->rowCount() == 10'000 && - view.conversationModel()->indexForStableKey(tailKey).row() == 9'999 && - view.conversationModel()->hiddenAuthoritativeItemCount() == 1 && - view.conversationModel()->hasMore(), - "bounded tail append did not retain the exact suffix and history chrome"); + view.conversationModel()->rowCount() == 10'001 && + view.conversationModel()->indexForStableKey(tailKey).row() == 10'000 && + view.conversationModel()->hiddenAuthoritativeItemCount() == 0, + "paused tail append did not expand its retained window exactly once"); result &= expect(anchorAfter == anchorBefore && view.horizontalScrollBar()->value() == horizontalBefore, - "bounded tail append and prefix trim did not preserve both " + "paused tail append did not preserve both " "viewport axes"); result &= expect( view.conversationModel() @@ -363,8 +363,9 @@ bool boundedTailAppendIsViewportProportional() { followingTail.card = message(80); followingTail.sectionKey = "section-80"; followingTail.historyActivity = true; + followingTail.authoritativeItemCount = 81; const std::string followingKey = stableKey(followingTail.card.key); - result &= expect(following.appendTailCard(std::move(followingTail), 80), + result &= expect(following.appendTailCard(std::move(followingTail)), "following tail append was accepted"); settle(); const QModelIndex finalIndex = @@ -400,12 +401,6 @@ bool boundedTailAppendIsViewportProportional() { result &= expect(rootedView.reconcile(std::move(rooted)), "rooted bounded-tail fixture reconciles"); settle(); - rootedView.verticalScrollBar()->triggerAction( - QAbstractSlider::SliderToMinimum); - rootedView.verticalScrollBar()->setValue( - rootedView.verticalScrollBar()->maximum() / 2); - settle(); - const auto rootedAnchor = firstVisible(rootedView); const qulonglong rootedRebuilds = rootedView.conversationModel() ->property("modelIndexRebuildCount") .toULongLong(); @@ -421,7 +416,8 @@ bool boundedTailAppendIsViewportProportional() { nestedTail.sectionKey = "rooted-section"; nestedTail.nested = true; nestedTail.historyActivity = true; - return rootedView.appendTailCard(std::move(nestedTail), 80); + nestedTail.authoritativeItemCount = serial + 1; + return rootedView.appendTailCard(std::move(nestedTail)); }; result &= expect(appendNested(80) && appendNested(81), "root-pinned nested tail appends were accepted"); @@ -439,8 +435,9 @@ bool boundedTailAppendIsViewportProportional() { rootedView.conversationModel() ->property("modelIndexRebuildCount") .toULongLong() == rootedRebuilds && - firstVisible(rootedView) == rootedAnchor, - "pinned Turn root trim lost identity, rebuilt history, or moved anchor"); + rootedView.isAtBottom(), + "pinned Turn root trim lost identity, rebuilt history, or stopped " + "following"); return result; } @@ -545,6 +542,30 @@ bool atomicPagingAndFollowingArrival() { return result; } +bool historyWindowLivesWithThePresentedThread() { + ConversationView view; + bool result = expect(view.historyLimitForThread("history-a", 200) == 80, + "a new thread begins with the canonical 80-row window"); + const auto retainedFirst = + view.requestNextHistoryPage("history-a", 200, true); + const auto retainedSecond = + view.requestNextHistoryPage("history-a", 200, true); + const auto provider = view.requestNextHistoryPage("history-a", 200, true); + result &= expect(retainedFirst.effectiveLimit == 160 && + !retainedFirst.requestProvider && + retainedSecond.effectiveLimit == 240 && + !retainedSecond.requestProvider && + provider.effectiveLimit == 320 && + provider.requestProvider, + "retained pages are consumed before one provider request"); + result &= expect(view.historyLimitForThread("history-b", 500) == 80, + "history windows remain independent per thread"); + view.forgetThreadPresentation("history-a"); + result &= expect(view.historyLimitForThread("history-a", 200) == 80, + "retiring a thread releases its presentation window"); + return result; +} + bool delayedThreadSelectionSpinner() { ConversationView view; view.resize(820, 600); @@ -554,6 +575,9 @@ bool delayedThreadSelectionSpinner() { bool result = expect(view.reconcile(source), "spinner source conversation reconciles"); settle(); + std::vector committedThreads; + view.setPresentationCommittedAction( + [&](const std::string &threadId) { committedThreads.push_back(threadId); }); view.beginThreadSelection("spinner-slow-target"); settle(); @@ -572,6 +596,8 @@ bool delayedThreadSelectionSpinner() { .isValid(), "thread selection immediately covers the outgoing message viewport " "with a blank centered loading surface"); + result &= expect(committedThreads.empty(), + "selection does not publish presentation readiness early"); QElapsedTimer early; early.start(); @@ -629,6 +655,9 @@ bool delayedThreadSelectionSpinner() { "spinner-slow-target", "turn", "message"})) .isValid(), "the complete target frame atomically removes and stops the spinner"); + result &= expect(committedThreads == + std::vector{"spinner-slow-target"}, + "the complete target frame publishes readiness once"); view.beginThreadSelection("spinner-fast-target"); view.reconcileStaged(singleMessageConversation("spinner-fast-target", @@ -636,7 +665,10 @@ bool delayedThreadSelectionSpinner() { settle(); result &= expect( overlay && !overlay->isVisible() && - !overlay->property("spinnerAnimationActive").toBool(), + !overlay->property("spinnerAnimationActive").toBool() && + committedThreads == + std::vector{"spinner-slow-target", + "spinner-fast-target"}, "a fast staged selection clears and reveals without spinner motion"); view.beginThreadSelection("spinner-stale-target"); @@ -648,7 +680,7 @@ bool delayedThreadSelectionSpinner() { result &= expect( view.property("staleThreadStagesIgnored").toULongLong() == ignoredBefore + 1 && - overlay && overlay->isVisible(), + overlay && overlay->isVisible() && committedThreads.size() == 2, "a superseded thread stage cannot reveal or stop the current load"); view.reconcileStaged(singleMessageConversation("spinner-final-target", "Final conversation")); @@ -659,7 +691,11 @@ bool delayedThreadSelectionSpinner() { ->indexForStableKey( stableKey(AuthoritativeItemKey{ "spinner-final-target", "turn", "message"})) - .isValid(), + .isValid() && + committedThreads == + std::vector{"spinner-slow-target", + "spinner-fast-target", + "spinner-final-target"}, "the newest thread identity alone completes the loading surface"); return result; } @@ -761,8 +797,9 @@ bool directTailGrowsTheRetainedTurnSurface() { tail.nested = true; tail.activeTurn = true; tail.historyActivity = true; + tail.authoritativeItemCount = 2; const std::string answerKey = stableKey(tail.card.key); - result &= expect(view.appendTailCard(std::move(tail), 80), + result &= expect(view.appendTailCard(std::move(tail)), "the first nested direct-tail card appends"); settle(); @@ -995,6 +1032,7 @@ int main(int argc, char **argv) { boundedTailAppendIsViewportProportional() && targetedVisibilityChangeIsLocal() && atomicPagingAndFollowingArrival() && + historyWindowLivesWithThePresentedThread() && delayedThreadSelectionSpinner() && virtualTurnSurfaceAndInteractivePromotion() && directTailGrowsTheRetainedTurnSurface() && From 57a452382ab2fee655bfda1f0dad5da58a8c8124 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 13:01:57 +0200 Subject: [PATCH 20/39] Maintain conversation indexes incrementally --- docs/qt-virtualized-conversation-view.md | 35 +- docs/two-thread-shared-node-graph.md | 12 +- docs/ui-ux-internal-api.md | 26 +- src/codex/middle/ConversationHeightIndex.cpp | 294 ++++--- src/codex/middle/ConversationHeightIndex.h | 53 +- src/codex/middle/ConversationItemModel.cpp | 790 ++++++++++++------ src/codex/middle/ConversationItemModel.h | 65 +- src/codex/middle/ConversationView.cpp | 444 +++++++--- src/codex/middle/ConversationView.h | 19 +- tests/codex/ConversationItemModelTest.cpp | 55 +- .../codex/ConversationVirtualizationTest.cpp | 30 +- 11 files changed, 1250 insertions(+), 573 deletions(-) diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md index 1597971..5385844 100644 --- a/docs/qt-virtualized-conversation-view.md +++ b/docs/qt-virtualized-conversation-view.md @@ -130,31 +130,30 @@ Model changes have these exact meanings: - a changed retained card or structural role emits `dataChanged` for that row and the affected roles only; - a validated canonical tail uses one `beginInsertRows/endInsertRows`; stable - lookup tables retain absolute deque ordinals, so dropping the bounded prefix - does not reindex the surviving rows; + lookup tables point to nodes in a conversation-specific order-statistic row + tree, so dropping a prefix or changing the middle does not reindex surviving + rows; - an identical snapshot, card, or visibility tuple emits no signal and does not increment a presentation-work counter. `middle::ConversationHeightIndex` is the view's variable-row geometry index. -It stores integer row extents in a Fenwick prefix tree. `top`, `bottom`, total -extent, position-to-row lookup, and a changed row height are logarithmic. A -tail append extends the tree from prefix sums without traversing existing -heights. A leading removal advances a physical Fenwick origin; the special -pinned-Turn-root case replaces only the next prefix slot. Neither operation -traverses the retained suffix. Non-tail insertion/removal/movement is uncommon -structural work and rebuilds the prefix tree from the already validated model -order. Geometry values are nonnegative and accumulated as `qint64`; scrollbar -conversion is a separate view concern. +It stores integer row extents in a dedicated order-statistic tree whose nodes +carry subtree counts and `qint64` extent sums. `top`, `bottom`, total extent, +position-to-row lookup, a changed height, and tail or middle +insertion/removal/movement are logarithmic tree-path operations. Only `assign` +for a complete row sequence increments the rebuild counter. Geometry values +are nonnegative; scrollbar conversion is a separate view concern. The deterministic foundation test exercises 10,000 rows and asserts no Qt -widget construction is involved. At that size, position lookup and one-row -height update each take at most 15 Fenwick steps, and appending rows leaves the -rebuild counter unchanged. +widget construction is involved. At that size, lookup and one-row height +updates remain on logarithmic paths; exact middle insertion, movement, and +removal leave the model identity, stable-key section, and height rebuild +counters unchanged. ## Item-view and passive-delegate contract `ConversationView` is a narrowly specialized `QAbstractItemView`. The model -owns row identity and order; a Fenwick index maps content positions to +owns row identity and order; an extent order-statistic tree maps positions to variable-height rows; the view materializes only rows whose behavior currently requires a real control. It does not create placeholder widgets. Cached height records contain only stable key, width, and measured height and therefore @@ -390,9 +389,9 @@ after paging qualification. ## Remaining limitations -- A same-thread non-tail structural move/insert/remove rebuilds the Fenwick - tree from validated row extents. This is deliberate uncommon structural work; - ordinary scrolling, streaming, completion, and tail append stay bounded. +- A root insertion/removal can genuinely change nesting, width, and collapse + visibility for every row in that one Turn. That Turn alone is re-evaluated; + unrelated Turns and the loaded conversation are not traversed. - A first interaction with a passive row constructs that one real editor. The row is measured before exposure, so this trades one local interaction cost for loaded-history-independent idle and scrolling cost. diff --git a/docs/two-thread-shared-node-graph.md b/docs/two-thread-shared-node-graph.md index f71f8d6..df6cfce 100644 --- a/docs/two-thread-shared-node-graph.md +++ b/docs/two-thread-shared-node-graph.md @@ -278,17 +278,19 @@ Existing native widgets and styling remain the renderer. Conversation history remains in NodeGraph, while the adapter supplies the established view with one bounded 80-activity DTO plus any pinned owning prompts. The native conversation is a variable-height `QAbstractItemView` backed by a thin -`ConversationItemModel` and Fenwick height index. Passive rows are delegate +`ConversationItemModel` plus presentation-only row-order and variable-height +order-statistic indexes. Passive rows are delegate painted; real `ConversationCard` widgets exist only for rich rows in the viewport plus bounded overscan. Selection and Load 80 stage only initially visible rich editors beneath a hidden owner and expose one complete final -frame. Stable keys, row-local interaction records, and exact row/pixel anchors +frame. Stable keys, row-local interaction records, stable-key Turn boundaries, +and exact row/pixel anchors preserve both scroll axes across eviction and rematerialization. Ordinary graph deltas resolve directly to one model index; offscreen changes construct and paint no QWidget. An ordinary canonical last-item delta is verified under one -short graph read and appended with one Qt insert signal; absolute row ordinals -and a lazy height origin permit the history prefix to be trimmed without -scanning the retained conversation. Non-tail or ambiguous structure uses the +short graph read and appended with one Qt insert signal; stable row nodes and +logarithmic extent paths permit a history prefix or middle row to change +without rebuilding the retained conversation. Non-tail or ambiguous structure uses the exact row placement/removal APIs; only authority replacement, paging, and an explicit rescan use a complete projection. The view owns per-thread history windows and publishes one post-stage completion boundary so Shell reveals diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index 234bafb..b92485d 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -231,8 +231,10 @@ structure, visibility, and accessibility values. - `appendTail(tail)` accepts only a unique card in the exact last Turn position, changes the former tail's `LastInTurnRole`, and emits one row insertion. `trimHistoryTo(limit)` retains an owning Turn root where needed - and removes only the bounded prefix. Stable/target maps use absolute deque - ordinals, so surviving rows are not reindexed. + and removes only the bounded prefix. Rows live in a conversation-specific + order-statistic tree; stable-key and exact-NodeRef maps point to stable row + nodes, so insert, remove, move, and surviving identity lookup never rebuild + or renumber a loaded-history-sized index. - `setHistoryChrome(hidden, providerHasMore)` changes only Load More facts; `setActiveTurn(row, active)` changes only the exact root role. - `setVisibility(visibility)` changes only the rows whose presented role @@ -243,15 +245,13 @@ structure, visibility, and accessibility values. ### `middle::ConversationHeightIndex` -`ConversationHeightIndex` is a non-QObject Fenwick prefix index owned by the -view. It stores only nonnegative row extents. `top`, `bottom`, total height, -position-to-row lookup, and a changed row height are logarithmic; a tail append -extends the tree and a leading trim advances its physical origin without -traversing retained rows. The pinned-root row-one removal updates one Fenwick -slot and advances the same origin. Uncommon non-tail insert, remove, or move -operations rebuild from the already validated model order. Geometry uses -`qint64` internally and is converted to scrollbar coordinates only at the view -boundary. The index contains no card values or authority. +`ConversationHeightIndex` is a non-QObject order-statistic extent tree owned by +the view. It stores only nonnegative row extents plus subtree row counts and +`qint64` sums. `top`, `bottom`, total height, position-to-row lookup, a changed +row height, and tail or non-tail insert/remove/move operations touch only +logarithmic tree paths. `assign` is the explicit complete-sequence operation +and the only operation counted as a rebuild. Scrollbar conversion remains a +separate view concern. The index contains no card values or authority. ### `middle::ConversationView` @@ -317,7 +317,9 @@ released, and QWidget work never occurs while a graph or channel lock is held. the separate prompt `NodeRef`. Prompt retirement cannot remove the row. - `applyRowChange(value)` resolves the projected neighbor keys against the current bounded model and emits only the required insert, move, or structural - row update. Coalesced sibling changes are applied in canonical neighbor order. + row update. Stable-key section boundaries and the extent tree are updated + only for the affected old/new Turn; later sections are neither shifted nor + rebuilt. Coalesced sibling changes are applied in canonical neighbor order. - `removeCardTarget(ref)` removes only the row currently indexed by that exact Item `NodeRef`; unrelated and already-transferred prompt removals are no-ops. - `appendTailCard(tail)` is the ordinary structural fast diff --git a/src/codex/middle/ConversationHeightIndex.cpp b/src/codex/middle/ConversationHeightIndex.cpp index 7deff18..d47d3e2 100644 --- a/src/codex/middle/ConversationHeightIndex.cpp +++ b/src/codex/middle/ConversationHeightIndex.cpp @@ -3,7 +3,7 @@ #include "codex/middle/ConversationHeightIndex.h" #include -#include +#include namespace codexui::codex::middle { namespace { @@ -12,102 +12,89 @@ int validHeight(int height) noexcept { return std::max(0, height); } } // namespace +ConversationHeightIndex::~ConversationHeightIndex() = default; + void ConversationHeightIndex::clear() noexcept { - heights_.clear(); - tree_.assign(1, 0); - offset_ = 0; + root_.reset(); lastLookupSteps_ = 0; lastUpdateSteps_ = 0; } void ConversationHeightIndex::reset(std::size_t count, int estimatedHeight) { - heights_.assign(count, validHeight(estimatedHeight)); - offset_ = 0; - rebuild(); + std::vector heights(count, validHeight(estimatedHeight)); + assign(heights); } void ConversationHeightIndex::assign(std::span heights) { - heights_.clear(); - heights_.reserve(heights.size()); - for (int height : heights) - heights_.push_back(validHeight(height)); - offset_ = 0; - rebuild(); + lastUpdateSteps_ = 0; + root_ = makeRows(heights, lastUpdateSteps_); + ++rebuildCount_; + lastLookupSteps_ = 0; + lastUpdateSteps_ = 0; } void ConversationHeightIndex::insert(std::size_t row, std::span heights) { - row = std::min(row, size()); if (heights.empty()) return; - if (row == size()) { - heights_.reserve(heights_.size() + heights.size()); - tree_.reserve(tree_.size() + heights.size()); - for (int height : heights) - append(validHeight(height)); - return; - } - normalize(); - std::vector inserted; - inserted.reserve(heights.size()); - for (int height : heights) - inserted.push_back(validHeight(height)); - heights_.insert(heights_.begin() + static_cast(row), - inserted.begin(), inserted.end()); - rebuild(); + row = std::min(row, size()); + lastUpdateSteps_ = 0; + auto [left, right] = split(std::move(root_), row, lastUpdateSteps_); + std::unique_ptr inserted = makeRows(heights, lastUpdateSteps_); + root_ = merge(merge(std::move(left), std::move(inserted), lastUpdateSteps_), + std::move(right), lastUpdateSteps_); } void ConversationHeightIndex::remove(std::size_t row, std::size_t count) { if (row >= size() || count == 0) return; count = std::min(count, size() - row); - if (row == 0) { - offset_ += count; - lastUpdateSteps_ = 0; - return; - } - if (row == 1 && count == 1 && offset_ + 1 < heights_.size()) { - const int retainedRootHeight = heights_[offset_]; - static_cast(setHeight(1, retainedRootHeight)); - ++offset_; - return; - } - normalize(); - if (row + count == heights_.size()) { - heights_.resize(row); - tree_.resize(row + 1); - lastUpdateSteps_ = 0; - return; - } - heights_.erase(heights_.begin() + static_cast(row), - heights_.begin() + static_cast(row + count)); - rebuild(); + lastUpdateSteps_ = 0; + auto [left, suffix] = split(std::move(root_), row, lastUpdateSteps_); + auto [removed, right] = + split(std::move(suffix), count, lastUpdateSteps_); + root_ = merge(std::move(left), std::move(right), lastUpdateSteps_); } void ConversationHeightIndex::move(std::size_t sourceRow, std::size_t count, std::size_t destinationRow) { if (sourceRow >= size() || count == 0) return; - normalize(); - count = std::min(count, heights_.size() - sourceRow); - destinationRow = std::min(destinationRow, heights_.size() - count); + count = std::min(count, size() - sourceRow); + destinationRow = std::min(destinationRow, size() - count); if (sourceRow == destinationRow) return; - std::vector moved( - heights_.begin() + static_cast(sourceRow), - heights_.begin() + static_cast(sourceRow + count)); - heights_.erase(heights_.begin() + static_cast(sourceRow), - heights_.begin() + - static_cast(sourceRow + count)); - heights_.insert(heights_.begin() + - static_cast(destinationRow), - std::make_move_iterator(moved.begin()), - std::make_move_iterator(moved.end())); - rebuild(); + + lastUpdateSteps_ = 0; + auto [left, suffix] = + split(std::move(root_), sourceRow, lastUpdateSteps_); + auto [moved, right] = + split(std::move(suffix), count, lastUpdateSteps_); + root_ = merge(std::move(left), std::move(right), lastUpdateSteps_); + auto [before, after] = + split(std::move(root_), destinationRow, lastUpdateSteps_); + root_ = merge(merge(std::move(before), std::move(moved), lastUpdateSteps_), + std::move(after), lastUpdateSteps_); +} + +std::size_t ConversationHeightIndex::size() const noexcept { + return nodeCount(root_); } int ConversationHeightIndex::height(std::size_t row) const noexcept { - return row < size() ? heights_[offset_ + row] : 0; + const Node *current = root_.get(); + while (current) { + const std::size_t leftCount = nodeCount(current->left); + if (row < leftCount) { + current = current->left.get(); + continue; + } + if (row == leftCount) + return current->height; + row -= leftCount + 1; + current = current->right.get(); + } + return 0; } bool ConversationHeightIndex::setHeight(std::size_t row, @@ -115,19 +102,29 @@ bool ConversationHeightIndex::setHeight(std::size_t row, if (row >= size()) return false; nextHeight = validHeight(nextHeight); - const std::size_t physicalRow = offset_ + row; - const qint64 delta = static_cast(nextHeight) - heights_[physicalRow]; - if (delta == 0) { - lastUpdateSteps_ = 0; - return false; - } - heights_[physicalRow] = nextHeight; + std::vector path; + Node *current = root_.get(); lastUpdateSteps_ = 0; - for (std::size_t index = physicalRow + 1; index < tree_.size(); - index += index & (~index + 1)) { - tree_[index] += delta; + while (current) { + path.push_back(current); ++lastUpdateSteps_; + const std::size_t leftCount = nodeCount(current->left); + if (row < leftCount) { + current = current->left.get(); + continue; + } + if (row == leftCount) + break; + row -= leftCount + 1; + current = current->right.get(); } + if (!current || current->height == nextHeight) { + lastUpdateSteps_ = 0; + return false; + } + current->height = nextHeight; + for (auto position = path.rbegin(); position != path.rend(); ++position) + updateNode(*position); return true; } @@ -140,7 +137,7 @@ qint64 ConversationHeightIndex::bottom(std::size_t row) const noexcept { } qint64 ConversationHeightIndex::totalHeight() const noexcept { - return prefix(size()); + return nodeTotal(root_); } std::size_t ConversationHeightIndex::rowAt(qint64 contentY) const noexcept { @@ -152,69 +149,118 @@ std::size_t ConversationHeightIndex::rowAt(qint64 contentY) const noexcept { return 0; contentY = std::clamp(contentY, 0, total - 1); - std::size_t bit = 1; - while ((bit << 1) < tree_.size()) - bit <<= 1; - std::size_t index = 0; - qint64 sum = 0; - const qint64 target = physicalPrefix(offset_) + contentY; - for (; bit != 0; bit >>= 1) { + std::size_t precedingRows = 0; + const Node *current = root_.get(); + while (current) { ++lastLookupSteps_; - const std::size_t next = index + bit; - if (next < tree_.size() && sum + tree_[next] <= target) { - index = next; - sum += tree_[next]; + const qint64 leftHeight = nodeTotal(current->left); + if (contentY < leftHeight) { + current = current->left.get(); + continue; } + contentY -= leftHeight; + const std::size_t leftCount = nodeCount(current->left); + if (contentY < current->height) + return precedingRows + leftCount; + contentY -= current->height; + precedingRows += leftCount + 1; + current = current->right.get(); } - const std::size_t physicalRow = std::min(index, heights_.size() - 1); - return std::min(physicalRow - offset_, size() - 1); + return size() - 1; } qint64 ConversationHeightIndex::prefix(std::size_t count) const noexcept { count = std::min(count, size()); - return physicalPrefix(offset_ + count) - physicalPrefix(offset_); -} - -qint64 -ConversationHeightIndex::physicalPrefix(std::size_t count) const noexcept { - count = std::min(count, heights_.size()); qint64 result = 0; - for (std::size_t index = count; index != 0; index -= index & (~index + 1)) - result += tree_[index]; + const Node *current = root_.get(); + while (current && count != 0) { + const std::size_t leftCount = nodeCount(current->left); + if (count <= leftCount) { + current = current->left.get(); + continue; + } + result += nodeTotal(current->left) + current->height; + count -= leftCount + 1; + current = current->right.get(); + } return result; } -void ConversationHeightIndex::append(int height) { - const std::size_t oldCount = heights_.size(); - const std::size_t index = oldCount + 1; - const std::size_t lowBit = index & (~index + 1); - const qint64 preceding = physicalPrefix(oldCount) - - physicalPrefix(index > lowBit ? index - lowBit : 0); - heights_.push_back(height); - tree_.push_back(preceding + height); - lastUpdateSteps_ = 1; +std::uint64_t ConversationHeightIndex::nextPriority() noexcept { + priorityState_ ^= priorityState_ >> 12; + priorityState_ ^= priorityState_ << 25; + priorityState_ ^= priorityState_ >> 27; + return priorityState_ * 0x2545f4914f6cdd1dULL; +} + +std::size_t ConversationHeightIndex::nodeCount( + const std::unique_ptr &node) noexcept { + return node ? node->count : 0; +} + +qint64 ConversationHeightIndex::nodeTotal( + const std::unique_ptr &node) noexcept { + return node ? node->total : 0; } -void ConversationHeightIndex::normalize() { - if (offset_ == 0) +void ConversationHeightIndex::updateNode(Node *node) noexcept { + if (!node) return; - heights_.erase(heights_.begin(), - heights_.begin() + static_cast(offset_)); - offset_ = 0; - rebuild(); -} - -void ConversationHeightIndex::rebuild() { - tree_.assign(heights_.size() + 1, 0); - for (std::size_t index = 1; index < tree_.size(); ++index) { - tree_[index] += heights_[index - 1]; - const std::size_t parent = index + (index & (~index + 1)); - if (parent < tree_.size()) - tree_[parent] += tree_[index]; + node->count = nodeCount(node->left) + 1 + nodeCount(node->right); + node->total = nodeTotal(node->left) + node->height + nodeTotal(node->right); +} + +std::pair, + std::unique_ptr> +ConversationHeightIndex::split(std::unique_ptr root, + std::size_t leftCount, + std::size_t &steps) { + if (!root) + return {}; + ++steps; + if (nodeCount(root->left) >= leftCount) { + auto [left, middle] = + split(std::move(root->left), leftCount, steps); + root->left = std::move(middle); + updateNode(root.get()); + return {std::move(left), std::move(root)}; } - ++rebuildCount_; - lastLookupSteps_ = 0; - lastUpdateSteps_ = 0; + const std::size_t remaining = leftCount - nodeCount(root->left) - 1; + auto [middle, right] = split(std::move(root->right), remaining, steps); + root->right = std::move(middle); + updateNode(root.get()); + return {std::move(root), std::move(right)}; +} + +std::unique_ptr +ConversationHeightIndex::merge(std::unique_ptr left, + std::unique_ptr right, + std::size_t &steps) { + if (!left) + return right; + if (!right) + return left; + ++steps; + if (left->priority >= right->priority) { + left->right = merge(std::move(left->right), std::move(right), steps); + updateNode(left.get()); + return left; + } + right->left = merge(std::move(left), std::move(right->left), steps); + updateNode(right.get()); + return right; +} + +std::unique_ptr +ConversationHeightIndex::makeRows(std::span heights, + std::size_t &steps) { + std::unique_ptr result; + for (int height : heights) { + auto node = + std::make_unique(validHeight(height), nextPriority()); + result = merge(std::move(result), std::move(node), steps); + } + return result; } } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationHeightIndex.h b/src/codex/middle/ConversationHeightIndex.h index 3a1f15e..d552d36 100644 --- a/src/codex/middle/ConversationHeightIndex.h +++ b/src/codex/middle/ConversationHeightIndex.h @@ -6,17 +6,25 @@ #include #include +#include +#include #include -#include +#include namespace codexui::codex::middle { // Variable-height prefix index for the conversation's flat visual rows. -// Ordinary position lookup and one-row height changes are logarithmic. Tail -// appends extend the Fenwick tree without traversing retained rows; uncommon -// non-tail structure changes rebuild from the already validated model order. +// Position lookup, one-row height changes, and structural changes all touch +// only an order-statistic path. A complete rebuild is reserved for assigning a +// different authoritative row sequence. class ConversationHeightIndex final { public: + ConversationHeightIndex() = default; + ~ConversationHeightIndex(); + + ConversationHeightIndex(const ConversationHeightIndex &) = delete; + ConversationHeightIndex &operator=(const ConversationHeightIndex &) = delete; + void clear() noexcept; void reset(std::size_t count, int estimatedHeight); void assign(std::span heights); @@ -25,9 +33,7 @@ class ConversationHeightIndex final { void move(std::size_t sourceRow, std::size_t count, std::size_t destinationRow); - [[nodiscard]] std::size_t size() const noexcept { - return heights_.size() - offset_; - } + [[nodiscard]] std::size_t size() const noexcept; [[nodiscard]] bool empty() const noexcept { return size() == 0; } [[nodiscard]] int height(std::size_t row) const noexcept; [[nodiscard]] bool setHeight(std::size_t row, int height) noexcept; @@ -47,15 +53,34 @@ class ConversationHeightIndex final { } private: + struct Node { + Node(int height, std::uint64_t priority) + : height(height), total(height), priority(priority) {} + + int height = 0; + std::size_t count = 1; + qint64 total = 0; + std::uint64_t priority = 0; + std::unique_ptr left; + std::unique_ptr right; + }; + [[nodiscard]] qint64 prefix(std::size_t count) const noexcept; - [[nodiscard]] qint64 physicalPrefix(std::size_t count) const noexcept; - void append(int height); - void normalize(); - void rebuild(); + [[nodiscard]] std::uint64_t nextPriority() noexcept; + static std::size_t nodeCount(const std::unique_ptr &node) noexcept; + static qint64 nodeTotal(const std::unique_ptr &node) noexcept; + static void updateNode(Node *node) noexcept; + static std::pair, std::unique_ptr> + split(std::unique_ptr root, std::size_t leftCount, + std::size_t &steps); + static std::unique_ptr merge(std::unique_ptr left, + std::unique_ptr right, + std::size_t &steps); + [[nodiscard]] std::unique_ptr + makeRows(std::span heights, std::size_t &steps); - std::vector heights_; - std::vector tree_{0}; - std::size_t offset_ = 0; + std::unique_ptr root_; + std::uint64_t priorityState_ = 0x243f6a8885a308d3ULL; mutable std::size_t lastLookupSteps_ = 0; std::size_t lastUpdateSteps_ = 0; std::size_t rebuildCount_ = 0; diff --git a/src/codex/middle/ConversationItemModel.cpp b/src/codex/middle/ConversationItemModel.cpp index e5235ea..3d0b14b 100644 --- a/src/codex/middle/ConversationItemModel.cpp +++ b/src/codex/middle/ConversationItemModel.cpp @@ -6,7 +6,6 @@ #include #include -#include #include #include #include @@ -116,8 +115,10 @@ bool compatible(const VisibleCardData &before, ConversationItemModel::ConversationItemModel(QObject *parent) : QAbstractListModel(parent) {} +ConversationItemModel::~ConversationItemModel() = default; + int ConversationItemModel::rowCount(const QModelIndex &parent) const { - return parent.isValid() ? 0 : static_cast(rows_.size()); + return parent.isValid() ? 0 : static_cast(nodeCount(rows_)); } QVariant ConversationItemModel::data(const QModelIndex &index, int role) const { @@ -192,18 +193,22 @@ bool ConversationItemModel::replaceConversation(ConversationSnapshot snapshot) { if (!rowsAreUnique(desired)) return false; - const bool identical = - nextThreadId == threadId_ && - nextHiddenCount == hiddenAuthoritativeItemCount_ && - nextHasMore == hasMore_ && desired.size() == rows_.size() && - std::equal(rows_.begin(), rows_.end(), desired.begin()); + bool identical = nextThreadId == threadId_ && + nextHiddenCount == hiddenAuthoritativeItemCount_ && + nextHasMore == hasMore_ && + desired.size() == nodeCount(rows_); + for (std::size_t position = 0; identical && position < desired.size(); + ++position) { + const RowNode *current = nodeAt(position); + identical = current && current->value == desired[position]; + } if (identical) return false; beginResetModel(); - rows_.clear(); - rows_.insert(rows_.end(), std::make_move_iterator(desired.begin()), - std::make_move_iterator(desired.end())); + clearRows(); + for (Row &row : desired) + insertRow(nodeCount(rows_), std::move(row)); threadId_ = nextThreadId; hiddenAuthoritativeItemCount_ = nextHiddenCount; hasMore_ = nextHasMore; @@ -220,33 +225,31 @@ bool ConversationItemModel::prependHistoryPage(ConversationSnapshot snapshot) { const std::size_t nextHiddenCount = snapshot.hiddenAuthoritativeItemCount; const bool nextHasMore = snapshot.hasMore; std::vector desired = flatten(std::move(snapshot)); - if (!rowsAreUnique(desired) || desired.size() < rows_.size()) + if (!rowsAreUnique(desired) || desired.size() < nodeCount(rows_)) return false; std::size_t retained = 0; for (const Row &candidate : desired) { - if (retained < rows_.size() && - candidate.stableKey == rows_[retained].stableKey) { + const RowNode *current = nodeAt(retained); + if (current && candidate.stableKey == current->value.stableKey) { ++retained; continue; } if (stableRows_.contains(candidate.stableKey)) return false; } - if (retained != rows_.size()) + if (retained != nodeCount(rows_)) return false; bool changed = nextHiddenCount != hiddenAuthoritativeItemCount_ || nextHasMore != hasMore_; - bool indexesDirty = false; std::size_t desiredPosition = 0; std::size_t modelPosition = 0; while (desiredPosition < desired.size()) { - if (modelPosition < rows_.size() && - desired[desiredPosition].stableKey == rows_[modelPosition].stableKey) { - if (rows_[modelPosition] != desired[desiredPosition]) { - indexesDirty = indexesDirty || rows_[modelPosition].card.target != - desired[desiredPosition].card.target; + const RowNode *current = nodeAt(modelPosition); + if (current && desired[desiredPosition].stableKey == + current->value.stableKey) { + if (current->value != desired[desiredPosition]) { updateRow(static_cast(modelPosition), std::move(desired[desiredPosition])); changed = true; @@ -259,31 +262,26 @@ bool ConversationItemModel::prependHistoryPage(ConversationSnapshot snapshot) { const std::size_t firstDesired = desiredPosition; while ( desiredPosition < desired.size() && - !(modelPosition < rows_.size() && - desired[desiredPosition].stableKey == rows_[modelPosition].stableKey)) + !([&] { + const RowNode *retainedRow = nodeAt(modelPosition); + return retainedRow && desired[desiredPosition].stableKey == + retainedRow->value.stableKey; + })()) ++desiredPosition; const std::size_t count = desiredPosition - firstDesired; const int firstRow = static_cast(modelPosition); const int lastRow = static_cast(modelPosition + count - 1); beginInsertRows({}, firstRow, lastRow); - rows_.insert( - rows_.begin() + static_cast(modelPosition), - std::make_move_iterator(desired.begin() + - static_cast(firstDesired)), - std::make_move_iterator(desired.begin() + - static_cast(desiredPosition))); - rebuildIndexes(); + for (std::size_t inserted = firstDesired; inserted < desiredPosition; + ++inserted) + insertRow(modelPosition++, std::move(desired[inserted])); endInsertRows(); incrementProperty("modelInsertCount"); changed = true; - indexesDirty = false; - modelPosition += count; } hiddenAuthoritativeItemCount_ = nextHiddenCount; hasMore_ = nextHasMore; - if (indexesDirty) - rebuildIndexes(); if (changed) incrementProperty("modelHistoryPrependCount"); return changed; @@ -308,54 +306,44 @@ bool ConversationItemModel::reconcile(ConversationSnapshot snapshot) { desiredKeys.insert(row.stableKey); bool changed = chromeChanged; - bool indexesDirty = false; - for (std::size_t offset = rows_.size(); offset > 0;) { + for (std::size_t offset = nodeCount(rows_); offset > 0;) { std::size_t last = offset - 1; - if (desiredKeys.contains(rows_[last].stableKey)) { + const RowNode *lastNode = nodeAt(last); + if (lastNode && desiredKeys.contains(lastNode->value.stableKey)) { offset = last; continue; } std::size_t first = last; - while (first > 0 && !desiredKeys.contains(rows_[first - 1].stableKey)) + while (first > 0 && + !desiredKeys.contains(nodeAt(first - 1)->value.stableKey)) --first; beginRemoveRows({}, static_cast(first), static_cast(last)); - rows_.erase(rows_.begin() + static_cast(first), - rows_.begin() + static_cast(last + 1)); + for (std::size_t position = last + 1; position > first; --position) + (void)takeRow(position - 1); endRemoveRows(); incrementProperty("modelRemoveCount"); changed = true; - indexesDirty = true; offset = first; } std::vector inserted(desired.size(), false); for (std::size_t position = 0; position < desired.size(); ++position) { - if (position < rows_.size() && - rows_[position].stableKey == desired[position].stableKey) + const RowNode *current = nodeAt(position); + if (current && current->value.stableKey == desired[position].stableKey) continue; - const auto found = - std::find_if(rows_.begin() + static_cast( - std::min(position, rows_.size())), - rows_.end(), [&](const Row &row) { - return row.stableKey == desired[position].stableKey; - }); - if (found == rows_.end()) { + const auto found = stableRows_.find(desired[position].stableKey); + const std::optional source = + found == stableRows_.end() ? std::nullopt : rowOf(found->second); + if (!source) { std::size_t count = 1; while (position + count < desired.size() && - std::ranges::none_of( - rows_, - [&](const Row &row) { - return row.stableKey == desired[position + count].stableKey; - })) + !stableRows_.contains(desired[position + count].stableKey)) ++count; beginInsertRows({}, static_cast(position), static_cast(position + count - 1)); - rows_.insert( - rows_.begin() + static_cast(position), - std::make_move_iterator(desired.begin() + - static_cast(position)), - std::make_move_iterator( - desired.begin() + static_cast(position + count))); + for (std::size_t inserted = 0; inserted < count; ++inserted) + insertRow(position + inserted, + std::move(desired[position + inserted])); endInsertRows(); std::fill(inserted.begin() + static_cast(position), inserted.begin() + @@ -363,37 +351,28 @@ bool ConversationItemModel::reconcile(ConversationSnapshot snapshot) { true); incrementProperty("modelInsertCount"); changed = true; - indexesDirty = true; position += count - 1; continue; } - const std::size_t source = - static_cast(std::distance(rows_.begin(), found)); - beginMoveRows({}, static_cast(source), static_cast(source), {}, + beginMoveRows({}, *source, *source, {}, static_cast(position)); - Row moved = std::move(rows_[source]); - rows_.erase(rows_.begin() + static_cast(source)); - rows_.insert(rows_.begin() + static_cast(position), - std::move(moved)); + std::unique_ptr moved = takeRow(static_cast(*source)); + insertRow(position, std::move(moved->value)); endMoveRows(); incrementProperty("modelMoveCount"); changed = true; - indexesDirty = true; } for (std::size_t position = 0; position < desired.size(); ++position) { if (inserted[position]) continue; - if (rows_[position] == desired[position]) + const RowNode *current = nodeAt(position); + if (current && current->value == desired[position]) continue; - indexesDirty = indexesDirty || - rows_[position].card.target != desired[position].card.target; updateRow(static_cast(position), std::move(desired[position])); changed = true; } - if (indexesDirty) - rebuildIndexes(); return changed; } @@ -403,18 +382,15 @@ ConversationItemModel::updateCard(VisibleCardData card) { const auto found = stableRows_.find(key); if (found == stableRows_.end()) return CardUpdateResult::Missing; - const std::optional modelRow = logicalRow(found->second); + const std::optional modelRow = rowOf(found->second); if (!modelRow) return CardUpdateResult::Missing; - Row ¤t = rows_[static_cast(*modelRow)]; + Row ¤t = found->second->value; if (!compatible(current.card, card)) return CardUpdateResult::Incompatible; if (current.card == card) return CardUpdateResult::Unchanged; - const nodegraph::Node *oldTarget = - current.card.target ? current.card.target.get() : nullptr; - const nodegraph::Node *newTarget = card.target ? card.target.get() : nullptr; Row replacement; replacement.card = std::move(card); replacement.stableKey = current.stableKey; @@ -427,12 +403,6 @@ ConversationItemModel::updateCard(VisibleCardData card) { replacement.activeTurn = current.activeTurn; replacement.historyActivity = current.historyActivity; updateRow(*modelRow, std::move(replacement)); - if (oldTarget != newTarget) { - if (oldTarget) - targetRows_.erase(oldTarget); - if (newTarget) - targetRows_.insert_or_assign(newTarget, found->second); - } return CardUpdateResult::Changed; } @@ -447,26 +417,25 @@ ConversationItemModel::insertCard(int rowIndex, (candidate.card.target && targetRows_.contains(candidate.card.target.get()))) return StructuralChangeResult::Duplicate; + const SectionStructure before = sectionStructure(candidate.sectionKey); + const std::string changedKey = candidate.stableKey; const bool previousInSection = rowIndex > 0 && - rows_[static_cast(rowIndex - 1)].sectionKey == - candidate.sectionKey; + row(rowIndex - 1)->sectionKey == candidate.sectionKey; const bool nextInSection = rowIndex < rowCount() && - rows_[static_cast(rowIndex)].sectionKey == - candidate.sectionKey; + row(rowIndex)->sectionKey == candidate.sectionKey; candidate.firstInTurn = !previousInSection; candidate.lastInTurn = !nextInSection; beginInsertRows({}, rowIndex, rowIndex); - rows_.insert(rows_.begin() + static_cast(rowIndex), - std::move(candidate)); - rebuildIndexes(); + RowNode *inserted = insertRow(static_cast(rowIndex), + std::move(candidate)); endInsertRows(); incrementProperty("modelInsertCount"); incrementProperty("modelExactInsertCount"); - refreshSectionStructure(rows_[static_cast(rowIndex)].sectionKey); + refreshSectionStructure(inserted->value.sectionKey, before, changedKey); return StructuralChangeResult::Changed; } @@ -476,16 +445,16 @@ ConversationItemModel::removeTarget(const nodegraph::NodeRef &target) { if (!targetIndex.isValid()) return StructuralChangeResult::Missing; const int rowIndex = targetIndex.row(); - const std::string sectionKey = - rows_[static_cast(rowIndex)].sectionKey; + const std::string sectionKey = row(rowIndex)->sectionKey; + const std::string changedKey = row(rowIndex)->stableKey; + const SectionStructure before = sectionStructure(sectionKey); beginRemoveRows({}, rowIndex, rowIndex); - rows_.erase(rows_.begin() + static_cast(rowIndex)); - rebuildIndexes(); + (void)takeRow(static_cast(rowIndex)); endRemoveRows(); incrementProperty("modelRemoveCount"); incrementProperty("modelExactRemoveCount"); - refreshSectionStructure(sectionKey); + refreshSectionStructure(sectionKey, before, changedKey); return StructuralChangeResult::Changed; } @@ -502,64 +471,61 @@ ConversationItemModel::moveTarget(const nodegraph::NodeRef &target, return StructuralChangeResult::Invalid; const int sourceRow = targetIndex.row(); - const Row ¤t = rows_[static_cast(sourceRow)]; + const Row ¤t = targetRows_.at(target.get())->value; + const std::string changedKey = current.stableKey; + const SectionStructure oldBefore = sectionStructure(current.sectionKey); Row replacement = rowFromPlacement(std::move(placement)); if (replacement.stableKey != current.stableKey || !compatible(current.card, replacement.card) || (replacement.turnRoot && replacement.nested)) return StructuralChangeResult::Invalid; - std::vector sectionOrder; - std::vector rootOrder; - sectionOrder.reserve(rows_.size()); - rootOrder.reserve(rows_.size()); - for (int rowIndex = 0; rowIndex < rowCount(); ++rowIndex) { - if (rowIndex == sourceRow) - continue; - if (static_cast(sectionOrder.size()) == destinationRow) { - sectionOrder.push_back(replacement.sectionKey); - rootOrder.push_back(replacement.turnRoot); - } - sectionOrder.push_back( - rows_[static_cast(rowIndex)].sectionKey); - rootOrder.push_back(rows_[static_cast(rowIndex)].turnRoot); - } - if (static_cast(sectionOrder.size()) == destinationRow) { - sectionOrder.push_back(replacement.sectionKey); - rootOrder.push_back(replacement.turnRoot); - } - std::unordered_set completedSections; - std::string previousSection; - for (std::size_t position = 0; position < sectionOrder.size(); ++position) { - const std::string §ion = sectionOrder[position]; - if (section == previousSection) { - if (rootOrder[position]) - return StructuralChangeResult::Invalid; - continue; - } - if (!previousSection.empty()) - completedSections.insert(previousSection); - if (completedSections.contains(section)) - return StructuralChangeResult::Invalid; - previousSection = section; - } + const auto rowAfterRemoval = [&](int position) -> const RowNode * { + if (position < 0 || position >= rowCount() - 1) + return nullptr; + const int original = position < sourceRow ? position : position + 1; + return nodeAt(static_cast(original)); + }; + const RowNode *previous = rowAfterRemoval(destinationRow - 1); + const RowNode *next = rowAfterRemoval(destinationRow); + const bool previousInSection = + previous && previous->value.sectionKey == replacement.sectionKey; + const bool nextInSection = + next && next->value.sectionKey == replacement.sectionKey; + const auto section = sectionRows_.find(replacement.sectionKey); + const std::size_t remainingSectionRows = + section == sectionRows_.end() + ? 0 + : section->second.count - + static_cast(current.sectionKey == + replacement.sectionKey); + const bool remainingRoot = + section != sectionRows_.end() && section->second.root && + section->second.root != targetRows_.at(target.get()); + if ((remainingSectionRows != 0 && !previousInSection && !nextInSection) || + (replacement.turnRoot && (remainingRoot || previousInSection)) || + (!replacement.turnRoot && nextInSection && next->value.turnRoot)) + return StructuralChangeResult::Invalid; const std::string oldSection = current.sectionKey; + const SectionStructure newBefore = + replacement.sectionKey == oldSection + ? oldBefore + : sectionStructure(replacement.sectionKey); const bool movedRows = sourceRow != destinationRow; if (movedRows) { const int destinationChild = destinationRow > sourceRow ? destinationRow + 1 : destinationRow; beginMoveRows({}, sourceRow, sourceRow, {}, destinationChild); - Row moved = std::move(rows_[static_cast(sourceRow)]); - rows_.erase(rows_.begin() + static_cast(sourceRow)); - rows_.insert(rows_.begin() + static_cast(destinationRow), - std::move(moved)); - rebuildIndexes(); + std::unique_ptr moved = + takeRow(static_cast(sourceRow)); + insertRow(static_cast(destinationRow), + std::move(moved->value)); endMoveRows(); incrementProperty("modelMoveCount"); } - Row &moved = rows_[static_cast(destinationRow)]; + Row &moved = nodeAt(static_cast(destinationRow))->value; replacement.firstInTurn = moved.firstInTurn; replacement.lastInTurn = moved.lastInTurn; const bool presentationChanged = moved != replacement; @@ -568,11 +534,10 @@ ConversationItemModel::moveTarget(const nodegraph::NodeRef &target, if (sourceRow == destinationRow && !presentationChanged) return StructuralChangeResult::Unchanged; - rebuildIndexes(); - refreshSectionStructure(oldSection); - if (rows_[static_cast(destinationRow)].sectionKey != oldSection) - refreshSectionStructure( - rows_[static_cast(destinationRow)].sectionKey); + refreshSectionStructure(oldSection, oldBefore, changedKey); + const std::string newSection = row(destinationRow)->sectionKey; + if (newSection != oldSection) + refreshSectionStructure(newSection, newBefore, changedKey); incrementProperty(movedRows ? "modelExactMoveCount" : "modelExactPlacementUpdateCount"); return StructuralChangeResult::Changed; @@ -586,12 +551,12 @@ bool ConversationItemModel::appendTail(ConversationTailCard tail) { return false; const bool startsSection = - rows_.empty() || rows_.back().sectionKey != tail.sectionKey; + rowCount() == 0 || row(rowCount() - 1)->sectionKey != tail.sectionKey; if ((!startsSection && tail.turnRoot) || (startsSection && tail.nested)) return false; - if (!rows_.empty() && !startsSection) { - Row &previous = rows_.back(); + if (rowCount() != 0 && !startsSection) { + Row &previous = nodeAt(static_cast(rowCount() - 1))->value; previous.lastInTurn = false; emit dataChanged(index(rowCount() - 1), index(rowCount() - 1), {LastInTurnRole}); @@ -611,14 +576,8 @@ bool ConversationItemModel::appendTail(ConversationTailCard tail) { row.historyActivity = tail.historyActivity; const int insertedRow = rowCount(); - const std::size_t ordinal = rowBase_ + rows_.size(); beginInsertRows({}, insertedRow, insertedRow); - rows_.push_back(std::move(row)); - stableRows_.emplace(key, ordinal); - if (rows_.back().card.target) - targetRows_.emplace(rows_.back().card.target.get(), ordinal); - if (rows_.back().historyActivity) - ++historyActivityCount_; + insertRow(static_cast(insertedRow), std::move(row)); endInsertRows(); incrementProperty("modelInsertCount"); incrementProperty("modelTailAppendCount"); @@ -628,13 +587,13 @@ bool ConversationItemModel::appendTail(ConversationTailCard tail) { ConversationItemModel::HistoryTrim ConversationItemModel::trimHistoryTo(std::size_t activityLimit) { HistoryTrim result; - if (historyActivityCount_ <= activityLimit || rows_.empty()) + if (historyActivityCount_ <= activityLimit || rowCount() == 0) return result; - Row &first = rows_.front(); + Row &first = nodeAt(0)->value; result.sectionKey = first.sectionKey; - if (first.historyActivity && first.turnRoot && rows_.size() > 1 && - rows_[1].sectionKey == first.sectionKey) { + if (first.historyActivity && first.turnRoot && rowCount() > 1 && + row(1)->sectionKey == first.sectionKey) { first.historyActivity = false; --historyActivityCount_; result.pinnedRoot = true; @@ -647,19 +606,20 @@ ConversationItemModel::trimHistoryTo(std::size_t activityLimit) { // suffix. If one is the complete leading section, leave the window one row // over budget until its authoritative acknowledgement or a complete // reconciliation can place it without changing optimistic ordering. - if (!first.historyActivity && (!first.turnRoot || rows_.size() == 1 || - rows_[1].sectionKey != first.sectionKey)) + if (!first.historyActivity && + (!first.turnRoot || rowCount() == 1 || + row(1)->sectionKey != first.sectionKey)) return result; int removeCount = 1; int removeRow = 0; - if (!first.historyActivity && first.turnRoot && rows_.size() > 1 && - rows_[1].sectionKey == first.sectionKey) { + if (!first.historyActivity && first.turnRoot && rowCount() > 1 && + row(1)->sectionKey == first.sectionKey) { result.sectionKey = first.sectionKey; - if (rows_.size() > 2 && rows_[2].sectionKey == first.sectionKey) { + if (rowCount() > 2 && row(2)->sectionKey == first.sectionKey) { // Retain the pinned owner at logical row zero while dropping the oldest - // nested activity. Moving that one row across the deque prefix keeps all - // later absolute identity ordinals unchanged. + // nested activity. The order-statistic tree removes that one row without + // changing the identity nodes of the retained suffix. removeRow = 1; } else { removeCount = 2; @@ -667,33 +627,17 @@ ConversationItemModel::trimHistoryTo(std::size_t activityLimit) { } for (int offset = 0; offset < removeCount; ++offset) { - const Row &removed = rows_[static_cast(removeRow + offset)]; + const Row &removed = *row(removeRow + offset); result.removedStableKeys.push_back(removed.stableKey); - if (removed.historyActivity) { - --historyActivityCount_; + if (removed.historyActivity) ++result.hiddenIncrement; - } } result.row = removeRow; result.count = removeCount; beginRemoveRows({}, removeRow, removeRow + removeCount - 1); - if (removeRow == 1) { - Row retainedRoot = std::move(rows_.front()); - eraseRowIdentity(rows_[1]); - rows_.pop_front(); - rows_.front() = std::move(retainedRoot); - ++rowBase_; - stableRows_.insert_or_assign(rows_.front().stableKey, rowBase_); - if (rows_.front().card.target) - targetRows_.insert_or_assign(rows_.front().card.target.get(), rowBase_); - } else { - for (int offset = 0; offset < removeCount; ++offset) { - eraseRowIdentity(rows_.front()); - rows_.pop_front(); - ++rowBase_; - } - } + for (int offset = 0; offset < removeCount; ++offset) + (void)takeRow(static_cast(removeRow)); endRemoveRows(); incrementProperty("modelRemoveCount"); incrementProperty("modelBoundedFrontTrimCount"); @@ -701,9 +645,9 @@ ConversationItemModel::trimHistoryTo(std::size_t activityLimit) { } bool ConversationItemModel::setActiveTurn(int rowIndex, bool active) { - Row *value = rowIndex >= 0 && rowIndex < rowCount() - ? &rows_[static_cast(rowIndex)] - : nullptr; + RowNode *node = rowIndex >= 0 ? nodeAt(static_cast(rowIndex)) + : nullptr; + Row *value = node ? &node->value : nullptr; if (!value || !value->turnRoot || value->activeTurn == active) return false; value->activeTurn = active; @@ -724,22 +668,22 @@ bool ConversationItemModel::setVisibility(Visibility visibility) { visibility_ = visibility; int first = -1; bool changed = false; - for (std::size_t position = 0; position < rows_.size(); ++position) { - Row &row = rows_[position]; - const bool presented = isPresented(row.card); - if (presented == row.presented) { + for (int position = 0; position < rowCount(); ++position) { + Row ¤t = nodeAt(static_cast(position))->value; + const bool presented = isPresented(current.card); + if (presented == current.presented) { if (first >= 0) { - emit dataChanged(index(first), index(static_cast(position) - 1), + emit dataChanged(index(first), index(position - 1), {PresentedRole}); incrementProperty("modelDataChangeCount"); first = -1; } continue; } - row.presented = presented; + current.presented = presented; changed = true; if (first < 0) - first = static_cast(position); + first = position; } if (first < 0) return changed; @@ -750,9 +694,9 @@ bool ConversationItemModel::setVisibility(Visibility visibility) { const ConversationItemModel::Row * ConversationItemModel::row(int rowIndex) const noexcept { - return rowIndex >= 0 && static_cast(rowIndex) < rows_.size() - ? &rows_[static_cast(rowIndex)] - : nullptr; + const RowNode *node = + rowIndex >= 0 ? nodeAt(static_cast(rowIndex)) : nullptr; + return node ? &node->value : nullptr; } const VisibleCardData * @@ -766,7 +710,7 @@ ConversationItemModel::indexForStableKey(const std::string &key) const { const auto found = stableRows_.find(key); if (found == stableRows_.end()) return {}; - const std::optional row = logicalRow(found->second); + const std::optional row = rowOf(found->second); return row ? index(*row) : QModelIndex{}; } @@ -777,7 +721,7 @@ ConversationItemModel::indexForTarget(const nodegraph::NodeRef &target) const { const auto found = targetRows_.find(target.get()); if (found == targetRows_.end()) return {}; - const std::optional modelRow = logicalRow(found->second); + const std::optional modelRow = rowOf(found->second); const Row *candidate = modelRow ? row(*modelRow) : nullptr; return candidate && candidate->card.target == target ? index(*modelRow) : QModelIndex{}; @@ -847,78 +791,106 @@ bool ConversationItemModel::sectionPlacementIsValid( (candidate.turnRoot && candidate.nested)) return false; - bool sectionSeen = false; - bool sectionClosed = false; - bool rootSeen = false; - for (int position = 0; position <= rowCount(); ++position) { - const Row *row = - position == rowIndex - ? &candidate - : this->row(position < rowIndex ? position : position - 1); - if (!row) - continue; - if (row->sectionKey != candidate.sectionKey) { - if (sectionSeen) - sectionClosed = true; - continue; - } - if (sectionClosed) - return false; - if (row->turnRoot) { - if (rootSeen || sectionSeen) - return false; - rootSeen = true; - } - sectionSeen = true; - } - return true; + const RowNode *previous = + rowIndex > 0 ? nodeAt(static_cast(rowIndex - 1)) : nullptr; + const RowNode *next = rowIndex < rowCount() + ? nodeAt(static_cast(rowIndex)) + : nullptr; + const bool previousInSection = + previous && previous->value.sectionKey == candidate.sectionKey; + const bool nextInSection = + next && next->value.sectionKey == candidate.sectionKey; + const auto found = sectionRows_.find(candidate.sectionKey); + if (found != sectionRows_.end() && + !previousInSection && !nextInSection) + return false; + if (candidate.turnRoot) + return (found == sectionRows_.end() || !found->second.root) && + !previousInSection; + return !nextInSection || !next->value.turnRoot; +} + +ConversationItemModel::SectionStructure +ConversationItemModel::sectionStructure(const std::string §ionKey) const { + SectionStructure result; + const auto found = sectionRows_.find(sectionKey); + if (found == sectionRows_.end()) + return result; + if (found->second.first) + result.first = found->second.first->value.stableKey; + if (found->second.last) + result.last = found->second.last->value.stableKey; + if (found->second.root) + result.root = found->second.root->value.stableKey; + return result; } void ConversationItemModel::refreshSectionStructure( - const std::string §ionKey) { + const std::string §ionKey, const SectionStructure &before, + const std::string &changedKey) { if (sectionKey.empty()) return; - int first = -1; - int last = -1; - int root = -1; - for (int rowIndex = 0; rowIndex < rowCount(); ++rowIndex) { - const Row &row = rows_[static_cast(rowIndex)]; - if (row.sectionKey != sectionKey) - continue; - if (first < 0) - first = rowIndex; - last = rowIndex; - if (row.turnRoot) - root = rowIndex; - } - if (first < 0) + const auto found = sectionRows_.find(sectionKey); + if (found == sectionRows_.end()) return; - for (int rowIndex = first; rowIndex <= last; ++rowIndex) { - Row &row = rows_[static_cast(rowIndex)]; + const SectionStructure after = sectionStructure(sectionKey); + std::vector affected; + affected.reserve(7); + const auto retain = [this, &affected](const std::string &key) { + if (key.empty()) + return; + const auto found = stableRows_.find(key); + if (found != stableRows_.end() && + std::ranges::find(affected, found->second) == affected.end()) + affected.push_back(found->second); + }; + retain(before.first); + retain(before.last); + retain(before.root); + retain(after.first); + retain(after.last); + retain(after.root); + retain(changedKey); + + if (before.root != after.root) { + affected.clear(); + RowNode *node = found->second.first; + while (node && node->value.sectionKey == sectionKey) { + affected.push_back(node); + node = nextNode(node); + } + } + + for (RowNode *node : affected) { + incrementProperty("modelSectionStructureRowsTouched"); + const std::optional rowPosition = rowOf(node); + if (!rowPosition || node->value.sectionKey != sectionKey) + continue; + Row ¤t = node->value; QList roles; - const bool firstInTurn = rowIndex == first; - const bool lastInTurn = rowIndex == last; - const bool nested = root >= 0 && rowIndex != root; - if (row.firstInTurn != firstInTurn) { - row.firstInTurn = firstInTurn; + const bool firstInTurn = node == found->second.first; + const bool lastInTurn = node == found->second.last; + const bool nested = found->second.root && node != found->second.root; + if (current.firstInTurn != firstInTurn) { + current.firstInTurn = firstInTurn; roles.push_back(FirstInTurnRole); } - if (row.lastInTurn != lastInTurn) { - row.lastInTurn = lastInTurn; + if (current.lastInTurn != lastInTurn) { + current.lastInTurn = lastInTurn; roles.push_back(LastInTurnRole); } - if (row.nested != nested) { - row.nested = nested; + if (current.nested != nested) { + current.nested = nested; roles.push_back(NestedCardRole); } - if (row.activeTurn && !row.turnRoot) { - row.activeTurn = false; + if (current.activeTurn && !current.turnRoot) { + current.activeTurn = false; roles.push_back(ActiveTurnRole); } if (roles.empty()) continue; - emit dataChanged(index(rowIndex), index(rowIndex), roles); + emit dataChanged(index(*rowPosition), index(*rowPosition), roles); incrementProperty("modelDataChangeCount"); } } @@ -936,29 +908,277 @@ bool ConversationItemModel::isPresented( void ConversationItemModel::rebuildIndexes() { stableRows_.clear(); targetRows_.clear(); - stableRows_.reserve(rows_.size()); - targetRows_.reserve(rows_.size()); - rowBase_ = 0; + sectionRows_.clear(); + stableRows_.reserve(nodeCount(rows_)); + targetRows_.reserve(nodeCount(rows_)); historyActivityCount_ = 0; - for (std::size_t position = 0; position < rows_.size(); ++position) { - Row &row = rows_[position]; - stableRows_.emplace(row.stableKey, position); - if (row.card.target) - targetRows_.emplace(row.card.target.get(), position); - if (row.historyActivity) + std::vector pending; + RowNode *current = rows_.get(); + while (current || !pending.empty()) { + while (current) { + pending.push_back(current); + current = current->left.get(); + } + current = pending.back(); + pending.pop_back(); + stableRows_.emplace(current->value.stableKey, current); + if (current->value.card.target) + targetRows_.emplace(current->value.card.target.get(), current); + if (current->value.historyActivity) ++historyActivityCount_; + addSectionIdentity(current); + current = current->right.get(); } incrementProperty("modelIndexRebuildCount"); } +ConversationItemModel::RowNode * +ConversationItemModel::nodeAt(std::size_t row) const noexcept { + RowNode *current = rows_.get(); + while (current) { + const std::size_t leftCount = nodeCount(current->left); + if (row < leftCount) { + current = current->left.get(); + continue; + } + if (row == leftCount) + return current; + row -= leftCount + 1; + current = current->right.get(); + } + return nullptr; +} + std::optional -ConversationItemModel::logicalRow(std::size_t ordinal) const { - if (ordinal < rowBase_ || ordinal - rowBase_ >= rows_.size()) +ConversationItemModel::rowOf(const RowNode *node) const noexcept { + if (!node) return std::nullopt; - const std::size_t value = ordinal - rowBase_; - if (value > static_cast(std::numeric_limits::max())) + std::size_t result = nodeCount(node->left); + while (node->parent) { + if (node == node->parent->right.get()) + result += nodeCount(node->parent->left) + 1; + node = node->parent; + } + if (node != rows_.get() || + result > static_cast(std::numeric_limits::max())) return std::nullopt; - return static_cast(value); + return static_cast(result); +} + +ConversationItemModel::RowNode * +ConversationItemModel::insertRow(std::size_t row, Row value) { + auto inserted = std::make_unique(std::move(value), nextPriority()); + RowNode *result = inserted.get(); + auto [left, right] = splitRows(std::move(rows_), row); + rows_ = mergeRows(mergeRows(std::move(left), std::move(inserted)), + std::move(right)); + stableRows_.insert_or_assign(result->value.stableKey, result); + if (result->value.card.target) + targetRows_.insert_or_assign(result->value.card.target.get(), result); + if (result->value.historyActivity) + ++historyActivityCount_; + addSectionIdentity(result); + return result; +} + +std::unique_ptr +ConversationItemModel::takeRow(std::size_t row) { + RowNode *candidate = nodeAt(row); + if (!candidate) + return {}; + removeSectionIdentity(candidate); + auto [left, suffix] = splitRows(std::move(rows_), row); + auto [removed, right] = splitRows(std::move(suffix), 1); + rows_ = mergeRows(std::move(left), std::move(right)); + if (!removed) + return {}; + eraseRowIdentity(removed->value); + if (removed->value.historyActivity) + --historyActivityCount_; + removed->parent = nullptr; + return removed; +} + +void ConversationItemModel::clearRows() noexcept { + stableRows_.clear(); + targetRows_.clear(); + sectionRows_.clear(); + historyActivityCount_ = 0; + rows_.reset(); +} + +std::uint64_t ConversationItemModel::nextPriority() noexcept { + priorityState_ ^= priorityState_ >> 12; + priorityState_ ^= priorityState_ << 25; + priorityState_ ^= priorityState_ >> 27; + return priorityState_ * 0x2545f4914f6cdd1dULL; +} + +std::size_t ConversationItemModel::nodeCount( + const std::unique_ptr &node) noexcept { + return node ? node->count : 0; +} + +void ConversationItemModel::updateNode(RowNode *node) noexcept { + if (!node) + return; + node->count = nodeCount(node->left) + 1 + nodeCount(node->right); + if (node->left) + node->left->parent = node; + if (node->right) + node->right->parent = node; +} + +std::pair, + std::unique_ptr> +ConversationItemModel::splitRows(std::unique_ptr root, + std::size_t leftCount) { + if (!root) + return {}; + if (nodeCount(root->left) >= leftCount) { + auto [left, middle] = splitRows(std::move(root->left), leftCount); + root->left = std::move(middle); + updateNode(root.get()); + root->parent = nullptr; + if (left) + left->parent = nullptr; + return {std::move(left), std::move(root)}; + } + const std::size_t remaining = leftCount - nodeCount(root->left) - 1; + auto [middle, right] = splitRows(std::move(root->right), remaining); + root->right = std::move(middle); + updateNode(root.get()); + root->parent = nullptr; + if (right) + right->parent = nullptr; + return {std::move(root), std::move(right)}; +} + +std::unique_ptr +ConversationItemModel::mergeRows(std::unique_ptr left, + std::unique_ptr right) { + if (!left) { + if (right) + right->parent = nullptr; + return right; + } + if (!right) { + left->parent = nullptr; + return left; + } + if (left->priority >= right->priority) { + left->right = mergeRows(std::move(left->right), std::move(right)); + updateNode(left.get()); + left->parent = nullptr; + return left; + } + right->left = mergeRows(std::move(left), std::move(right->left)); + updateNode(right.get()); + right->parent = nullptr; + return right; +} + +ConversationItemModel::RowNode * +ConversationItemModel::previousNode(RowNode *node) noexcept { + if (!node) + return nullptr; + if (node->left) { + node = node->left.get(); + while (node->right) + node = node->right.get(); + return node; + } + while (node->parent && node == node->parent->left.get()) + node = node->parent; + return node->parent; +} + +ConversationItemModel::RowNode * +ConversationItemModel::nextNode(RowNode *node) noexcept { + if (!node) + return nullptr; + if (node->right) { + node = node->right.get(); + while (node->left) + node = node->left.get(); + return node; + } + while (node->parent && node == node->parent->right.get()) + node = node->parent; + return node->parent; +} + +void ConversationItemModel::addSectionIdentity(RowNode *node) { + if (!node || node->value.sectionKey.empty()) + return; + SectionIndex §ion = sectionRows_[node->value.sectionKey]; + const std::optional position = rowOf(node); + if (!section.member) { + section.member = node; + section.first = node; + section.last = node; + } else if (position) { + const std::optional first = rowOf(section.first); + const std::optional last = rowOf(section.last); + if (!first || *position < *first) + section.first = node; + if (!last || *position > *last) + section.last = node; + } + if (node->value.turnRoot) + section.root = node; + ++section.count; +} + +void ConversationItemModel::removeSectionIdentity(RowNode *node) { + if (!node || node->value.sectionKey.empty()) + return; + const auto found = sectionRows_.find(node->value.sectionKey); + if (found == sectionRows_.end()) + return; + SectionIndex §ion = found->second; + RowNode *previous = previousNode(node); + RowNode *next = nextNode(node); + if (section.root == node) + section.root = nullptr; + if (section.first == node) + section.first = + next && next->value.sectionKey == node->value.sectionKey ? next + : nullptr; + if (section.last == node) + section.last = + previous && previous->value.sectionKey == node->value.sectionKey + ? previous + : nullptr; + if (section.count > 0) + --section.count; + if (section.count == 0) { + sectionRows_.erase(found); + return; + } + if (section.member == node) { + RowNode *replacement = previous; + if (!replacement || + replacement->value.sectionKey != node->value.sectionKey) + replacement = next; + section.member = replacement; + } + if (!section.first && section.member) { + section.first = section.member; + while (RowNode *candidate = previousNode(section.first)) { + if (candidate->value.sectionKey != node->value.sectionKey) + break; + section.first = candidate; + } + } + if (!section.last && section.member) { + section.last = section.member; + while (RowNode *candidate = nextNode(section.last)) { + if (candidate->value.sectionKey != node->value.sectionKey) + break; + section.last = candidate; + } + } } void ConversationItemModel::eraseRowIdentity(const Row &row) { @@ -972,7 +1192,10 @@ void ConversationItemModel::incrementProperty(const char *name) { } void ConversationItemModel::updateRow(int rowIndex, Row replacement) { - Row &before = rows_[static_cast(rowIndex)]; + RowNode *node = nodeAt(static_cast(rowIndex)); + if (!node) + return; + Row &before = node->value; QList roles; if (before.card.threadId != replacement.card.threadId) roles.push_back(ThreadIdRole); @@ -1006,7 +1229,36 @@ void ConversationItemModel::updateRow(int rowIndex, Row replacement) { roles.push_back(PresentationRole); roles.push_back(Qt::AccessibleTextRole); } + const std::string oldStableKey = before.stableKey; + const std::string oldSectionKey = before.sectionKey; + const bool oldTurnRoot = before.turnRoot; + const nodegraph::Node *oldTarget = + before.card.target ? before.card.target.get() : nullptr; + const bool oldHistoryActivity = before.historyActivity; + if (oldSectionKey != replacement.sectionKey || + oldTurnRoot != replacement.turnRoot) + removeSectionIdentity(node); before = std::move(replacement); + if (oldSectionKey != before.sectionKey || oldTurnRoot != before.turnRoot) + addSectionIdentity(node); + if (oldStableKey != before.stableKey) { + stableRows_.erase(oldStableKey); + stableRows_.insert_or_assign(before.stableKey, node); + } + const nodegraph::Node *newTarget = + before.card.target ? before.card.target.get() : nullptr; + if (oldTarget != newTarget) { + if (oldTarget) + targetRows_.erase(oldTarget); + if (newTarget) + targetRows_.insert_or_assign(newTarget, node); + } + if (oldHistoryActivity != before.historyActivity) { + if (before.historyActivity) + ++historyActivityCount_; + else + --historyActivityCount_; + } if (roles.empty()) return; emit dataChanged(index(rowIndex), index(rowIndex), roles); diff --git a/src/codex/middle/ConversationItemModel.h b/src/codex/middle/ConversationItemModel.h index 525c4ba..f78de25 100644 --- a/src/codex/middle/ConversationItemModel.h +++ b/src/codex/middle/ConversationItemModel.h @@ -7,10 +7,11 @@ #include -#include +#include #include #include #include +#include #include namespace codexui::codex::middle { @@ -85,6 +86,7 @@ class ConversationItemModel final : public QAbstractListModel { }; explicit ConversationItemModel(QObject *parent = nullptr); + ~ConversationItemModel() override; [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; @@ -135,26 +137,71 @@ class ConversationItemModel final : public QAbstractListModel { [[nodiscard]] bool hasMore() const noexcept { return hasMore_; } private: + struct RowNode { + explicit RowNode(Row value, std::uint64_t priority) + : value(std::move(value)), priority(priority) {} + + Row value; + std::uint64_t priority = 0; + std::size_t count = 1; + std::unique_ptr left; + std::unique_ptr right; + RowNode *parent = nullptr; + }; + + struct SectionIndex { + RowNode *member = nullptr; + RowNode *first = nullptr; + RowNode *last = nullptr; + RowNode *root = nullptr; + std::size_t count = 0; + }; + + struct SectionStructure { + std::string first; + std::string last; + std::string root; + }; + [[nodiscard]] std::vector flatten(ConversationSnapshot &&snapshot) const; [[nodiscard]] bool rowsAreUnique(const std::vector &rows) const; [[nodiscard]] Row rowFromPlacement(ConversationRowPlacement placement) const; [[nodiscard]] bool sectionPlacementIsValid(int row, const Row &candidate) const; - void refreshSectionStructure(const std::string §ionKey); + [[nodiscard]] SectionStructure + sectionStructure(const std::string §ionKey) const; + void refreshSectionStructure(const std::string §ionKey, + const SectionStructure &before, + const std::string &changedKey); [[nodiscard]] bool isPresented(const VisibleCardData &card) const noexcept; void rebuildIndexes(); + [[nodiscard]] RowNode *nodeAt(std::size_t row) const noexcept; + [[nodiscard]] std::optional rowOf(const RowNode *node) const noexcept; + RowNode *insertRow(std::size_t row, Row value); + [[nodiscard]] std::unique_ptr takeRow(std::size_t row); + void clearRows() noexcept; + [[nodiscard]] std::uint64_t nextPriority() noexcept; + static std::size_t nodeCount(const std::unique_ptr &node) noexcept; + static void updateNode(RowNode *node) noexcept; + static std::pair, std::unique_ptr> + splitRows(std::unique_ptr root, std::size_t leftCount); + static std::unique_ptr + mergeRows(std::unique_ptr left, + std::unique_ptr right); + static RowNode *previousNode(RowNode *node) noexcept; + static RowNode *nextNode(RowNode *node) noexcept; + void addSectionIdentity(RowNode *node); + void removeSectionIdentity(RowNode *node); void incrementProperty(const char *name); void updateRow(int row, Row replacement); - [[nodiscard]] std::optional logicalRow(std::size_t ordinal) const; void eraseRowIdentity(const Row &row); - std::deque rows_; - // Absolute ordinals let a bounded front trim avoid rewriting every stable - // identity in the retained suffix. - std::unordered_map stableRows_; - std::unordered_map targetRows_; - std::size_t rowBase_ = 0; + std::unique_ptr rows_; + std::unordered_map stableRows_; + std::unordered_map targetRows_; + std::unordered_map sectionRows_; std::size_t historyActivityCount_ = 0; + std::uint64_t priorityState_ = 0x9e3779b97f4a7c15ULL; std::string threadId_; std::size_t hiddenAuthoritativeItemCount_ = 0; bool hasMore_ = false; diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 28a26ce..46f6c07 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -1060,6 +1060,9 @@ bool ConversationView::applyRowChange(ConversationRowChange change) { } const int sourceRow = source.isValid() ? source.row() : -1; + const std::string oldSection = + sourceRow >= 0 ? model_->row(sourceRow)->sectionKey : std::string{}; + const std::string newSection = change.placement.sectionKey; int destinationRow = -1; if (change.previousCardKey) { const QModelIndex previous = @@ -1115,7 +1118,8 @@ bool ConversationView::applyRowChange(ConversationRowChange change) { if (result == ConversationItemModel::StructuralChangeResult::Unchanged) return true; - finishExactStructureChange(anchor, follow); + finishExactStructureChange(anchor, follow, sourceRow, destinationRow, key, + oldSection, newSection); incrementProperty(this, "targetedStructuralRowChanges"); return true; } @@ -1128,6 +1132,8 @@ bool ConversationView::removeCardTarget(const nodegraph::NodeRef &target) { if (!index.isValid() || !row) return false; const std::string key = row->stableKey; + const std::string oldSection = row->sectionKey; + const int removedRow = index.row(); const Anchor anchor = captureAnchor(); const bool follow = mode_ == Mode::Following; const QScopedValueRollback applying(applying_, true); @@ -1146,14 +1152,216 @@ bool ConversationView::removeCardTarget(const nodegraph::NodeRef &target) { heightCache_.erase(key); cardCollapsedStates_.erase(key); cardInteractionStates_.erase(key); - finishExactStructureChange(anchor, follow); + finishExactStructureChange(anchor, follow, removedRow, -1, key, oldSection, + {}); incrementProperty(this, "targetedStructuralRemovals"); return true; } void ConversationView::finishExactStructureChange(const Anchor &anchor, - bool follow) { - rebuildSectionRanges(); + bool follow, int sourceRow, + int destinationRow, + std::string changedKey, + std::string oldSection, + std::string newSection) { + std::unordered_map previousSections; + for (const std::string *section : {&oldSection, &newSection}) { + if (section->empty() || previousSections.contains(*section)) + continue; + if (const auto found = sectionRanges_.find(*section); + found != sectionRanges_.end()) + previousSections.emplace(*section, found->second); + } + + if (sourceRow < 0) { + const std::array inserted{0}; + heights_.insert(static_cast(destinationRow), inserted); + } else if (destinationRow < 0) { + heights_.remove(static_cast(sourceRow), 1); + } else if (sourceRow != destinationRow) { + heights_.move(static_cast(sourceRow), 1, + static_cast(destinationRow)); + } + + const auto nearSectionRow = [this](const std::string §ion, + int preferred) { + if (preferred >= 0 && preferred < model_->rowCount()) { + const ConversationItemModel::Row *candidate = model_->row(preferred); + if (candidate && candidate->sectionKey == section) + return preferred; + } + for (const int neighbor : {preferred - 1, preferred + 1}) { + if (neighbor < 0 || neighbor >= model_->rowCount()) + continue; + const ConversationItemModel::Row *candidate = model_->row(neighbor); + if (candidate && candidate->sectionKey == section) + return neighbor; + } + return -1; + }; + const auto updateSection = [&](const std::string §ion, + int preferred) { + if (section.empty()) + return; + const auto previous = previousSections.find(section); + SectionRange replacement = previous == previousSections.end() + ? SectionRange{} + : previous->second; + bool rescan = false; + const auto validRow = [this, §ion](const std::string &key, + bool requireRoot) { + const std::optional position = modelSectionRow(key); + const ConversationItemModel::Row *row = + position ? model_->row(*position) : nullptr; + return row && row->sectionKey == section && + (!requireRoot || row->turnRoot); + }; + if (!replacement.root.empty() && !validRow(replacement.root, true)) + replacement.root.clear(); + if (!replacement.first.empty() && + (!validRow(replacement.first, false) || + !rowPresented(*modelSectionRow(replacement.first)))) { + replacement.first.clear(); + rescan = true; + } + if (!replacement.last.empty() && + (!validRow(replacement.last, false) || + !rowPresented(*modelSectionRow(replacement.last)))) { + replacement.last.clear(); + rescan = true; + } + + const QModelIndex changedIndex = model_->indexForStableKey(changedKey); + const ConversationItemModel::Row *changed = + model_->row(changedIndex.row()); + const bool changedInSection = + changedIndex.isValid() && changed && changed->sectionKey == section; + if (changedInSection && changed->turnRoot) + replacement.root = changedKey; + const std::string oldRoot = previous == previousSections.end() + ? std::string{} + : previous->second.root; + if (replacement.root != oldRoot) + rescan = true; + + if (sourceRow >= 0 && destinationRow >= 0 && sourceRow != destinationRow && + previous != previousSections.end() && + (previous->second.first == changedKey || + previous->second.last == changedKey)) + rescan = true; + if (changedInSection && rowPresented(changedIndex.row())) { + const std::optional first = modelSectionRow(replacement.first); + const std::optional last = modelSectionRow(replacement.last); + if (!first || changedIndex.row() < *first) + replacement.first = changedKey; + if (!last || changedIndex.row() > *last) + replacement.last = changedKey; + } else if (previous != previousSections.end() && + (previous->second.first == changedKey || + previous->second.last == changedKey)) { + rescan = true; + } + + if (rescan) { + rebuildSectionRange(section, nearSectionRow(section, preferred)); + return; + } + const std::optional root = modelSectionRow(replacement.root); + const ConversationItemModel::Row *rootRow = + root ? model_->row(*root) : nullptr; + replacement.active = rootRow && rootRow->activeTurn; + if (replacement.first.empty() && replacement.root.empty()) { + sectionRanges_.erase(section); + if (activeSectionKey_ == section) + activeSectionKey_.clear(); + } else { + if (replacement.active) + activeSectionKey_ = section; + else if (activeSectionKey_ == section) + activeSectionKey_.clear(); + sectionRanges_.insert_or_assign(section, std::move(replacement)); + } + incrementProperty(this, "conversationSectionRangeLocalUpdates"); + }; + updateSection(oldSection, std::max(0, sourceRow)); + if (newSection != oldSection) + updateSection(newSection, destinationRow); + + std::unordered_set affectedKeys{std::move(changedKey)}; + bool rootStructureChanged = false; + for (const std::string *section : {&oldSection, &newSection}) { + if (section->empty()) + continue; + const auto before = previousSections.find(*section); + const auto after = sectionRanges_.find(*section); + if (before != previousSections.end()) { + affectedKeys.insert(before->second.first); + affectedKeys.insert(before->second.last); + affectedKeys.insert(before->second.root); + } + if (after != sectionRanges_.end()) { + affectedKeys.insert(after->second.first); + affectedKeys.insert(after->second.last); + affectedKeys.insert(after->second.root); + } + const std::string beforeRoot = before == previousSections.end() + ? std::string{} + : before->second.root; + const std::string afterRoot = after == sectionRanges_.end() + ? std::string{} + : after->second.root; + rootStructureChanged = rootStructureChanged || beforeRoot != afterRoot; + } + + if (rootStructureChanged) { + for (const std::string *section : {&oldSection, &newSection}) { + if (section->empty()) + continue; + const auto range = sectionRanges_.find(*section); + const std::optional member = + range == sectionRanges_.end() + ? std::nullopt + : modelSectionRow(!range->second.first.empty() + ? range->second.first + : range->second.root); + if (!member) + continue; + int first = *member; + while (first > 0 && model_->row(first - 1)->sectionKey == *section) + --first; + for (int rowIndex = first; rowIndex < model_->rowCount(); ++rowIndex) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || row->sectionKey != *section) + break; + affectedKeys.insert(row->stableKey); + } + } + } + + const auto refreshExtent = [this](const std::string &key) { + if (key.empty()) + return; + const QModelIndex index = model_->indexForStableKey(key); + const ConversationItemModel::Row *row = model_->row(index.row()); + if (!index.isValid() || !row) + return; + if (!rowPresented(index.row())) { + static_cast( + heights_.setHeight(static_cast(index.row()), 0)); + return; + } + int cardHeight = estimatedCardHeight(row->card); + if (const auto cached = heightCache_.find(key); + cached != heightCache_.end() && + cached->second.width == rowWidth(*row)) + cardHeight = cached->second.height; + static_cast(heights_.setHeight( + static_cast(index.row()), + std::max(1, cardHeight) + rowSpacing(index.row()))); + }; + for (const std::string &key : affectedKeys) + refreshExtent(key); + std::vector released; for (auto &[key, card] : materializedCards_) { const QModelIndex index = model_->indexForStableKey(key); @@ -1163,6 +1371,9 @@ void ConversationView::finishExactStructureChange(const Anchor &anchor, released.push_back(key); continue; } + if (!affectedKeys.contains(key) && row->sectionKey != oldSection && + row->sectionKey != newSection) + continue; if (card->data() != row->card) { captureCardInteractionState(key, card, false); static_cast(card->applyPresentation(row->card)); @@ -1172,6 +1383,8 @@ void ConversationView::finishExactStructureChange(const Anchor &anchor, const int height = measureCard(card, rowWidth(*row)); heightCache_.insert_or_assign( key, HeightRecord{rowWidth(*row), height}); + static_cast(heights_.setHeight( + static_cast(index.row()), height + rowSpacing(index.row()))); } for (const std::string &key : released) { const auto found = materializedCards_.find(key); @@ -1183,7 +1396,8 @@ void ConversationView::finishExactStructureChange(const Anchor &anchor, } empty_->setVisible(model_->rowCount() == 0); - rebuildHeightIndex(); + setProperty("conversationHeightIndexUpdateSteps", + static_cast(heights_.lastUpdateSteps())); updateScrollRange(); if (follow) setScrollValue(verticalScrollBar()->maximum()); @@ -1316,15 +1530,12 @@ bool ConversationView::appendTailCard(ConversationTailCard tail) { } if (appended->turnRoot) - sectionRootRows_.insert_or_assign(appended->sectionKey, - storedSectionRow(appendedRow)); + sectionRanges_[appended->sectionKey].root = appended->stableKey; if (rowPresented(appendedRow)) { SectionRange &range = sectionRanges_[appended->sectionKey]; - if (range.first < 0) - range.first = storedSectionRow(appendedRow); - range.last = storedSectionRow(appendedRow); - if (appended->turnRoot) - range.root = storedSectionRow(appendedRow); + if (range.first.empty()) + range.first = appended->stableKey; + range.last = appended->stableKey; range.active = range.active || appended->activeTurn; } if (startsActiveSection) @@ -1398,23 +1609,15 @@ bool ConversationView::appendTailCard(ConversationTailCard tail) { heights_.remove(static_cast(trim.row), static_cast(trim.count)); - sectionRowOrigin_ += trim.row == 1 ? 1 : trim.count; const ConversationItemModel::Row *newFirst = model_->row(0); - if (trim.row == 1 && newFirst && newFirst->sectionKey == trim.sectionKey) { - SectionRange &range = sectionRanges_[trim.sectionKey]; - range.first = sectionRowOrigin_; - range.root = sectionRowOrigin_; - sectionRootRows_.insert_or_assign(trim.sectionKey, sectionRowOrigin_); + if (trim.row == 1 && newFirst && + newFirst->sectionKey == trim.sectionKey) { + // The pinned root and the presented section boundaries stay unchanged; + // only one nested prefix extent was removed. } else if (newFirst && newFirst->sectionKey == trim.sectionKey) { - SectionRange &range = sectionRanges_[trim.sectionKey]; - range.first = sectionRowOrigin_; - if (range.root >= 0 && range.root < sectionRowOrigin_) { - range.root = -1; - sectionRootRows_.erase(trim.sectionKey); - } + rebuildSectionRange(trim.sectionKey, 0); } else { sectionRanges_.erase(trim.sectionKey); - sectionRootRows_.erase(trim.sectionKey); if (activeSectionKey_ == trim.sectionKey) activeSectionKey_.clear(); } @@ -1752,40 +1955,34 @@ bool ConversationView::rowPresented(int rowIndex) const { return false; if (!row->nested) return true; - const auto root = sectionRootRows_.find(row->sectionKey); - if (root == sectionRootRows_.end()) + const auto section = sectionRanges_.find(row->sectionKey); + if (section == sectionRanges_.end() || section->second.root.empty()) return true; - const std::optional rootIndex = modelSectionRow(root->second); + const std::optional rootIndex = modelSectionRow(section->second.root); const ConversationItemModel::Row *rootRow = rootIndex ? model_->row(*rootIndex) : nullptr; return !rootRow || !rowCollapsed(*rootRow); } void ConversationView::rebuildSectionRanges() { - sectionRowOrigin_ = 0; activeSectionKey_.clear(); sectionRanges_.clear(); - sectionRootRows_.clear(); sectionRanges_.reserve(static_cast(model_->rowCount())); - sectionRootRows_.reserve(static_cast(model_->rowCount())); for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { const ConversationItemModel::Row *row = model_->row(rowIndex); if (!row) continue; if (row->turnRoot) - sectionRootRows_.insert_or_assign(row->sectionKey, - storedSectionRow(rowIndex)); + sectionRanges_[row->sectionKey].root = row->stableKey; } for (int rowIndex = 0; rowIndex < model_->rowCount(); ++rowIndex) { const ConversationItemModel::Row *row = model_->row(rowIndex); if (!row || !rowPresented(rowIndex)) continue; SectionRange &range = sectionRanges_[row->sectionKey]; - if (range.first < 0) - range.first = storedSectionRow(rowIndex); - range.last = storedSectionRow(rowIndex); - if (row->turnRoot) - range.root = storedSectionRow(rowIndex); + if (range.first.empty()) + range.first = row->stableKey; + range.last = row->stableKey; range.active = range.active || (row->turnRoot && row->activeTurn); if (row->turnRoot && row->activeTurn) activeSectionKey_ = row->sectionKey; @@ -1793,6 +1990,72 @@ void ConversationView::rebuildSectionRanges() { incrementProperty(this, "conversationSectionRangeRebuilds"); } +void ConversationView::rebuildSectionRange(const std::string §ionKey, + int nearRow) { + if (sectionKey.empty()) + return; + if (nearRow < 0 || nearRow >= model_->rowCount() || + model_->row(nearRow)->sectionKey != sectionKey) { + const auto retained = sectionRanges_.find(sectionKey); + const std::optional retainedRow = + retained == sectionRanges_.end() + ? std::nullopt + : modelSectionRow(!retained->second.first.empty() + ? retained->second.first + : retained->second.root); + if (!retainedRow) { + sectionRanges_.erase(sectionKey); + if (activeSectionKey_ == sectionKey) + activeSectionKey_.clear(); + return; + } + nearRow = *retainedRow; + } + + int first = nearRow; + while (first > 0) { + const ConversationItemModel::Row *candidate = model_->row(first - 1); + if (!candidate || candidate->sectionKey != sectionKey) + break; + --first; + } + SectionRange replacement; + int end = first; + for (; end < model_->rowCount(); ++end) { + const ConversationItemModel::Row *candidate = model_->row(end); + if (!candidate || candidate->sectionKey != sectionKey) + break; + if (candidate->turnRoot) + replacement.root = candidate->stableKey; + } + const std::optional rootRow = modelSectionRow(replacement.root); + const ConversationItemModel::Row *root = + rootRow ? model_->row(*rootRow) : nullptr; + const bool childrenPresented = !root || !rowCollapsed(*root); + for (int candidateIndex = first; candidateIndex < end; ++candidateIndex) { + const ConversationItemModel::Row *candidate = model_->row(candidateIndex); + if (!candidate->presented || (candidate->nested && !childrenPresented)) + continue; + if (replacement.first.empty()) + replacement.first = candidate->stableKey; + replacement.last = candidate->stableKey; + replacement.active = + replacement.active || (candidate->turnRoot && candidate->activeTurn); + } + if (replacement.first.empty() && replacement.root.empty()) { + sectionRanges_.erase(sectionKey); + if (activeSectionKey_ == sectionKey) + activeSectionKey_.clear(); + } else { + if (replacement.active) + activeSectionKey_ = sectionKey; + else if (activeSectionKey_ == sectionKey) + activeSectionKey_.clear(); + sectionRanges_.insert_or_assign(sectionKey, std::move(replacement)); + } + incrementProperty(this, "conversationSectionRangeLocalUpdates"); +} + void ConversationView::updateSectionRangeForPresentationChange( int rowIndex, bool wasPresented) { const ConversationItemModel::Row *changed = model_->row(rowIndex); @@ -1800,14 +2063,15 @@ void ConversationView::updateSectionRangeForPresentationChange( return; if (rowPresented(rowIndex)) { - const qint64 storedRow = storedSectionRow(rowIndex); SectionRange &range = sectionRanges_[changed->sectionKey]; - if (range.first < 0 || storedRow < range.first) - range.first = storedRow; - if (range.last < 0 || storedRow > range.last) - range.last = storedRow; + const std::optional first = modelSectionRow(range.first); + const std::optional last = modelSectionRow(range.last); + if (!first || rowIndex < *first) + range.first = changed->stableKey; + if (!last || rowIndex > *last) + range.last = changed->stableKey; if (changed->turnRoot) - range.root = storedRow; + range.root = changed->stableKey; range.active = range.active || (changed->turnRoot && changed->activeTurn); return; } @@ -1815,33 +2079,7 @@ void ConversationView::updateSectionRangeForPresentationChange( // Hiding a targeted row is uncommon (global visibility changes use the // structural rebuild path). Recompute only its canonical turn, never the // loaded conversation. - SectionRange replacement; - int first = rowIndex; - while (first > 0) { - const ConversationItemModel::Row *candidate = model_->row(first - 1); - if (!candidate || candidate->sectionKey != changed->sectionKey) - break; - --first; - } - for (int candidateIndex = first; candidateIndex < model_->rowCount(); - ++candidateIndex) { - const ConversationItemModel::Row *candidate = model_->row(candidateIndex); - if (!candidate || candidate->sectionKey != changed->sectionKey) - break; - if (!rowPresented(candidateIndex)) - continue; - if (replacement.first < 0) - replacement.first = storedSectionRow(candidateIndex); - replacement.last = storedSectionRow(candidateIndex); - if (candidate->turnRoot) - replacement.root = storedSectionRow(candidateIndex); - replacement.active = - replacement.active || (candidate->turnRoot && candidate->activeTurn); - } - if (replacement.first < 0) - sectionRanges_.erase(changed->sectionKey); - else - sectionRanges_.insert_or_assign(changed->sectionKey, replacement); + rebuildSectionRange(changed->sectionKey, rowIndex); } int ConversationView::rowSpacing(int rowIndex) const { @@ -1855,25 +2093,25 @@ int ConversationView::rowSpacing(int rowIndex) const { int ConversationView::rowSpacing(int rowIndex, const SectionRange *section) const { - if (!section || section->root < 0 || section->last <= section->root) + if (!section || section->root.empty() || section->last.empty()) + return CardSpacing; + const std::optional root = modelSectionRow(section->root); + const std::optional last = modelSectionRow(section->last); + if (!root || !last || *last <= *root) return CardSpacing; - const qint64 storedRow = storedSectionRow(rowIndex); - if (storedRow == section->root) + if (rowIndex == *root) return 14; - if (storedRow == section->last) + if (rowIndex == *last) return CardSpacing + 10; return CardSpacing; } -qint64 ConversationView::storedSectionRow(int modelRow) const noexcept { - return sectionRowOrigin_ + modelRow; -} - -std::optional ConversationView::modelSectionRow(qint64 storedRow) const { - const qint64 modelRow = storedRow - sectionRowOrigin_; - if (modelRow < 0 || modelRow >= model_->rowCount()) +std::optional +ConversationView::modelSectionRow(const std::string &stableKey) const { + if (stableKey.empty()) return std::nullopt; - return static_cast(modelRow); + const QModelIndex index = model_->indexForStableKey(stableKey); + return index.isValid() ? std::optional(index.row()) : std::nullopt; } void ConversationView::rebuildHeightIndex() { @@ -2078,8 +2316,16 @@ void ConversationView::configureCardForRow( if (!card) return; const auto section = sectionRanges_.find(row.sectionKey); - const bool fragmentedRoot = row.turnRoot && section != sectionRanges_.end() && - section->second.last > section->second.root; + const std::optional root = + section == sectionRanges_.end() + ? std::nullopt + : modelSectionRow(section->second.root); + const std::optional last = + section == sectionRanges_.end() + ? std::nullopt + : modelSectionRow(section->second.last); + const bool fragmentedRoot = + row.turnRoot && root && last && *last > *root; card->setProperty("turnContainer", row.turnRoot); card->setNestedPresentation(row.nested); card->setVirtualTurnRootPresentation(fragmentedRoot); @@ -2338,15 +2584,15 @@ void ConversationView::setCardCollapsed(const std::string &key, break; if (!rowPresented(last)) continue; - if (replacement.first < 0) - replacement.first = storedSectionRow(last); - replacement.last = storedSectionRow(last); + if (replacement.first.empty()) + replacement.first = candidate->stableKey; + replacement.last = candidate->stableKey; if (candidate->turnRoot) - replacement.root = storedSectionRow(last); + replacement.root = candidate->stableKey; replacement.active = replacement.active || (candidate->turnRoot && candidate->activeTurn); } - if (replacement.first < 0) + if (replacement.first.empty() && replacement.root.empty()) sectionRanges_.erase(row->sectionKey); else sectionRanges_.insert_or_assign(row->sectionKey, replacement); @@ -2902,13 +3148,13 @@ void ConversationView::paintEvent(QPaintEvent *event) { !paintedSections.insert(row->sectionKey).second) continue; const auto section = sectionRanges_.find(row->sectionKey); - if (section == sectionRanges_.end() || section->second.root < 0 || - section->second.last <= section->second.root) + if (section == sectionRanges_.end() || + section->second.root.empty() || section->second.last.empty()) continue; const SectionRange &range = section->second; const std::optional root = modelSectionRow(range.root); const std::optional sectionLast = modelSectionRow(range.last); - if (!root || !sectionLast) + if (!root || !sectionLast || *sectionLast <= *root) continue; const qreal top = static_cast(leadingChromeHeight()) + @@ -2940,10 +3186,14 @@ void ConversationView::paintEvent(QPaintEvent *event) { option.initFrom(this); option.rect = rowRect(rowIndex); const auto section = sectionRanges_.find(row->sectionKey); - if (section != sectionRanges_.end() && - section->second.root == storedSectionRow(rowIndex) && - section->second.last > section->second.root) - option.viewItemPosition = QStyleOptionViewItem::Beginning; + if (section != sectionRanges_.end()) { + const std::optional root = + modelSectionRow(section->second.root); + const std::optional last = + modelSectionRow(section->second.last); + if (root && last && *root == rowIndex && *last > *root) + option.viewItemPosition = QStyleOptionViewItem::Beginning; + } if (!option.rect.intersects(event->rect())) continue; if (selectionModel() && diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index ec92544..0e2b436 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -189,9 +189,9 @@ class ConversationView final : public QAbstractItemView { }; struct SectionRange { - qint64 first = -1; - qint64 last = -1; - qint64 root = -1; + std::string first; + std::string last; + std::string root; bool active = false; }; @@ -216,7 +216,11 @@ class ConversationView final : public QAbstractItemView { [[nodiscard]] std::optional applyCardPresentationOwned(VisibleCardData card, nodegraph::NodeRef materializedPrompt = {}); - void finishExactStructureChange(const Anchor &anchor, bool follow); + void finishExactStructureChange(const Anchor &anchor, bool follow, + int sourceRow, int destinationRow, + std::string changedKey, + std::string oldSection, + std::string newSection); [[nodiscard]] bool cardVisible(const VisibleCardData &card) const noexcept; void setThread(const std::string &threadId); void storeCurrentThreadState(); @@ -230,6 +234,7 @@ class ConversationView final : public QAbstractItemView { void rebuildHeightIndex(); void rebuildSectionRanges(); + void rebuildSectionRange(const std::string §ionKey, int nearRow); void updateSectionRangeForPresentationChange(int row, bool wasPresented); [[nodiscard]] int estimatedCardHeight(const VisibleCardData &card) const; [[nodiscard]] int rowWidth(const ConversationItemModel::Row &row) const; @@ -239,8 +244,8 @@ class ConversationView final : public QAbstractItemView { [[nodiscard]] bool rowPresented(int row) const; [[nodiscard]] int rowSpacing(int row) const; [[nodiscard]] int rowSpacing(int row, const SectionRange *section) const; - [[nodiscard]] qint64 storedSectionRow(int modelRow) const noexcept; - [[nodiscard]] std::optional modelSectionRow(qint64 storedRow) const; + [[nodiscard]] std::optional + modelSectionRow(const std::string &stableKey) const; [[nodiscard]] QRect rowRect(int row) const; [[nodiscard]] int measureCard(ConversationCard *card, int width) const; [[nodiscard]] bool updateMeasuredHeight(int row, int cardHeight, @@ -301,8 +306,6 @@ class ConversationView final : public QAbstractItemView { std::unordered_map stagedHeights_; std::unordered_map heightCache_; std::unordered_map sectionRanges_; - std::unordered_map sectionRootRows_; - qint64 sectionRowOrigin_ = 0; std::string activeSectionKey_; std::unordered_map cardInteractionStates_; std::unordered_map cardCollapsedStates_; diff --git a/tests/codex/ConversationItemModelTest.cpp b/tests/codex/ConversationItemModelTest.cpp index 97987d8..8731f04 100644 --- a/tests/codex/ConversationItemModelTest.cpp +++ b/tests/codex/ConversationItemModelTest.cpp @@ -200,8 +200,10 @@ bool testStableIdentityAndExactSignals() { result &= require( model.indexForTarget(second).row() == 1 && model.indexForStableKey("item:6:thread4:turn14:same-wire-id-b") - .row() == 1, - "stable and exact target indexes were not rebuilt"); + .row() == 1 && + model.property("modelIndexRebuildCount").toULongLong() == + indexRebuilds, + "exact structure changes rebuilt or lost stable identity indexes"); log.clear(); result &= require(model.replaceConversation(snapshot({}, "replacement")) && @@ -296,6 +298,31 @@ bool testVisibilityAndLargeModelRemainDataOnly() { log.clear(); result &= require(!model.setVisibility({false, false}) && log.changed.empty(), "identical visibility emitted work"); + VisibleCardData inserted; + inserted.key = AuthoritativeItemKey{"large", "large-turn", "inserted"}; + inserted.kind = CardKind::AgentMessage; + inserted.threadId = "large"; + inserted.turnId = "large-turn"; + inserted.itemId = "inserted"; + inserted.payload = AgentMessageData{"inserted", true}; + ConversationRowPlacement insertedPlacement; + insertedPlacement.card = std::move(inserted); + insertedPlacement.sectionKey = "large-section"; + insertedPlacement.nested = true; + const qulonglong indexRebuilds = + model.property("modelIndexRebuildCount").toULongLong(); + const qulonglong sectionRows = + model.property("modelSectionStructureRowsTouched").toULongLong(); + result &= require( + model.insertCard(Count / 2, std::move(insertedPlacement)) == + ConversationItemModel::StructuralChangeResult::Changed && + model.rowCount() == Count + 1 && + model.property("modelIndexRebuildCount").toULongLong() == + indexRebuilds && + model.property("modelSectionStructureRowsTouched").toULongLong() - + sectionRows <= + 7, + "a middle insert traversed the ten-thousand-row Turn section"); return result; } @@ -355,6 +382,7 @@ bool testBoundedTailAppendKeepsAbsoluteIdentityIndexes() { const ConversationItemModel::HistoryTrim trim = model.trimHistoryTo(Count); result &= require( trim.row == 1 && trim.count == 1 && trim.hiddenIncrement == 1 && + model.historyActivityCount() == Count && log.inserted.size() == 1 && log.inserted.front().first == Count + 1 && log.removed.size() == 1 && log.removed.front().first == 1 && log.removed.front().last == 1, @@ -389,7 +417,7 @@ bool testHeightIndexIsBoundedAndExact() { std::upper_bound(prefix.begin(), prefix.end(), y) - prefix.begin() - 1); if (!require(index.rowAt(y) == std::min(expected, Count - 1), "position-to-row lookup returned the wrong row") || - !require(index.lastLookupSteps() <= 15, + !require(index.lastLookupSteps() <= 64, "position-to-row lookup exceeded logarithmic steps")) { result = false; break; @@ -401,7 +429,7 @@ bool testHeightIndexIsBoundedAndExact() { result &= require(index.setHeight(5000, heights[5000] + 91) && index.totalHeight() == totalBefore + 91 && index.rebuildCount() == rebuilds && - index.lastUpdateSteps() <= 15, + index.lastUpdateSteps() <= 64, "one height update was not exact and logarithmic"); const std::vector appended{41, 42}; index.insert(index.size(), appended); @@ -412,15 +440,20 @@ bool testHeightIndexIsBoundedAndExact() { const std::vector inserted{77}; index.insert(3, inserted); - result &= - require(index.height(3) == 77 && index.rebuildCount() == rebuilds + 1, - "non-tail insertion did not rebuild exact prefix state"); + result &= require(index.height(3) == 77 && + index.rebuildCount() == rebuilds && + index.lastUpdateSteps() <= 128, + "non-tail insertion was not exact and bounded"); index.move(3, 1, 8); - result &= require(index.height(8) == 77, - "height movement did not retain the moved extent"); + result &= require(index.height(8) == 77 && + index.rebuildCount() == rebuilds && + index.lastUpdateSteps() <= 256, + "height movement did not retain a bounded moved extent"); index.remove(8, 1); - result &= require(index.size() == Count + 2, - "height removal did not restore the expected row count"); + result &= require(index.size() == Count + 2 && + index.rebuildCount() == rebuilds && + index.lastUpdateSteps() <= 128, + "height removal was not exact and bounded"); ConversationHeightIndex prefixIndex; const std::vector prefixHeights{50, 20, 30, 40}; diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 7f7c201..5fa4fc9 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -224,7 +224,7 @@ bool exactStructuralRowsPreserveTheViewport() { nodegraph::NodeRef insertedTarget; { auto write = graph.write(); - for (int row = 0; row < 24; ++row) + for (int row = 0; row < 10'000; ++row) targets.push_back(write.upsert( {nodegraph::NodeKind::Item, "exact-row-" + std::to_string(row)})); insertedTarget = @@ -246,7 +246,7 @@ bool exactStructuralRowsPreserveTheViewport() { settle(); const auto anchorBeforeInsert = firstVisible(view); - VisibleCardData inserted = message(1000, "Inserted in the middle"); + VisibleCardData inserted = message(10'001, "Inserted in the middle"); inserted.target = insertedTarget; ConversationRowChange insertion; insertion.placement = @@ -255,6 +255,14 @@ bool exactStructuralRowsPreserveTheViewport() { insertion.nextCardKey = message(8).key; const qulonglong resetsBefore = view.conversationModel()->property("modelResetCount").toULongLong(); + const qulonglong identityRebuildsBefore = view.conversationModel() + ->property( + "modelIndexRebuildCount") + .toULongLong(); + const qulonglong sectionRebuildsBefore = + view.property("conversationSectionRangeRebuilds").toULongLong(); + const qulonglong heightRebuildsBefore = + view.property("conversationHeightIndexRebuilds").toULongLong(); result &= expect(view.applyRowChange(std::move(insertion)) && view.conversationModel() ->indexForTarget(insertedTarget) @@ -281,8 +289,9 @@ bool exactStructuralRowsPreserveTheViewport() { "an exact row move preserves the viewport anchor"); const auto anchorBeforeRemoval = firstVisible(view); - result &= expect( - view.removeCardTarget(targets.front()) && + const bool removed = view.removeCardTarget(targets.front()); + const bool exactBounded = + removed && !view.conversationModel()->indexForTarget(targets.front()).isValid() && firstVisible(view) == anchorBeforeRemoval && view.conversationModel()->property("modelResetCount").toULongLong() == @@ -296,9 +305,18 @@ bool exactStructuralRowsPreserveTheViewport() { view.conversationModel() ->property("modelExactRemoveCount") .toULongLong() == 1 && - view.materializedCardCount() <= 48, + view.conversationModel() + ->property("modelIndexRebuildCount") + .toULongLong() == identityRebuildsBefore && + view.property("conversationSectionRangeRebuilds").toULongLong() == + sectionRebuildsBefore && + view.property("conversationHeightIndexRebuilds").toULongLong() == + heightRebuildsBefore && + view.materializedCardCount() <= 48; + result &= expect( + exactBounded, "exact structural operations use narrow model signals and bounded " - "widgets without a model reset"); + "widgets without rebuilding ten thousand retained indexes"); return result; } From b2de44ab0dedb825274b271e25bf5e06524f96c2 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 13:25:09 +0200 Subject: [PATCH 21/39] Bound conversation presentation work per frame --- docs/qt-virtualized-conversation-view.md | 16 +- docs/two-thread-shared-node-graph.md | 9 +- docs/ui-ux-internal-api.md | 21 ++- src/codex/ShellWidget.cpp | 48 +++++- src/codex/middle/ConversationView.cpp | 125 ++++++++++++++- .../codex/ConversationVirtualizationTest.cpp | 12 +- tests/codex/ShellIntegrationTest.cpp | 151 ++++++++++++++++++ 7 files changed, 362 insertions(+), 20 deletions(-) diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md index 5385844..b90b49a 100644 --- a/docs/qt-virtualized-conversation-view.md +++ b/docs/qt-virtualized-conversation-view.md @@ -97,8 +97,10 @@ The code follows the existing problem boundaries directly: survive materialization changes. 10. Selection and Load 80 prepare the new model/geometry and initial visible materialization behind the existing stable surface, then reveal one complete - frame. Ordinary streaming is coalesced within one GUI frame and affects only - the addressed row. + frame. Ordinary streaming identities are coalesced for one GUI frame, then + projected from latest NodeGraph state at no more than eight distinct rows + per 16 ms presentation pass. Remaining identities retain their order for the + next nonzero-delay pass; an empty queue schedules no further work. No snapshot authority, event journal, projector, callback registry, generic observer, message bus, third logic thread, or alternate transport is introduced. @@ -207,6 +209,16 @@ correlate visible behavior with work: ordinary scrolling and streaming; - targeted structural tail appends and their model/section rebuild deltas. +The Shell exposes the ordinary-row presentation budget, rows processed in the +last pass, maximum rows observed in one pass, deferred-pass count, and pane +commit count. A 24-target deterministic burst requires at least three passes, +reaches every latest graph value, creates no card QWidget, leaves ThreadPane, +Inspector, and shell chrome counters unchanged, and becomes timer-idle after +the queue drains. Exact structural changes above a paused stable anchor issue +no viewport repaint; visible insert/remove damage begins at the changed row, +and visible moves repaint only their affected interval unless they cross the +anchor, where only the changed side below the anchor is invalidated. + ## Final ownership and lifecycle `ConversationView` owns one `ConversationItemModel`, one diff --git a/docs/two-thread-shared-node-graph.md b/docs/two-thread-shared-node-graph.md index df6cfce..6dfb38e 100644 --- a/docs/two-thread-shared-node-graph.md +++ b/docs/two-thread-shared-node-graph.md @@ -366,7 +366,10 @@ Deleted threads and provider resets reparent affected local prompts to explicit recovery state; reconnection never resends a non-idempotent operation. Conversation row identities remain indexed for the bounded selected history window, while QWidget/editor ownership is limited to visible rich interaction -plus bounded overscan. +plus bounded overscan. Qt coalesces ordinary selected-conversation identities +for a frame and projects at most eight latest row values per 16 ms pass; it +retains ordered remainder identities without another authority and schedules +nothing once that queue drains. Structural retirement remains synchronous. Qualification covers the standalone target/tests, exact source-derived inventory, graph atomicity, non-blocking read contention, removal lifetime, @@ -375,6 +378,10 @@ payloads, worker ownership, scoped identity collisions, realtime append/final semantics, and bounded visible-only rendering. It also includes a Qt heartbeat while 4,096 distinct inbound items plus 4,096 streaming deltas saturate and drain the notification queue. +The shell integration suite additionally queues 24 distinct current-row +changes before presentation, proves the eight-row pass ceiling, observes at +least three GUI passes and exact final values, and verifies that the drained +queue produces no idle commit loop or ThreadPane/Inspector/chrome work. The direct CodexBridge integration test exercises every supported UI wire family rather than only counting method names: hydrate/reload, history paging, diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index b92485d..f79760d 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -596,9 +596,12 @@ implementation and creates all visible child panes on Qt-main. Destruction removes application event filters/notifiers before child teardown. Its `eventFilter(QObject*, QEvent*)` returns the middle region's decision for eligible wheel events and otherwise preserves Qt's normal dispatch. Graph -notifications are frame-coalesced only after worker reduction; removals are -handled synchronously. Shell never waits for graph access and never clears -user input merely because a wake write failed after queue admission. +notifications are frame-coalesced only after worker reduction. An ordered +queue projects at most eight distinct ordinary conversation rows from latest +NodeGraph state per 16 ms GUI pass; any remainder schedules exactly one later +nonzero-delay pass, while authoritative structural changes and removals retain +their exact handling. Shell never waits for graph access and never clears user +input merely because a wake write failed after queue admission. ### DTO identity and value types @@ -744,10 +747,14 @@ derived from current graph state; they never become application authority. 1. Detach removals synchronously. 2. Route only identities relevant to ThreadPane, selected conversation, visible Inspector behavior, and effective chrome. -3. Union streaming identities for one display frame. -4. Project the latest current DTO for each affected surface. -5. Let the old widget compare stable identities and values. Repeating the same - DTO must perform zero presentation work. +3. Union streaming identities in arrival order for one display frame. +4. Project latest current DTOs for no more than eight distinct ordinary + conversation rows in that pass; retain any remainder for one later 16 ms + pass and stop scheduling as soon as the queue is empty. +5. Project each other affected surface only when its explicit dependency was + addressed. +6. Let the receiving view compare stable identities and values. Repeating the + same DTO must perform zero presentation work. ### Load 80 more activities diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index cc5136e..b125738 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -64,6 +65,7 @@ namespace { constexpr auto DraftThreadId = "draft:new-thread"; constexpr int GraphRetryDelayMilliseconds = 8; +constexpr std::size_t ConversationPresentationRowsPerPass = 8; bool containsKind(const nodegraph::GraphChanged &change, std::initializer_list kinds) { @@ -998,6 +1000,9 @@ struct ShellWidget::Impl final { Impl(ShellWidget *owner, FrontendSession &session) : owner(owner), session(session), uiAdapter(session.nodeGraph()), alive(std::make_shared(true)) { + owner->setProperty("conversationPresentationRowsPerPassBudget", + static_cast( + ConversationPresentationRowsPerPass)); buildUi(); connectUi(); const auto token = alive; @@ -1095,7 +1100,7 @@ struct ShellWidget::Impl final { bool pendingThreadPane = false; std::vector pendingThreadRows; bool pendingConversation = false; - std::vector pendingConversationItems; + std::deque pendingConversationItems; bool pendingInspector = false; bool pendingChrome = false; bool draftSelectionScheduled = false; @@ -1671,6 +1676,10 @@ void ShellWidget::Impl::schedulePaneCommit(bool immediate) { } void ShellWidget::Impl::commitPendingPanes() { + owner->setProperty("paneCommitInvocations", + owner->property("paneCommitInvocations").toULongLong() + + 1); + owner->setProperty("conversationPresentationRowsInLastPass", qulonglong{0}); bool retry = false; if (!pendingThreadPane && !pendingThreadRows.empty()) { std::vector rows = std::move(pendingThreadRows); @@ -1709,20 +1718,37 @@ void ShellWidget::Impl::commitPendingPanes() { } } if (!pendingConversation && !pendingConversationItems.empty()) { - std::vector items = std::move(pendingConversationItems); - pendingConversationItems.clear(); + std::size_t appliedRows = 0; bool requiresStructuralReconcile = false; - for (const nodegraph::NodeRef &item : items) { + while (appliedRows < ConversationPresentationRowsPerPass && + !pendingConversationItems.empty()) { + nodegraph::NodeRef item = std::move(pendingConversationItems.front()); + pendingConversationItems.pop_front(); auto card = uiAdapter.card(boundGraphThread, item); if (!card || !middleRegion->conversation().applyCardPresentation( std::move(*card))) { + pendingConversationItems.push_front(std::move(item)); requiresStructuralReconcile = true; break; } + ++appliedRows; } + owner->setProperty("conversationPresentationRowsInLastPass", + static_cast(appliedRows)); + owner->setProperty( + "conversationPresentationRowsProcessed", + owner->property("conversationPresentationRowsProcessed") + .toULongLong() + + static_cast(appliedRows)); + owner->setProperty( + "conversationPresentationMaxRowsPerPass", + std::max(owner->property("conversationPresentationMaxRowsPerPass") + .toULongLong(), + static_cast(appliedRows))); if (requiresStructuralReconcile) { pendingConversation = true; - } else { + } + if (appliedRows != 0) { ++conversationRoutes; owner->setProperty("conversationRoutes", static_cast(conversationRoutes)); @@ -1730,6 +1756,12 @@ void ShellWidget::Impl::commitPendingPanes() { "targetedConversationRoutes", owner->property("targetedConversationRoutes").toULongLong() + 1); } + if (!pendingConversation && !pendingConversationItems.empty()) + owner->setProperty( + "conversationPresentationDeferredPasses", + owner->property("conversationPresentationDeferredPasses") + .toULongLong() + + 1); } if (pendingConversation && !pendingConversationItems.empty() && boundGraphThread && @@ -2071,8 +2103,10 @@ void ShellWidget::Impl::handleGraphChanged( } if (conversation.structural) { if (!pendingConversation) { - pendingConversationItems = conversation.items; - } else if (pendingConversationItems != conversation.items) { + pendingConversationItems.assign(conversation.items.begin(), + conversation.items.end()); + } else if (!std::ranges::equal(pendingConversationItems, + conversation.items)) { // More than one structural transaction was coalesced. The complete // projection is the only safe way to establish the combined order. pendingConversationItems.clear(); diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 46f6c07..a6e6f34 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -1287,7 +1287,7 @@ void ConversationView::finishExactStructureChange(const Anchor &anchor, if (newSection != oldSection) updateSection(newSection, destinationRow); - std::unordered_set affectedKeys{std::move(changedKey)}; + std::unordered_set affectedKeys{changedKey}; bool rootStructureChanged = false; for (const std::string *section : {&oldSection, &newSection}) { if (section->empty()) @@ -1409,7 +1409,128 @@ void ConversationView::finishExactStructureChange(const Anchor &anchor, else restoreAnchor(anchor); layoutMaterializedCards(); - viewport()->update(); + + QRect damage; + const QRect viewportBounds = viewport()->rect(); + const auto addPresentedRow = [&](int rowIndex) { + const QRect row = rowRect(rowIndex).intersected(viewportBounds); + if (!row.isEmpty()) + damage = damage.united(row); + }; + for (const std::string &key : affectedKeys) { + const QModelIndex index = model_->indexForStableKey(key); + if (index.isValid()) + addPresentedRow(index.row()); + } + + const auto firstPresentedAtOrAfter = [this](int rowIndex) { + if (heights_.totalHeight() <= 0 || rowIndex >= model_->rowCount()) + return -1; + rowIndex = std::max(0, rowIndex); + const qint64 top = heights_.top(static_cast(rowIndex)); + if (top >= heights_.totalHeight()) + return -1; + const int candidate = static_cast(heights_.rowAt(top)); + return candidate >= rowIndex && rowPresented(candidate) ? candidate : -1; + }; + const auto lastPresentedAtOrBefore = [this](int rowIndex) { + if (heights_.totalHeight() <= 0 || rowIndex < 0) + return -1; + rowIndex = std::min(rowIndex, model_->rowCount() - 1); + const qint64 bottom = heights_.bottom(static_cast(rowIndex)); + if (bottom <= 0) + return -1; + const int candidate = static_cast(heights_.rowAt(bottom - 1)); + return candidate <= rowIndex && rowPresented(candidate) ? candidate : -1; + }; + const auto addInterval = [&](int firstRow, int lastRow) { + const int first = firstPresentedAtOrAfter(firstRow); + const int last = lastPresentedAtOrBefore(lastRow); + if (first >= 0 && last >= first) { + const QRect interval = + rowRect(first).united(rowRect(last)).intersected(viewportBounds); + if (!interval.isEmpty()) + damage = damage.united( + QRect(0, interval.top(), viewport()->width(), interval.height())); + } + }; + const auto addFromRowToBottom = [&](int changedRow) { + const int first = firstPresentedAtOrAfter(changedRow); + if (first >= 0) { + const QRect firstRect = rowRect(first); + if (firstRect.bottom() >= viewportBounds.top() && + firstRect.top() <= viewportBounds.bottom()) { + const int top = std::max(viewportBounds.top(), firstRect.top()); + damage = damage.united( + QRect(0, top, viewport()->width(), + viewportBounds.bottom() - top + 1)); + } + } + }; + + const QModelIndex anchorIndex = + model_->indexForStableKey(anchor.stableKey); + const int finalAnchorRow = anchorIndex.isValid() ? anchorIndex.row() : -1; + const bool changedAnchor = !anchor.stableKey.empty() && + anchor.stableKey == changedKey; + if (changedAnchor) { + damage = damage.united(viewportBounds); + } else if (sourceRow >= 0 && destinationRow >= 0 && + sourceRow != destinationRow) { + int oldAnchorRow = finalAnchorRow; + if (finalAnchorRow >= 0 && sourceRow < destinationRow && + finalAnchorRow >= sourceRow && finalAnchorRow < destinationRow) { + ++oldAnchorRow; + } else if (finalAnchorRow >= 0 && sourceRow > destinationRow && + finalAnchorRow > destinationRow && + finalAnchorRow <= sourceRow) { + --oldAnchorRow; + } + const bool sourceAboveAnchor = + oldAnchorRow >= 0 && sourceRow < oldAnchorRow; + const bool destinationAboveAnchor = + finalAnchorRow >= 0 && destinationRow < finalAnchorRow; + if (sourceAboveAnchor && destinationAboveAnchor) { + // Restoring the stable anchor compensates the complete moved interval. + } else if (sourceAboveAnchor) { + addFromRowToBottom(destinationRow); + } else if (destinationAboveAnchor) { + addFromRowToBottom(sourceRow); + } else { + addInterval(std::min(sourceRow, destinationRow), + std::max(sourceRow, destinationRow)); + } + } else if (sourceRow < 0) { + if (finalAnchorRow < 0 || destinationRow >= finalAnchorRow) + addFromRowToBottom(destinationRow); + } else { + const bool removedAboveAnchor = + finalAnchorRow >= 0 && sourceRow <= finalAnchorRow; + if (!removedAboveAnchor) + addFromRowToBottom(sourceRow); + if (!removedAboveAnchor && + firstPresentedAtOrAfter(sourceRow) < 0) { + const qint64 contentBottom = static_cast(leadingChromeHeight()) + + heights_.totalHeight() - + verticalScrollBar()->value(); + if (contentBottom >= viewportBounds.top() && + contentBottom <= viewportBounds.bottom()) { + const int top = static_cast(contentBottom); + damage = damage.united( + QRect(0, top, viewport()->width(), + viewportBounds.bottom() - top + 1)); + } + } + } + damage = damage.intersected(viewportBounds); + if (!damage.isEmpty()) { + viewport()->update(damage); + incrementProperty(this, "targetedStructuralRepaints"); + setProperty("lastTargetedStructuralRepaintHeight", damage.height()); + } else { + incrementProperty(this, "targetedStructuralOffscreenRepaintsAvoided"); + setProperty("lastTargetedStructuralRepaintHeight", 0); + } incrementProperty(this, "graphRefreshPasses"); updateMaterializationProperties(); storeCurrentThreadState(); diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 5fa4fc9..f80b09b 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -263,6 +263,11 @@ bool exactStructuralRowsPreserveTheViewport() { view.property("conversationSectionRangeRebuilds").toULongLong(); const qulonglong heightRebuildsBefore = view.property("conversationHeightIndexRebuilds").toULongLong(); + const qulonglong structuralRepaintsBefore = + view.property("targetedStructuralRepaints").toULongLong(); + const qulonglong offscreenRepaintsBefore = + view.property("targetedStructuralOffscreenRepaintsAvoided") + .toULongLong(); result &= expect(view.applyRowChange(std::move(insertion)) && view.conversationModel() ->indexForTarget(insertedTarget) @@ -312,11 +317,16 @@ bool exactStructuralRowsPreserveTheViewport() { sectionRebuildsBefore && view.property("conversationHeightIndexRebuilds").toULongLong() == heightRebuildsBefore && + view.property("targetedStructuralRepaints").toULongLong() == + structuralRepaintsBefore && + view.property("targetedStructuralOffscreenRepaintsAvoided") + .toULongLong() == + offscreenRepaintsBefore + 3 && view.materializedCardCount() <= 48; result &= expect( exactBounded, "exact structural operations use narrow model signals and bounded " - "widgets without rebuilding ten thousand retained indexes"); + "widgets without rebuilding or repainting ten thousand retained rows"); return result; } diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 7499751..9396432 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -866,6 +866,156 @@ void qtHeartbeatSurvivesLargeInboundTraffic(Configuration &configuration) { "large inbound traffic uses explicit notification coalescing"); } +void conversationPresentationBurstIsFrameBounded( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + NodeGraph &graph = FrontendSessionTestPeer::graph(session); + WorkerLogic worker(graph, channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + constexpr int ItemCount = 24; + makeReady(worker); + applyThread(worker, "bounded-stream-thread", "Bounded stream thread"); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "turn/started", + std::nullopt, + {{"threadId", Value("bounded-stream-thread")}, + {"turn", Value(Value::Object{{"id", Value("bounded-stream-turn")}, + {"status", Value("inProgress")}})}}})); + for (int index = 0; index < ItemCount; ++index) { + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "item/started", + std::nullopt, + {{"threadId", Value("bounded-stream-thread")}, + {"turnId", Value("bounded-stream-turn")}, + {"item", + Value(Value::Object{ + {"id", Value("bounded-stream-item-" + std::to_string(index))}, + {"type", Value("agentMessage")}, + {"text", Value("initial-" + std::to_string(index))}})}}})); + } + markThreadReady(session, worker, "bounded-stream-thread"); + + auto *threadList = + shell.findChild(QStringLiteral("threadList")); + require(spinUntil([&] { + return threadItem(threadList, "bounded-stream-thread") != nullptr; + }), + "the bounded streaming fixture reaches the thread pane"); + require(selectThread(threadList, "bounded-stream-thread"), + "the bounded streaming fixture binds the conversation view"); + auto *conversation = dynamic_cast( + shell.findChild(QStringLiteral("conversationScroll"))); + require(conversation && spinUntil([&] { + return conversation->conversationModel()->rowCount() == ItemCount && + !conversation->structuralStagingActive() && + conversation->viewport()->updatesEnabled(); + }), + "the bounded streaming fixture exposes its complete initial model"); + if (!conversation) + return; + + std::vector items; + items.reserve(ItemCount); + { + auto read = graph.tryRead(); + for (int index = 0; read && index < ItemCount; ++index) { + items.push_back(read->find(scopedItemNodeId( + scopedTurnNodeId("bounded-stream-thread", "bounded-stream-turn"), + "bounded-stream-item-" + std::to_string(index)))); + } + } + require(items.size() == ItemCount && + std::ranges::all_of(items, + [](const NodeRef &item) { return !!item; }), + "the bounded streaming fixture resolves every exact item target"); + if (items.size() != ItemCount || + !std::ranges::all_of(items, [](const NodeRef &item) { return !!item; })) + return; + + // Let selection/staging timers become fully idle before measuring the + // presentation scheduler itself. + spin(80); + const qulonglong threadRoutesBefore = + shell.property("threadPaneRoutes").toULongLong(); + const qulonglong inspectorRoutesBefore = + shell.property("inspectorRoutes").toULongLong(); + const qulonglong shellCommitsBefore = + shell.property("shellRenderCommits").toULongLong(); + const qulonglong constructionsBefore = + conversation->property("conversationCardConstructions").toULongLong(); + shell.setProperty("conversationPresentationRowsProcessed", qulonglong{0}); + shell.setProperty("conversationPresentationMaxRowsPerPass", qulonglong{0}); + shell.setProperty("conversationPresentationDeferredPasses", qulonglong{0}); + + bool admitted = true; + for (int index = 0; index < ItemCount; ++index) { + GraphChange change; + { + auto write = graph.write(); + write.setField(items[static_cast(index)], "text", + Value("final-" + std::to_string(index))); + change = write.finish(); + } + admitted = messageAdmitted(channels.sendGraphChanged(std::move(change))) && + admitted; + } + require(admitted, "every distinct presentation delta enters the Qt queue"); + + const bool finalStatePresented = spinUntil( + [&] { + if (conversation->conversationModel()->rowCount() != ItemCount) + return false; + for (int row = 0; row < ItemCount; ++row) { + const middle::VisibleCardData *card = + conversation->conversationModel()->card(row); + const auto *message = + card ? std::get_if(&card->payload) + : nullptr; + if (!message || message->text != "final-" + std::to_string(row)) + return false; + } + return true; + }, + 2000); + require(finalStatePresented, + "a multi-frame presentation burst reaches every latest graph value"); + + // One already-scheduled timer may have become redundant as the final pass + // emptied the queue. Measure only after that timer has had time to fire. + spin(40); + const qulonglong idleCommits = + shell.property("paneCommitInvocations").toULongLong(); + spin(80); + require( + shell.property("conversationPresentationRowsProcessed").toULongLong() == + ItemCount && + shell.property("conversationPresentationMaxRowsPerPass") + .toULongLong() <= + shell.property("conversationPresentationRowsPerPassBudget") + .toULongLong() && + shell.property("conversationPresentationDeferredPasses") + .toULongLong() >= 2, + "ordinary conversation projection is capped per GUI frame"); + require(shell.property("paneCommitInvocations").toULongLong() == + idleCommits, + "an empty presentation queue schedules no idle pane commits"); + require( + shell.property("threadPaneRoutes").toULongLong() == threadRoutesBefore && + shell.property("inspectorRoutes").toULongLong() == + inspectorRoutesBefore && + shell.property("shellRenderCommits").toULongLong() == + shellCommitsBefore && + conversation->property("conversationCardConstructions") + .toULongLong() == constructionsBefore, + "stream coalescing leaves unrelated panes and QWidget population alone"); +} + void graphBackedShellPreservesDraftsAndPrompts(Configuration &configuration) { FrontendSession session(configuration); ThreadChannels &channels = FrontendSessionTestPeer::channels(session); @@ -3205,6 +3355,7 @@ int main(int argc, char **argv) { removedAffectedOptimisticRetryDoesNotReadReleasedNode(*configuration); typedActionsAreExactOnceAndBounded(*configuration); qtHeartbeatSurvivesLargeInboundTraffic(*configuration); + conversationPresentationBurstIsFrameBounded(*configuration); graphBackedShellPreservesDraftsAndPrompts(*configuration); initialHydrationUsesTheEstablishedBoundedWindow(*configuration); completedLiveAgentAppearsWithoutThreadReselection(*configuration); From 92b13cd00c5124836428e403002bbb0269562096 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 13:51:03 +0200 Subject: [PATCH 22/39] Separate conversation authority and delta paths --- docs/qt-virtualized-conversation-view.md | 31 +++- docs/two-thread-shared-node-graph.md | 5 + docs/ui-ux-internal-api.md | 37 ++-- src/codex/ShellWidget.cpp | 159 +++++++++++++----- src/codex/middle/ConversationItemModel.h | 4 +- src/codex/middle/ConversationView.cpp | 39 ++++- src/codex/middle/ConversationView.h | 21 ++- src/codex/ui/NodeGraphUiAdapter.cpp | 22 +-- src/codex/ui/NodeGraphUiAdapter.h | 27 ++- tests/codex/ConversationItemModelTest.cpp | 13 +- .../codex/ConversationVirtualizationTest.cpp | 10 +- tests/codex/NodeGraphUiAdapterTest.cpp | 8 + tests/codex/ShellIntegrationTest.cpp | 71 ++++++++ 13 files changed, 347 insertions(+), 100 deletions(-) diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md index b90b49a..db64af4 100644 --- a/docs/qt-virtualized-conversation-view.md +++ b/docs/qt-virtualized-conversation-view.md @@ -131,6 +131,8 @@ Model changes have these exact meanings: - absent/present same-thread keys use contiguous remove/insert ranges; - a changed retained card or structural role emits `dataChanged` for that row and the affected roles only; +- the established direct full-snapshot API computes the same precise + insert/remove/move/data differences but is not used by Shell graph routing; - a validated canonical tail uses one `beginInsertRows/endInsertRows`; stable lookup tables point to nodes in a conversation-specific order-statistic row tree, so dropping a prefix or changing the middle does not reindex surviving @@ -241,15 +243,26 @@ verifies that the exact `NodeRef` is the last child of the last canonical Turn and refuses prompt-materialization aliases. `ConversationView::appendTailCard` then changes only the old tail edge, the inserted row, an optional pinned leading owner, and scroll chrome. Coalesced multi-item structure, non-tail -insertion, removal, movement, and aliases deliberately fall back to the full -projection because only that projection can establish their complete order. - -On selection or Load 80, passive rows need no construction. Only initially -visible rich rows are created and measured one per nonzero-delay staging pass -beneath the hidden host. The old complete view or stable loading cover remains -visible until model order, row extents, visible editors, and the restored anchor -are ready for one commit. Ordinary deltas bypass structural staging and resolve -directly to one stable model index. +insertion, removal, movement, and aliases retain the union of exact NodeRefs; +the adapter supplies canonical neighbor identities and the view emits only the +required row operations. Graph-read contention is a distinct retry result and +can never be mistaken for authoritative row removal. The former same-thread +whole-snapshot fallback is absent from Shell routing. The established direct +`ConversationView::reconcile` API remains available to non-Shell consumers and +implements precise Qt row differences rather than an unconditional reset. +Only a proven rejection while applying an already-projected exact row batch +may request an explicit authority-recovery replacement; this path has a +dedicated counter and remains zero through coalesced structural qualification. + +On selection or explicit rescan, the model performs an authority replacement. +Load 80 instead accepts only a same-thread ordered superset and emits precise +history insertions and row-local changes without a reset. In both cases passive +rows need no construction; initially visible rich rows are created and measured +one per nonzero-delay staging pass beneath the hidden host. The old complete +view or stable loading cover remains visible until model order, row extents, +visible editors, and the restored anchor are ready for one commit. Ordinary +deltas bypass structural staging and resolve directly to one stable model +index. ## Delegate and editor boundary diff --git a/docs/two-thread-shared-node-graph.md b/docs/two-thread-shared-node-graph.md index 6dfb38e..7778ec5 100644 --- a/docs/two-thread-shared-node-graph.md +++ b/docs/two-thread-shared-node-graph.md @@ -382,6 +382,11 @@ The shell integration suite additionally queues 24 distinct current-row changes before presentation, proves the eight-row pass ceiling, observes at least three GUI passes and exact final values, and verifies that the drained queue produces no idle commit loop or ThreadPane/Inspector/chrome work. +Two different structural transactions queued before one presentation commit +retain their exact NodeRef union and reach final canonical neighbor order with +only insert/move/data signals. Same-thread model reconciliation is absent; +selection/rescan is an explicit replacement and Load 80 is an explicit staged +ordered-superset insertion. The direct CodexBridge integration test exercises every supported UI wire family rather than only counting method names: hydrate/reload, history paging, diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index f79760d..c628009 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -124,8 +124,9 @@ always means “no coherent value was available now”, never “render empty” prompt `NodeRef` for acknowledgement. - `rowChange(thread, item)` projects one live selected-thread Item, its exact section/root/nesting facts, and the immediate canonical card keys on either - side. It is the non-snapshot input for an exact middle insertion or move; - it retains no ordering state after the read guard is released. + side. It is the non-snapshot input for an exact middle insertion or move and + distinguishes a busy graph read from an authoritative absence; it retains no + ordering state after the read guard is released. - `tailCard(thread, item)` additionally requires that the exact item be the last child of the last canonical Turn and that it not participate in prompt-materialization aliasing. It returns one `ConversationTailCard` with @@ -142,7 +143,7 @@ always means “no coherent value was available now”, never “render empty” | `conversation` | `thread`, positive effective `itemLimit`; returns optional complete snapshot | Pre: the caller has observed `conversationInfo.readyForDisplay`; this primitive projects the graph's current content and does not itself infer temporal hydration completeness. Limit is clamped to at least one. Success preserves canonical order and root ownership. Invalid target/contention returns `nullopt`. | | `card` | exact `thread` and `item`; returns optional card DTO | Success requires the item still be a child of a Turn owned by the exact thread. It never searches by payload IDs. | | `promptMaterialization` | exact `thread` and authoritative `item`; returns optional card DTO | Success requires one live related local prompt owned by the thread with a valid submission ID and awaiting-materialization state. No relation inference or payload-ID search is permitted. | -| `rowChange` | exact `thread` and live `item`; returns optional row-change DTO | Success requires current Turn ownership by the exact thread. Immediate neighbor keys reflect canonical graph order with a materializing local prompt suppressed behind its authoritative row. | +| `rowChange` | exact `thread` and live `item`; returns `ConversationRowProjection` | `graphBusy` requests one nonzero-delay retry and is never interpreted as removal. A present change requires current Turn ownership by the exact thread; an absent change with `graphBusy == false` is authoritative absence. Immediate neighbor keys reflect canonical graph order with a materializing local prompt suppressed behind its authoritative row. | | `tailCard` | exact `thread` and `item`; returns optional tail DTO | Success requires the exact canonical last item of the exact canonical last Turn, usable loaded-count state, and no prompt alias. The DTO is non-authoritative and owns only values needed for one Qt append. | ### `middle::ThreadPane` @@ -221,10 +222,10 @@ structure, visibility, and accessibility values. `moveTarget(ref, destination, placement)` are the exact structural operations. They reject duplicate/stale/ambiguous targets and emit only the matching insert, remove, move, and affected structural-role changes. -- `reconcile(snapshot)` remains a temporary same-thread compatibility fallback - while integration routes are migrated to those explicit operations. - Identical effective input emits no signal and increments no presentation - counter. +- `reconcile(snapshot)` preserves the established direct `ConversationView` + contract with precise ordered row differences. Production Shell graph + routing never uses it as a delta fallback: selection/rescan calls replacement, + paging calls ordered-superset insertion, and live changes carry exact refs. - `updateCard(card)` resolves the stable key once and returns `Missing`, `Incompatible`, `Unchanged`, or `Changed`. Only `Changed` emits row-local `dataChanged` with the affected roles. @@ -302,11 +303,13 @@ released, and QWidget work never occurs while a graph or channel lock is held. gray ring with a 3 px stroke; its 33 ms animation timer exists only while the ring is visible. A superseded thread identity cannot reveal or dismiss the current cover. -- `reconcileStaged(snapshot)` preserves that final-state contract for initial - selection and Load 80. Only rich rows expected in the initial viewport and - bounded overscan are constructed and measured one at a time beneath the - hidden staging host; passive rows need no construction. The old complete - surface or stable loading cover remains visible until one final commit. +- `reconcileStaged(snapshot)` preserves that final-state contract for explicit + selection/rescan replacement. `prependHistoryPageStaged(snapshot)` applies + Load 80 as a same-thread ordered superset without reset or retained-row + movement. Only rich rows expected in the initial viewport and bounded + overscan are constructed and measured one at a time beneath the hidden + staging host; passive rows need no construction. The old complete surface or + stable loading cover remains visible until one final commit. - `applyCardPresentation(card)` is the ordinary exact-row path. An identical value is a no-op. An offscreen update changes model data and cached/indexed facts without constructing, laying out, or painting a QWidget. A visible @@ -326,7 +329,8 @@ released, and QWidget work never occurs while a graph or channel lock is held. path after `NodeGraphUiAdapter::tailCard` validates canonical placement. It emits one insert, performs an optional bounded prefix trim, preserves the stable anchor or existing follow state, and never rebuilds model, section, or - height indexes. `false` requests complete structural reconciliation. + height indexes. `false` keeps the exact NodeRef on the canonical-neighbor row + path; it does not request a snapshot diff. - `historyLimitForThread`, `requestNextHistoryPage`, and `forgetThreadPresentation` own the requested/effective 80-row window and its lifecycle beside that thread's anchor/follow state. Canonical counts are @@ -365,11 +369,12 @@ released, and QWidget work never occurs while a graph or channel lock is held. | `setEmptyMessage` | display `QString` value | Changes only empty-label text; model rows remain. Anchor is preserved. | | `setPresentationOptions` | complete local options | Updates model presentation roles and visible/materialized rows without a graph query. Existing user fold choices win over initial-fold defaults. | | `presentationOptions` | returns value copy | Pure query. | -| `reconcile` | complete snapshot const reference; returns changed bool | Pre: unique section/card stable keys and correct root keys. Post: model order, indexed geometry, bounded editors, delegate surface, and scroll policy match one complete target. False means no effective model change. | +| `reconcile` | complete snapshot const reference; returns changed bool | Explicit immediate authority replacement used by direct consumers/tests. Pre: unique section/card stable keys and correct root keys. Post: model order, indexed geometry, bounded editors, delegate surface, and scroll policy match one complete target. False means no effective model change. | | `beginThreadSelection` | exact selected thread ID | Immediately covers only the message viewport and starts one 500 ms visual-delay timer. Repeating the same pending identity is a no-op; a new identity cancels superseded staging. | -| `reconcileStaged` | owned complete snapshot | Same final-state contract as `reconcile`; only initially visible rich editors are prepared beneath the hidden host in bounded event-loop passes before one atomic reveal. | +| `reconcileStaged` | owned complete snapshot | Explicit different-thread or rescan replacement; only initially visible rich editors are prepared beneath the hidden host in bounded event-loop passes before one atomic reveal. | +| `prependHistoryPageStaged` | owned same-thread ordered superset | Inserts missing history ranges and patches changed retained rows without a model reset or movement, then reveals one complete staged frame. | | `applyCardPresentation` | one exact `VisibleCardData`; returns optional local impact | Wrong thread/key/incompatible kind returns `nullopt`; identical data returns `None`; otherwise only the resolved row, its genuine section-edge geometry, and its visible editor/delegate rectangle may change. | -| `appendTailCard` | one validated `ConversationTailCard`; returns bool | Exact canonical tail updates the view-owned history window, inserts directly, and optionally trims the prefix without retained-history traversal. Wrong thread, duplicate/invalid placement, or active staging returns false for complete reconciliation. | +| `appendTailCard` | one validated `ConversationTailCard`; returns bool | Exact canonical tail updates the view-owned history window, inserts directly, and optionally trims the prefix without retained-history traversal. Wrong thread, duplicate/invalid placement, or active staging returns false so the same NodeRef proceeds through exact neighbor placement. | | `historyLimitForThread`, `requestNextHistoryPage` | thread ID plus current canonical history facts | Update only per-thread presentation-window counters and return the effective projection limit/provider-request decision. | | `forgetThreadPresentation`, `presentedThreadId` | retired thread ID / pure current-frame query | Releases per-thread window/anchor state or reports the complete frame currently owned by the model. | | `conversationModel`, `materializedCardCount` | borrowed model pointer / integer count | Inspection only. The model is non-authoritative and the widget count remains viewport proportional. | diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index b125738..bc31abb 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -52,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -219,25 +220,27 @@ struct ConversationRoute { bool affected = false; bool structural = false; std::vector items; + bool authorityReplacement = false; }; ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, const nodegraph::NodeGraph &graph, const nodegraph::NodeRef &selectedThread) { if (change.rescanRequired) - return {true, true, {}}; + return {true, true, {}, true}; if (!selectedThread) return {}; constexpr std::size_t MaximumFilteredNodes = 64; if (change.affected.size() + change.removed.size() > MaximumFilteredNodes) - return {true, true, {}}; + return {true, true, {}, true}; const std::optional read = graph.tryRead(); if (!read) - return {true, true, {}}; + return {true, true, {}, true}; const std::string &selectedId = selectedThread->id().canonical; ConversationRoute route; + bool selectedStructureChanged = false; const auto addItem = [&](const nodegraph::NodeRef &item) { if (item && std::ranges::find(route.items, item) == route.items.end()) route.items.push_back(item); @@ -247,7 +250,7 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, return; if (node == selectedThread) { if (!read->contains(node)) { - route = {true, true, {}}; + route = {true, true, {}, true}; return; } constexpr std::array Fields{ @@ -259,6 +262,7 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, })) { route.affected = true; route.structural = true; + selectedStructureChanged = true; } return; } @@ -319,7 +323,7 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, } catch (const std::invalid_argument &) { // A queued NodeRef may have been retired by a later graph transaction. // Conservatively refresh rather than risk missing a selected update. - route = {true, true, {}}; + route = {true, true, {}, true}; } }; @@ -329,7 +333,7 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, if (!node) continue; if (node == selectedThread) - return {true, true, {}}; + return {true, true, {}, true}; if (node->id().kind == nodegraph::NodeKind::Item) { route.affected = true; route.structural = true; @@ -341,6 +345,8 @@ ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, route.structural = true; } } + if (selectedStructureChanged && route.items.empty()) + route.authorityReplacement = true; return route; } @@ -1100,6 +1106,9 @@ struct ShellWidget::Impl final { bool pendingThreadPane = false; std::vector pendingThreadRows; bool pendingConversation = false; + bool pendingConversationHistoryPage = false; + bool pendingConversationAuthorityReplacement = false; + bool historyPageAwaitingProvider = false; std::deque pendingConversationItems; bool pendingInspector = false; bool pendingChrome = false; @@ -1401,7 +1410,9 @@ void ShellWidget::Impl::connectUi() { middleRegion->composer().setActions(std::move(composerActions)); middleRegion->conversation().setLoadMoreAction([this] { - if (!boundGraphThread) + if (!boundGraphThread || pendingConversation || + historyPageAwaitingProvider || + middleRegion->conversation().structuralStagingActive()) return; const auto info = uiAdapter.conversationInfo(boundGraphThread); if (!info) { @@ -1413,16 +1424,19 @@ void ShellWidget::Impl::connectUi() { middleRegion->conversation().requestNextHistoryPage( boundGraphThread->id().canonical, info->authoritativeItemCount, info->providerHasMore); - pendingConversation = true; - pendingConversationItems.clear(); - commitPendingPanes(); - if (!request.requestProvider) + if (!request.requestProvider) { + pendingConversation = true; + pendingConversationHistoryPage = true; + pendingConversationAuthorityReplacement = false; + pendingConversationItems.clear(); + commitPendingPanes(); return; + } nodegraph::NodeAction action{boundGraphThread, nodegraph::NodeActionKind::LoadHistory}; - static_cast(sendNodeAction( + historyPageAwaitingProvider = sendNodeAction( std::move(action), - QStringLiteral("History request was not admitted; try again."))); + QStringLiteral("History request was not admitted; try again.")); }); middleRegion->conversation().setPromptMaterializedAction( [this](nodegraph::NodeRef localPrompt) { @@ -1560,6 +1574,9 @@ void ShellWidget::Impl::bindGraphPanes(nodegraph::NodeRef selectedThread) { if (selectedThread && boundGraphThread != selectedThread) middleRegion->conversation().beginThreadSelection( selectedThread->id().canonical); + pendingConversationHistoryPage = false; + pendingConversationAuthorityReplacement = false; + historyPageAwaitingProvider = false; boundGraphThread = std::move(selectedThread); graphPanesBound = true; if (auto threads = uiAdapter.threads(boundGraphThread)) @@ -1571,6 +1588,7 @@ void ShellWidget::Impl::bindGraphPanes(nodegraph::NodeRef selectedThread) { pendingThreadPane = false; pendingThreadRows.clear(); pendingConversation = !conversationReady; + pendingConversationAuthorityReplacement = !conversationReady; pendingConversationItems.clear(); pendingInspector = !inspectorReady; if (pendingConversation || pendingInspector) @@ -1609,7 +1627,10 @@ bool ShellWidget::Impl::refreshConversation() { return false; middleRegion->conversation().setEmptyMessage( QStringLiteral("No materialized activity.")); - middleRegion->conversation().reconcileStaged(std::move(*snapshot)); + if (pendingConversationHistoryPage) + middleRegion->conversation().prependHistoryPageStaged(std::move(*snapshot)); + else + middleRegion->conversation().reconcileStaged(std::move(*snapshot)); return true; } @@ -1681,6 +1702,7 @@ void ShellWidget::Impl::commitPendingPanes() { 1); owner->setProperty("conversationPresentationRowsInLastPass", qulonglong{0}); bool retry = false; + bool conversationReadRetry = false; if (!pendingThreadPane && !pendingThreadRows.empty()) { std::vector rows = std::move(pendingThreadRows); pendingThreadRows.clear(); @@ -1763,7 +1785,8 @@ void ShellWidget::Impl::commitPendingPanes() { .toULongLong() + 1); } - if (pendingConversation && !pendingConversationItems.empty() && + if (pendingConversation && !pendingConversationAuthorityReplacement && + !pendingConversationItems.empty() && boundGraphThread && !middleRegion->conversation().structuralStagingActive()) { std::optional materialization; @@ -1792,6 +1815,7 @@ void ShellWidget::Impl::commitPendingPanes() { .applyPromptMaterialization(std::move(*materialization)) .has_value()) { pendingConversation = false; + pendingConversationAuthorityReplacement = false; pendingConversationItems.clear(); ++conversationRoutes; owner->setProperty("conversationRoutes", @@ -1806,7 +1830,8 @@ void ShellWidget::Impl::commitPendingPanes() { 1); } } - if (pendingConversation && pendingConversationItems.size() == 1 && + if (pendingConversation && !pendingConversationAuthorityReplacement && + pendingConversationItems.size() == 1 && boundGraphThread && !middleRegion->conversation().structuralStagingActive()) { auto tail = uiAdapter.tailCard(boundGraphThread, @@ -1814,6 +1839,7 @@ void ShellWidget::Impl::commitPendingPanes() { if (tail) { if (middleRegion->conversation().appendTailCard(std::move(*tail))) { pendingConversation = false; + pendingConversationAuthorityReplacement = false; pendingConversationItems.clear(); ++conversationRoutes; owner->setProperty("conversationRoutes", @@ -1829,7 +1855,8 @@ void ShellWidget::Impl::commitPendingPanes() { } } } - if (pendingConversation && !pendingConversationItems.empty() && + if (pendingConversation && !pendingConversationAuthorityReplacement && + !pendingConversationItems.empty() && boundGraphThread && !middleRegion->conversation().structuralStagingActive()) { bool exact = true; @@ -1842,12 +1869,24 @@ void ShellWidget::Impl::commitPendingPanes() { // Live projections run first so prompt retirement can transfer the stable // row to its authoritative NodeRef before the removed prompt is examined. for (const nodegraph::NodeRef &item : pendingConversationItems) { - auto change = uiAdapter.rowChange(boundGraphThread, item); - if (!change) { + auto projection = uiAdapter.rowChange(boundGraphThread, item); + if (projection.graphBusy) { + conversationReadRetry = true; + exact = false; + break; + } + if (!projection) { unresolved.push_back(item); continue; } - rowChanges.push_back(std::move(*change)); + rowChanges.push_back(std::move(*projection)); + } + + if (conversationReadRetry) { + retry = true; + owner->setProperty( + "conversationGraphReadRetries", + owner->property("conversationGraphReadRetries").toULongLong() + 1); } std::vector postponedRemovals; @@ -1912,25 +1951,23 @@ void ShellWidget::Impl::commitPendingPanes() { } std::vector orderedRows; orderedRows.reserve(rowChanges.size()); - std::vector emitted(rowChanges.size(), false); - while (exact && orderedRows.size() < rowChanges.size()) { - std::optional ready; - for (std::size_t index = 0; index < rowChanges.size(); ++index) { - if (!emitted[index] && predecessors[index] == 0) { - ready = index; - break; - } - } - if (!ready) { - exact = false; - break; - } - const std::size_t index = *ready; - emitted[index] = true; + std::priority_queue, + std::greater<>> + readyRows; + for (std::size_t index = 0; index < rowChanges.size(); ++index) + if (predecessors[index] == 0) + readyRows.push(index); + while (!readyRows.empty()) { + const std::size_t index = readyRows.top(); + readyRows.pop(); orderedRows.push_back(index); - for (const std::size_t next : following[index]) - --predecessors[next]; + for (const std::size_t next : following[index]) { + if (--predecessors[next] == 0) + readyRows.push(next); + } } + if (orderedRows.size() != rowChanges.size()) + exact = false; for (const std::size_t rowIndex : orderedRows) { if (!exact) @@ -1982,6 +2019,7 @@ void ShellWidget::Impl::commitPendingPanes() { } if (exact) { pendingConversation = false; + pendingConversationAuthorityReplacement = false; pendingConversationItems.clear(); ++conversationRoutes; owner->setProperty("conversationRoutes", @@ -2002,9 +2040,22 @@ void ShellWidget::Impl::commitPendingPanes() { 1); } } - if (pendingConversation) { + if (pendingConversation && + middleRegion->conversation().structuralStagingActive()) { + retry = true; + } else if (pendingConversation && !conversationReadRetry) { + if (!pendingConversationAuthorityReplacement && + !pendingConversationHistoryPage && !pendingConversationItems.empty()) { + owner->setProperty( + "conversationInvariantRecoveryReplacements", + owner->property("conversationInvariantRecoveryReplacements") + .toULongLong() + + 1); + } if (refreshConversation()) { pendingConversation = false; + pendingConversationHistoryPage = false; + pendingConversationAuthorityReplacement = false; pendingConversationItems.clear(); ++conversationRoutes; owner->setProperty("conversationRoutes", @@ -2101,17 +2152,33 @@ void ShellWidget::Impl::handleGraphChanged( pendingThreadRows.end()) pendingThreadRows.push_back(thread); } - if (conversation.structural) { - if (!pendingConversation) { + if (conversation.structural && historyPageAwaitingProvider) { + pendingConversation = true; + pendingConversationHistoryPage = true; + pendingConversationAuthorityReplacement = false; + historyPageAwaitingProvider = false; + pendingConversationItems.clear(); + } else if (conversation.authorityReplacement) { + pendingConversation = true; + pendingConversationHistoryPage = false; + pendingConversationAuthorityReplacement = true; + historyPageAwaitingProvider = false; + pendingConversationItems.clear(); + } else if (conversation.structural) { + if (!conversation.items.empty() && !pendingConversation) { + pendingConversation = true; pendingConversationItems.assign(conversation.items.begin(), conversation.items.end()); - } else if (!std::ranges::equal(pendingConversationItems, - conversation.items)) { - // More than one structural transaction was coalesced. The complete - // projection is the only safe way to establish the combined order. - pendingConversationItems.clear(); + pendingConversationHistoryPage = false; + pendingConversationAuthorityReplacement = false; + } else if (!conversation.items.empty() && + !pendingConversationHistoryPage && + !pendingConversationAuthorityReplacement) { + for (const nodegraph::NodeRef &item : conversation.items) + if (std::ranges::find(pendingConversationItems, item) == + pendingConversationItems.end()) + pendingConversationItems.push_back(item); } - pendingConversation = true; } else if (conversation.affected && !pendingConversation) { for (const nodegraph::NodeRef &item : conversation.items) if (std::ranges::find(pendingConversationItems, item) == diff --git a/src/codex/middle/ConversationItemModel.h b/src/codex/middle/ConversationItemModel.h index f78de25..8dfeaac 100644 --- a/src/codex/middle/ConversationItemModel.h +++ b/src/codex/middle/ConversationItemModel.h @@ -101,8 +101,8 @@ class ConversationItemModel final : public QAbstractListModel { // A history page is a same-thread superset that retains every existing row // in order. It inserts only the missing ranges and updates changed row facts. [[nodiscard]] bool prependHistoryPage(ConversationSnapshot snapshot); - // Compatibility reconciliation remains while integration routes are moved - // to the explicit operations below. + // Preserves the established direct ConversationView API. Shell graph + // routing never uses this whole-snapshot ordered diff as a delta fallback. [[nodiscard]] bool reconcile(ConversationSnapshot snapshot); [[nodiscard]] CardUpdateResult updateCard(VisibleCardData card); [[nodiscard]] StructuralChangeResult diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index a6e6f34..187f745 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -674,7 +674,8 @@ void ConversationView::setThread(const std::string &threadId) { bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { if (!committingStructuralStage_ && pendingStructuralSnapshot_) cancelStructuralStaging(); - return reconcileOwned(ConversationSnapshot(snapshot)); + return reconcileOwned(ConversationSnapshot(snapshot), + SnapshotOperation::OrderedReconciliation); } void ConversationView::beginThreadSelection(const std::string &threadId) { @@ -688,7 +689,8 @@ void ConversationView::beginThreadSelection(const std::string &threadId) { incrementProperty(this, "threadSelectionLoadsStarted"); } -bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { +bool ConversationView::reconcileOwned(ConversationSnapshot snapshot, + SnapshotOperation operation) { const std::string targetThreadId = snapshot.threadId; const bool switchedThread = snapshot.threadId != threadId_; const Anchor currentAnchor = captureAnchor(); @@ -713,7 +715,18 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { viewport()->setUpdatesEnabled(false); stopFollowingAnimation(); - const bool changed = model_->reconcile(std::move(snapshot)); + bool changed = false; + switch (operation) { + case SnapshotOperation::OrderedReconciliation: + changed = model_->reconcile(std::move(snapshot)); + break; + case SnapshotOperation::AuthorityReplacement: + changed = model_->replaceConversation(std::move(snapshot)); + break; + case SnapshotOperation::HistoryPrepend: + changed = model_->prependHistoryPage(std::move(snapshot)); + break; + } if (!targetThreadId.empty()) { HistoryWindow &history = historyWindows_[targetThreadId]; const std::size_t represented = model_->historyActivityCount(); @@ -809,6 +822,21 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot) { } void ConversationView::reconcileStaged(ConversationSnapshot snapshot) { + stageSnapshot(std::move(snapshot), SnapshotOperation::AuthorityReplacement); +} + +void ConversationView::prependHistoryPageStaged( + ConversationSnapshot snapshot) { + stageSnapshot(std::move(snapshot), SnapshotOperation::HistoryPrepend); +} + +void ConversationView::stageSnapshot(ConversationSnapshot snapshot, + SnapshotOperation operation) { + if (operation == SnapshotOperation::HistoryPrepend && + snapshot.threadId != threadId_) { + incrementProperty(this, "invalidHistoryPageStages"); + return; + } if (!loadingThreadId_.empty() && snapshot.threadId != loadingThreadId_) { incrementProperty(this, "staleThreadStagesIgnored"); @@ -819,6 +847,7 @@ void ConversationView::reconcileStaged(ConversationSnapshot snapshot) { else cancelStructuralStaging(); pendingStructuralSnapshot_ = std::move(snapshot); + pendingSnapshotOperation_ = operation; buildPendingLocations(); choosePendingStageRows(); pendingStructuralCardIndex_ = 0; @@ -978,12 +1007,13 @@ void ConversationView::runStructuralStagePass() { } ConversationSnapshot completed = std::move(*pendingStructuralSnapshot_); + const SnapshotOperation operation = pendingSnapshotOperation_; pendingStructuralSnapshot_.reset(); pendingStructuralCardKeys_.clear(); pendingStructuralCardIndex_ = 0; pendingLocations_.clear(); const QScopedValueRollback committing(committingStructuralStage_, true); - static_cast(reconcileOwned(std::move(completed))); + static_cast(reconcileOwned(std::move(completed), operation)); for (auto &[key, card] : stagedCards_) { static_cast(key); delete card; @@ -995,6 +1025,7 @@ void ConversationView::runStructuralStagePass() { void ConversationView::cancelStructuralStaging() { pendingStructuralSnapshot_.reset(); + pendingSnapshotOperation_ = SnapshotOperation::AuthorityReplacement; pendingLocations_.clear(); pendingStructuralCardKeys_.clear(); pendingStructuralCardIndex_ = 0; diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index 0e2b436..6afb57a 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -73,7 +73,11 @@ class ConversationView final : public QAbstractItemView { // selection. A delayed spinner remains presentation-only; the incoming // snapshot still commits through reconcileStaged as one complete frame. void beginThreadSelection(const std::string &threadId); + // Commits a different-thread selection or explicit authority rescan. void reconcileStaged(ConversationSnapshot snapshot); + // Commits a same-thread ordered superset using precise row insertions and + // row-local value changes; retained rows are never reset or moved. + void prependHistoryPageStaged(ConversationSnapshot snapshot); [[nodiscard]] bool structuralStagingActive() const noexcept { return pendingStructuralSnapshot_.has_value(); } @@ -91,8 +95,8 @@ class ConversationView final : public QAbstractItemView { // A missing target is not treated as a structural authority replacement. [[nodiscard]] bool removeCardTarget(const nodegraph::NodeRef &target); // Applies one canonical tail insertion without traversing retained model - // rows. Returns false when the delta is not the exact append shape, so the - // caller can use complete structural reconciliation. + // rows. Returns false when the delta is not the exact append shape; the + // caller then uses exact neighbor placement for that same NodeRef. [[nodiscard]] bool appendTailCard(ConversationTailCard tail); // History-window and staged-presentation state belong to the item view. @@ -212,7 +216,14 @@ class ConversationView final : public QAbstractItemView { std::vector edits; }; - [[nodiscard]] bool reconcileOwned(ConversationSnapshot snapshot); + enum class SnapshotOperation { + OrderedReconciliation, + AuthorityReplacement, + HistoryPrepend, + }; + + [[nodiscard]] bool reconcileOwned(ConversationSnapshot snapshot, + SnapshotOperation operation); [[nodiscard]] std::optional applyCardPresentationOwned(VisibleCardData card, nodegraph::NodeRef materializedPrompt = {}); @@ -280,6 +291,8 @@ class ConversationView final : public QAbstractItemView { void buildPendingLocations(); void choosePendingStageRows(); + void stageSnapshot(ConversationSnapshot snapshot, + SnapshotOperation operation); [[nodiscard]] VisibleCardData *pendingCard(const std::string &key); [[nodiscard]] const PendingLocation * pendingLocation(const std::string &key) const; @@ -319,6 +332,8 @@ class ConversationView final : public QAbstractItemView { QString emptyMessage_; std::string loadingThreadId_; std::optional pendingStructuralSnapshot_; + SnapshotOperation pendingSnapshotOperation_ = + SnapshotOperation::AuthorityReplacement; std::unordered_map pendingLocations_; std::vector pendingStructuralCardKeys_; std::size_t pendingStructuralCardIndex_ = 0; diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp index 9053b85..e005196 100644 --- a/src/codex/ui/NodeGraphUiAdapter.cpp +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -1616,27 +1616,29 @@ std::optional NodeGraphUiAdapter::promptMaterialization( return std::nullopt; } -std::optional +NodeGraphUiAdapter::ConversationRowProjection NodeGraphUiAdapter::rowChange(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const { if (!graph_ || !thread || !item) - return std::nullopt; + return {}; auto read = graph_->tryRead(); - if (!read || !read->contains(thread) || !read->contains(item) || + if (!read) + return {true, std::nullopt}; + if (!read->contains(thread) || !read->contains(item) || read->removed(thread) || read->removed(item) || thread->id().kind != nodegraph::NodeKind::Thread || item->id().kind != nodegraph::NodeKind::Item) - return std::nullopt; + return {}; const nodegraph::NodeRef turn = read->parent(item); if (!turn || turn->id().kind != nodegraph::NodeKind::Turn || read->parent(turn) != thread) - return std::nullopt; + return {}; const auto itemState = read->state(item); const auto turnState = read->state(turn); const auto threadState = read->state(thread); if (!itemState || !turnState || !threadState) - return std::nullopt; + return {}; const std::string threadId = thread->id().canonical; const auto turnId = [&](const nodegraph::NodeRef &owner) { @@ -1699,7 +1701,7 @@ NodeGraphUiAdapter::rowChange(const nodegraph::NodeRef &thread, const std::optional itemKey = projectedKey(item, turn, hiddenInTurn); if (!itemKey) - return std::nullopt; + return {}; std::optional previous; std::optional next; @@ -1712,7 +1714,7 @@ NodeGraphUiAdapter::rowChange(const nodegraph::NodeRef &thread, } } if (itemIndex == itemCount) - return std::nullopt; + return {}; for (std::size_t offset = itemIndex; offset > 0 && !previous; --offset) previous = projectedKey(read->childAt(turn, offset - 1), turn, hiddenInTurn); @@ -1728,7 +1730,7 @@ NodeGraphUiAdapter::rowChange(const nodegraph::NodeRef &thread, } } if (turnIndex == turnCount) - return std::nullopt; + return {}; for (std::size_t offset = turnIndex; offset > 0 && !previous; --offset) { const nodegraph::NodeRef owner = read->childAt(thread, offset - 1); if (!owner || owner->id().kind != nodegraph::NodeKind::Turn) @@ -1777,7 +1779,7 @@ NodeGraphUiAdapter::rowChange(const nodegraph::NodeRef &thread, graphString(graphField(*itemState, "type")) != "localPrompt"; result.previousCardKey = std::move(previous); result.nextCardKey = std::move(next); - return result; + return {false, std::move(result)}; } std::optional diff --git a/src/codex/ui/NodeGraphUiAdapter.h b/src/codex/ui/NodeGraphUiAdapter.h index 52a60c3..c584d14 100644 --- a/src/codex/ui/NodeGraphUiAdapter.h +++ b/src/codex/ui/NodeGraphUiAdapter.h @@ -25,6 +25,29 @@ class NodeGraphUiAdapter final { bool providerHasMore = false; }; + struct ConversationRowProjection { + bool graphBusy = false; + std::optional change; + + [[nodiscard]] explicit operator bool() const noexcept { + return change.has_value(); + } + [[nodiscard]] middle::ConversationRowChange &operator*() noexcept { + return *change; + } + [[nodiscard]] const middle::ConversationRowChange & + operator*() const noexcept { + return *change; + } + [[nodiscard]] middle::ConversationRowChange *operator->() noexcept { + return &*change; + } + [[nodiscard]] const middle::ConversationRowChange * + operator->() const noexcept { + return &*change; + } + }; + explicit NodeGraphUiAdapter(const nodegraph::NodeGraph &graph) noexcept; [[nodiscard]] std::optional @@ -49,13 +72,13 @@ class NodeGraphUiAdapter final { // Projects one live Item together with its immediate canonical row // neighbors. This is the bounded structural adapter for non-tail insertion // and actual movement; it never returns a complete conversation snapshot. - [[nodiscard]] std::optional + [[nodiscard]] ConversationRowProjection rowChange(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const; // Projects only a canonical last item of the selected thread. It is the // bounded structural fast path for ordinary append; any non-tail or prompt - // alias case returns nullopt and uses complete reconciliation instead. + // alias case returns nullopt and proceeds through exact neighbor placement. [[nodiscard]] std::optional tailCard(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item) const; diff --git a/tests/codex/ConversationItemModelTest.cpp b/tests/codex/ConversationItemModelTest.cpp index 8731f04..38ea6d2 100644 --- a/tests/codex/ConversationItemModelTest.cpp +++ b/tests/codex/ConversationItemModelTest.cpp @@ -143,8 +143,9 @@ bool testStableIdentityAndExactSignals() { log.clear(); result &= require( - !model.reconcile(snapshot({card("same-wire-id-a", first, "one"), - card("same-wire-id-b", second, "two")})) && + !model.replaceConversation( + snapshot({card("same-wire-id-a", first, "one"), + card("same-wire-id-b", second, "two")})) && log.resets == 0 && log.inserted.empty() && log.removed.empty() && log.moved.empty() && log.changed.empty(), "identical model state emitted presentation work"); @@ -284,9 +285,9 @@ bool testVisibilityAndLargeModelRemainDataOnly() { } section.rootCardKey = section.cards.front().key; data.sections.push_back(std::move(section)); - bool result = - require(model.reconcile(std::move(data)) && model.rowCount() == Count, - "ten-thousand-row model was not indexed"); + bool result = require( + model.replaceConversation(std::move(data)) && model.rowCount() == Count, + "ten-thousand-row model was not indexed"); SignalLog log(model); result &= require( model.setVisibility({false, false}) && @@ -350,7 +351,7 @@ bool testBoundedTailAppendKeepsAbsoluteIdentityIndexes() { } section.rootCardKey = section.cards.front().key; data.sections.push_back(std::move(section)); - bool result = require(model.reconcile(std::move(data)), + bool result = require(model.replaceConversation(std::move(data)), "tail fixture was not accepted"); SignalLog log(model); const qulonglong rebuilds = diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index f80b09b..d278353 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -534,10 +534,12 @@ bool atomicPagingAndFollowingArrival() { settle(); const int rowsBefore = view.conversationModel()->rowCount(); const int widgetsBefore = view.materializedCardCount(); + const qulonglong resetsBefore = + view.conversationModel()->property("modelResetCount").toULongLong(); ConversationSnapshot loaded = conversation(160); loaded.hasMore = true; - view.reconcileStaged(std::move(loaded)); + view.prependHistoryPageStaged(std::move(loaded)); const bool deferred = view.structuralStagingActive(); result &= expect( deferred ? view.conversationModel()->rowCount() == rowsBefore && @@ -553,8 +555,12 @@ bool atomicPagingAndFollowingArrival() { settle(); result &= expect(!view.structuralStagingActive() && view.conversationModel()->rowCount() == 160 && + view.conversationModel() + ->property("modelResetCount") + .toULongLong() == resetsBefore && view.materializedCardCount() <= 48, - "Load 80 commits one complete virtualized frame"); + "Load 80 commits one complete virtualized frame without a " + "model reset"); ConversationSnapshot appended = conversation(161); view.reconcileStaged(std::move(appended)); diff --git a/tests/codex/NodeGraphUiAdapterTest.cpp b/tests/codex/NodeGraphUiAdapterTest.cpp index c6710c7..ba30c95 100644 --- a/tests/codex/NodeGraphUiAdapterTest.cpp +++ b/tests/codex/NodeGraphUiAdapterTest.cpp @@ -287,6 +287,14 @@ bool projectsExactRowPlacementAndNeighbors() { result &= require(placement && placement->previousCardKey == lastKey && !placement->nextCardKey, "a canonical move did not update the exact row neighbors"); + { + auto write = graph.write(); + const auto busy = adapter.rowChange(thread, moved); + result &= require(busy.graphBusy && !busy, + "row projection distinguishes graph contention from an " + "authoritative removal"); + static_cast(write.finish()); + } return result; } diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 9396432..896d82f 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -1227,6 +1227,9 @@ void initialHydrationUsesTheEstablishedBoundedWindow( loadMore->text() == QStringLiteral("Load 19 more activities"), "the old Load More surface reports only unrepresented retained " "activities after pinning the structural root"); + const qulonglong pagingResetsBefore = + conversation->conversationModel()->property("modelResetCount") + .toULongLong(); if (loadMore) loadMore->click(); const bool pagingDeferred = conversation->structuralStagingActive(); @@ -1243,6 +1246,10 @@ void initialHydrationUsesTheEstablishedBoundedWindow( "Load More exposes all retained graph rows in one complete frame"); require(conversation->materializedCardCount() <= 48, "Load More does not create one QWidget per retained graph row"); + require(conversation->conversationModel() + ->property("modelResetCount") + .toULongLong() == pagingResetsBefore, + "Load More extends the current model without an authority reset"); const std::vector messages = takeQtMessages(channels); require(std::ranges::none_of(messages, [](const QtToWorkerMessage &message) { const auto *action = std::get_if(&message); @@ -2080,6 +2087,9 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( const qulonglong structuralResetsBefore = conversation->conversationModel()->property("modelResetCount") .toULongLong(); + const qulonglong recoveryReplacementsBefore = + shell.property("conversationInvariantRecoveryReplacements") + .toULongLong(); GraphChange middleInsertion; { auto write = graph.write(); @@ -2138,6 +2148,67 @@ void backgroundGraphChangesDoNotRefreshSelectedConversation( .toULongLong() == structuralResetsBefore, "middle insertion and movement use no conversation model reset"); + NodeRef coalescedFirst; + NodeRef coalescedSecond; + GraphChange firstCoalescedInsertion; + GraphChange secondCoalescedInsertion; + { + auto write = graph.write(); + NodeState state; + state.status = NodeStatus::Running; + state.fields = {{"id", Value("coalesced-first")}, + {"type", Value("agentMessage")}, + {"text", Value("Coalesced first")}}; + coalescedFirst = write.upsert({NodeKind::Item, "coalesced-first"}, + std::move(state)); + write.setParent(selectedTurn, coalescedFirst); + write.replaceChildren( + selectedTurn, + std::array{selectedItem, coalescedFirst, + authoritativePrompt, insertedItem}); + firstCoalescedInsertion = write.finish(); + } + { + auto write = graph.write(); + NodeState state; + state.status = NodeStatus::Running; + state.fields = {{"id", Value("coalesced-second")}, + {"type", Value("agentMessage")}, + {"text", Value("Coalesced second")}}; + coalescedSecond = write.upsert({NodeKind::Item, "coalesced-second"}, + std::move(state)); + write.setParent(selectedTurn, coalescedSecond); + write.replaceChildren( + selectedTurn, + std::array{selectedItem, coalescedSecond, coalescedFirst, + authoritativePrompt, insertedItem}); + secondCoalescedInsertion = write.finish(); + } + require(messageAdmitted(channels.sendGraphChanged( + std::move(firstCoalescedInsertion))) && + messageAdmitted(channels.sendGraphChanged( + std::move(secondCoalescedInsertion))), + "two structural transactions queue before one presentation commit"); + require( + spinUntil([&] { + return conversation->conversationModel() + ->indexForTarget(coalescedSecond) + .row() == 1 && + conversation->conversationModel() + ->indexForTarget(coalescedFirst) + .row() == 2; + }) && + conversation->conversationModel() + ->property("modelExactInsertCount") + .toULongLong() == exactInsertsBefore + 3 && + conversation->conversationModel() + ->property("modelResetCount") + .toULongLong() == structuralResetsBefore && + shell.property("conversationInvariantRecoveryReplacements") + .toULongLong() == recoveryReplacementsBefore, + "coalesced structural identities retain exact final order without a " + "snapshot fallback or model reset"); + QPointer removedWidget = selectedCard; const qulonglong exactRemovalsBefore = conversation->conversationModel()->property("modelExactRemoveCount") From 1026d5fb0308b7e05644b94df72c7ab39288f5c7 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 18:28:29 +0200 Subject: [PATCH 23/39] Preserve retained activity anchor during paging --- src/codex/middle/ConversationView.cpp | 35 +++++++- src/codex/middle/ConversationView.h | 1 + .../codex/ConversationVirtualizationTest.cpp | 80 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 187f745..2810073 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -693,7 +693,10 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot, SnapshotOperation operation) { const std::string targetThreadId = snapshot.threadId; const bool switchedThread = snapshot.threadId != threadId_; - const Anchor currentAnchor = captureAnchor(); + const Anchor currentAnchor = + operation == SnapshotOperation::HistoryPrepend + ? captureHistoryPrependAnchor() + : captureAnchor(); setThread(snapshot.threadId); Anchor targetAnchor = currentAnchor; if (switchedThread) { @@ -2887,6 +2890,36 @@ ConversationView::Anchor ConversationView::captureAnchor() const { return anchor; } +ConversationView::Anchor +ConversationView::captureHistoryPrependAnchor() const { + Anchor anchor = captureAnchor(); + if (anchor.stableKey.empty()) + return anchor; + const QModelIndex anchored = model_->indexForStableKey(anchor.stableKey); + if (!anchored.isValid()) + return anchor; + const ConversationItemModel::Row *anchoredRow = model_->row(anchored.row()); + if (!anchoredRow || anchoredRow->historyActivity) + return anchor; + + // A turn root may be displayed solely to own the retained suffix. Loading + // an earlier page can insert newly revealed siblings after that root, not + // before it. In that shape the root is presentation chrome for paging: the + // first retained activity is the stable semantic anchor whose old pixel + // position must survive the insertion. + for (int rowIndex = anchored.row() + 1; rowIndex < model_->rowCount(); + ++rowIndex) { + const ConversationItemModel::Row *row = model_->row(rowIndex); + if (!row || !row->historyActivity || !rowPresented(rowIndex) || + heights_.height(static_cast(rowIndex)) == 0) + continue; + anchor.stableKey = row->stableKey; + anchor.pixelOffset = rowRect(rowIndex).top(); + break; + } + return anchor; +} + void ConversationView::restoreAnchor(const Anchor &anchor) { int value = anchor.absoluteValue; if (!anchor.stableKey.empty()) { diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index 6afb57a..db71695 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -236,6 +236,7 @@ class ConversationView final : public QAbstractItemView { void setThread(const std::string &threadId); void storeCurrentThreadState(); [[nodiscard]] Anchor captureAnchor() const; + [[nodiscard]] Anchor captureHistoryPrependAnchor() const; void restoreAnchor(const Anchor &anchor); void setScrollValue(int value); void stopFollowingAnimation(); diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index d278353..1673e8e 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -57,6 +57,39 @@ ConversationSnapshot conversation(std::size_t count, return result; } +ConversationSnapshot pinnedTurnPage(std::size_t firstActivity, + std::size_t activityCount) { + ConversationSnapshot result; + result.threadId = "virtual-thread"; + TurnSection section; + section.key = "shared-turn-section"; + section.turnId = "shared-turn"; + VisibleCardData root{ + AuthoritativeItemKey{"virtual-thread", "shared-turn", "root"}, + CardKind::UserMessage, + "virtual-thread", + "shared-turn", + "root", + UserMessageData{"Continue"}}; + section.rootCardKey = root.key; + section.rootPinned = firstActivity != 1; + section.cards.push_back(std::move(root)); + for (std::size_t serial = firstActivity; + serial < firstActivity + activityCount; ++serial) { + const std::string suffix = std::to_string(serial); + section.cards.push_back( + {AuthoritativeItemKey{"virtual-thread", "shared-turn", + "item-" + suffix}, + CardKind::AgentMessage, + "virtual-thread", + "shared-turn", + "item-" + suffix, + AgentMessageData{"Answer " + suffix, true}}); + } + result.sections.push_back(std::move(section)); + return result; +} + void settle(int passes = 4) { while (passes-- > 0) QApplication::processEvents(QEventLoop::AllEvents, 20); @@ -536,6 +569,12 @@ bool atomicPagingAndFollowingArrival() { const int widgetsBefore = view.materializedCardCount(); const qulonglong resetsBefore = view.conversationModel()->property("modelResetCount").toULongLong(); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + settle(); + const auto anchorBeforePage = firstVisible(view); + result &= expect(view.mode() == ConversationView::Mode::Paused && + !anchorBeforePage.first.empty(), + "Load 80 begins from an explicit paused viewport anchor"); ConversationSnapshot loaded = conversation(160); loaded.hasMore = true; @@ -561,6 +600,11 @@ bool atomicPagingAndFollowingArrival() { view.materializedCardCount() <= 48, "Load 80 commits one complete virtualized frame without a " "model reset"); + result &= expect(firstVisible(view) == anchorBeforePage, + "Load 80 preserves the exact paused row and pixel offset"); + + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMaximum); + settle(); ConversationSnapshot appended = conversation(161); view.reconcileStaged(std::move(appended)); @@ -576,6 +620,41 @@ bool atomicPagingAndFollowingArrival() { return result; } +bool pinnedTurnPagingPreservesRetainedActivityAnchor() { + ConversationView view; + view.resize(820, 600); + view.show(); + ConversationSnapshot initial = pinnedTurnPage(80, 80); + initial.hasMore = true; + bool result = expect(view.reconcile(initial), + "a bounded page retains its owning turn root"); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + settle(); + const QModelIndex retained = + view.conversationModel()->indexForStableKey(stableKey( + AuthoritativeItemKey{"virtual-thread", "shared-turn", "item-80"})); + const int retainedTop = view.visualRect(retained).top(); + result &= expect(retained.isValid() && retainedTop > 0 && + view.mode() == ConversationView::Mode::Paused, + "the first retained activity establishes a paused paging " + "anchor below its pinned owner"); + + ConversationSnapshot loaded = pinnedTurnPage(1, 159); + view.prependHistoryPageStaged(std::move(loaded)); + result &= expect(waitUntil([&] { return !view.structuralStagingActive(); }, + 5000), + "the pinned-root history page commits"); + settle(); + const QModelIndex retainedAfter = + view.conversationModel()->indexForStableKey(stableKey( + AuthoritativeItemKey{"virtual-thread", "shared-turn", "item-80"})); + result &= expect(retainedAfter.isValid() && + view.visualRect(retainedAfter).top() == retainedTop, + "paging anchors the first retained activity rather than " + "the root pinned outside the old history window"); + return result; +} + bool historyWindowLivesWithThePresentedThread() { ConversationView view; bool result = expect(view.historyLimitForThread("history-a", 200) == 80, @@ -1066,6 +1145,7 @@ int main(int argc, char **argv) { boundedTailAppendIsViewportProportional() && targetedVisibilityChangeIsLocal() && atomicPagingAndFollowingArrival() && + pinnedTurnPagingPreservesRetainedActivityAnchor() && historyWindowLivesWithThePresentedThread() && delayedThreadSelectionSpinner() && virtualTurnSurfaceAndInteractivePromotion() && From 3e37c79c882246d65c0e8276b9da61b2b7bf2cb0 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 18:36:34 +0200 Subject: [PATCH 24/39] Document final conversation view qualification --- docs/qt-virtualized-conversation-view.md | 41 ++++++++++++++---------- docs/two-thread-shared-node-graph.md | 8 +++-- docs/ui-ux-internal-api.md | 23 +++++++------ 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md index db64af4..3f611ae 100644 --- a/docs/qt-virtualized-conversation-view.md +++ b/docs/qt-virtualized-conversation-view.md @@ -331,8 +331,10 @@ The independently reused integrated ASan/UBSan build also passes 19/19 with no sanitizer diagnostic. The supported NodeGraph/typed-queue/worker TSan boundary passes 5/5 with no race report; Qt itself is not run under TSan because the system Qt libraries are not instrumented. `npm run release --prefix web` -passes 83/83 WebUI tests, the 10,000-item profile, the Vite production build, +passes 85/85 WebUI tests, the 10,000-item profile, the Vite production build, Chromium responsive/focus qualification, and relocatable artifact verification. +The current profile reports 45.77 ms hydration, 34.69 ms projection, and +9.40 ms for 2,000 streaming deltas. ## Final performance measurements @@ -341,15 +343,15 @@ and benchmark as the baseline. Values below are medians. | Loaded rows | Initial reveal | Conversation cards | Descendant QWidgets | Peak resident memory | 240-position sweep | Mean sweep position | One bounded tail append | | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| 320 | 16 ms | 0 | 8 | 86,116 KiB | 260.2 ms | 1.08 ms | 1.44 ms | -| 1,280 | 25 ms | 0 | 8 | 85,160 KiB | 278.1 ms | 1.16 ms | 1.60 ms | -| 10,000 | 97 ms | 0 | 8 | 100,308 KiB | 355.9 ms | 1.48 ms | 1.73 ms | +| 320 | 11 ms | 0 | 8 | 78,092 KiB | 313.8 ms | 1.31 ms | 0.45 ms | +| 1,280 | 33 ms | 0 | 8 | 78,500 KiB | 355.6 ms | 1.48 ms | 0.51 ms | +| 10,000 | 259 ms | 0 | 8 | 99,164 KiB | 448.4 ms | 1.87 ms | 0.48 ms | The old 320/1,280-row initial reveal was 733/4,040 ms with 4,209/16,809 descendant widgets and 125,324/281,616 KiB peak RSS. At 1,280 rows the final -initial reveal is approximately 162 times faster and uses approximately 70% +initial reveal is approximately 122 times faster and uses approximately 72% less peak resident memory. Four times the loaded history now changes the -scroll sweep by approximately 6.9%, while widget count remains exactly eight; +scroll sweep by approximately 13.3%, while widget count remains exactly eight; 10,000 passive rows still create zero `ConversationCard` widgets. The original baseline did not record process CPU counters separately, so the directly comparable CPU-time proxy is the single-threaded initial-reveal and scroll-sweep @@ -358,10 +360,10 @@ wall time above rather than a fabricated percentage. The bounded append column measures the complete synchronous view operation, including exact anchor/follow restoration and visible materialization. Every sample reported zero model-index rebuilds and zero section-range rebuilds; the -1.44–1.73 ms spread from 320 through 10,000 rows demonstrates that loaded +0.45–0.51 ms spread from 320 through 10,000 rows demonstrates that loaded history is not traversed. -During a 60-fps live 1,600-line command interval with continuous outer scrolling, +During a 60-fps live 1,200-line command interval with continuous outer scrolling, mean decoded-frame luminance deltas were 2.211289 in Conversation, 0.000012 in ThreadPane, 0.000003 in Inspector, 0 in the shell header, and 0.000689 in the settings/composer region. The tiny non-conversation values are H.264/cursor @@ -376,14 +378,16 @@ alive across all scenarios. Obsolete movies were removed first. Replacement movies and compact contact sheets are under `../../build/codexui-adapter-qualification/capture/qt-virtualized-final/`: -- `initial-very-long-thread.mp4`: atomic selection of a copied 42,911-event +- `initial-very-long-thread.mp4`: atomic selection of a copied 10,023-event read-only local thread fixture; the first changed conversation surface is complete. - `load-80-anchor.mp4`: exact-pixel paused anchor while 80 earlier activities - are inserted; no temporary blank extent is exposed. + are inserted. When a Turn root was retained only as the owner of the old + bounded suffix, the first old activity—not that pinned owner—remains at its + exact pixel offset; no temporary blank extent is exposed. - `heterogeneous-history-scroll.mp4`: repeated sweeps through the long mixed history while editor/widget count remains bounded. -- `streaming-outer-scroll.mp4`: a real 1,600-line command while the outer +- `streaming-outer-scroll.mp4`: a real 1,200-line command while the outer viewport repeatedly leaves and returns to the tail; scrolling remains uninterrupted through running-to-completed transition. - `streaming-command-output-scroll.mp4`: nested command-output selection and @@ -396,21 +400,24 @@ movies and compact contact sheets are under - `selection-fold-focus.mp4`: delegate promotion, real text selection/copy, fold/unfold, and visible Tab/Backtab focus. Clipboard verification returned the exact selected sentence. -- `approval-request.mp4` and `approval-reject.mp4`: exact command approval - details and rejection; the requested probe file was never created. -- `user-input-request.mp4` and `user-input-answer.mp4`: Plan-mode embedded +- `approval-reject.mp4` plus `approval-pending.png` and + `approval-rejected.png`: exact command approval details and rejection; the + requested probe file was never created. +- `user-input-answer.mp4` plus `input-pending.png`, `input-dialog.png`, and + `input-answered.png`: Plan-mode embedded Alpha/Beta request, Review dialog, authored selection, exact submission, and authoritative `Alpha selected.` completion. - `direct-tail-append.mp4`: the final Debug binary was reconnected without restarting the bridge; two follow-tail prompt/final turns were admitted and completed without blank reservation, ending with the exact authoritative - response `SECOND BOUNDED TAIL VERIFIED.` + response `FINAL BOUNDED TAIL VERIFIED.` The movies supplement deterministic geometry and interaction assertions; lossy video alone cannot prove a sub-frame timing bound. No source, remote, GitHub, WebUI behavior, transport, thread, or graph-ownership change was made for the -recording setup. The temporary 168 MiB copied long-thread fixture was deleted -after paging qualification. +recording setup. The temporary copied long-thread fixture, state database, +configuration, second UI, and paging bridge were deleted after qualification; +the primary bridge stayed alive across its scenarios. ## Remaining limitations diff --git a/docs/two-thread-shared-node-graph.md b/docs/two-thread-shared-node-graph.md index 7778ec5..756393f 100644 --- a/docs/two-thread-shared-node-graph.md +++ b/docs/two-thread-shared-node-graph.md @@ -459,7 +459,7 @@ re-executed inside this final sandbox. Their most recent complete passing runs remain the 17/17 native and full browser/Xvfb evidence recorded above; neither listener path nor WebUI source changed in the post-polish commits. -### Qt item-view requalification (2026-09-09) +### Qt item-view requalification (2026-09-10) The retained-card surface has now been replaced without changing the graph, worker, bridge, mailbox, eventfd, or two-thread ownership boundaries. The final @@ -471,12 +471,14 @@ and recording inventory are in `qt-virtualized-conversation-view.md`. The current persistent Debug build passes 19/19 native suites. Integrated ASan/UBSan also passes 19/19 without a diagnostic, and the supported independent NodeGraph/queue/worker TSan boundary passes 5/5 without a race report. WebUI is -unchanged and its full release gate passes 83/83 tests, the profile, production +unchanged and its full release gate passes 85/85 tests, the profile, production build, Chromium qualification, and artifact verification. At 320/1,280/10,000 passive rows, the final Debug benchmark retains exactly eight descendant QWidgets and zero `ConversationCard` instances. Median initial -reveal is 7/14/76 ms and the 240-position sweep is 279.9/282.9/339.9 ms. The +reveal is 11/33/259 ms and the 240-position sweep is 313.8/355.6/448.4 ms. One +bounded tail append remains 0.45/0.51/0.48 ms with zero model-index or +section-range rebuilds. The isolated full application was recorded through atomic long-thread selection, Load 80, manual outer and nested scrolling during long commands, paused steering, selection/copy, folds/focus, approval rejection, and Plan-mode input diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index c628009..bccd27c 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -768,8 +768,11 @@ derived from current graph state; they never become application authority. provider call. 3. Otherwise send one exact `LoadHistory` action only when the provider reports more history. -4. Preserve the stable row and exact pixel anchor while the expanded complete snapshot is reconciled; - never expose reserved empty space followed by delayed cards. +4. Preserve the stable row and exact pixel anchor while the expanded complete + snapshot is reconciled. If the first visible Turn root was pinned solely to + own the old bounded suffix, anchor the first retained activity instead, + because newly revealed siblings are inserted after that owner. +5. Never expose reserved empty space followed by delayed cards. ### Admit and acknowledge a prompt @@ -832,7 +835,7 @@ uncontrolled connection. No remote or GitHub operation is used to maintain this document. -### Qt item-view qualification (2026-09-09) +### Qt item-view qualification (2026-09-10) The final `QAbstractItemView` implementation was exercised through the complete Debug application on isolated Xvfb display `:99`, connected to one @@ -841,15 +844,17 @@ scenario. Obsolete retained-widget recordings were removed before replacement. Current movies and contact sheets are under `../../build/codexui-adapter-qualification/capture/qt-virtualized-final/`. -The recordings prove atomic selection of a copied 42,911-event thread; exact -paused anchoring while Load 80 inserts earlier rows; repeated heterogeneous -history sweeps; outer and nested scrolling during 1,600/1,800-line commands; +The recordings prove atomic selection of a copied 10,023-event thread; exact +paused anchoring while Load 80 inserts earlier rows, including a Turn owner +pinned outside the old activity suffix; repeated heterogeneous history sweeps; +outer and nested scrolling during a 1,200-line command; running-to-completed command transition; normal prompt and steering admission; authoritative steering acknowledgement below a paused viewport; delegate promotion, selection/copy, fold/unfold and visible focus; command approval rejection; and Plan-mode user-input Review, selection, submission, and final -acknowledgement. The temporary copied session was deleted after the paging -recording, and the rejected approval probe created no file. +acknowledgement. The temporary copied session, state database, configuration, +second UI, and paging bridge were deleted after the paging recording, and the +rejected approval probe created no file. During an active-only 60-fps interval with continuous outer scrolling, mean decoded-frame luminance deltas were 2.211289 in Conversation, 0.000012 in @@ -862,7 +867,7 @@ deterministic tests rather than inferred from lossy video. The persistent Debug and integrated ASan/UBSan builds each pass all 19 native suites; ASan/UBSan reports no finding. The supported independent NodeGraph, typed-queue, and worker TSan boundary passes 5/5 without a race report. WebUI is -unchanged and its release gate passes 83/83 tests, performance profiling, +unchanged and its release gate passes 85/85 tests, performance profiling, production bundling, Chromium responsive/focus qualification, and relocatable artifact verification. Full ownership, delegate/editor decisions, benchmark tables, movie names, and remaining limitations are recorded in From 196a73fb1e952130dc5de218f82f65692b30e235 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 20:26:09 +0200 Subject: [PATCH 25/39] Preserve steering position across turn replacement --- src/codex/nodegraph/ProtocolUpdater.cpp | 67 ++++++++++++++ .../codex/ConversationVirtualizationTest.cpp | 75 ++++++++++++++++ tests/codex/nodegraph/ProtocolUpdaterTest.cpp | 88 ++++++++++++++++++- 3 files changed, 229 insertions(+), 1 deletion(-) diff --git a/src/codex/nodegraph/ProtocolUpdater.cpp b/src/codex/nodegraph/ProtocolUpdater.cpp index a078a05..d17ad54 100644 --- a/src/codex/nodegraph/ProtocolUpdater.cpp +++ b/src/codex/nodegraph/ProtocolUpdater.cpp @@ -1118,6 +1118,73 @@ std::vector replaceAuthoritativeChildren( if (write.find(node->id()) == node) write.remove(node); } + + // A steering prompt occupies the position at which the user submitted it, + // even when its provider userMessage arrived after activity caused by that + // prompt. ingestItem establishes that position, but a containing + // thread/read or turns/list replacement applies provider order after all + // items have been ingested. Preserve only the locally submitted rows at + // their existing boundary; all other children still follow provider order. + if (parent && parent->id().kind == NodeKind::Turn) { + const auto retainsSubmittedSlot = [&](const NodeRef &node) { + if (!node || node->id().kind != NodeKind::Item || + write.find(node->id()) != node) + return false; + if (isExplicitLocalOptimistic(write, node) && + canonicalValue(member(write.state(node)->fields, "type")) == + "localPrompt") + return true; + const Value *submission = + member(write.state(node)->fields, "localSubmissionId"); + return submission && (submission->asUInt64() || submission->asInt64()); + }; + + std::unordered_set finalNodes; + finalNodes.reserve(authoritative.size()); + for (const NodeRef &node : authoritative) + if (node && write.find(node->id()) == node) + finalNodes.insert(node.get()); + + std::unordered_set submittedNodes; + submittedNodes.reserve(authoritative.size()); + for (const NodeRef &node : existing) + if (node && finalNodes.contains(node.get()) && + retainsSubmittedSlot(node)) + submittedNodes.insert(node.get()); + + if (!submittedNodes.empty()) { + std::vector leading; + std::unordered_map> after; + const Node *preceding = nullptr; + for (const NodeRef &node : existing) { + if (!node || !finalNodes.contains(node.get())) + continue; + if (!submittedNodes.contains(node.get())) { + preceding = node.get(); + continue; + } + if (preceding) + after[preceding].push_back(node); + else + leading.push_back(node); + } + + std::vector ordered; + ordered.reserve(authoritative.size()); + ordered.insert(ordered.end(), leading.begin(), leading.end()); + for (NodeRef &node : authoritative) { + if (!node || submittedNodes.contains(node.get())) + continue; + const Node *identity = node.get(); + ordered.emplace_back(std::move(node)); + if (const auto positioned = after.find(identity); + positioned != after.end()) + ordered.insert(ordered.end(), positioned->second.begin(), + positioned->second.end()); + } + authoritative = std::move(ordered); + } + } return authoritative; } diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 1673e8e..5a65001 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -942,6 +942,80 @@ bool directTailGrowsTheRetainedTurnSurface() { return result; } +bool acknowledgedSteeringMovesAboveFollowingActivity() { + ConversationSnapshot snapshot = conversation(12); + TurnSection active; + active.key = "steering-section"; + active.turnId = "steering-turn"; + VisibleCardData root{ + AuthoritativeItemKey{"virtual-thread", "steering-turn", "root"}, + CardKind::UserMessage, + "virtual-thread", + "steering-turn", + "root", + UserMessageData{"Opening prompt"}}; + active.rootCardKey = root.key; + active.cards.push_back(std::move(root)); + VisibleCardData steering{LocalPromptKey{812}, + CardKind::UserMessage, + "virtual-thread", + "steering-turn", + "provider-steering", + UserMessageData{"Acknowledged steering"}}; + const std::string steeringKey = stableKey(steering.key); + active.cards.push_back(std::move(steering)); + snapshot.activeTurnId = active.turnId; + snapshot.sections.push_back(std::move(active)); + + ConversationView view; + view.resize(820, 360); + view.show(); + bool result = expect(view.reconcile(std::move(snapshot)), + "acknowledged steering fixture reconciles"); + settle(); + const QModelIndex steeringIndex = + view.conversationModel()->indexForStableKey(steeringKey); + const QRect before = view.visualRect(steeringIndex); + const VisibleCardData *settledSteering = + view.conversationModel()->card(steeringIndex.row()); + result &= expect(steeringIndex.isValid() && settledSteering && + settledSteering->kind == CardKind::UserMessage && + view.mode() == ConversationView::Mode::Following && + before.bottom() <= view.viewport()->height(), + "the settled steering card begins at the followed tail"); + + ConversationTailCard activity; + activity.card = { + AuthoritativeItemKey{"virtual-thread", "steering-turn", "after-steer"}, + CardKind::AgentMessage, + "virtual-thread", + "steering-turn", + "after-steer", + AgentMessageData{"Activity caused by the steering prompt", false}}; + activity.sectionKey = "steering-section"; + activity.nested = true; + activity.activeTurn = true; + activity.historyActivity = true; + const std::string activityKey = stableKey(activity.card.key); + result &= expect(view.appendTailCard(std::move(activity)), + "post-steering activity appends through the bounded path"); + settle(); + + const QRect after = view.visualRect(steeringIndex); + const QModelIndex activityIndex = + view.conversationModel()->indexForStableKey(activityKey); + result &= expect( + view.conversationModel()->indexForStableKey(steeringKey) == + steeringIndex && + after.top() < before.top() && activityIndex.isValid() && + after.bottom() < view.visualRect(activityIndex).top() && + view.visualRect(activityIndex).bottom() <= view.viewport()->height() && + view.mode() == ConversationView::Mode::Following, + "an acknowledged steering card moves upward when following activity " + "arrives instead of remaining pinned to the viewport bottom"); + return result; +} + bool selectionFocusAndOneGesturePromotion() { ConversationView view; view.resize(820, 600); @@ -1150,6 +1224,7 @@ int main(int argc, char **argv) { delayedThreadSelectionSpinner() && virtualTurnSurfaceAndInteractivePromotion() && directTailGrowsTheRetainedTurnSurface() && + acknowledgedSteeringMovesAboveFollowingActivity() && selectionFocusAndOneGesturePromotion() && outsideTextDragDoesNotReenterTheView(); if (result) diff --git a/tests/codex/nodegraph/ProtocolUpdaterTest.cpp b/tests/codex/nodegraph/ProtocolUpdaterTest.cpp index f48514f..f1194e8 100644 --- a/tests/codex/nodegraph/ProtocolUpdaterTest.cpp +++ b/tests/codex/nodegraph/ProtocolUpdaterTest.cpp @@ -1885,6 +1885,7 @@ void steeringMaterializationKeepsTheSubmittedSlot() { NodeGraph graph; ProtocolUpdater updater(graph); NodeRef local; + NodeRef root; NodeRef intervening; { auto write = graph.write(); @@ -1894,12 +1895,13 @@ void steeringMaterializationKeepsTheSubmittedSlot() { scopedTurnNodeId("steering-thread", "steering-turn")); write.setField(turn, "protocolId", Value("steering-turn")); write.setField(turn, "protocolThreadId", Value("steering-thread")); - NodeRef root = write.upsert( + root = write.upsert( scopedItemNodeId(turn->id(), "opening-prompt")); write.setField(root, "protocolId", Value("opening-prompt")); write.setField(root, "type", Value("userMessage")); local = write.upsert({NodeKind::Item, "local-steering"}); write.setField(local, "type", Value("localPrompt")); + write.setField(local, "local", Value(true)); write.setField(local, "submissionId", Value(std::uint64_t{77})); write.setField(local, "clientUserMessageId", Value("steering-client")); write.setField(local, "startsTurn", Value(false)); @@ -1948,6 +1950,90 @@ void steeringMaterializationKeepsTheSubmittedSlot() { authoritativePosition < interveningPosition, "a correlated steering item retains the submitted local slot and " "its stable visual identity ahead of later activity"); + read.reset(); + + Value::Array replacementItems{ + Value(Value::Object{{"id", Value("opening-prompt")}, + {"type", Value("userMessage")}}), + Value(Value::Object{{"id", Value("intervening-activity")}, + {"type", Value("agentMessage")}}), + Value(Value::Object{{"id", Value("replacement-activity")}, + {"type", Value("agentMessage")}}), + Value(Value::Object{{"id", Value("provider-steering")}, + {"type", Value("userMessage")}, + {"clientId", Value("steering-client")}, + {"text", Value("Steer here")}})}; + Value::Object replacementTurn{ + {"id", Value("steering-turn")}, + {"items", Value(std::move(replacementItems))}}; + Value::Object replacementThread{ + {"id", Value("steering-thread")}, + {"turns", Value(Value::Array{Value(std::move(replacementTurn))})}}; + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", + ProtocolRequestId("steering-replacement"), + Value::Object{{"thread", Value(std::move(replacementThread))}}})); + + NodeRef replacementActivity; + { + read = graph.tryRead(); + const NodeRef replacementTurn = + findTurn(*read, "steering-thread", "steering-turn"); + replacementActivity = + findItem(*read, "steering-thread", "steering-turn", + "replacement-activity"); + const auto replacementOrder = read->children(replacementTurn); + require(replacementOrder == + std::vector{root, local, authoritative, intervening, + replacementActivity}, + "a full provider turn replacement cannot move a steering prompt " + "or its authoritative identity behind later activity"); + } + read.reset(); + + { + auto write = graph.write(); + write.remove(local); + static_cast(write.finish()); + } + Value::Array retiredReplacementItems{ + Value(Value::Object{{"id", Value("opening-prompt")}, + {"type", Value("userMessage")}}), + Value(Value::Object{{"id", Value("intervening-activity")}, + {"type", Value("agentMessage")}}), + Value(Value::Object{{"id", Value("replacement-activity")}, + {"type", Value("agentMessage")}}), + Value(Value::Object{{"id", Value("post-retirement-activity")}, + {"type", Value("agentMessage")}}), + Value(Value::Object{{"id", Value("provider-steering")}, + {"type", Value("userMessage")}, + {"text", Value("Steer here")}})}; + Value::Object retiredReplacementTurn{ + {"id", Value("steering-turn")}, + {"items", Value(std::move(retiredReplacementItems))}}; + Value::Object retiredReplacementThread{ + {"id", Value("steering-thread")}, + {"turns", + Value(Value::Array{Value(std::move(retiredReplacementTurn))})}}; + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", + ProtocolRequestId("retired-steering-replacement"), + Value::Object{ + {"thread", Value(std::move(retiredReplacementThread))}}})); + { + read = graph.tryRead(); + const NodeRef replacementTurn = + findTurn(*read, "steering-thread", "steering-turn"); + const NodeRef postRetirement = + findItem(*read, "steering-thread", "steering-turn", + "post-retirement-activity"); + const auto replacementOrder = read->children(replacementTurn); + require(replacementOrder == + std::vector{root, authoritative, intervening, + replacementActivity, postRetirement}, + "retiring the local prompt cannot release its authoritative You " + "card to a later provider-arrival slot"); + } } void turnRootsAndPagedHistoryStayExplicit() { From 9f45ac0a4c0c63a80550c1a7ae62e0d524fe8877 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 21:37:52 +0200 Subject: [PATCH 26/39] Preserve pending prompt projection order --- src/codex/ui/NodeGraphUiAdapter.cpp | 26 ++++++-- tests/codex/NodeGraphUiAdapterTest.cpp | 92 ++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp index e005196..be6ba1a 100644 --- a/src/codex/ui/NodeGraphUiAdapter.cpp +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -1906,6 +1906,17 @@ NodeGraphUiAdapter::conversation(const nodegraph::NodeRef &thread, std::unordered_set boundedAuthoritativeItems; if (retainedAuthoritativeCount) { + const std::vector pendingPrompts = + read->related(thread, nodegraph::RelationKind::PendingPrompt); + std::unordered_set pendingPromptNodes; + pendingPromptNodes.reserve(pendingPrompts.size()); + for (const nodegraph::NodeRef &prompt : pendingPrompts) + if (prompt && read->contains(prompt) && !read->removed(prompt) && + prompt->id().kind == nodegraph::NodeKind::Item) + pendingPromptNodes.insert(prompt.get()); + + std::unordered_set positionedPrompts; + positionedPrompts.reserve(pendingPromptNodes.size()); std::size_t remaining = itemLimit; for (std::size_t turnOffset = turns.size(); turnOffset > 0 && remaining > 0; --turnOffset) { @@ -1918,8 +1929,15 @@ NodeGraphUiAdapter::conversation(const nodegraph::NodeRef &thread, item->id().kind != nodegraph::NodeKind::Item) continue; const auto state = read->state(item); - if (!state || graphString(graphField(*state, "type")) == "localPrompt") + if (!state) + continue; + if (graphString(graphField(*state, "type")) == "localPrompt") { + if (pendingPromptNodes.contains(item.get())) { + input.items.push_back(item); + positionedPrompts.insert(item.get()); + } continue; + } input.items.push_back(item); boundedAuthoritativeItems.insert(item.get()); --remaining; @@ -1930,10 +1948,10 @@ NodeGraphUiAdapter::conversation(const nodegraph::NodeRef &thread, // User-authored optimistic/recovery prompts are explicitly protected from // history paging. The worker maintains this narrow relation, so retaining // them does not require scanning all historical items. - for (const nodegraph::NodeRef &prompt : - read->related(thread, nodegraph::RelationKind::PendingPrompt)) { + for (const nodegraph::NodeRef &prompt : pendingPrompts) { if (!prompt || !read->contains(prompt) || read->removed(prompt) || - prompt->id().kind != nodegraph::NodeKind::Item) + prompt->id().kind != nodegraph::NodeKind::Item || + positionedPrompts.contains(prompt.get())) continue; const nodegraph::NodeRef turn = read->parent(prompt); const auto position = turnPositions.find(turn.get()); diff --git a/tests/codex/NodeGraphUiAdapterTest.cpp b/tests/codex/NodeGraphUiAdapterTest.cpp index ba30c95..6d357cd 100644 --- a/tests/codex/NodeGraphUiAdapterTest.cpp +++ b/tests/codex/NodeGraphUiAdapterTest.cpp @@ -151,6 +151,97 @@ bool limitsHistoryButPinsTheOwningPrompt() { "pinned root does not own the turn"); } +bool boundedOptimisticPromptKeepsItsCanonicalSlot() { + nodegraph::NodeGraph graph; + NodeRef thread; + NodeRef turn; + NodeRef root; + NodeRef local; + NodeRef answer; + { + auto write = graph.write(); + NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{2}); + thread = write.upsert({NodeKind::Thread, "bounded-prompt-thread"}, + std::move(threadState)); + turn = write.upsert({NodeKind::Turn, "bounded-prompt-turn"}); + write.setField(turn, "id", "bounded-prompt-turn"); + root = write.upsert( + {NodeKind::Item, "bounded-prompt-root"}, + itemState("bounded-prompt-root", "userMessage", "Opening prompt")); + NodeState localState = + itemState("bounded-local-steering", "localPrompt", "Steer here"); + localState.fields.emplace("local", true); + localState.fields.emplace("submissionId", std::uint64_t{77}); + localState.fields.emplace("dispatchState", "dispatching"); + localState.fields.emplace("startsTurn", false); + local = write.upsert({NodeKind::Item, "bounded-local-steering"}, + std::move(localState)); + answer = write.upsert( + {NodeKind::Item, "bounded-prompt-answer"}, + itemState("bounded-prompt-answer", "agentMessage", "First answer")); + write.setParent(thread, turn); + write.setParent(turn, root); + write.setParent(turn, local); + write.setParent(turn, answer); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, root); + write.relate(thread, nodegraph::RelationKind::PendingPrompt, local); + static_cast(write.finish()); + } + + NodeGraphUiAdapter adapter(graph); + const auto optimistic = adapter.conversation(thread, 80); + const middle::CardKey rootKey = middle::AuthoritativeItemKey{ + "bounded-prompt-thread", "bounded-prompt-turn", + "bounded-prompt-root"}; + const middle::CardKey steeringKey = middle::LocalPromptKey{77}; + const middle::CardKey answerKey = middle::AuthoritativeItemKey{ + "bounded-prompt-thread", "bounded-prompt-turn", + "bounded-prompt-answer"}; + if (!require(optimistic && optimistic->sections.size() == 1 && + optimistic->sections.front().cards.size() == 3, + "bounded optimistic steering projection was unavailable") || + !require(optimistic->sections.front().cards[0].key == rootKey && + optimistic->sections.front().cards[1].key == steeringKey && + optimistic->sections.front().cards[2].key == answerKey, + "bounded projection moved an optimistic steering prompt out " + "of its canonical sibling slot")) + return false; + + NodeRef authoritative; + { + auto write = graph.write(); + write.setField(local, "dispatchState", "awaitingMaterialization"); + authoritative = write.upsert( + {NodeKind::Item, "bounded-authoritative-steering"}, + itemState("bounded-authoritative-steering", "userMessage", + "Steer here")); + write.setField(authoritative, "localSubmissionId", std::uint64_t{77}); + write.setParent(turn, authoritative); + write.relate(authoritative, + nodegraph::RelationKind::PromptMaterialization, local); + write.replaceChildren( + turn, std::array{root, local, authoritative, answer}); + write.setField(thread, "historyLoadedItemCount", std::uint64_t{3}); + static_cast(write.finish()); + } + + const auto materialized = adapter.conversation(thread, 80); + return require(materialized && materialized->sections.size() == 1 && + materialized->sections.front().cards.size() == 3, + "bounded steering materialization was unavailable") && + require(materialized->sections.front().cards[0].key == rootKey && + materialized->sections.front().cards[1].key == + steeringKey && + materialized->sections.front().cards[2].key == answerKey, + "steering materialization changed the projected row order") && + require(materialized->sections.front().cards[1].kind == + middle::CardKind::UserMessage && + materialized->sections.front().cards[1].target == local, + "steering materialization did not preserve its stable local " + "row while awaiting UI acknowledgement"); +} + bool projectsOnlyTheExactCanonicalTail() { nodegraph::NodeGraph graph; NodeRef thread; @@ -400,6 +491,7 @@ int main() { using namespace codexui::codex::ui; if (!projectsCanonicalTurnStructureAndRoot() || !limitsHistoryButPinsTheOwningPrompt() || + !boundedOptimisticPromptKeepsItsCanonicalSlot() || !projectsOnlyTheExactCanonicalTail() || !projectsExactPromptMaterialization() || !projectsExactRowPlacementAndNeighbors() || From 6f81d99484e9a08539f25847974076fd9dada67c Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 22:51:39 +0200 Subject: [PATCH 27/39] Keep virtual conversation geometry exact --- src/codex/middle/ConversationView.cpp | 73 ++++++---- src/codex/middle/ConversationView.h | 1 + tests/codex/ConversationCardsTest.cpp | 14 +- .../codex/ConversationVirtualizationTest.cpp | 127 ++++++++++++++++++ 4 files changed, 183 insertions(+), 32 deletions(-) diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 2810073..80d81a0 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -147,6 +148,7 @@ class ConversationLoadingOverlay final : public QWidget { namespace { constexpr int CardSpacing = 8; +constexpr int CardFrameExtent = 2; constexpr int HistoryButtonHeight = 32; constexpr int NestedCardIndent = 12; constexpr int NativeScrollLineStep = 20; @@ -210,6 +212,13 @@ struct PassivePresentation { int verticalMargin = 10; }; +QFont passiveBlockFont(bool metadata) { + QFont font = QApplication::font(); + if (metadata) + font.setPointSizeF(std::max(7.0, font.pointSizeF() - 1.0)); + return font; +} + PassivePresentation passivePresentation(const VisibleCardData &card) { PassivePresentation result; std::visit( @@ -302,15 +311,13 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { if (!row) return {}; const PassivePresentation presentation = passivePresentation(row->card); - int height = 24 + 2 * presentation.verticalMargin; + int height = CardFrameExtent + 24 + 2 * presentation.verticalMargin; if (!collapsed) { const int bodyWidth = std::max(1, option.rect.width() - 24); bool first = true; for (std::size_t block = 0; block < presentation.blocks.size(); ++block) { const PassiveBlock &value = presentation.blocks[block]; - QFont font = option.font; - if (value.metadata) - font.setPointSizeF(std::max(7.0, font.pointSizeF() - 1.0)); + const QFont font = passiveBlockFont(value.metadata); height += (first ? 6 : 6) + documentHeight(row->stableKey, block, value, bodyWidth, font); first = false; @@ -343,7 +350,7 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { painter->drawRoundedRect(bounds, 10.0, 10.0); } - QFont titleFont = option.font; + QFont titleFont = QApplication::font(); titleFont.setWeight(QFont::DemiBold); painter->setFont(titleFont); painter->setPen(presentation.titleColor); @@ -351,20 +358,19 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { const QRect titleRect(option.rect.left() + 12, top, std::max(0, option.rect.width() - 88), 24); painter->drawText(titleRect, Qt::AlignLeft | Qt::AlignVCenter, - option.fontMetrics.elidedText(presentation.title, - Qt::ElideRight, - titleRect.width())); + QFontMetrics(titleFont).elidedText( + presentation.title, Qt::ElideRight, + titleRect.width())); if (!presentation.status.isEmpty()) { - QFont statusFont = option.font; - statusFont.setPointSizeF(std::max(7.0, statusFont.pointSizeF() - 1.0)); + const QFont statusFont = QApplication::font(); painter->setFont(statusFont); painter->setPen(QColor(QStringLiteral("#667085"))); const QRect statusRect(option.rect.right() - 205, top, 145, 24); painter->drawText(statusRect, Qt::AlignRight | Qt::AlignVCenter, - option.fontMetrics.elidedText(presentation.status, - Qt::ElideRight, - statusRect.width())); + QFontMetrics(statusFont).elidedText( + presentation.status, Qt::ElideRight, + statusRect.width())); } if (!collapsed) { @@ -372,9 +378,7 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { const int bodyWidth = std::max(1, option.rect.width() - 24); for (std::size_t block = 0; block < presentation.blocks.size(); ++block) { const PassiveBlock &value = presentation.blocks[block]; - QFont font = option.font; - if (value.metadata) - font.setPointSizeF(std::max(7.0, font.pointSizeF() - 1.0)); + const QFont font = passiveBlockFont(value.metadata); const int height = documentHeight(row->stableKey, block, value, bodyWidth, font); paintDocument( @@ -448,6 +452,9 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { record.document->setDefaultFont(font); record.document->setDefaultStyleSheet( QStringLiteral("a{color:#5471a6;text-decoration:none;}")); + QTextOption textOption = record.document->defaultTextOption(); + textOption.setWrapMode(QTextOption::WordWrap); + record.document->setDefaultTextOption(textOption); if (value.markdown) record.document->setMarkdown(value.text, QTextDocument::MarkdownFeatures( @@ -639,7 +646,7 @@ void ConversationView::setPresentationOptions(PresentationOptions options) { setScrollValue(verticalScrollBar()->maximum()); else restoreAnchor(anchor); - updateMaterialization(false); + updateMaterialization(true); viewport()->update(); } @@ -792,7 +799,7 @@ bool ConversationView::reconcileOwned(ConversationSnapshot snapshot, setScrollValue(verticalScrollBar()->maximum()); else restoreAnchor(targetAnchor); - updateMaterialization(false); + updateMaterialization(true); if (follow) setScrollValue(verticalScrollBar()->maximum()); @@ -1437,7 +1444,7 @@ void ConversationView::finishExactStructureChange(const Anchor &anchor, setScrollValue(verticalScrollBar()->maximum()); else restoreAnchor(anchor); - updateMaterialization(false); + updateMaterialization(true); if (follow) setScrollValue(verticalScrollBar()->maximum()); else @@ -1802,7 +1809,7 @@ bool ConversationView::appendTailCard(ConversationTailCard tail) { setScrollValue(verticalScrollBar()->maximum()); else restoreAnchor(anchor); - updateMaterialization(false); + updateMaterialization(true); if (follow) setScrollValue(verticalScrollBar()->maximum()); else @@ -1997,7 +2004,7 @@ ConversationView::applyCardPresentationOwned( setScrollValue(verticalScrollBar()->maximum()); else restoreAnchor(presentationAnchor); - updateMaterialization(false); + updateMaterialization(true); if (followedBefore) setScrollValue(verticalScrollBar()->maximum()); else @@ -2316,6 +2323,7 @@ qint64 ConversationView::naturalContentHeight() const noexcept { void ConversationView::updateScrollRange() { if (!model_ || !viewport()) return; + const QScopedValueRollback adjusting(adjustingScrollRange_, true); const int viewportHeight = std::max(0, viewport()->height()); const qint64 maximum64 = std::max(0, naturalContentHeight() - viewportHeight); @@ -2648,6 +2656,7 @@ void ConversationView::updateMaterialization(bool preserveAnchor) { if (materializing_) return; const QScopedValueRollback materializing(materializing_, true); + incrementProperty(this, "conversationMaterializationPasses"); const Anchor anchor = preserveAnchor ? captureAnchor() : Anchor{}; const bool follow = mode_ == Mode::Following; @@ -2786,7 +2795,7 @@ void ConversationView::setCardCollapsed(const std::string &key, static_cast(heights_.lastUpdateSteps())); updateScrollRange(); restoreAnchor(anchor); - updateMaterialization(false); + updateMaterialization(true); restoreAnchor(anchor); layoutMaterializedCards(); viewport()->update(); @@ -2877,8 +2886,12 @@ ConversationView::Anchor ConversationView::captureAnchor() const { std::max(0, static_cast(verticalScrollBar()->value()) - leadingChromeHeight()); std::size_t rowIndex = heights_.rowAt(contentY); - while (rowIndex < heights_.size() && heights_.height(rowIndex) == 0) + while (rowIndex < heights_.size()) { + if (heights_.height(rowIndex) != 0 && + rowRect(static_cast(rowIndex)).bottom() >= 0) + break; ++rowIndex; + } if (rowIndex >= heights_.size()) return anchor; const ConversationItemModel::Row *row = @@ -3003,7 +3016,6 @@ bool ConversationView::applyWheel(QWheelEvent *event) { mode_ = Mode::Paused; if (isAtBottom()) mode_ = Mode::Following; - updateMaterialization(false); storeCurrentThreadState(); event->accept(); return true; @@ -3032,7 +3044,7 @@ void ConversationView::scrollTo(const QModelIndex &index, ScrollHint hint) { target += geometry.bottom() - viewport()->height() + 1; setScrollValue(target); handleUserScrollValue(verticalScrollBar()->value()); - updateMaterialization(false); + updateMaterialization(true); } QModelIndex ConversationView::indexAt(const QPoint &point) const { @@ -3138,8 +3150,13 @@ void ConversationView::updateGeometries() { void ConversationView::scrollContentsBy(int dx, int dy) { static_cast(dx); static_cast(dy); - if (!materializing_) - updateMaterialization(false); + if (!programmaticScroll_ && !applying_ && !adjustingScrollRange_) { + stopFollowingAnimation(); + pausedByComposerGrowth_ = false; + mode_ = isAtBottom() ? Mode::Following : Mode::Paused; + } + if (!materializing_ && !adjustingScrollRange_) + updateMaterialization(true); layoutMaterializedCards(); viewport()->update(); } @@ -3422,7 +3439,7 @@ void ConversationView::resizeEvent(QResizeEvent *event) { setScrollValue(verticalScrollBar()->maximum()); else restoreAnchor(anchor); - updateMaterialization(false); + updateMaterialization(true); viewport()->update(); storeCurrentThreadState(); } diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index db71695..2544e67 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -348,6 +348,7 @@ class ConversationView final : public QAbstractItemView { bool pausedByComposerGrowth_ = false; bool dispatchingNativeWheel_ = false; bool materializing_ = false; + bool adjustingScrollRange_ = false; bool structuralStagePassScheduled_ = false; bool committingStructuralStage_ = false; // A synthetic event ignored by a card child can propagate back through the diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index e50666a..2a0ab21 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -1188,13 +1188,19 @@ bool testFollowPauseAndStableAnchor() { "follow animation is monotonic and reaches the new bottom"); const int beforeWheelNotch = view.verticalScrollBar()->value(); + const auto beforeWheelAnchor = firstVisible(view); + const QModelIndex beforeWheelIndex = + view.conversationModel()->indexForStableKey(beforeWheelAnchor.first); mouseWheelNotch(view, 120); + const int nativeWheelDistance = + std::min(beforeWheelNotch, + view.verticalScrollBar()->singleStep() * + std::max(1, QApplication::wheelScrollLines())); result &= expect( view.mode() == ConversationView::Mode::Paused && !view.isAtBottom() && - beforeWheelNotch - view.verticalScrollBar()->value() == - std::min(beforeWheelNotch, - view.verticalScrollBar()->singleStep() * - std::max(1, QApplication::wheelScrollLines())), + beforeWheelIndex.isValid() && + view.visualRect(beforeWheelIndex).top() - beforeWheelAnchor.second == + nativeWheelDistance, "native mouse-wheel handling uses the configured line " "distance and " "pauses following immediately"); diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 5a65001..0a5ea5f 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -2,6 +2,7 @@ #include "codex/middle/ConversationCards.h" #include "codex/middle/ConversationView.h" +#include "codex/ui/UiStyle.h" #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include @@ -1142,6 +1144,128 @@ bool selectionFocusAndOneGesturePromotion() { return result; } +bool passiveAndInteractivePresentationShareExactGeometry() { + ConversationSnapshot snapshot; + snapshot.threadId = "geometry-invariant"; + TurnSection section; + section.key = "geometry-section"; + section.turnId = "turn"; + for (int row = 0; row < 50; ++row) { + std::string markdown; + const int paragraphs = row == 25 ? 22 : 3 + row % 5; + for (int paragraph = 0; paragraph < paragraphs; ++paragraph) + markdown += std::string(100, 'W') + "\n\n"; + section.cards.push_back( + {AuthoritativeItemKey{"geometry-invariant", "turn", + "update-" + std::to_string(row)}, + CardKind::AgentMessage, + "geometry-invariant", + "turn", + "update-" + std::to_string(row), + AgentMessageData{std::move(markdown), false}}); + } + const VisibleCardData update = section.cards[25]; + snapshot.sections.push_back(std::move(section)); + + ConversationView view; + view.resize(620, 360); + view.show(); + bool result = expect(view.reconcile(std::move(snapshot)), + "geometry-invariant fixture reconciles"); + settle(); + const QModelIndex index = view.conversationModel()->index(25); + view.scrollTo(index, QAbstractItemView::PositionAtCenter); + settle(); + result &= expect(materializedCard(view, stableKey(update.key)) == nullptr, + "geometry-invariant row begins in passive presentation"); + const int passiveHeight = view.visualRect(index).height(); + view.setCurrentIndex(index); + settle(); + const int interactiveHeight = view.visualRect(index).height(); + if (interactiveHeight != passiveHeight) + std::cerr << "geometry mismatch: passive=" << passiveHeight + << " interactive=" << interactiveHeight << '\n'; + result &= expect(materializedCard(view, stableKey(update.key)) && + interactiveHeight == passiveHeight, + "passive Markdown and its interactive widget have exact " + "row-height parity"); + return result; +} + +bool bidirectionalLazyMeasurementPreservesNativeScrollMotion() { + ConversationSnapshot snapshot; + snapshot.threadId = "lazy-scroll-anchor"; + TurnSection section; + section.key = "lazy-scroll-section"; + section.turnId = "turn"; + for (int row = 0; row < 240; ++row) { + std::string markdown; + const int paragraphs = 1 + row % 9; + for (int paragraph = 0; paragraph < paragraphs; ++paragraph) + markdown += std::string(90 + row % 37, 'W') + "\n\n"; + section.cards.push_back( + {AuthoritativeItemKey{"lazy-scroll-anchor", "turn", + "update-" + std::to_string(row)}, + CardKind::AgentMessage, + "lazy-scroll-anchor", + "turn", + "update-" + std::to_string(row), + AgentMessageData{std::move(markdown), false}}); + } + snapshot.sections.push_back(std::move(section)); + + ConversationView view; + view.resize(620, 360); + view.show(); + bool result = expect(view.reconcile(std::move(snapshot)), + "lazy-scroll anchor fixture reconciles"); + settle(); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum() / 2); + settle(); + + const auto verifySteps = [&](QAbstractSlider::SliderAction action, + int expectedDelta) { + for (int step = 0; step < 80; ++step) { + const auto before = firstVisible(view); + if (before.first.empty()) + return false; + const QModelIndex retained = + view.conversationModel()->indexForStableKey(before.first); + const int valueBefore = view.verticalScrollBar()->value(); + view.verticalScrollBar()->triggerAction(action); + settle(1); + if (view.verticalScrollBar()->value() == valueBefore) + return true; + if (!retained.isValid() || + view.visualRect(retained).top() - before.second != expectedDelta) + return false; + } + return true; + }; + result &= expect(verifySteps(QAbstractSlider::SliderSingleStepSub, + view.verticalScrollBar()->singleStep()), + "upward scrolling preserves exact motion while rows above " + "become measured"); + result &= expect(verifySteps(QAbstractSlider::SliderSingleStepAdd, + -view.verticalScrollBar()->singleStep()), + "downward scrolling preserves exact motion while rows " + "below become measured"); + const qulonglong passesBefore = + view.property("conversationMaterializationPasses").toULongLong(); + const QPointF local = view.viewport()->rect().center(); + QWheelEvent wheel(local, view.viewport()->mapToGlobal(local.toPoint()), {}, + QPoint(0, 120), Qt::NoButton, Qt::NoModifier, + Qt::NoScrollPhase, false); + result &= expect(view.forwardWheelEvent(&wheel), + "conversation accepts one native wheel gesture"); + settle(1); + result &= expect( + view.property("conversationMaterializationPasses").toULongLong() == + passesBefore + 1, + "one wheel update performs exactly one materialization pass"); + return result; +} + bool outsideTextDragDoesNotReenterTheView() { ConversationSnapshot snapshot; snapshot.threadId = "virtual-thread"; @@ -1213,6 +1337,7 @@ bool outsideTextDragDoesNotReenterTheView() { int main(int argc, char **argv) { QApplication application(argc, argv); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); using namespace codexui::codex::middle; const bool result = viewportProportionalFoundation() && exactStructuralRowsPreserveTheViewport() && @@ -1225,6 +1350,8 @@ int main(int argc, char **argv) { virtualTurnSurfaceAndInteractivePromotion() && directTailGrowsTheRetainedTurnSurface() && acknowledgedSteeringMovesAboveFollowingActivity() && + passiveAndInteractivePresentationShareExactGeometry() && + bidirectionalLazyMeasurementPreservesNativeScrollMotion() && selectionFocusAndOneGesturePromotion() && outsideTextDragDoesNotReenterTheView(); if (result) From 8ba61f72bb1ee17c1169a3055a53780e55f912f1 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 23:12:54 +0200 Subject: [PATCH 28/39] Bound command output presentation cost --- src/codex/middle/ConversationCards.cpp | 227 ++++++++++++++++-- src/codex/middle/ConversationCards.h | 21 +- src/codex/middle/ConversationView.cpp | 48 +++- src/codex/middle/ConversationView.h | 4 +- src/codex/middle/MiddleRegionWidget.cpp | 10 + tests/codex/ConversationCardsTest.cpp | 44 +++- .../codex/ConversationVirtualizationTest.cpp | 72 ++++++ 7 files changed, 377 insertions(+), 49 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 46c480e..504e074 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -79,7 +81,31 @@ QStringList textList(const std::vector &values) { std::string utf8(const QString &value) { return value.toUtf8().toStdString(); } QString trimmedTrailingLines(const QString &value) { - return text(trimTrailingEmptyLines(utf8(value))); + qsizetype end = value.size(); + while (end > 0) { + while (end > 0 && + (value.at(end - 1) == QLatin1Char('\n') || + value.at(end - 1) == QLatin1Char('\r'))) + --end; + if (end == 0) + break; + + qsizetype lineStart = end; + while (lineStart > 0 && value.at(lineStart - 1) != QLatin1Char('\n') && + value.at(lineStart - 1) != QLatin1Char('\r')) + --lineStart; + bool whitespaceOnly = true; + for (qsizetype offset = lineStart; offset < end; ++offset) { + if (!value.at(offset).isSpace()) { + whitespaceOnly = false; + break; + } + } + if (!whitespaceOnly) + break; + end = lineStart; + } + return end == value.size() ? value : value.first(end); } bool initiallyCollapsed(CardKind kind, bool commandInitiallyCollapsed, @@ -783,6 +809,12 @@ bool ContentSizedTextView::measureAtCurrentWidth(bool notifyParent) { frame + static_cast(std::ceil(document()->size().height())); } wantedHeight = std::clamp(wantedHeight, 0, maximumHeight()); + return setPreferredContentHeight(wantedHeight, notifyParent); +} + +bool ContentSizedTextView::setPreferredContentHeight(int height, + bool notifyParent) { + const int wantedHeight = std::clamp(height, 0, maximumHeight()); if (wantedHeight == preferredHeight_) return false; preferredHeight_ = wantedHeight; @@ -796,11 +828,19 @@ bool ContentSizedTextView::contentHeightCapped() const noexcept { } CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) - : ContentSizedTextView(MaximumCommandOutputHeight, parent) { + : QPlainTextEdit(parent) { + setReadOnly(true); + setMinimumHeight(0); + setMaximumHeight(MaximumCommandOutputHeight); + setLineWrapMode(QPlainTextEdit::WidgetWidth); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + document()->setDocumentMargin(CommandTextPadding); setProperty("kind", "code"); setObjectName(QStringLiteral("commandOutputView")); setStyleSheet(QStringLiteral( - "QTextEdit#commandOutputView{background:#111827;color:#e5e7eb;" + "QPlainTextEdit#commandOutputView{background:#111827;color:#e5e7eb;" "border-radius:6px;}")); connect(verticalScrollBar(), &QScrollBar::valueChanged, this, @@ -821,8 +861,9 @@ CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) }); connect(verticalScrollBar(), &QScrollBar::actionTriggered, this, [this](int) { preservedScrollValue_ = verticalScrollBar()->sliderPosition(); - followsLatest_ = - preservedScrollValue_ >= verticalScrollBar()->maximum() - 1; + // QPlainTextEdit scroll values are block based: one unit is a complete + // output line, not a one-pixel rounding tolerance. + followsLatest_ = preservedScrollValue_ >= verticalScrollBar()->maximum(); }); connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, [this](int, int) { @@ -835,6 +876,52 @@ CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) settleScroll(); } +bool CommandOutputView::retainsWheelGesture(QWheelEvent *event) { + if (!event) + return false; + const int delta = !event->pixelDelta().isNull() ? event->pixelDelta().y() + : event->angleDelta().y(); + QScrollBar *bar = verticalScrollBar(); + const bool canScroll = bar->maximum() > bar->minimum() && + ((delta > 0 && bar->value() > bar->minimum()) || + (delta < 0 && bar->value() < bar->maximum())); + const bool hasDirection = delta != 0; + if (event->phase() == Qt::ScrollBegin) { + wheelGestureActive_ = true; + wheelGestureDecided_ = hasDirection; + wheelGestureOwned_ = hasDirection && canScroll; + } else if (event->phase() == Qt::ScrollEnd) { + const bool retained = + wheelGestureActive_ && wheelGestureDecided_ && wheelGestureOwned_; + wheelGestureActive_ = false; + wheelGestureDecided_ = false; + wheelGestureOwned_ = false; + return retained; + } else if (event->phase() == Qt::NoScrollPhase) { + return canScroll; + } else if (!wheelGestureActive_) { + wheelGestureActive_ = true; + wheelGestureDecided_ = hasDirection; + wheelGestureOwned_ = hasDirection && canScroll; + } else if (!wheelGestureDecided_ && hasDirection) { + wheelGestureDecided_ = true; + wheelGestureOwned_ = canScroll; + } + return wheelGestureDecided_ && wheelGestureOwned_; +} + +QSize CommandOutputView::sizeHint() const { + QSize result = QPlainTextEdit::sizeHint(); + result.setHeight(preferredHeight_); + return result; +} + +QSize CommandOutputView::minimumSizeHint() const { + QSize result = QPlainTextEdit::minimumSizeHint(); + result.setHeight(0); + return result; +} + CommandOutputView::ScrollState CommandOutputView::scrollState() const { return {followsLatest_, preservedScrollValue_}; } @@ -844,10 +931,12 @@ bool CommandOutputView::followsLatest() const noexcept { } bool CommandOutputView::isHeightCapped() const noexcept { - return contentHeightCapped(); + return preferredHeight_ >= maximumHeight(); } bool CommandOutputView::setOutput(const QString &output) { + QElapsedTimer commitTimer; + commitTimer.start(); const QString displayOutput = trimmedTrailingLines(output); if (currentOutput_ == displayOutput) return false; @@ -870,16 +959,93 @@ bool CommandOutputView::setOutput(const QString &output) { preservedScrollValue_ = retainedValue; programmaticScroll_ = false; // Once the output has reached its bounded height, subsequent text cannot - // change the enclosing card's geometry. Avoid a complete QTextDocument - // measurement and ancestor LayoutRequest for the common streaming case. - if (!retainedHeightIsCapped || !appendOnly || displayOutput.isEmpty()) + // change the enclosing card's geometry. Avoid whole-document geometry and + // an ancestor LayoutRequest for the common streaming case. + if (outputRequiresMaximumHeight(displayOutput)) { + static_cast(setPreferredContentHeight(maximumHeight(), true)); + setProperty("boundedOutputMeasurements", + property("boundedOutputMeasurements").toULongLong() + 1); + } else if (!retainedHeightIsCapped || !appendOnly || displayOutput.isEmpty()) { static_cast(measureAtCurrentWidth(true)); - else + setProperty("fullOutputMeasurements", + property("fullOutputMeasurements").toULongLong() + 1); + } else { viewport()->update(); + } settleScroll(); + setProperty("lastOutputCommitMicros", commitTimer.nsecsElapsed() / 1000); + return true; +} + +bool CommandOutputView::outputRequiresMaximumHeight( + const QString &output) const { + if (output.isEmpty()) + return false; + const int lineHeight = std::max(1, fontMetrics().lineSpacing()); + const int availableHeight = + std::max(1, maximumHeight() - 2 * frameWidth()); + const int requiredLines = availableHeight / lineHeight + 1; + const int availableWidth = std::max(1, viewport()->width()); + int visualLines = 0; + qsizetype begin = 0; + while (begin <= output.size()) { + const qsizetype end = output.indexOf(QLatin1Char('\n'), begin); + const qsizetype length = + end < 0 ? output.size() - begin : end - begin; + const int advance = + fontMetrics().horizontalAdvance(output.sliced(begin, length)); + visualLines += std::max(1, (advance + availableWidth - 1) / availableWidth); + if (visualLines >= requiredLines) + return true; + if (end < 0) + break; + begin = end + 1; + } + return false; +} + +bool CommandOutputView::measureAtCurrentWidth(bool notifyParent) { + if (currentOutput_.isEmpty()) + return setPreferredContentHeight(0, notifyParent); + if (outputRequiresMaximumHeight(currentOutput_)) + return setPreferredContentHeight(maximumHeight(), notifyParent); + + qreal contentHeight = 2.0 * document()->documentMargin(); + for (QTextBlock block = document()->begin(); block.isValid(); + block = block.next()) { + contentHeight += blockBoundingRect(block).height(); + if (contentHeight + 2 * frameWidth() >= maximumHeight()) + return setPreferredContentHeight(maximumHeight(), notifyParent); + } + return setPreferredContentHeight( + 2 * frameWidth() + static_cast(std::ceil(contentHeight)), + notifyParent); +} + +bool CommandOutputView::setPreferredContentHeight(int height, + bool notifyParent) { + const int wantedHeight = std::clamp(height, 0, maximumHeight()); + if (wantedHeight == preferredHeight_) + return false; + preferredHeight_ = wantedHeight; + if (notifyParent) + updateGeometry(); return true; } +void CommandOutputView::resizeEvent(QResizeEvent *event) { + QPlainTextEdit::resizeEvent(event); + if (outputRequiresMaximumHeight(currentOutput_)) { + static_cast(setPreferredContentHeight(maximumHeight(), true)); + setProperty("boundedOutputMeasurements", + property("boundedOutputMeasurements").toULongLong() + 1); + return; + } + static_cast(measureAtCurrentWidth(true)); + setProperty("fullOutputMeasurements", + property("fullOutputMeasurements").toULongLong() + 1); +} + void CommandOutputView::restoreScrollState(const ScrollState &state) { followsLatest_ = state.followsLatest; preservedScrollValue_ = std::max(0, state.value); @@ -892,7 +1058,13 @@ void CommandOutputView::wheelEvent(QWheelEvent *event) { : event->angleDelta().y(); if (delta > 0) followsLatest_ = false; - ContentSizedTextView::wheelEvent(event); + const bool atBoundary = bar->maximum() <= bar->minimum() || + (delta > 0 && bar->value() <= bar->minimum()) || + (delta < 0 && bar->value() >= bar->maximum()); + if (atBoundary) + event->accept(); + else + QPlainTextEdit::wheelEvent(event); preservedScrollValue_ = bar->value(); followsLatest_ = isAtBottom(); } @@ -904,12 +1076,6 @@ void CommandOutputView::settleScroll() { QScrollBar *bar = verticalScrollBar(); const bool wasProgrammatic = programmaticScroll_; programmaticScroll_ = true; - if (followsLatest_) { - QTextCursor cursor = textCursor(); - cursor.movePosition(QTextCursor::End); - setTextCursor(cursor); - ensureCursorVisible(); - } const int target = followsLatest_ ? bar->maximum() @@ -922,7 +1088,7 @@ void CommandOutputView::settleScroll() { } bool CommandOutputView::isAtBottom() const { - return verticalScrollBar()->value() >= verticalScrollBar()->maximum() - 1; + return verticalScrollBar()->value() >= verticalScrollBar()->maximum(); } class ConversationCard::Impl final { @@ -1303,6 +1469,12 @@ class ConversationCard::Impl final { contentLayout->addWidget(command); contentLayout->addWidget(output); contentLayout->addWidget(metadata); + const QMargins outerMargins = layout->contentsMargins(); + const int editorWidth = std::max( + 1, owner->contentsRect().width() - outerMargins.left() - + outerMargins.right()); + command->resize(editorWidth, command->maximumHeight()); + output->resize(editorWidth, output->maximumHeight()); updateComposition(execution); } @@ -1621,11 +1793,15 @@ class ConversationCard::Impl final { ConversationCard::ConversationCard(const VisibleCardData &data, QWidget *parent, bool commandInitiallyCollapsed, bool imageInitiallyCollapsed, - bool fileChangesInitiallyCollapsed) - : QFrame(parent), - impl_(std::make_unique(this, data, commandInitiallyCollapsed, - imageInitiallyCollapsed, - fileChangesInitiallyCollapsed)) {} + bool fileChangesInitiallyCollapsed, + int initialWidth) + : QFrame(parent) { + if (initialWidth > 0) + resize(initialWidth, 1); + impl_ = std::make_unique(this, data, commandInitiallyCollapsed, + imageInitiallyCollapsed, + fileChangesInitiallyCollapsed); +} ConversationCard::~ConversationCard() = default; @@ -1776,10 +1952,11 @@ ConversationCard *createConversationCard(const VisibleCardData &data, QWidget *parent, bool commandInitiallyCollapsed, bool imageInitiallyCollapsed, - bool fileChangesInitiallyCollapsed) { + bool fileChangesInitiallyCollapsed, + int initialWidth) { return new ConversationCard(data, parent, commandInitiallyCollapsed, imageInitiallyCollapsed, - fileChangesInitiallyCollapsed); + fileChangesInitiallyCollapsed, initialWidth); } } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index 0205947..28911e8 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -6,6 +6,7 @@ #include "codex/middle/MiddleTypes.h" #include +#include #include #include @@ -40,6 +41,7 @@ class ContentSizedTextView : public QTextEdit { [[nodiscard]] bool contentHeightCapped() const noexcept; private: + [[nodiscard]] bool setPreferredContentHeight(int height, bool notifyParent); int preferredHeight_ = 0; bool pinScrollToStart_ = false; bool wheelGestureActive_ = false; @@ -47,7 +49,7 @@ class ContentSizedTextView : public QTextEdit { bool wheelGestureOwned_ = false; }; -class CommandOutputView final : public ContentSizedTextView { +class CommandOutputView final : public QPlainTextEdit { public: struct ScrollState { bool followsLatest = true; @@ -61,6 +63,9 @@ class CommandOutputView final : public ContentSizedTextView { [[nodiscard]] ScrollState scrollState() const; [[nodiscard]] bool followsLatest() const noexcept; [[nodiscard]] bool isHeightCapped() const noexcept; + [[nodiscard]] bool retainsWheelGesture(QWheelEvent *event); + QSize sizeHint() const override; + QSize minimumSizeHint() const override; // Returns false for a true no-op. Programmatic document/range changes do // not alter the user's follow/paused choice. @@ -68,16 +73,24 @@ class CommandOutputView final : public ContentSizedTextView { void restoreScrollState(const ScrollState &state); protected: + void resizeEvent(QResizeEvent *event) override; void wheelEvent(QWheelEvent *event) override; private: + [[nodiscard]] bool measureAtCurrentWidth(bool notifyParent); + [[nodiscard]] bool setPreferredContentHeight(int height, bool notifyParent); void settleScroll(); [[nodiscard]] bool isAtBottom() const; + [[nodiscard]] bool outputRequiresMaximumHeight(const QString &output) const; bool followsLatest_ = true; bool programmaticScroll_ = false; bool settlingScroll_ = false; bool userScrollActive_ = false; + bool wheelGestureActive_ = false; + bool wheelGestureDecided_ = false; + bool wheelGestureOwned_ = false; + int preferredHeight_ = 0; int preservedScrollValue_ = 0; QString currentOutput_; }; @@ -90,7 +103,8 @@ class ConversationCard : public QFrame { QWidget *parent = nullptr, bool commandInitiallyCollapsed = true, bool imageInitiallyCollapsed = true, - bool fileChangesInitiallyCollapsed = true); + bool fileChangesInitiallyCollapsed = true, + int initialWidth = 0); ~ConversationCard() override; [[nodiscard]] CardKind cardKind() const noexcept; @@ -136,7 +150,8 @@ class ConversationCard : public QFrame { createConversationCard(const VisibleCardData &data, QWidget *parent = nullptr, bool commandInitiallyCollapsed = true, bool imageInitiallyCollapsed = true, - bool fileChangesInitiallyCollapsed = true); + bool fileChangesInitiallyCollapsed = true, + int initialWidth = 0); } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 80d81a0..e26ef03 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -1002,11 +1004,11 @@ void ConversationView::runStructuralStagePass() { const PendingLocation *location = pendingLocation(key); if (!data || !location) continue; - ConversationCard *card = createCard(*data, stagingHost_, key); - card->setNestedPresentation(location->nested); - card->setAuthoritativeTurnActive(location->activeTurn); const int width = std::max( 0, viewport()->width() - (location->nested ? 2 * NestedCardIndent : 0)); + ConversationCard *card = createCard(*data, stagingHost_, key, width); + card->setNestedPresentation(location->nested); + card->setAuthoritativeTurnActive(location->activeTurn); const int height = measureCard(card, width); card->hide(); stagedCards_.insert_or_assign(key, card); @@ -2441,11 +2443,12 @@ std::pair ConversationView::materializationRows() const { ConversationCard *ConversationView::createCard(const VisibleCardData &data, QWidget *parent, - const std::string &key) { + const std::string &key, + int width) { ConversationCard *card = createConversationCard( data, parent, !presentationOptions_.commandsInitiallyExpanded, !presentationOptions_.imagesInitiallyExpanded, - !presentationOptions_.fileChangesInitiallyExpanded); + !presentationOptions_.fileChangesInitiallyExpanded, width); card->setProperty("conversationAnchorKey", QString::fromStdString(key)); if (const auto collapsed = cardCollapsedStates_.find(key); collapsed != cardCollapsedStates_.end()) @@ -2527,7 +2530,11 @@ ConversationCard *ConversationView::materializeRow(int rowIndex, stagedCards_.erase(staged); stagedHeights_.erase(row->stableKey); } else { - card = createCard(row->card, stagingHost_, row->stableKey); + QElapsedTimer constructionTimer; + constructionTimer.start(); + card = createCard(row->card, stagingHost_, row->stableKey, rowWidth(*row)); + card->setProperty("conversationConstructionMicros", + constructionTimer.nsecsElapsed() / 1000); incrementProperty(this, "conversationCardConstructions"); } card->hide(); @@ -2583,7 +2590,16 @@ void ConversationView::captureCardInteractionState( continue; state.edits.push_back({ordinal, cursor.position(), cursor.anchor()}); } - if (!state.labels.empty() || !state.edits.empty()) + const auto plainEdits = card->findChildren(); + for (int ordinal = 0; ordinal < plainEdits.size(); ++ordinal) { + const QTextCursor cursor = plainEdits.at(ordinal)->textCursor(); + if (!cursor.hasSelection()) + continue; + state.plainEdits.push_back( + {ordinal, cursor.position(), cursor.anchor()}); + } + if (!state.labels.empty() || !state.edits.empty() || + !state.plainEdits.empty()) cardInteractionStates_.insert_or_assign(key, std::move(state)); else if (!preserveExistingWhenEmpty) cardInteractionStates_.erase(key); @@ -2608,12 +2624,30 @@ void ConversationView::restoreCardInteractionState(const std::string &key, if (selection.ordinal < 0 || selection.ordinal >= edits.size()) continue; QTextEdit *edit = edits.at(selection.ordinal); + QScrollBar *bar = edit->verticalScrollBar(); + const int retainedScroll = bar->value(); + const int maximum = std::max(0, edit->document()->characterCount() - 1); + QTextCursor cursor(edit->document()); + cursor.setPosition(std::clamp(selection.anchor, 0, maximum)); + cursor.setPosition(std::clamp(selection.position, 0, maximum), + QTextCursor::KeepAnchor); + edit->setTextCursor(cursor); + bar->setValue(retainedScroll); + } + const auto plainEdits = card->findChildren(); + for (const EditSelection &selection : retained->second.plainEdits) { + if (selection.ordinal < 0 || selection.ordinal >= plainEdits.size()) + continue; + QPlainTextEdit *edit = plainEdits.at(selection.ordinal); + QScrollBar *bar = edit->verticalScrollBar(); + const int retainedScroll = bar->value(); const int maximum = std::max(0, edit->document()->characterCount() - 1); QTextCursor cursor(edit->document()); cursor.setPosition(std::clamp(selection.anchor, 0, maximum)); cursor.setPosition(std::clamp(selection.position, 0, maximum), QTextCursor::KeepAnchor); edit->setTextCursor(cursor); + bar->setValue(retainedScroll); } } diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index 2544e67..7db292d 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -214,6 +214,7 @@ class ConversationView final : public QAbstractItemView { struct CardInteractionState { std::vector labels; std::vector edits; + std::vector plainEdits; }; enum class SnapshotOperation { @@ -286,7 +287,8 @@ class ConversationView final : public QAbstractItemView { const ConversationItemModel::Row &row); [[nodiscard]] ConversationCard *createCard(const VisibleCardData &data, QWidget *parent, - const std::string &key); + const std::string &key, + int width); void setCardCollapsed(const std::string &key, ConversationCard *card, bool collapsed); diff --git a/src/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp index 2aaba8b..2b10f38 100644 --- a/src/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -533,6 +533,11 @@ bool MiddleRegionWidget::routeScrollEvent(QObject *watched, QEvent *event) { ancestor = ancestor->parentWidget()) { if (auto *nested = qobject_cast(ancestor); nested && nested != conversationView) { + if (auto *outputView = dynamic_cast(nested)) { + if (outputView->retainsWheelGesture(wheel)) + return false; + break; + } if (auto *commandView = dynamic_cast(nested)) { if (commandView->retainsWheelGesture(wheel)) @@ -551,6 +556,11 @@ bool MiddleRegionWidget::routeScrollEvent(QObject *watched, QEvent *event) { ancestor && ancestor != conversationRegion; ancestor = ancestor->parentWidget()) { if (auto *nested = qobject_cast(ancestor)) { + if (auto *outputView = dynamic_cast(nested)) { + if (outputView->retainsWheelGesture(wheel)) + return false; + break; + } if (auto *commandView = dynamic_cast(nested)) { if (commandView->retainsWheelGesture(wheel)) diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 2a0ab21..8dd2618 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -1306,7 +1307,7 @@ bool testPausedExpandedCommandStaysPainted() { "completed command is expanded before incoming cards"); QPointer outputView = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; if (outputView && outputView->verticalScrollBar()->maximum() > 0) { @@ -2146,7 +2147,8 @@ bool testMutableCardsAndCommandOutput() { auto *commandCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "command"}})]; auto *output = dynamic_cast( - commandCard->findChild(QStringLiteral("commandOutputView"))); + commandCard->findChild( + QStringLiteral("commandOutputView"))); auto *commandText = dynamic_cast( commandCard->findChild(QStringLiteral("commandTextView"))); auto *commandStatus = @@ -2761,7 +2763,8 @@ bool testCardFoldingGeometryAndRetention() { result &= expect(applyConversation(view, snapshot), "folded command accepts a streamed content update"); auto *output = dynamic_cast( - commandCard->findChild(QStringLiteral("commandOutputView"))); + commandCard->findChild( + QStringLiteral("commandOutputView"))); result &= spinUntil([&] { return output && output->toPlainText().contains(QStringLiteral("streamed line 4")); @@ -3266,7 +3269,7 @@ bool testInitialCommandGeometrySettlement() { result &= expect(setFolded(commandCard, false), "initially folded command can be expanded for inspection"); auto *outputView = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= expect(commandCard && outputView && !outputView->isHidden() && @@ -3540,7 +3543,7 @@ bool testBottomAnchoredCommandOutputGrowth() { ? commandCard->findChild(QStringLiteral("commandStatus")) : nullptr; auto *output = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= expect(commandCard && metadata && metadata->isHidden() && status && @@ -3610,6 +3613,11 @@ bool testBottomAnchoredCommandOutputGrowth() { const qulonglong geometryBeforeAppend = view.property("conversationGeometryPasses").toULongLong(); QPointer retainedCommand = commandCard; + QTextCursor selectedOutput(output->document()); + selectedOutput.setPosition(12); + selectedOutput.setPosition(38, QTextCursor::KeepAnchor); + output->setTextCursor(selectedOutput); + const QString selectionBeforeAppend = output->textCursor().selectedText(); live.output += "one more append-only streaming line\n"; result &= expect(applyConversation(view, snapshot), "capped output accepts another streaming append"); @@ -3622,6 +3630,9 @@ bool testBottomAnchoredCommandOutputGrowth() { .y() == cardBottomBefore, "append-only capped output repaints its retained card without a " "conversation geometry pass"); + result &= expect(output->textCursor().selectedText() == selectionBeforeAppend, + "append-only command streaming preserves output text " + "selection"); return result; } @@ -3650,7 +3661,7 @@ bool testCommandOutputStateAcrossNavigation() { "navigation command expands from its compact default"); auto *initialOutput = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= @@ -3664,7 +3675,7 @@ bool testCommandOutputStateAcrossNavigation() { spin(); commandCard = card(view, stableKey(command.key)); initialOutput = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= expect(initialOutput && initialOutput->followsLatest() && @@ -3677,6 +3688,11 @@ bool testCommandOutputStateAcrossNavigation() { initialOutput->verticalScrollBar()->triggerAction( QAbstractSlider::SliderSingleStepSub); spin(); + QTextCursor retainedSelection(initialOutput->document()); + retainedSelection.setPosition(output.size() - 48); + retainedSelection.setPosition(output.size() - 22, QTextCursor::KeepAnchor); + initialOutput->setTextCursor(retainedSelection); + const QString selectedText = retainedSelection.selectedText(); const int pausedValue = initialOutput->verticalScrollBar()->value(); result &= expect(!initialOutput->followsLatest(), "command output is paused before thread navigation"); @@ -3688,13 +3704,15 @@ bool testCommandOutputStateAcrossNavigation() { commandCard = card(view, stableKey(command.key)); auto *restoredOutput = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= expect(restoredOutput && !restoredOutput->followsLatest() && - restoredOutput->verticalScrollBar()->value() == pausedValue, - "thread navigation restores paused command output state"); + restoredOutput->verticalScrollBar()->value() == pausedValue && + restoredOutput->textCursor().selectedText() == selectedText, + "thread navigation restores paused command output and selection " + "state"); return result; } @@ -4246,7 +4264,7 @@ bool testFocusedGraphCardSurvivesViewportReconciliation() { "the focus-pinning fixture exposes its command output"); QPointer output = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; if (!commandCard || !output) @@ -4378,7 +4396,7 @@ bool testGraphRootFoldSuppressesAndRestoresChildExtent() { "the root-fold fixture materializes an expanded child"); QPointer output = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; if (!rootCard || !commandCard || !output) @@ -4409,7 +4427,7 @@ bool testGraphRootFoldSuppressesAndRestoresChildExtent() { }); commandCard = card(view, stableKey(command.key)); output = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; const auto childStateAfter = diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 0a5ea5f..9ada14e 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -1266,6 +1267,76 @@ bool bidirectionalLazyMeasurementPreservesNativeScrollMotion() { return result; } +bool largeIncomingCommandUsesBoundedFinalWidthLayout() { + const std::string thread = "bounded-command"; + VisibleCardData root{ + AuthoritativeItemKey{thread, "turn", "root"}, + CardKind::UserMessage, + thread, + "turn", + "root", + UserMessageData{"Run the command"}}; + ConversationSnapshot snapshot; + snapshot.threadId = thread; + snapshot.sections.push_back( + {"command-section", "turn", {root}, root.key}); + + ConversationView view; + ConversationView::PresentationOptions options = view.presentationOptions(); + options.commandsInitiallyExpanded = true; + view.setPresentationOptions(options); + view.resize(760, 480); + view.show(); + bool result = expect(view.reconcile(std::move(snapshot)), + "bounded-command fixture reconciles"); + settle(); + + std::string output; + output.reserve(192 * 1024); + while (output.size() < 192 * 1024) + output += "0123456789abcdef command output line for bounded layout\n"; + output.resize(192 * 1024); + ConversationTailCard tail; + tail.card = {AuthoritativeItemKey{thread, "turn", "command"}, + CardKind::CommandExecution, + thread, + "turn", + "command", + CommandExecutionData{"printf diagnostic", std::move(output), + "inProgress", "/workspace", {}, {}}, + true}; + const std::string key = stableKey(tail.card.key); + tail.sectionKey = "command-section"; + tail.nested = true; + tail.activeTurn = true; + tail.historyActivity = true; + tail.authoritativeItemCount = 2; + + QElapsedTimer timer; + timer.start(); + result &= expect(view.appendTailCard(std::move(tail)), + "large command appends through the direct-tail path"); + const qint64 appendMicros = timer.nsecsElapsed() / 1000; + settle(); + ConversationCard *commandCard = materializedCard(view, key); + CommandOutputView *commandOutput = commandCard + ? dynamic_cast( + commandCard->findChild( + QStringLiteral( + "commandOutputView"))) + : nullptr; + result &= expect( + commandOutput && commandOutput->viewport()->width() > 500 && + commandOutput->property("boundedOutputMeasurements").toULongLong() >= + 1 && + commandOutput->property("fullOutputMeasurements").toULongLong() == 0 && + commandOutput->document()->characterCount() > 190 * 1024, + "large command output is retained but bypasses whole-document geometry " + "at its final row width"); + view.setProperty("largeCommandAppendMicros", appendMicros); + return result; +} + bool outsideTextDragDoesNotReenterTheView() { ConversationSnapshot snapshot; snapshot.threadId = "virtual-thread"; @@ -1352,6 +1423,7 @@ int main(int argc, char **argv) { acknowledgedSteeringMovesAboveFollowingActivity() && passiveAndInteractivePresentationShareExactGeometry() && bidirectionalLazyMeasurementPreservesNativeScrollMotion() && + largeIncomingCommandUsesBoundedFinalWidthLayout() && selectionFocusAndOneGesturePromotion() && outsideTextDragDoesNotReenterTheView(); if (result) From aab27232cf9878d390b7fa0fa0325ac6df5f9b02 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 10 Sep 2026 23:56:47 +0200 Subject: [PATCH 29/39] Reuse Markdown documents across presentation --- src/codex/middle/ConversationCards.cpp | 295 ++++++++++++++---- src/codex/middle/ConversationCards.h | 43 ++- src/codex/middle/ConversationPresentation.cpp | 118 +++++++ src/codex/middle/ConversationPresentation.h | 24 ++ src/codex/middle/ConversationView.cpp | 277 +++++++++++++--- src/codex/middle/ConversationView.h | 1 + tests/codex/ConversationCardsTest.cpp | 104 +++--- .../codex/ConversationVirtualizationTest.cpp | 210 +++++++++++-- 8 files changed, 888 insertions(+), 184 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 504e074..9b18996 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -458,32 +458,11 @@ QLabel *makeLabel(const QString &value, const char *kind = "body", return label; } -QString markdownHtml(const QString &markdown) { - QTextDocument document; - document.setMarkdown(markdown, QTextDocument::MarkdownFeatures( - QTextDocument::MarkdownDialectGitHub) | - QTextDocument::MarkdownNoHTML); - return document.toHtml(); -} - -QLabel *makeMarkdownLabel(const QString &value, QWidget *parent = nullptr) { - auto *label = new QLabel(parent); - label->setProperty("kind", "body"); - label->setTextFormat(Qt::RichText); - label->setWordWrap(true); - label->setMinimumWidth(0); - // QTextDocument and QLabel round rich-text line geometry independently. - // Keep one descent of paint space below the measured document so the final - // baseline cannot be clipped when a nested card is fixed to heightForWidth. - label->setContentsMargins(0, 0, 0, MarkdownBottomPaintGuard); - label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - label->setOpenExternalLinks(true); - label->setTextInteractionFlags(Qt::TextSelectableByMouse | - Qt::LinksAccessibleByMouse | - Qt::LinksAccessibleByKeyboard); - label->setProperty("markdownSource", value); - label->setText(markdownHtml(value)); - return label; +MarkdownTextView *makeMarkdownView( + const QString &value, std::shared_ptr preparedDocument, + int initialWidth, QWidget *parent = nullptr) { + return new MarkdownTextView(value, std::move(preparedDocument), initialWidth, + parent); } bool setVisibleText(QLabel *label, const QString &text) { @@ -497,18 +476,15 @@ bool setVisibleText(QLabel *label, const QString &text) { return changed; } -bool setVisibleMarkdown(QLabel *label, const QString &markdown) { +bool setVisibleMarkdown(MarkdownTextView *view, const QString &markdown) { const bool visible = !markdown.isEmpty(); - const bool contentChanged = - label->property("markdownSource").toString() != markdown; - const bool explicitlyVisible = !label->isHidden(); + const bool contentChanged = view->markdownSource() != markdown; + const bool explicitlyVisible = !view->isHidden(); const bool changed = contentChanged || explicitlyVisible != visible; - if (contentChanged) { - label->setProperty("markdownSource", markdown); - label->setText(markdownHtml(markdown)); - } + if (contentChanged) + view->setContent(markdown); if (explicitlyVisible != visible) - label->setVisible(visible); + view->setVisible(visible); return changed; } @@ -677,6 +653,158 @@ bool presentationEquals(const VisibleCardData &left, } // namespace +MarkdownTextView::MarkdownTextView( + const QString &markdown, + std::shared_ptr preparedDocument, int initialWidth, + QWidget *parent) + : QTextBrowser(parent), + document_(preparedDocument ? preparedDocument + : std::make_shared()) { + setObjectName(QStringLiteral("markdownTextView")); + setProperty("kind", "body"); + setStyleSheet(QStringLiteral( + "QTextBrowser#markdownTextView{background:transparent;border:0;" + "padding:0;margin:0;}")); + setFrameShape(QFrame::NoFrame); + setContentsMargins(0, 0, 0, 0); + setReadOnly(true); + setOpenExternalLinks(true); + setOpenLinks(true); + setLineWrapMode(QTextEdit::WidgetWidth); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + // The editor already owns an exact document-height cache. A fixed vertical + // policy prevents QLayout from caching a speculative height-for-width query + // made against an intermediate narrow parent during card construction. + QSizePolicy policy(QSizePolicy::Ignored, QSizePolicy::Fixed); + setSizePolicy(policy); + setMinimumSize(0, 0); + if (initialWidth > 0) + resize(initialWidth, 1); + if (preparedDocument) { + preferredDocumentWidth_ = + std::max(1, static_cast(std::lround(document_->textWidth()))); + preferredHeight_ = + std::max(1, static_cast(std::ceil(document_->size().height())) + + MarkdownBottomPaintGuard); + } + setDocument(document_.get()); + configureDocument(); + if (preparedDocument) { + markdown_ = markdown; + markdownTail_ = + presentation::markdownTailState(*document_, QStringView(markdown_)); + setProperty("markdownSource", markdown_); + } else { + setContent(markdown); + } +} + +MarkdownTextView::~MarkdownTextView() { + // QTextEdit's base destructor still refers to its current document after + // derived members have been destroyed. Detach the shared cache document + // first so its lifetime remains explicit. + setDocument(new QTextDocument(this)); + document_.reset(); +} + +void MarkdownTextView::configureDocument() { + document_->setDocumentMargin(0); + document_->setDefaultFont(font()); + document_->setDefaultStyleSheet( + QStringLiteral("a{color:#5471a6;text-decoration:none;}")); + QTextOption option = document_->defaultTextOption(); + option.setWrapMode(QTextOption::WordWrap); + document_->setDefaultTextOption(option); +} + +bool MarkdownTextView::setContent(const QString &markdown) { + if (markdown_ == markdown) + return false; + const QTextCursor retainedCursor = textCursor(); + const bool retainedSelection = retainedCursor.hasSelection(); + const int retainedPosition = retainedCursor.position(); + const int retainedAnchor = retainedCursor.anchor(); + if (!presentation::appendMarkdownDocument( + *document_, QStringView(markdown_), QStringView(markdown), + markdownTail_)) { + presentation::replaceMarkdownDocument(*document_, markdown, markdownTail_); + } + markdown_ = markdown; + setProperty("markdownSource", markdown_); + if (retainedSelection) { + const int maximum = std::max(0, document_->characterCount() - 1); + QTextCursor restored(document_.get()); + restored.setPosition(std::clamp(retainedAnchor, 0, maximum)); + restored.setPosition(std::clamp(retainedPosition, 0, maximum), + QTextCursor::KeepAnchor); + setTextCursor(restored); + } + refreshPreferredHeight(std::max(1, viewport()->width() - 2)); + updateGeometry(); + viewport()->update(); + return true; +} + +const QString &MarkdownTextView::markdownSource() const noexcept { + return markdown_; +} + +std::shared_ptr MarkdownTextView::sharedDocument() const { + return document_; +} + +bool MarkdownTextView::hasSelectedText() const { + return textCursor().hasSelection(); +} + +int MarkdownTextView::selectionStart() const { + const QTextCursor cursor = textCursor(); + return cursor.hasSelection() ? cursor.selectionStart() : -1; +} + +QString MarkdownTextView::selectedText() const { + return textCursor().selectedText(); +} + +void MarkdownTextView::setSelection(int start, int length) { + const int maximum = std::max(0, document_->characterCount() - 1); + QTextCursor cursor(document_.get()); + cursor.setPosition(std::clamp(start, 0, maximum)); + cursor.setPosition(std::clamp(start + length, 0, maximum), + QTextCursor::KeepAnchor); + setTextCursor(cursor); +} + +int MarkdownTextView::heightForWidth(int width) const { + if (markdown_.isEmpty()) + return 0; + // QVBoxLayout can ask with both the frame-inclusive and assigned child + // width. The viewport is the single authoritative rich-text paint width. + const int viewportWidth = viewport()->width(); + refreshPreferredHeight( + std::max(1, viewportWidth > 0 ? viewportWidth - 2 : width - 4)); + return preferredHeight_; +} + +QSize MarkdownTextView::sizeHint() const { + QSize result = QTextBrowser::sizeHint(); + result.setHeight(heightForWidth(std::max(1, width()))); + return result; +} + +QSize MarkdownTextView::minimumSizeHint() const { return {0, 0}; } + +void MarkdownTextView::refreshPreferredHeight(int documentWidth) const { + if (preferredDocumentWidth_ == documentWidth && preferredHeight_ > 0) + return; + document_->setTextWidth(documentWidth); + preferredDocumentWidth_ = documentWidth; + preferredHeight_ = + std::max(1, static_cast(std::ceil(document_->size().height())) + + MarkdownBottomPaintGuard); +} + ContentSizedTextView::ContentSizedTextView(int maximumContentHeight, QWidget *parent) : QTextEdit(parent) { @@ -1095,11 +1223,13 @@ class ConversationCard::Impl final { public: Impl(ConversationCard *owner, const VisibleCardData &initial, bool commandInitiallyCollapsed, bool imageInitiallyCollapsed, - bool fileChangesInitiallyCollapsed) + bool fileChangesInitiallyCollapsed, + std::shared_ptr preparedMarkdownDocument) : owner(owner), current(initial), collapsed(initiallyCollapsed(initial.kind, commandInitiallyCollapsed, imageInitiallyCollapsed, - fileChangesInitiallyCollapsed)) { + fileChangesInitiallyCollapsed)), + preparedMarkdownDocument(std::move(preparedMarkdownDocument)) { owner->setObjectName(QStringLiteral("conversationCard")); owner->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); owner->setProperty("conversationCardKey", @@ -1128,6 +1258,10 @@ class ConversationCard::Impl final { contentLayout = new QVBoxLayout(content); contentLayout->setContentsMargins(0, 0, 0, 0); contentLayout->setSpacing(6); + const QMargins cardMargins = layout->contentsMargins(); + content->resize(std::max(1, owner->contentsRect().width() - + cardMargins.left() - cardMargins.right()), + 1); layout->addWidget(content); QObject::connect(disclosure, &QToolButton::clicked, owner, @@ -1259,6 +1393,8 @@ class ConversationCard::Impl final { for (QLabel *label : {title, body, metadata}) if (label) label->setStyleSheet(QString{}); + if (markdownBody) + markdownBody->setStyleSheet(QString{}); if (metadata) { metadata->clear(); metadata->hide(); @@ -1407,6 +1543,16 @@ class ConversationCard::Impl final { contentLayout->addWidget(images); } + std::shared_ptr takePreparedMarkdownDocument() { + return std::exchange(preparedMarkdownDocument, {}); + } + + int markdownContentWidth() const { + const QMargins margins = layout->contentsMargins(); + return std::max(1, owner->contentsRect().width() - margins.left() - + margins.right()); + } + void setImages(const QStringList &paths, bool forceRebuild = false) { images->setPaths(paths, forceRebuild); } @@ -1414,14 +1560,16 @@ class ConversationCard::Impl final { void createComposition(const UserMessageData &message) { owner->setProperty("messageRole", "user"); title->setText(QStringLiteral("You")); - body = makeMarkdownLabel({}, content); - contentLayout->addWidget(body); + markdownBody = makeMarkdownView(text(message.text), + takePreparedMarkdownDocument(), + markdownContentWidth(), content); + contentLayout->addWidget(markdownBody); createImageContainer(); updateComposition(message); } void updateComposition(const UserMessageData &message) { - setVisibleMarkdown(body, text(message.text)); + setVisibleMarkdown(markdownBody, text(message.text)); setImages(textList(message.imagePaths)); } @@ -1429,8 +1577,10 @@ class ConversationCard::Impl final { owner->setProperty("messageRole", "agent"); title->setText(QStringLiteral("Codex")); showPhase({}, QStringLiteral("agentMessagePhase")); - body = makeMarkdownLabel({}, content); - contentLayout->addWidget(body); + markdownBody = makeMarkdownView(text(message.text), + takePreparedMarkdownDocument(), + markdownContentWidth(), content); + contentLayout->addWidget(markdownBody); updateComposition(message); } @@ -1451,7 +1601,7 @@ class ConversationCard::Impl final { setStatusTone(phase, phaseStatus); layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, message.finalAnswer ? 10 : 8); - setVisibleMarkdown(body, text(message.text)); + setVisibleMarkdown(markdownBody, text(message.text)); } void createComposition(const CommandExecutionData &execution) { @@ -1507,7 +1657,9 @@ class ConversationCard::Impl final { title->setText(QStringLiteral("Agent activity")); metadata = makeLabel({}, "meta", content); body = makeLabel({}, "body", content); - detail = makeMarkdownLabel({}, content); + detail = makeMarkdownView(text(activity.resultText), + takePreparedMarkdownDocument(), + markdownContentWidth(), content); contentLayout->addWidget(metadata); contentLayout->addWidget(body); contentLayout->addWidget(detail); @@ -1523,13 +1675,15 @@ class ConversationCard::Impl final { void createComposition(const ReasoningData &reasoning) { title->setText(QStringLiteral("Reasoning")); - body = makeMarkdownLabel({}, content); - contentLayout->addWidget(body); + markdownBody = makeMarkdownView(text(reasoning.summary), + takePreparedMarkdownDocument(), + markdownContentWidth(), content); + contentLayout->addWidget(markdownBody); updateComposition(reasoning); } void updateComposition(const ReasoningData &reasoning) { - setVisibleMarkdown(body, text(reasoning.summary)); + setVisibleMarkdown(markdownBody, text(reasoning.summary)); } void createComposition(const FileChangesData &changes) { @@ -1576,13 +1730,15 @@ class ConversationCard::Impl final { void createComposition(const PlanData &plan) { title->setText(QStringLiteral("Plan")); - body = makeMarkdownLabel({}, content); - contentLayout->addWidget(body); + markdownBody = makeMarkdownView(presentation::planMarkdown(plan), + takePreparedMarkdownDocument(), + markdownContentWidth(), content); + contentLayout->addWidget(markdownBody); updateComposition(plan); } void updateComposition(const PlanData &plan) { - setVisibleMarkdown(body, presentation::planMarkdown(plan)); + setVisibleMarkdown(markdownBody, presentation::planMarkdown(plan)); } void createComposition(const ImageGenerationData &image) { @@ -1629,9 +1785,11 @@ class ConversationCard::Impl final { QStringLiteral("QFrame#pendingPromptCard{background:transparent;" "border:1px solid transparent;border-radius:8px;}")); title->setText(QStringLiteral("You")); - body = makeMarkdownLabel({}, content); + markdownBody = makeMarkdownView(text(prompt.prompt), + takePreparedMarkdownDocument(), + markdownContentWidth(), content); metadata = makeLabel({}, "meta", content); - contentLayout->addWidget(body); + contentLayout->addWidget(markdownBody); contentLayout->addWidget(metadata); recovery = new QPushButton(QStringLiteral("Restore to composer"), content); recovery->setObjectName(QStringLiteral("promptRecoveryButton")); @@ -1663,7 +1821,7 @@ class ConversationCard::Impl final { } void updateComposition(const LocalPromptData &prompt) { - setVisibleMarkdown(body, text(prompt.prompt)); + setVisibleMarkdown(markdownBody, text(prompt.prompt)); setImages(textList(prompt.imagePaths)); refreshPendingPresentation(); } @@ -1700,11 +1858,17 @@ class ConversationCard::Impl final { changed = changed || previousPhase != lifecycle || phaseWasVisible != !lifecycle.isEmpty(); for (QLabel *label : {title, body, metadata}) { + if (!label) + continue; if (label->styleSheet() != style) { label->setStyleSheet(style); changed = true; } } + if (markdownBody && markdownBody->styleSheet() != style) { + markdownBody->setStyleSheet(style); + changed = true; + } QString status; if (failed) @@ -1774,8 +1938,9 @@ class ConversationCard::Impl final { QWidget *content = nullptr; QVBoxLayout *contentLayout = nullptr; QLabel *body = nullptr; + MarkdownTextView *markdownBody = nullptr; QLabel *metadata = nullptr; - QLabel *detail = nullptr; + MarkdownTextView *detail = nullptr; ContentSizedTextView *command = nullptr; CommandOutputView *output = nullptr; QTimer *animationTimer = nullptr; @@ -1786,6 +1951,7 @@ class ConversationCard::Impl final { std::optional pendingFeedbackDeadlineMs; ImageRibbon *images = nullptr; QStringList fileChangeOpenPaths; + std::shared_ptr preparedMarkdownDocument; bool authoritativeTurnActive = false; int turnRootBottomMargin = 10; }; @@ -1794,13 +1960,16 @@ ConversationCard::ConversationCard(const VisibleCardData &data, QWidget *parent, bool commandInitiallyCollapsed, bool imageInitiallyCollapsed, bool fileChangesInitiallyCollapsed, - int initialWidth) + int initialWidth, + std::shared_ptr + markdownDocument) : QFrame(parent) { if (initialWidth > 0) resize(initialWidth, 1); impl_ = std::make_unique(this, data, commandInitiallyCollapsed, imageInitiallyCollapsed, - fileChangesInitiallyCollapsed); + fileChangesInitiallyCollapsed, + std::move(markdownDocument)); } ConversationCard::~ConversationCard() = default; @@ -1813,6 +1982,13 @@ const VisibleCardData &ConversationCard::data() const noexcept { return impl_->current; } +std::shared_ptr ConversationCard::markdownDocument() const { + if (impl_->markdownBody) + return impl_->markdownBody->sharedDocument(); + return impl_->detail ? impl_->detail->sharedDocument() + : std::shared_ptr{}; +} + bool ConversationCard::isCollapsed() const noexcept { return impl_->collapsed; } void ConversationCard::setCollapsed(bool collapsed) { @@ -1953,10 +2129,13 @@ ConversationCard *createConversationCard(const VisibleCardData &data, bool commandInitiallyCollapsed, bool imageInitiallyCollapsed, bool fileChangesInitiallyCollapsed, - int initialWidth) { + int initialWidth, + std::shared_ptr + markdownDocument) { return new ConversationCard(data, parent, commandInitiallyCollapsed, imageInitiallyCollapsed, - fileChangesInitiallyCollapsed, initialWidth); + fileChangesInitiallyCollapsed, initialWidth, + std::move(markdownDocument)); } } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index 28911e8..1e03513 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -3,10 +3,12 @@ #ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONCARDS_H #define CODEXUI_CODEX_MIDDLE_CONVERSATIONCARDS_H +#include "codex/middle/ConversationPresentation.h" #include "codex/middle/MiddleTypes.h" #include #include +#include #include #include @@ -17,6 +19,7 @@ class QLabel; class QPaintEvent; class QResizeEvent; class QTimer; +class QTextDocument; class QVBoxLayout; class QWheelEvent; @@ -24,6 +27,39 @@ namespace codexui::codex::middle { enum class PresentationImpact { None, PaintOnly, GeometryChanged }; +class MarkdownTextView final : public QTextBrowser { + Q_OBJECT + +public: + explicit MarkdownTextView( + const QString &markdown, + std::shared_ptr preparedDocument = {}, + int initialWidth = 0, + QWidget *parent = nullptr); + ~MarkdownTextView() override; + + bool setContent(const QString &markdown); + [[nodiscard]] const QString &markdownSource() const noexcept; + [[nodiscard]] std::shared_ptr sharedDocument() const; + [[nodiscard]] bool hasSelectedText() const; + [[nodiscard]] int selectionStart() const; + [[nodiscard]] QString selectedText() const; + void setSelection(int start, int length); + [[nodiscard]] int heightForWidth(int width) const override; + [[nodiscard]] QSize sizeHint() const override; + [[nodiscard]] QSize minimumSizeHint() const override; + +private: + void configureDocument(); + void refreshPreferredHeight(int documentWidth) const; + + std::shared_ptr document_; + QString markdown_; + presentation::MarkdownTailState markdownTail_; + mutable int preferredDocumentWidth_ = 0; + mutable int preferredHeight_ = 0; +}; + class ContentSizedTextView : public QTextEdit { public: explicit ContentSizedTextView(int maximumContentHeight, @@ -104,11 +140,13 @@ class ConversationCard : public QFrame { bool commandInitiallyCollapsed = true, bool imageInitiallyCollapsed = true, bool fileChangesInitiallyCollapsed = true, - int initialWidth = 0); + int initialWidth = 0, + std::shared_ptr markdownDocument = {}); ~ConversationCard() override; [[nodiscard]] CardKind cardKind() const noexcept; [[nodiscard]] const VisibleCardData &data() const noexcept; + [[nodiscard]] std::shared_ptr markdownDocument() const; [[nodiscard]] bool isCollapsed() const noexcept; void setCollapsed(bool collapsed); bool setAuthoritativeTurnActive(bool active); @@ -151,7 +189,8 @@ createConversationCard(const VisibleCardData &data, QWidget *parent = nullptr, bool commandInitiallyCollapsed = true, bool imageInitiallyCollapsed = true, bool fileChangesInitiallyCollapsed = true, - int initialWidth = 0); + int initialWidth = 0, + std::shared_ptr markdownDocument = {}); } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationPresentation.cpp b/src/codex/middle/ConversationPresentation.cpp index 4480380..69f4103 100644 --- a/src/codex/middle/ConversationPresentation.cpp +++ b/src/codex/middle/ConversationPresentation.cpp @@ -6,6 +6,9 @@ #include "codex/ui/UiStyle.h" #include +#include +#include +#include #include #include @@ -15,6 +18,10 @@ namespace { constexpr qsizetype MaximumGenericActivityCharacters = 4096; +constexpr QTextDocument::MarkdownFeatures MarkdownFeatures = + QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub) | + QTextDocument::MarkdownNoHTML; + QString text(std::string_view value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -33,6 +40,82 @@ QString displayChangeKind(std::string_view kind) { return UiStyle::humanizeLabel(text(kind)); } +qsizetype lastSimpleMarkdownParagraphStart(QStringView source) { + qsizetype paragraphStart = 0; + qsizetype candidateStart = 0; + qsizetype lineStart = 0; + while (lineStart <= source.size()) { + qsizetype lineEnd = source.indexOf(QLatin1Char('\n'), lineStart); + if (lineEnd < 0) + lineEnd = source.size(); + QStringView line = source.sliced(lineStart, lineEnd - lineStart); + if (!line.isEmpty() && line.back() == QLatin1Char('\r')) + line.chop(1); + bool blank = true; + for (QChar character : line) { + if (!character.isSpace()) { + blank = false; + break; + } + } + if (blank) { + candidateStart = std::min(source.size(), lineEnd + 1); + } else if (candidateStart > paragraphStart) { + paragraphStart = candidateStart; + } + if (lineEnd == source.size()) + break; + lineStart = lineEnd + 1; + } + return paragraphStart; +} + +bool simpleMarkdownParagraphs(QStringView source) { + qsizetype lineStart = 0; + while (lineStart <= source.size()) { + qsizetype lineEnd = source.indexOf(QLatin1Char('\n'), lineStart); + if (lineEnd < 0) + lineEnd = source.size(); + QStringView line = source.sliced(lineStart, lineEnd - lineStart); + if (!line.isEmpty() && line.back() == QLatin1Char('\r')) + line.chop(1); + qsizetype indentation = 0; + while (indentation < line.size() && + line.at(indentation) == QLatin1Char(' ')) + ++indentation; + const QStringView content = line.sliced(indentation); + const bool heading = + content.startsWith(QLatin1Char('#')) && + (content.size() == 1 || content.at(1).isSpace()); + const bool quote = content.startsWith(QLatin1Char('>')); + const bool fence = content.startsWith(QLatin1StringView("```")) || + content.startsWith(QLatin1StringView("~~~")); + const bool unorderedList = + content.size() >= 2 && + (content.at(0) == QLatin1Char('-') || + content.at(0) == QLatin1Char('*') || + content.at(0) == QLatin1Char('+')) && + content.at(1).isSpace(); + qsizetype digits = 0; + while (digits < content.size() && content.at(digits).isDigit()) + ++digits; + const bool orderedList = + digits > 0 && digits + 1 < content.size() && + (content.at(digits) == QLatin1Char('.') || + content.at(digits) == QLatin1Char(')')) && + content.at(digits + 1).isSpace(); + const bool referenceDefinition = content.startsWith(QLatin1Char('[')); + const bool table = content.contains(QLatin1Char('|')); + if (indentation >= 4 || heading || quote || fence || unorderedList || + orderedList || referenceDefinition || table) + return false; + if (lineEnd == source.size()) + break; + lineStart = lineEnd + 1; + } + return true; +} + } // namespace QString statusLabel(std::string_view status) { @@ -106,4 +189,39 @@ QString boundedGenericActivityDetail(const GenericActivityData &activity) { return rendered + QStringLiteral("\n\n[Activity details truncated]"); } +MarkdownTailState markdownTailState(const QTextDocument &document, + QStringView markdown) { + if (markdown.isEmpty()) + return {}; + const qsizetype tail = lastSimpleMarkdownParagraphStart(markdown); + if (!simpleMarkdownParagraphs(markdown.sliced(tail))) + return {}; + const QTextBlock lastBlock = document.lastBlock(); + return lastBlock.isValid() ? MarkdownTailState{tail, lastBlock.position()} + : MarkdownTailState{}; +} + +void replaceMarkdownDocument(QTextDocument &document, const QString &markdown, + MarkdownTailState &tailState) { + document.setMarkdown(markdown, MarkdownFeatures); + tailState = markdownTailState(document, QStringView(markdown)); +} + +bool appendMarkdownDocument(QTextDocument &document, QStringView previous, + QStringView next, + MarkdownTailState &tailState) { + if (!tailState.valid() || !next.startsWith(previous)) + return false; + const QStringView reparsedTail = next.sliced(tailState.sourceOffset); + if (!simpleMarkdownParagraphs(reparsedTail)) + return false; + QTextCursor cursor(&document); + cursor.setPosition(tailState.documentPosition); + cursor.movePosition(QTextCursor::End, QTextCursor::KeepAnchor); + cursor.removeSelectedText(); + cursor.insertMarkdown(reparsedTail.toString(), MarkdownFeatures); + tailState = markdownTailState(document, next); + return true; +} + } // namespace codexui::codex::middle::presentation diff --git a/src/codex/middle/ConversationPresentation.h b/src/codex/middle/ConversationPresentation.h index 356a83c..da27c33 100644 --- a/src/codex/middle/ConversationPresentation.h +++ b/src/codex/middle/ConversationPresentation.h @@ -6,11 +6,23 @@ #include "codex/middle/MiddleTypes.h" #include +#include #include +class QTextDocument; + namespace codexui::codex::middle::presentation { +struct MarkdownTailState { + qsizetype sourceOffset = -1; + int documentPosition = -1; + + [[nodiscard]] bool valid() const noexcept { + return sourceOffset >= 0 && documentPosition >= 0; + } +}; + // Pure display-value helpers shared by the passive delegate and the rich card // editor. They own no state and do not decide which renderer a row uses. [[nodiscard]] QString statusLabel(std::string_view status); @@ -21,6 +33,18 @@ namespace codexui::codex::middle::presentation { [[nodiscard]] QString boundedGenericActivityDetail(const GenericActivityData &activity); +// A streamed Markdown document has one mutable trailing block while all +// preceding blocks are already final. These helpers preserve the Qt Markdown +// dialect while replacing only that tail when it is independently reparsable. +void replaceMarkdownDocument(QTextDocument &document, const QString &markdown, + MarkdownTailState &tailState); +[[nodiscard]] MarkdownTailState +markdownTailState(const QTextDocument &document, QStringView markdown); +[[nodiscard]] bool appendMarkdownDocument(QTextDocument &document, + QStringView previous, + QStringView next, + MarkdownTailState &tailState); + } // namespace codexui::codex::middle::presentation #endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONPRESENTATION_H diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index e26ef03..5913e63 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ #include #include #include +#include #include #include @@ -151,6 +153,7 @@ namespace { constexpr int CardSpacing = 8; constexpr int CardFrameExtent = 2; +constexpr int CardBodyHorizontalInsets = 28; constexpr int HistoryButtonHeight = 32; constexpr int NestedCardIndent = 12; constexpr int NativeScrollLineStep = 20; @@ -214,6 +217,13 @@ struct PassivePresentation { int verticalMargin = 10; }; +struct PassivePointerHit { + bool text = false; + bool action = false; + QString link; + QString tooltip; +}; + QFont passiveBlockFont(bool metadata) { QFont font = QApplication::font(); if (metadata) @@ -295,6 +305,16 @@ PassivePresentation passivePresentation(const VisibleCardData &card) { } class ConversationPassiveDelegate final : public QStyledItemDelegate { + struct DocumentRecord { + QString text; + int width = 0; + bool markdown = false; + QFont font; + std::shared_ptr document; + presentation::MarkdownTailState markdownTail; + std::uint64_t used = 0; + }; + public: explicit ConversationPassiveDelegate(QObject *parent) : QStyledItemDelegate(parent) {} @@ -315,7 +335,8 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { const PassivePresentation presentation = passivePresentation(row->card); int height = CardFrameExtent + 24 + 2 * presentation.verticalMargin; if (!collapsed) { - const int bodyWidth = std::max(1, option.rect.width() - 24); + const int bodyWidth = + std::max(1, option.rect.width() - CardBodyHorizontalInsets); bool first = true; for (std::size_t block = 0; block < presentation.blocks.size(); ++block) { const PassiveBlock &value = presentation.blocks[block]; @@ -377,7 +398,8 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { if (!collapsed) { int blockTop = top + 30; - const int bodyWidth = std::max(1, option.rect.width() - 24); + const int bodyWidth = + std::max(1, option.rect.width() - CardBodyHorizontalInsets); for (std::size_t block = 0; block < presentation.blocks.size(); ++block) { const PassiveBlock &value = presentation.blocks[block]; const QFont font = passiveBlockFont(value.metadata); @@ -415,27 +437,166 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { painter->restore(); } + std::shared_ptr + takeMarkdownDocument(const std::string &stableKey, + const VisibleCardData &card, int cardWidth) const { + const PassivePresentation value = passivePresentation(card); + const int bodyWidth = std::max(1, cardWidth - CardBodyHorizontalInsets); + for (std::size_t block = 0; block < value.blocks.size(); ++block) { + const PassiveBlock &candidate = value.blocks[block]; + if (!candidate.markdown) + continue; + const std::string key = stableKey + ':' + std::to_string(block); + const auto found = documents_.find(key); + const QFont font = passiveBlockFont(candidate.metadata); + if (found == documents_.end() || found->second.text != candidate.text || + found->second.width != bodyWidth || !found->second.markdown || + found->second.font != font) + return {}; + std::shared_ptr document = found->second.document; + documents_.erase(found); + incrementProperty(parent(), "conversationDelegateDocumentTransfers"); + return document; + } + return {}; + } + + void adoptMarkdownDocument(const std::string &stableKey, + const VisibleCardData &card, int cardWidth, + std::shared_ptr document) const { + if (!document) + return; + const PassivePresentation value = passivePresentation(card); + const int bodyWidth = std::max(1, cardWidth - CardBodyHorizontalInsets); + for (std::size_t block = 0; block < value.blocks.size(); ++block) { + const PassiveBlock &candidate = value.blocks[block]; + if (!candidate.markdown) + continue; + const std::string key = stableKey + ':' + std::to_string(block); + if (!documents_.contains(key) && documents_.size() >= 128) { + const auto oldest = + std::ranges::min_element(documents_, {}, [](const auto &entry) { + return entry.second.used; + }); + if (oldest != documents_.end()) + documents_.erase(oldest); + } + document->setTextWidth(bodyWidth); + DocumentRecord record; + record.text = candidate.text; + record.width = bodyWidth; + record.markdown = true; + record.font = passiveBlockFont(candidate.metadata); + record.document = std::move(document); + record.markdownTail = presentation::markdownTailState( + *record.document, QStringView(record.text)); + record.used = ++documentUse_; + documents_.insert_or_assign(key, std::move(record)); + incrementProperty(parent(), "conversationDelegateDocumentReturns"); + return; + } + } + + PassivePointerHit pointerHit(const QStyleOptionViewItem &option, + const QModelIndex &index, + const QPoint &position, + bool collapsed) const { + const auto *conversation = + qobject_cast(index.model()); + const ConversationItemModel::Row *row = + conversation ? conversation->row(index.row()) : nullptr; + if (!row || !option.rect.contains(position)) + return {}; + const PassivePresentation presentation = passivePresentation(row->card); + const int top = option.rect.top() + presentation.verticalMargin; + if (QRect(option.rect.right() - 52, top, 24, 24).contains(position)) + return {.action = true, .tooltip = QStringLiteral("Copy")}; + if (QRect(option.rect.right() - 28, top, 24, 24).contains(position)) + return {.action = true, + .tooltip = collapsed ? QStringLiteral("Expand") + : QStringLiteral("Collapse")}; + if (collapsed) + return {}; + + int blockTop = top + 30; + const int bodyWidth = + std::max(1, option.rect.width() - CardBodyHorizontalInsets); + for (std::size_t block = 0; block < presentation.blocks.size(); ++block) { + const PassiveBlock &value = presentation.blocks[block]; + const std::string key = row->stableKey + ':' + std::to_string(block); + const auto found = documents_.find(key); + const QFont font = passiveBlockFont(value.metadata); + if (found == documents_.end() || found->second.text != value.text || + found->second.width != bodyWidth || + found->second.markdown != value.markdown || + found->second.font != font) + return {}; + const int height = + std::max(1, static_cast(std::ceil( + found->second.document->size().height())) + + (value.markdown ? 4 : 0)); + const QRect blockRect(option.rect.left() + 12, blockTop, bodyWidth, + height); + if (blockRect.contains(position)) { + const QPoint local = position - blockRect.topLeft(); + const int character = + found->second.document->documentLayout()->hitTest( + QPointF(local), Qt::ExactHit); + if (character < 0) + return {}; + QTextCursor cursor(found->second.document.get()); + cursor.setPosition(std::clamp( + character, 0, found->second.document->characterCount() - 1)); + const QString link = cursor.charFormat().anchorHref(); + return {.text = true, .link = link, .tooltip = link}; + } + blockTop += height + 6; + } + return {}; + } + private: - struct DocumentRecord { - QString text; - int width = 0; - bool markdown = false; - QFont font; - std::unique_ptr document; - std::uint64_t used = 0; - }; + bool appendDocument(DocumentRecord &record, const PassiveBlock &value) const { + if (!value.text.startsWith(record.text)) + return false; + if (record.markdown) { + if (!presentation::appendMarkdownDocument( + *record.document, QStringView(record.text), + QStringView(value.text), record.markdownTail)) + return false; + } else { + QTextCursor cursor(record.document.get()); + cursor.movePosition(QTextCursor::End); + cursor.insertText(value.text.sliced(record.text.size())); + } + record.text = value.text; + incrementProperty(parent(), "conversationDelegateIncrementalAppends"); + return true; + } QTextDocument *document(const std::string &stableKey, std::size_t block, const PassiveBlock &value, int width, const QFont &font) const { const std::string key = stableKey + ':' + std::to_string(block); auto found = documents_.find(key); - if (found == documents_.end() || found->second.text != value.text || - found->second.width != width || - found->second.markdown != value.markdown || - found->second.font != font) { - if (found != documents_.end()) - documents_.erase(found); + const bool compatible = + found != documents_.end() && + found->second.markdown == value.markdown && found->second.font == font; + if (found != documents_.end() && !compatible) { + documents_.erase(found); + found = documents_.end(); + } + if (compatible && found->second.text != value.text && + !appendDocument(found->second, value)) { + documents_.erase(found); + found = documents_.end(); + } + if (found != documents_.end() && found->second.width != width) { + found->second.width = width; + found->second.document->setTextWidth(width); + incrementProperty(parent(), "conversationDelegateWidthRelayouts"); + } + if (found == documents_.end()) { if (documents_.size() >= 128) { const auto oldest = std::ranges::min_element(documents_, {}, [](const auto &entry) { @@ -449,7 +610,7 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { record.width = width; record.markdown = value.markdown; record.font = font; - record.document = std::make_unique(); + record.document = std::make_shared(); record.document->setDocumentMargin(0); record.document->setDefaultFont(font); record.document->setDefaultStyleSheet( @@ -458,14 +619,13 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { textOption.setWrapMode(QTextOption::WordWrap); record.document->setDefaultTextOption(textOption); if (value.markdown) - record.document->setMarkdown(value.text, - QTextDocument::MarkdownFeatures( - QTextDocument::MarkdownDialectGitHub) | - QTextDocument::MarkdownNoHTML); + presentation::replaceMarkdownDocument( + *record.document, value.text, record.markdownTail); else record.document->setPlainText(value.text); record.document->setTextWidth(width); found = documents_.emplace(key, std::move(record)).first; + incrementProperty(parent(), "conversationDelegateDocumentRebuilds"); } found->second.used = ++documentUse_; return found->second.document.get(); @@ -2445,10 +2605,15 @@ ConversationCard *ConversationView::createCard(const VisibleCardData &data, QWidget *parent, const std::string &key, int width) { + auto *delegate = + static_cast(itemDelegate()); + std::shared_ptr markdownDocument = + delegate->takeMarkdownDocument(key, data, width); ConversationCard *card = createConversationCard( data, parent, !presentationOptions_.commandsInitiallyExpanded, !presentationOptions_.imagesInitiallyExpanded, - !presentationOptions_.fileChangesInitiallyExpanded, width); + !presentationOptions_.fileChangesInitiallyExpanded, width, + std::move(markdownDocument)); card->setProperty("conversationAnchorKey", QString::fromStdString(key)); if (const auto collapsed = cardCollapsedStates_.find(key); collapsed != cardCollapsedStates_.end()) @@ -2564,6 +2729,15 @@ void ConversationView::releaseCard(const std::string &key, cardCollapsedStates_.insert_or_assign(key, card->isCollapsed()); if (const auto state = card->commandOutputScrollState()) commandOutputStates_.insert_or_assign(key, *state); + const QModelIndex index = model_->indexForStableKey(key); + const ConversationItemModel::Row *row = + index.isValid() ? model_->row(index.row()) : nullptr; + if (index.isValid() && row && card->canApply(row->card)) { + auto *delegate = + static_cast(itemDelegate()); + delegate->adoptMarkdownDocument(key, row->card, rowWidth(*row), + card->markdownDocument()); + } card->setViewportVisible(false); delete card; incrementProperty(this, "conversationRowsReleased"); @@ -3195,6 +3369,30 @@ void ConversationView::scrollContentsBy(int dx, int dy) { viewport()->update(); } +bool ConversationView::viewportEvent(QEvent *event) { + if (event && event->type() == QEvent::ToolTip) { + auto *help = static_cast(event); + const QModelIndex index = indexAt(help->pos()); + const ConversationItemModel::Row *row = + index.isValid() ? model_->row(index.row()) : nullptr; + if (index.isValid() && row && !cardForStableKey(row->stableKey)) { + QStyleOptionViewItem option; + option.initFrom(this); + option.rect = visualRect(index); + const auto *delegate = + static_cast(itemDelegate()); + const PassivePointerHit hit = + delegate->pointerHit(option, index, help->pos(), rowCollapsed(*row)); + if (!hit.tooltip.isEmpty()) { + QToolTip::showText(help->globalPos(), hit.tooltip, viewport(), + option.rect); + return true; + } + } + } + return QAbstractItemView::viewportEvent(event); +} + bool ConversationView::eventFilter(QObject *watched, QEvent *event) { auto *widget = qobject_cast(watched); ConversationCard *card = nullptr; @@ -3272,23 +3470,24 @@ void ConversationView::mouseMoveEvent(QMouseEvent *event) { return; } const QModelIndex index = indexAt(event->position().toPoint()); - if (index.isValid() && - !cardForStableKey(index.data(ConversationItemModel::StableKeyRole) - .toString() - .toStdString())) { - const Anchor anchor = captureAnchor(); - const bool follow = mode_ == Mode::Following; - const qint64 before = heights_.totalHeight(); - static_cast(materializeRow(index.row(), true)); - if (before != heights_.totalHeight()) { - updateScrollRange(); - if (follow) - setScrollValue(verticalScrollBar()->maximum()); - else - restoreAnchor(anchor); - } - layoutMaterializedCards(); - updateMaterializationProperties(); + const ConversationItemModel::Row *row = + index.isValid() ? model_->row(index.row()) : nullptr; + if (index.isValid() && row && !cardForStableKey(row->stableKey)) { + QStyleOptionViewItem option; + option.initFrom(this); + option.rect = visualRect(index); + const auto *delegate = + static_cast(itemDelegate()); + const PassivePointerHit hit = delegate->pointerHit( + option, index, event->position().toPoint(), rowCollapsed(*row)); + if (hit.action || !hit.link.isEmpty()) + viewport()->setCursor(Qt::PointingHandCursor); + else if (hit.text) + viewport()->setCursor(Qt::IBeamCursor); + else + viewport()->unsetCursor(); + } else { + viewport()->unsetCursor(); } QAbstractItemView::mouseMoveEvent(event); } diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index 7db292d..0f4d594 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -149,6 +149,7 @@ class ConversationView final : public QAbstractItemView { visualRegionForSelection(const QItemSelection &selection) const override; void updateGeometries() override; void scrollContentsBy(int dx, int dy) override; + bool viewportEvent(QEvent *event) override; bool eventFilter(QObject *watched, QEvent *event) override; void currentChanged(const QModelIndex ¤t, const QModelIndex &previous) override; diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 8dd2618..999989a 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -772,10 +772,15 @@ ConversationCard *card(ConversationView &view, const std::string &key) { if (!index.isValid() || !geometry.intersects(view.viewport()->rect())) return nullptr; const QPoint position = geometry.intersected(view.viewport()->rect()).center(); - QMouseEvent move(QEvent::MouseMove, QPointF(position), QPointF(position), - view.viewport()->mapToGlobal(position), Qt::NoButton, - Qt::NoButton, Qt::NoModifier); - QApplication::sendEvent(view.viewport(), &move); + QMouseEvent press(QEvent::MouseButtonPress, QPointF(position), + QPointF(position), view.viewport()->mapToGlobal(position), + Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &press); + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(position), + QPointF(position), + view.viewport()->mapToGlobal(position), Qt::LeftButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &release); QApplication::processEvents(); return findMaterialized(); } @@ -2131,11 +2136,16 @@ bool testMutableCardsAndCommandOutput() { for (const auto &value : snapshot.sections.front().cards) identities[stableKey(value.key)] = card(view, stableKey(value.key)); auto containsLabelText = [](QWidget *parent, const QString &needle) { - return std::ranges::any_of( + const bool labelContains = std::ranges::any_of( parent->findChildren(), [&needle](QLabel *label) { - return label->text().contains(needle) || - label->property("markdownSource").toString().contains(needle); + return label->text().contains(needle); }); + return labelContains || + std::ranges::any_of( + parent->findChildren(), + [&needle](MarkdownTextView *view) { + return view->markdownSource().contains(needle); + }); }; auto titleText = [](QWidget *parent) { const auto labels = parent->findChildren(); @@ -2177,22 +2187,17 @@ bool testMutableCardsAndCommandOutput() { CardKey{AuthoritativeItemKey{thread, "turn", "activity"}})]; auto *activityStatus = activityCard->findChild(QStringLiteral("agentActivityStatus")); - const auto userLabels = userCard->findChildren(); - result &= - expect(std::ranges::any_of( - userLabels, - [](QLabel *label) { - return label->property("markdownSource").toString() == - QStringLiteral( - "hello **Markdown**\n\n| Value | Rating |\n" - "|---|---|\n| State | 10 |\n\n" - "[Docs](https://example.com)") && - label->textFormat() == Qt::RichText && - label->text().contains(QStringLiteral("textInteractionFlags().testFlag( - Qt::LinksAccessibleByKeyboard); - }), - "authoritative user messages render GitHub Markdown tables"); + auto *userMarkdown = userCard->findChild(); + result &= expect( + userMarkdown && + userMarkdown->markdownSource() == + QStringLiteral("hello **Markdown**\n\n| Value | Rating |\n" + "|---|---|\n| State | 10 |\n\n" + "[Docs](https://example.com)") && + userMarkdown->toHtml().contains(QStringLiteral("textInteractionFlags().testFlag( + Qt::LinksAccessibleByKeyboard), + "authoritative user messages render GitHub Markdown tables"); result &= expect( titleText(agentCardWidget) == QStringLiteral("Codex") && agentPhase && agentPhase->text() == QStringLiteral("update") && @@ -2248,16 +2253,11 @@ bool testMutableCardsAndCommandOutput() { commandText->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, "short command text trims empty lines and uses its content height"); auto *pendingCard = identities[stableKey(CardKey{LocalPromptKey{77}})]; + auto *pendingMarkdown = pendingCard->findChild(); result &= expect( - std::ranges::any_of( - pendingCard->findChildren(), - [](QLabel *label) { - return label->property("markdownSource") - .toString() - .contains(QStringLiteral( - "[report.pdf](file:///tmp/report.pdf)")) && - label->textFormat() == Qt::RichText; - }), + pendingMarkdown && + pendingMarkdown->markdownSource().contains( + QStringLiteral("[report.pdf](file:///tmp/report.pdf)")), "pending prompts render file links before authoritative replacement"); commandCard->setCollapsed(false); @@ -3094,8 +3094,15 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { "/workspace"}}}}); const auto containsText = [](QWidget *widget, const QString &needle) { return std::ranges::any_of( - widget->findChildren(), - [&needle](QLabel *label) { return label->text().contains(needle); }); + widget->findChildren(), + [&needle](QLabel *label) { + return label->text().contains(needle); + }) || + std::ranges::any_of( + widget->findChildren(), + [&needle](MarkdownTextView *view) { + return view->markdownSource().contains(needle); + }); }; ConversationView view; @@ -3354,12 +3361,7 @@ bool testRootlessFinalAnswerGeometrySettlement() { result &= expect(applyConversation(view, snapshot), "rootless final answer accepts an authoritative update"); spin(); - QLabel *answerBody = nullptr; - for (QLabel *label : answerCard->findChildren()) - if (!label->property("markdownSource").toString().isEmpty()) { - answerBody = label; - break; - } + MarkdownTextView *answerBody = answerCard->findChild(); result &= expect(answerBody && answerCard->height() == answerCard->minimumHeight() && answerCard->height() < 200 && @@ -3437,25 +3439,15 @@ bool testRetainedNestedFinalAnswerGeometrySettlement() { view.scrollTo(answerIndex, QAbstractItemView::PositionAtTop); spin(40); ConversationCard *answerCard = card(view, stableKey(answer.key)); - QLabel *answerBody = nullptr; - if (answerCard) - for (QLabel *label : answerCard->findChildren()) - if (label->property("markdownSource").toString() == markdown) { - answerBody = label; - break; - } + MarkdownTextView *answerBody = + answerCard ? answerCard->findChild() : nullptr; int documentHeight = 0; - if (answerBody) { - QTextDocument document; - document.setDefaultFont(answerBody->font()); - document.setDocumentMargin(0); - document.setHtml(answerBody->text()); - document.setTextWidth(answerBody->width()); - documentHeight = static_cast(std::ceil(document.size().height())); - } + if (answerBody) + documentHeight = + static_cast(std::ceil(answerBody->document()->size().height())); result &= expect( promptIndex.isValid() && answerIndex.isValid() && answerCard && - answerBody && + answerBody && answerBody->markdownSource() == markdown && promptIndex.data(ConversationItemModel::TurnRootRole).toBool() && answerIndex.data(ConversationItemModel::NestedCardRole).toBool() && answerBody->height() >= diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 9ada14e..4d3976c 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include @@ -861,10 +863,15 @@ bool virtualTurnSurfaceAndInteractivePromotion() { Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(view.viewport(), &move); settle(); + sendViewportMouse(view, QEvent::MouseButtonPress, hover, Qt::LeftButton, + Qt::LeftButton); + sendViewportMouse(view, QEvent::MouseButtonRelease, hover, Qt::LeftButton, + Qt::NoButton); + settle(); ConversationCard *promoted = materializedCard(view, stableKey(root.key)); result &= expect(promoted && promoted->property("virtualTurnRoot").toBool() && promoted->parentWidget() == view.viewport(), - "hover promotes only the interactive root fragment to a " + "press promotes only the interactive root fragment to a " "real viewport editor"); result &= expect(view.materializedCardCount() == 1, "interactive promotion remains row-local and bounded"); @@ -1038,17 +1045,17 @@ bool selectionFocusAndOneGesturePromotion() { Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(view.viewport(), &move); settle(); + result &= expect(materializedCard(view, identity.first) == nullptr, + "passive Markdown hover constructs no editor"); + sendViewportMouse(view, QEvent::MouseButtonPress, hover, Qt::LeftButton, + Qt::LeftButton); + sendViewportMouse(view, QEvent::MouseButtonRelease, hover, Qt::LeftButton, + Qt::NoButton); + settle(); ConversationCard *card = materializedCard(view, identity.first); - QLabel *body = nullptr; - if (card) { - for (QLabel *label : card->findChildren()) - if (label->property("markdownSource").isValid()) { - body = label; - break; - } - } + MarkdownTextView *body = card ? card->findChild() : nullptr; result &= expect(card && body, - "hover promotes selectable Markdown to its real card"); + "one press promotes selectable Markdown to its real card"); if (!body) return false; body->setSelection(0, 6); @@ -1077,20 +1084,13 @@ bool selectionFocusAndOneGesturePromotion() { view.scrollTo(index, QAbstractItemView::PositionAtTop); settle(); const QPoint restoredHover = view.visualRect(index).center(); - QMouseEvent restoredMove(QEvent::MouseMove, QPointF(restoredHover), - QPointF(restoredHover), - view.viewport()->mapToGlobal(restoredHover), - Qt::NoButton, Qt::NoButton, Qt::NoModifier); - QApplication::sendEvent(view.viewport(), &restoredMove); + sendViewportMouse(view, QEvent::MouseButtonPress, restoredHover, + Qt::LeftButton, Qt::LeftButton); + sendViewportMouse(view, QEvent::MouseButtonRelease, restoredHover, + Qt::LeftButton, Qt::NoButton); settle(); card = materializedCard(view, identity.first); - body = nullptr; - if (card) - for (QLabel *label : card->findChildren()) - if (label->property("markdownSource").isValid()) { - body = label; - break; - } + body = card ? card->findChild() : nullptr; result &= expect(body && body->selectedText() == selected, "selection is restored after virtualized release and return"); @@ -1337,6 +1337,162 @@ bool largeIncomingCommandUsesBoundedFinalWidthLayout() { return result; } +bool streamingMarkdownReparsesOnlyMutableTail() { + std::string markdown; + markdown.reserve(192 * 1024); + for (int paragraph = 0; paragraph < 2400; ++paragraph) { + markdown += "Stable paragraph "; + markdown += std::to_string(paragraph); + markdown += " remains unchanged while the visible tail streams.\n\n"; + } + markdown += "Mutable **tail"; + + VisibleCardData update{ + AuthoritativeItemKey{"markdown-tail", "turn", "update"}, + CardKind::AgentMessage, + "markdown-tail", + "turn", + "update", + AgentMessageData{std::move(markdown), false}}; + ConversationSnapshot snapshot; + snapshot.threadId = update.threadId; + VisibleCardData sentinel{ + AuthoritativeItemKey{"markdown-tail", "sentinel-turn", "sentinel"}, + CardKind::UserMessage, + "markdown-tail", + "sentinel-turn", + "sentinel", + UserMessageData{"Keep the keyboard current row separate."}}; + snapshot.sections.push_back( + {"sentinel-section", sentinel.turnId, {sentinel}, std::nullopt}); + snapshot.sections.push_back( + {"markdown-section", update.turnId, {update}, std::nullopt}); + + ConversationView view; + view.resize(760, 480); + view.show(); + bool result = + expect(view.reconcile(std::move(snapshot)), + "large Markdown tail fixture reconciles through the delegate"); + settle(); + view.setCurrentIndex(view.conversationModel()->index(0)); + settle(); + const qulonglong rebuildsBefore = + view.property("conversationDelegateDocumentRebuilds").toULongLong(); + const qulonglong appendsBefore = + view.property("conversationDelegateIncrementalAppends").toULongLong(); + + auto &message = std::get(update.payload); + message.text += "** with a [link](https://example.com).\n\n" + "The final paragraph is complete."; + QElapsedTimer timer; + timer.start(); + result &= expect(view.applyCardPresentation(update).has_value(), + "the visible Markdown row accepts its streamed suffix"); + const qint64 updateMicros = timer.nsecsElapsed() / 1000; + settle(); + const QModelIndex index = + view.conversationModel()->indexForStableKey(stableKey(update.key)); + const int passiveHeight = view.visualRect(index).height(); + result &= expect( + view.property("conversationDelegateDocumentRebuilds").toULongLong() == + rebuildsBefore && + view.property("conversationDelegateIncrementalAppends") + .toULongLong() == appendsBefore + 1, + "streaming reparses only the mutable Markdown tail instead of rebuilding " + "the unchanged document"); + view.setProperty("largeMarkdownTailUpdateMicros", updateMicros); + + const QPoint hover = + view.visualRect(index).intersected(view.viewport()->rect()).center(); + QMouseEvent move(QEvent::MouseMove, QPointF(hover), QPointF(hover), + view.viewport()->mapToGlobal(hover), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &move); + settle(); + result &= expect(materializedCard(view, stableKey(update.key)) == nullptr, + "hovering a very large update performs no QWidget work"); + const qulonglong transfersBefore = + view.property("conversationDelegateDocumentTransfers").toULongLong(); + QElapsedTimer promotionTimer; + promotionTimer.start(); + sendViewportMouse(view, QEvent::MouseButtonPress, hover, Qt::LeftButton, + Qt::LeftButton); + sendViewportMouse(view, QEvent::MouseButtonRelease, hover, Qt::LeftButton, + Qt::NoButton); + const qint64 promotionMicros = promotionTimer.nsecsElapsed() / 1000; + settle(); + ConversationCard *card = materializedCard(view, stableKey(update.key)); + MarkdownTextView *body = card ? card->findChild() : nullptr; + view.setProperty("largeMarkdownPromotionMicros", promotionMicros); + result &= expect( + card && body && + view.property("conversationDelegateDocumentTransfers") + .toULongLong() == transfersBefore + 1 && + body->property("markdownSource").toString() == + QString::fromStdString(message.text) && + body->toHtml().contains(QStringLiteral("https://example.com")) && + card->height() == passiveHeight, + "interaction promotion preserves the complete streamed Markdown, link, " + "and delegate geometry"); + return result; +} + +bool passiveMarkdownHoverKeepsLinkSemanticsWithoutAnEditor() { + VisibleCardData sentinel{ + AuthoritativeItemKey{"passive-link", "sentinel", "sentinel"}, + CardKind::UserMessage, + "passive-link", + "sentinel", + "sentinel", + UserMessageData{"Keep current focus separate."}}; + VisibleCardData linked{ + AuthoritativeItemKey{"passive-link", "turn", "linked"}, + CardKind::AgentMessage, + "passive-link", + "turn", + "linked", + AgentMessageData{"[Docs](https://example.com)", false}}; + ConversationSnapshot snapshot; + snapshot.threadId = "passive-link"; + snapshot.sections.push_back( + {"sentinel-section", sentinel.turnId, {sentinel}, std::nullopt}); + snapshot.sections.push_back( + {"linked-section", linked.turnId, {linked}, std::nullopt}); + + ConversationView view; + view.resize(620, 320); + view.show(); + bool result = expect(view.reconcile(std::move(snapshot)), + "passive link fixture reconciles"); + settle(); + view.setCurrentIndex(view.conversationModel()->index(0)); + settle(); + const QModelIndex index = + view.conversationModel()->indexForStableKey(stableKey(linked.key)); + const QRect row = view.visualRect(index); + const QPoint anchor(row.left() + 16, row.top() + 46); + QMouseEvent move(QEvent::MouseMove, QPointF(anchor), QPointF(anchor), + view.viewport()->mapToGlobal(anchor), Qt::NoButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view.viewport(), &move); + settle(); + result &= expect( + !materializedCard(view, stableKey(linked.key)) && + view.viewport()->cursor().shape() == Qt::PointingHandCursor, + "a passive Markdown link keeps its pointing cursor without constructing " + "an editor"); + + QHelpEvent tooltip(QEvent::ToolTip, anchor, + view.viewport()->mapToGlobal(anchor)); + QApplication::sendEvent(view.viewport(), &tooltip); + result &= expect( + QToolTip::text() == QStringLiteral("https://example.com"), + "a passive Markdown link exposes its established URL tooltip"); + QToolTip::hideText(); + return result; +} + bool outsideTextDragDoesNotReenterTheView() { ConversationSnapshot snapshot; snapshot.threadId = "virtual-thread"; @@ -1371,13 +1527,7 @@ bool outsideTextDragDoesNotReenterTheView() { settle(); ConversationCard *card = materializedCard(view, stableKey(update.key)); - QLabel *body = nullptr; - if (card) - for (QLabel *label : card->findChildren()) - if (label->property("markdownSource").isValid()) { - body = label; - break; - } + MarkdownTextView *body = card ? card->findChild() : nullptr; result &= expect(card && body && view.currentIndex() == index, "padding press remains a bounded row interaction"); if (!body) @@ -1424,6 +1574,8 @@ int main(int argc, char **argv) { passiveAndInteractivePresentationShareExactGeometry() && bidirectionalLazyMeasurementPreservesNativeScrollMotion() && largeIncomingCommandUsesBoundedFinalWidthLayout() && + streamingMarkdownReparsesOnlyMutableTail() && + passiveMarkdownHoverKeepsLinkSemanticsWithoutAnEditor() && selectionFocusAndOneGesturePromotion() && outsideTextDragDoesNotReenterTheView(); if (result) From 04e8344e8ca8c6f90d1029b056c5deda85586c7a Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 11 Sep 2026 00:01:54 +0200 Subject: [PATCH 30/39] Cache image thumbnail presentation --- src/codex/middle/ConversationCards.cpp | 121 ++++++++++++++++++------- tests/codex/ConversationCardsTest.cpp | 44 ++++++++- 2 files changed, 127 insertions(+), 38 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 9b18996..97a9b34 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -281,10 +282,36 @@ bool openLocalFile(const QString &path) { QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath())); } +struct ImageFileIdentity { + QString absolutePath; + qint64 size = -1; + qint64 modifiedMilliseconds = -1; + qint64 metadataChangedMilliseconds = -1; + bool file = false; + + bool operator==(const ImageFileIdentity &) const = default; +}; + +ImageFileIdentity imageFileIdentity(const QString &path) { + const QFileInfo info(path); + return {info.absoluteFilePath(), info.size(), + info.lastModified().toMSecsSinceEpoch(), + info.metadataChangeTime().toMSecsSinceEpoch(), info.isFile()}; +} + +QString thumbnailCacheKey(const ImageFileIdentity &identity) { + return QStringLiteral("codexui-thumbnail:%1:%2:%3:%4") + .arg(identity.absolutePath) + .arg(identity.size) + .arg(identity.modifiedMilliseconds) + .arg(identity.metadataChangedMilliseconds); +} + class ImageThumbnail final : public QLabel { public: ImageThumbnail(QString path, QWidget *parent) - : QLabel(parent), path_(std::move(path)) { + : QLabel(parent), path_(std::move(path)), + identity_(imageFileIdentity(path_)) { setObjectName(QStringLiteral("messageImageThumbnail")); setProperty("kind", "imageThumbnail"); setCursor(Qt::PointingHandCursor); @@ -294,15 +321,30 @@ class ImageThumbnail final : public QLabel { setMinimumSize(72, 48); setMaximumSize(ThumbnailMaximumWidth, ThumbnailMaximumHeight); - QImageReader reader(path_); - reader.setAutoTransform(true); - const QSize source = reader.size(); - if (source.isValid()) - reader.setScaledSize(source.scaled(ThumbnailMaximumWidth - 8, - ThumbnailMaximumHeight - 8, - Qt::KeepAspectRatio)); - const QImage image = reader.read(); - if (image.isNull()) { + QPixmap pixmap; + const bool cacheHit = identity_.file && + QPixmapCache::find(thumbnailCacheKey(identity_), + &pixmap); + setProperty("imageCacheHit", cacheHit); + if (!cacheHit) { + QElapsedTimer decodeTimer; + decodeTimer.start(); + QImageReader reader(identity_.absolutePath); + reader.setAutoTransform(true); + const QSize source = reader.size(); + if (source.isValid()) + reader.setScaledSize(source.scaled(ThumbnailMaximumWidth - 8, + ThumbnailMaximumHeight - 8, + Qt::KeepAspectRatio)); + const QImage image = reader.read(); + setProperty("imageDecodeMicros", decodeTimer.nsecsElapsed() / 1000); + setProperty("imageDecodePerformed", true); + if (!image.isNull()) { + pixmap = QPixmap::fromImage(image); + QPixmapCache::insert(thumbnailCacheKey(identity_), pixmap); + } + } + if (pixmap.isNull()) { setAccessibleName(QStringLiteral("Image unavailable: %1") .arg(QFileInfo(path_).fileName())); setText(QStringLiteral("Image unavailable\n%1") @@ -316,13 +358,12 @@ class ImageThumbnail final : public QLabel { QStringLiteral("Open image: %1").arg(QFileInfo(path_).fileName())); setFocusPolicy(Qt::StrongFocus); setProperty("imageAvailable", true); - setPixmap(QPixmap::fromImage(image)); - setFixedSize(image.size() + QSize(8, 8)); + setPixmap(pixmap); + setFixedSize(pixmap.size() + QSize(8, 8)); } [[nodiscard]] bool represents(const QString &path) const { - return path_ == path && - property("imageAvailable").toBool() == QFileInfo(path).isFile(); + return path_ == path && identity_ == imageFileIdentity(path); } protected: @@ -368,6 +409,7 @@ class ImageThumbnail final : public QLabel { } QString path_; + ImageFileIdentity identity_; bool leftPressArmed_ = false; }; @@ -396,30 +438,42 @@ class ImageRibbon final : public QScrollArea { hide(); } - void setPaths(const QStringList &paths, bool forceRebuild = false) { - bool matches = !forceRebuild && layout_->count() == paths.size(); - for (qsizetype index = 0; matches && index < paths.size(); ++index) { - const auto *thumbnail = dynamic_cast( - layout_->itemAt(static_cast(index))->widget()); - matches = thumbnail && thumbnail->represents(paths.at(index)); - } - if (matches) { - setVisible(!paths.isEmpty()); - return; + void setPaths(const QStringList &paths) { + bool changed = false; + for (qsizetype index = 0; index < paths.size(); ++index) { + QLayoutItem *item = layout_->itemAt(static_cast(index)); + const auto *thumbnail = + item ? dynamic_cast(item->widget()) : nullptr; + if (thumbnail && thumbnail->represents(paths.at(index))) + continue; + if (item) { + item = layout_->takeAt(static_cast(index)); + delete item->widget(); + delete item; + } + layout_->insertWidget(static_cast(index), + new ImageThumbnail(paths.at(index), strip_), 0, + Qt::AlignVCenter); + changed = true; } - - while (QLayoutItem *item = layout_->takeAt(0)) { + while (layout_->count() > paths.size()) { + QLayoutItem *item = layout_->takeAt(paths.size()); delete item->widget(); delete item; + changed = true; + } + + if (!changed) { + setVisible(!paths.isEmpty()); + return; } - for (const QString &path : paths) - layout_->addWidget(new ImageThumbnail(path, strip_), 0, Qt::AlignVCenter); + const int retainedScroll = horizontalScrollBar()->value(); layout_->activate(); naturalSize_ = layout_->sizeHint().expandedTo(QSize(0, 0)); strip_->setFixedSize(naturalSize_); - horizontalScrollBar()->setValue(0); refreshHeight(); + horizontalScrollBar()->setValue(retainedScroll); setVisible(!paths.isEmpty()); } @@ -1553,8 +1607,8 @@ class ConversationCard::Impl final { margins.right()); } - void setImages(const QStringList &paths, bool forceRebuild = false) { - images->setPaths(paths, forceRebuild); + void setImages(const QStringList &paths) { + images->setPaths(paths); } void createComposition(const UserMessageData &message) { @@ -1757,11 +1811,8 @@ class ConversationCard::Impl final { title->setText(generated ? QStringLiteral("Generated image") : QStringLiteral("Image")); setVisibleText(body, text(image.revisedPrompt)); - // A generated image can become readable at the same path as its status - // advances, so its update remains the authoritative reload boundary. setImages(image.path.empty() ? QStringList{} - : QStringList{text(image.path)}, - true); + : QStringList{text(image.path)}); } void createComposition(const GenericActivityData &activity) { diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 999989a..85923ae 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -7104,15 +7104,20 @@ bool testMessageImagePresentation() { const QString portraitPath = directory.filePath(QStringLiteral("portrait.png")); const QString squarePath = directory.filePath(QStringLiteral("square.png")); + const QString replacementPath = + directory.filePath(QStringLiteral("replacement.png")); QImage source(640, 360, QImage::Format_ARGB32_Premultiplied); source.fill(QColor(QStringLiteral("#2f6feb"))); QImage portrait(320, 640, QImage::Format_ARGB32_Premultiplied); portrait.fill(QColor(QStringLiteral("#6941c6"))); QImage square(420, 420, QImage::Format_ARGB32_Premultiplied); square.fill(QColor(QStringLiteral("#18865e"))); + QImage replacement(500, 260, QImage::Format_ARGB32_Premultiplied); + replacement.fill(QColor(QStringLiteral("#bc5c32"))); bool result = expect(directory.isValid() && source.save(path) && - portrait.save(portraitPath) && square.save(squarePath), + portrait.save(portraitPath) && square.save(squarePath) && + replacement.save(replacementPath), "image test fixtures are real readable images"); VisibleCardData message{ @@ -7183,6 +7188,25 @@ bool testMessageImagePresentation() { result &= expect(retainedThumbnail == thumbnail, "an unchanged attachment retains its decoded thumbnail"); thumbnail = retainedThumbnail; + QPointer retainedFirst(thumbnails.at(0)); + QPointer replacedMiddle(thumbnails.at(1)); + QPointer retainedLast(thumbnails.at(2)); + payload.imagePaths.at(1) = utf8(replacementPath); + result &= expect(card->apply(message), + "one changed attachment invalidates card presentation"); + spin(); + auto changedThumbnails = + card->findChildren(QStringLiteral("messageImageThumbnail")); + std::ranges::sort(changedThumbnails, [ribbon](QLabel *left, QLabel *right) { + return left->mapTo(ribbon, QPoint{}).x() < + right->mapTo(ribbon, QPoint{}).x(); + }); + result &= expect(changedThumbnails.size() == 3 && + changedThumbnails.at(0) == retainedFirst && + changedThumbnails.at(2) == retainedLast && + replacedMiddle.isNull(), + "changing one attachment reconstructs only its thumbnail"); + thumbnail = changedThumbnails.empty() ? nullptr : changedThumbnails.front(); result &= expect(thumbnail && thumbnail->focusPolicy() == Qt::StrongFocus && !thumbnail->accessibleName().isEmpty(), "available image thumbnails expose a named keyboard target"); @@ -7289,6 +7313,14 @@ bool testGeneratedImagePresentationAndGenericBound() { QStringLiteral("messageImageThumbnail")); result &= expect(thumbnail && thumbnail->property("imageAvailable").toBool(), "generated-image card reuses the bounded thumbnail"); + QPointer retainedGenerated(thumbnail); + auto &generatedPayload = std::get(generated.payload); + generatedPayload.status = "inProgress"; + generatedPayload.revisedPrompt += " while streaming"; + result &= expect(generatedCard.apply(generated) && retainedGenerated == + thumbnail, + "a generated-image status update performs no thumbnail " + "rebuild or decode"); if (thumbnail) { const QPointF local(thumbnail->rect().center()); QMouseEvent press(QEvent::MouseButtonPress, local, local, @@ -7316,6 +7348,8 @@ bool testGeneratedImagePresentationAndGenericBound() { viewedCard.show(); spin(); const auto viewedLabels = viewedCard.findChildren(); + auto *viewedThumbnail = viewedCard.findChild( + QStringLiteral("messageImageThumbnail")); result &= expect( std::ranges::any_of(viewedLabels, [](QLabel *label) { @@ -7329,8 +7363,12 @@ bool testGeneratedImagePresentationAndGenericBound() { return label->objectName() == QStringLiteral("messageImageThumbnail") && label->property("imageAvailable").toBool(); - }), - "plain image-view cards use a neutral title and the shared thumbnail"); + }) && + viewedThumbnail && + viewedThumbnail->property("imageCacheHit").toBool() && + !viewedThumbnail->property("imageDecodePerformed").toBool(), + "plain image-view cards use a neutral title and reuse the cached " + "thumbnail without decoding"); VisibleCardData generic{ AuthoritativeItemKey{"generated", "turn", "unknown"}, From 6ccc504dfb8f71c77aba65795c01ae915b7d42ae Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 11 Sep 2026 00:23:25 +0200 Subject: [PATCH 31/39] Bound file change card presentation --- src/codex/middle/ConversationCards.cpp | 367 ++++++++++++++---- src/codex/middle/ConversationView.cpp | 61 +-- src/codex/middle/MiddleTypes.cpp | 40 +- src/codex/middle/MiddleTypes.h | 2 + src/codex/ui/NodeGraphUiAdapter.cpp | 16 +- tests/codex/ConversationCardsTest.cpp | 52 ++- .../codex/ConversationVirtualizationTest.cpp | 94 ++++- tests/codex/NodeGraphConversationUiTest.cpp | 15 +- 8 files changed, 527 insertions(+), 120 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 97a9b34..e36a733 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -592,6 +592,18 @@ struct DiffCounts { int deletions = 0; }; +struct FileChangesRendering { + struct Link { + int start = 0; + int length = 0; + }; + + QString text; + QStringList openPaths; + std::vector links; + std::optional counts; +}; + struct CardCopyContent { QString text; bool markdown = false; @@ -602,9 +614,10 @@ QString joinedCopyText(QStringList parts) { return parts.join(QStringLiteral("\n\n")); } -QString fileChangesHtml(const FileChangesData &data, QStringList &openPaths) { - openPaths.clear(); - QStringList rows; +FileChangesRendering fileChangesRendering(const FileChangesData &data) { + FileChangesRendering result; + DiffCounts total; + bool countsAvailable = false; for (const FileChangeData &change : data.changes) { if (change.path.empty()) continue; @@ -612,37 +625,197 @@ QString fileChangesHtml(const FileChangesData &data, QStringList &openPaths) { QFileInfo resolved(displayPath); if (resolved.isRelative() && !data.cwd.empty()) resolved = QFileInfo(QDir(text(data.cwd)), displayPath); - const int targetIndex = openPaths.size(); - openPaths.push_back(QDir::cleanPath(resolved.absoluteFilePath())); - + const int targetIndex = result.openPaths.size(); + result.openPaths.push_back(QDir::cleanPath(resolved.absoluteFilePath())); + + if (!result.text.isEmpty()) + result.text += QLatin1Char('\n'); + result.links.push_back( + {static_cast(result.text.size()), + static_cast(displayPath.size())}); + result.text += displayPath; + result.text += QStringLiteral(" · "); QString detail = displayChangeKind(change.kind); - if (change.additions && change.deletions) + if (change.additions && change.deletions) { detail += QStringLiteral(" +%1 −%2") .arg(*change.additions) .arg(*change.deletions); - rows.push_back( - QStringLiteral("%3" - "  ·  %4") - .arg(targetIndex) - .arg(QString::fromLatin1(UiStyle::blue), - displayPath.toHtmlEscaped(), detail.toHtmlEscaped())); + countsAvailable = true; + total.additions += *change.additions; + total.deletions += *change.deletions; + } + result.text += detail; + Q_ASSERT(targetIndex == static_cast(result.links.size()) - 1); } - return rows.join(QStringLiteral("
")); + if (countsAvailable) + result.counts = total; + return result; } -std::optional totalDiffCounts(const FileChangesData &data) { - DiffCounts total; - bool available = false; - for (const FileChangeData &change : data.changes) { - if (!change.additions || !change.deletions) - continue; - available = true; - total.additions += *change.additions; - total.deletions += *change.deletions; +class FileChangesView final : public QPlainTextEdit { +public: + explicit FileChangesView(QWidget *parent = nullptr) : QPlainTextEdit(parent) { + setObjectName(QStringLiteral("fileChangesList")); + setProperty("kind", "body"); + setStyleSheet(QStringLiteral( + "QPlainTextEdit#fileChangesList{background:transparent;border:0;" + "padding:0;margin:0;}")); + setFrameShape(QFrame::NoFrame); + setReadOnly(true); + setUndoRedoEnabled(false); + setLineWrapMode(QPlainTextEdit::WidgetWidth); + setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + setMinimumSize(0, 0); + setFocusPolicy(Qt::StrongFocus); + setAccessibleName(QStringLiteral("Changed files")); + document()->setDocumentMargin(0); + } + + void setContent(FileChangesRendering rendering) { + const QTextCursor retained = textCursor(); + const int retainedPosition = retained.position(); + const int retainedAnchor = retained.anchor(); + QElapsedTimer phaseTimer; + phaseTimer.start(); + setPlainText(rendering.text); + setProperty("fileChangesSetTextMicros", + phaseTimer.nsecsElapsed() / 1000); + phaseTimer.restart(); + openPaths_ = std::move(rendering.openPaths); + QTextCharFormat linkFormat; + linkFormat.setForeground(QColor(QString::fromLatin1(UiStyle::blue))); + linkFormat.setFontUnderline(false); + linkFormat.setAnchor(true); + QTextCursor cursor(document()); + cursor.beginEditBlock(); + for (std::size_t index = 0; index < rendering.links.size(); ++index) { + const FileChangesRendering::Link &link = rendering.links[index]; + linkFormat.setAnchorHref( + QStringLiteral("codexui-file:%1").arg(index)); + cursor.setPosition(link.start); + cursor.setPosition(link.start + link.length, QTextCursor::KeepAnchor); + cursor.mergeCharFormat(linkFormat); + } + cursor.endEditBlock(); + setProperty("fileChangesFormatLinksMicros", + phaseTimer.nsecsElapsed() / 1000); + phaseTimer.restart(); + const int maximum = std::max(0, document()->characterCount() - 1); + QTextCursor restored(document()); + restored.setPosition(std::clamp(retainedAnchor, 0, maximum)); + restored.setPosition(std::clamp(retainedPosition, 0, maximum), + QTextCursor::KeepAnchor); + setTextCursor(restored); + preferredWidth_ = 0; + preferredHeight_ = 0; + refreshPreferredHeight(std::max(1, viewport()->width())); + setProperty("fileChangesMeasureMicros", + phaseTimer.nsecsElapsed() / 1000); + updateGeometry(); } - return available ? std::optional{total} : std::nullopt; -} + + [[nodiscard]] QSize sizeHint() const override { + QSize result = QPlainTextEdit::sizeHint(); + result.setHeight(preferredHeight(std::max(1, viewport()->width()))); + return result; + } + + [[nodiscard]] QSize minimumSizeHint() const override { return {0, 0}; } + +protected: + void resizeEvent(QResizeEvent *event) override { + QPlainTextEdit::resizeEvent(event); + refreshPreferredHeight(std::max(1, viewport()->width())); + } + + void mousePressEvent(QMouseEvent *event) override { + pressedLink_ = event->button() == Qt::LeftButton + ? anchorAt(event->position().toPoint()) + : QString{}; + QPlainTextEdit::mousePressEvent(event); + } + + void mouseMoveEvent(QMouseEvent *event) override { + const QString link = anchorAt(event->position().toPoint()); + viewport()->setCursor(link.isEmpty() ? Qt::IBeamCursor + : Qt::PointingHandCursor); + if (!link.isEmpty()) + setToolTip(linkPath(link)); + else + setToolTip({}); + QPlainTextEdit::mouseMoveEvent(event); + } + + void mouseReleaseEvent(QMouseEvent *event) override { + const QString releasedLink = + event->button() == Qt::LeftButton + ? anchorAt(event->position().toPoint()) + : QString{}; + QPlainTextEdit::mouseReleaseEvent(event); + if (!pressedLink_.isEmpty() && releasedLink == pressedLink_ && + !textCursor().hasSelection()) + static_cast(activateLink(releasedLink)); + pressedLink_.clear(); + } + + void keyPressEvent(QKeyEvent *event) override { + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter || + event->key() == Qt::Key_Space) { + QTextCursor cursor = textCursor(); + QString link = cursor.charFormat().anchorHref(); + if (link.isEmpty() && cursor.position() > 0) { + cursor.setPosition(cursor.position() - 1); + link = cursor.charFormat().anchorHref(); + } + if (activateLink(link)) { + event->accept(); + return; + } + } + QPlainTextEdit::keyPressEvent(event); + } + + void wheelEvent(QWheelEvent *event) override { event->ignore(); } + +private: + [[nodiscard]] QString linkPath(const QString &link) const { + constexpr QLatin1StringView prefix("codexui-file:"); + if (!link.startsWith(prefix)) + return {}; + bool valid = false; + const int index = link.sliced(prefix.size()).toInt(&valid); + return valid && index >= 0 && index < openPaths_.size() + ? QDir::toNativeSeparators(openPaths_.at(index)) + : QString{}; + } + + bool activateLink(const QString &link) { + const QString path = linkPath(link); + return !path.isEmpty() && openLocalFile(path); + } + + int preferredHeight(int width) const { + refreshPreferredHeight(width); + return preferredHeight_; + } + + void refreshPreferredHeight(int width) const { + if (preferredWidth_ == width && preferredHeight_ > 0) + return; + document()->setTextWidth(width); + preferredWidth_ = width; + preferredHeight_ = + std::max(1, static_cast(std::ceil(document()->size().height()))); + } + + QStringList openPaths_; + QString pressedLink_; + mutable int preferredWidth_ = 0; + mutable int preferredHeight_ = 0; +}; CardCopyContent cardCopyContent(const VisibleCardData &card) { return std::visit( @@ -688,6 +861,46 @@ CardCopyContent cardCopyContent(const VisibleCardData &card) { card.payload); } +bool cardHasCopyContent(const VisibleCardData &card) { + return std::visit( + [](const auto &payload) { + using Payload = std::decay_t; + if constexpr (std::is_same_v) + return !payload.text.empty() || + std::ranges::any_of(payload.imagePaths, + [](const auto &path) { + return !path.empty(); + }); + else if constexpr (std::is_same_v) + return !payload.text.empty(); + else if constexpr (std::is_same_v) + return hasTextAfterTrimmingTrailingEmptyLines(payload.command) || + hasTextAfterTrimmingTrailingEmptyLines(payload.output); + else if constexpr (std::is_same_v) + return !payload.prompt.empty() || !payload.resultText.empty(); + else if constexpr (std::is_same_v) + return !payload.summary.empty(); + else if constexpr (std::is_same_v) + return std::ranges::any_of(payload.changes, [](const auto &change) { + return !change.path.empty(); + }); + else if constexpr (std::is_same_v) + return !payload.explanation.empty() || !payload.steps.empty() || + !payload.legacyText.empty(); + else if constexpr (std::is_same_v) + return !payload.revisedPrompt.empty() || !payload.path.empty(); + else if constexpr (std::is_same_v) + return !payload.displayDetail.empty(); + else + return !payload.prompt.empty() || + std::ranges::any_of(payload.imagePaths, + [](const auto &path) { + return !path.empty(); + }); + }, + card.payload); +} + bool presentationEquals(const VisibleCardData &left, const VisibleCardData &right) { if (left.kind != right.kind || left.activeWork != right.activeWork) @@ -1392,6 +1605,15 @@ class ConversationCard::Impl final { } if (becomingAuthoritative) promoteToAuthoritativeUserMessage(); + bool fileChangesBodyChanged = true; + if (!becomingAuthoritative && current.kind == CardKind::FileChanges && + next.kind == CardKind::FileChanges) { + const auto *before = std::get_if(¤t.payload); + const auto *after = std::get_if(&next.payload); + fileChangesBodyChanged = + !before || !after || before->changes != after->changes || + before->cwd != after->cwd; + } current = next; if (!presentationChanged) return PresentationImpact::None; @@ -1401,11 +1623,19 @@ class ConversationCard::Impl final { } const int previousNaturalHeight = commandLifecycleOnly ? naturalHeightForCurrentWidth() : -1; - std::visit([this](const auto &payload) { updateComposition(payload); }, - next.payload); + std::visit( + [this, fileChangesBodyChanged](const auto &payload) { + using Payload = std::decay_t; + if constexpr (std::is_same_v) + updateComposition(payload, fileChangesBodyChanged, !collapsed); + else + updateComposition(payload); + }, + next.payload); if (next.activeWork) setActiveWork(*next.activeWork); - refreshCopyPresentation(); + if (fileChangesBodyChanged) + refreshCopyPresentation(); refreshFoldPresentation(); const int nextNaturalHeight = commandLifecycleOnly ? naturalHeightForCurrentWidth() @@ -1413,8 +1643,11 @@ class ConversationCard::Impl final { const bool measuredLifecyclePaintOnly = commandLifecycleOnly && previousNaturalHeight >= 0 && nextNaturalHeight == previousNaturalHeight; - const bool geometryChanged = - !cappedCommandOutputOnly && !measuredLifecyclePaintOnly; + const bool fileChangesLifecycleOnly = next.kind == CardKind::FileChanges && + (!fileChangesBodyChanged || collapsed); + const bool geometryChanged = !cappedCommandOutputOnly && + !measuredLifecyclePaintOnly && + !fileChangesLifecycleOnly; if (geometryChanged) owner->updateGeometry(); owner->update(); @@ -1469,6 +1702,9 @@ class ConversationCard::Impl final { void setCollapsed(bool next) { if (collapsed == next) return; + if (!next && current.kind == CardKind::FileChanges) + updateComposition(std::get(current.payload), false, + true); collapsed = next; refreshFoldPresentation(); owner->updateGeometry(); @@ -1550,7 +1786,7 @@ class ConversationCard::Impl final { } void refreshCopyPresentation() { - copy->setVisible(!cardCopyContent(current).text.isEmpty()); + copy->setVisible(cardHasCopyContent(current)); } void showPhase(const QString &value, const QString &objectName) { @@ -1743,43 +1979,37 @@ class ConversationCard::Impl final { void createComposition(const FileChangesData &changes) { title->setText(QStringLiteral("File changes")); metadata = makeLabel({}, "meta", content); - body = makeLabel({}, "body", content); - body->setObjectName(QStringLiteral("fileChangesList")); - body->setTextFormat(Qt::RichText); - body->setOpenExternalLinks(false); - body->setTextInteractionFlags(Qt::TextSelectableByMouse | - Qt::LinksAccessibleByMouse | - Qt::LinksAccessibleByKeyboard); - QObject::connect(body, &QLabel::linkActivated, owner, - [this](const QString &link) { - constexpr QLatin1StringView prefix("codexui-file:"); - if (!link.startsWith(prefix)) - return; - bool valid = false; - const int index = link.sliced(prefix.size()).toInt(&valid); - if (valid && index >= 0 && - index < fileChangeOpenPaths.size()) - static_cast( - openLocalFile(fileChangeOpenPaths.at(index))); - }); - contentLayout->addWidget(body); + fileChanges = new FileChangesView(content); + contentLayout->addWidget(fileChanges); contentLayout->addWidget(metadata); - updateComposition(changes); - } - - void updateComposition(const FileChangesData &changes) { - const QString html = fileChangesHtml(changes, fileChangeOpenPaths); - if (body->text() != html) - body->setText(html); - body->setVisible(!html.isEmpty()); + updateComposition(changes, true, !collapsed); + } + + void updateComposition(const FileChangesData &changes, bool contentChanged, + bool presentBody) { + if (contentChanged) + fileChangesBodyReady = false; + if (presentBody && !fileChangesBodyReady) { + QElapsedTimer buildTimer; + buildTimer.start(); + FileChangesRendering rendering = fileChangesRendering(changes); + fileChanges->setVisible(!rendering.text.isEmpty()); + QStringList values{QStringLiteral("%1 paths").arg(changes.changes.size())}; + if (rendering.counts) + values << QStringLiteral("+%1 −%2") + .arg(rendering.counts->additions) + .arg(rendering.counts->deletions); + metadata->setText(values.join(QStringLiteral(" | "))); + metadata->show(); + fileChanges->setContent(std::move(rendering)); + owner->setProperty("fileChangesBodyBuildMicros", + buildTimer.nsecsElapsed() / 1000); + owner->setProperty( + "fileChangesBodyRebuilds", + owner->property("fileChangesBodyRebuilds").toULongLong() + 1); + fileChangesBodyReady = true; + } showStatus(text(changes.status), QStringLiteral("fileChangesStatus")); - QStringList values{QStringLiteral("%1 paths").arg(changes.changes.size())}; - if (const auto counts = totalDiffCounts(changes)) - values << QStringLiteral("+%1 −%2") - .arg(counts->additions) - .arg(counts->deletions); - metadata->setText(values.join(QStringLiteral(" | "))); - metadata->show(); } void createComposition(const PlanData &plan) { @@ -1994,6 +2224,7 @@ class ConversationCard::Impl final { MarkdownTextView *detail = nullptr; ContentSizedTextView *command = nullptr; CommandOutputView *output = nullptr; + FileChangesView *fileChanges = nullptr; QTimer *animationTimer = nullptr; QTimer *pendingDelayTimer = nullptr; QPushButton *recovery = nullptr; @@ -2001,7 +2232,7 @@ class ConversationCard::Impl final { bool viewportVisible = true; std::optional pendingFeedbackDeadlineMs; ImageRibbon *images = nullptr; - QStringList fileChangeOpenPaths; + bool fileChangesBodyReady = false; std::shared_ptr preparedMarkdownDocument; bool authoritativeTurnActive = false; int turnRootBottomMargin = 10; diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 5913e63..c865777 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -231,7 +231,8 @@ QFont passiveBlockFont(bool metadata) { return font; } -PassivePresentation passivePresentation(const VisibleCardData &card) { +PassivePresentation passivePresentation(const VisibleCardData &card, + bool includeBlocks = true) { PassivePresentation result; std::visit( [&](const auto &payload) { @@ -241,7 +242,8 @@ PassivePresentation passivePresentation(const VisibleCardData &card) { result.background = QColor(QStringLiteral("#eff5fe")); result.border = QColor(QStringLiteral("#b7cff9")); result.titleColor = QColor(QStringLiteral("#415882")); - result.blocks.push_back({text(payload.text), true, false}); + if (includeBlocks) + result.blocks.push_back({text(payload.text), true, false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Codex"); result.status = payload.finalAnswer ? QStringLiteral("final answer") @@ -256,46 +258,58 @@ PassivePresentation passivePresentation(const VisibleCardData &card) { QColor(payload.finalAnswer ? QStringLiteral("#59507f") : QStringLiteral("#6b5521")); result.verticalMargin = payload.finalAnswer ? 10 : 8; - result.blocks.push_back({text(payload.text), true, false}); + if (includeBlocks) + result.blocks.push_back({text(payload.text), true, false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Command execution"); result.status = presentation::statusLabel(payload.status); - result.blocks.push_back({text(payload.command), false, false}); - result.blocks.push_back({text(payload.output), false, false}); + if (includeBlocks) { + result.blocks.push_back({text(payload.command), false, false}); + result.blocks.push_back({text(payload.output), false, false}); + } } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Agent activity"); result.status = presentation::statusLabel(payload.status); - result.blocks.push_back( - {presentation::agentMetadata(payload), false, true}); - result.blocks.push_back({text(payload.prompt), false, false}); - result.blocks.push_back({text(payload.resultText), true, false}); + if (includeBlocks) { + result.blocks.push_back( + {presentation::agentMetadata(payload), false, true}); + result.blocks.push_back({text(payload.prompt), false, false}); + result.blocks.push_back({text(payload.resultText), true, false}); + } } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Reasoning"); - result.blocks.push_back({text(payload.summary), true, false}); + if (includeBlocks) + result.blocks.push_back({text(payload.summary), true, false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("File changes"); result.status = presentation::statusLabel(payload.status); - result.blocks.push_back( - {presentation::fileChangesText(payload), false, false}); + if (includeBlocks) + result.blocks.push_back( + {presentation::fileChangesText(payload), false, false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Plan"); - result.blocks.push_back( - {presentation::planMarkdown(payload), true, false}); + if (includeBlocks) + result.blocks.push_back( + {presentation::planMarkdown(payload), true, false}); } else if constexpr (std::is_same_v) { result.title = payload.status.empty() && payload.revisedPrompt.empty() ? QStringLiteral("Image") : QStringLiteral("Generated image"); result.status = presentation::statusLabel(payload.status); - result.blocks.push_back({text(payload.revisedPrompt), false, false}); + if (includeBlocks) + result.blocks.push_back( + {text(payload.revisedPrompt), false, false}); } else if constexpr (std::is_same_v) { result.title = presentation::genericActivityTitle(payload); result.status = presentation::statusLabel(payload.status); - result.blocks.push_back( - {presentation::boundedGenericActivityDetail(payload), false, - true}); + if (includeBlocks) + result.blocks.push_back( + {presentation::boundedGenericActivityDetail(payload), false, + true}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("You"); - result.blocks.push_back({text(payload.prompt), true, false}); + if (includeBlocks) + result.blocks.push_back({text(payload.prompt), true, false}); } }, card.payload); @@ -332,7 +346,8 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { conversation ? conversation->row(index.row()) : nullptr; if (!row) return {}; - const PassivePresentation presentation = passivePresentation(row->card); + const PassivePresentation presentation = + passivePresentation(row->card, !collapsed); int height = CardFrameExtent + 24 + 2 * presentation.verticalMargin; if (!collapsed) { const int bodyWidth = @@ -363,7 +378,8 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { if (!painter || !row) return; - const PassivePresentation presentation = passivePresentation(row->card); + const PassivePresentation presentation = + passivePresentation(row->card, !collapsed); painter->save(); painter->setRenderHint(QPainter::Antialiasing); const QRectF bounds = QRectF(option.rect).adjusted(0.5, 0.5, -0.5, -0.5); @@ -507,7 +523,8 @@ class ConversationPassiveDelegate final : public QStyledItemDelegate { conversation ? conversation->row(index.row()) : nullptr; if (!row || !option.rect.contains(position)) return {}; - const PassivePresentation presentation = passivePresentation(row->card); + const PassivePresentation presentation = + passivePresentation(row->card, !collapsed); const int top = option.rect.top() + presentation.verticalMargin; if (QRect(option.rect.right() - 52, top, 24, 24).contains(position)) return {.action = true, .tooltip = QStringLiteral("Copy")}; diff --git a/src/codex/middle/MiddleTypes.cpp b/src/codex/middle/MiddleTypes.cpp index 08fa9c4..0fa4906 100644 --- a/src/codex/middle/MiddleTypes.cpp +++ b/src/codex/middle/MiddleTypes.cpp @@ -89,6 +89,25 @@ bool whitespaceOnly(std::string_view value) noexcept { return true; } +std::size_t trimmedTrailingLinesEnd(std::string_view text) { + std::size_t end = text.size(); + while (end > 0) { + while (end > 0 && (text[end - 1] == '\n' || text[end - 1] == '\r')) + --end; + if (end == 0) + break; + + std::size_t lineStart = end; + while (lineStart > 0 && text[lineStart - 1] != '\n' && + text[lineStart - 1] != '\r') + --lineStart; + if (!whitespaceOnly(text.substr(lineStart, end - lineStart))) + break; + end = lineStart; + } + return end; +} + } // namespace std::string stableKey(const CardKey &key) { @@ -206,23 +225,12 @@ std::string trimUnicodeWhitespace(std::string_view text) { return found ? std::string(text.substr(first, last - first)) : std::string{}; } -std::string trimTrailingEmptyLines(std::string_view text) { - std::size_t end = text.size(); - while (end > 0) { - while (end > 0 && (text[end - 1] == '\n' || text[end - 1] == '\r')) - --end; - if (end == 0) - break; +bool hasTextAfterTrimmingTrailingEmptyLines(std::string_view text) { + return trimmedTrailingLinesEnd(text) != 0; +} - std::size_t lineStart = end; - while (lineStart > 0 && text[lineStart - 1] != '\n' && - text[lineStart - 1] != '\r') - --lineStart; - if (!whitespaceOnly(text.substr(lineStart, end - lineStart))) - break; - end = lineStart; - } - return std::string(text.substr(0, end)); +std::string trimTrailingEmptyLines(std::string_view text) { + return std::string(text.substr(0, trimmedTrailingLinesEnd(text))); } std::vector ConversationSnapshot::cardKeys() const { diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 6629080..5bfbdb6 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -47,6 +47,8 @@ using CardKey = std::variant; [[nodiscard]] std::string stableKey(const CardKey &key); [[nodiscard]] bool terminalOutputHasVisibleText(std::string_view output); [[nodiscard]] std::string trimUnicodeWhitespace(std::string_view text); +[[nodiscard]] bool +hasTextAfterTrimmingTrailingEmptyLines(std::string_view text); [[nodiscard]] std::string trimTrailingEmptyLines(std::string_view text); enum class PromptState { Queued, InFlight, Accepted, Failed }; diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp index be6ba1a..a08f992 100644 --- a/src/codex/ui/NodeGraphUiAdapter.cpp +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -539,8 +539,20 @@ VisibleCardData graphCardData(const nodegraph::NodeRef &item, FileChangeData entry{graphString(graphMember(*change, "path")), graphString(graphMember(*change, "kind")), std::nullopt, std::nullopt}; - if (std::string diff = graphString(graphMember(*change, "diff")); - !diff.empty()) { + const auto additions = + graphInteger(graphMember(*change, "additions")); + const auto deletions = + graphInteger(graphMember(*change, "deletions")); + if (additions && deletions && *additions >= 0 && *deletions >= 0) { + entry.additions = static_cast(std::min( + *additions, std::numeric_limits::max())); + entry.deletions = static_cast(std::min( + *deletions, std::numeric_limits::max())); + } else if (const nodegraph::Value *diffValue = + graphMember(*change, "diff"); + diffValue && diffValue->asString() && + !diffValue->asString()->empty()) { + const std::string &diff = *diffValue->asString(); const auto [additions, deletions] = graphDiffCounts(diff); entry.additions = additions; entry.deletions = deletions; diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 85923ae..c5213a3 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -2217,25 +2217,61 @@ bool testMutableCardsAndCommandOutput() { "agent activity exposes its canonical lowercase status in the header"); auto *filesCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "files"}})]; + filesCard->setCollapsed(false); + spin(); auto *filesStatus = filesCard->findChild(QStringLiteral("fileChangesStatus")); - auto *filesList = - filesCard->findChild(QStringLiteral("fileChangesList")); + auto *filesList = filesCard->findChild( + QStringLiteral("fileChangesList")); auto *planCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "plan"}})]; result &= expect( - containsLabelText(filesCard, - QStringLiteral("src/card.cpp")) && - containsLabelText(filesCard, QStringLiteral("+2 −1")) && + filesList && filesList->toPlainText().contains( + QStringLiteral("src/card.cpp")) && + filesList->toPlainText().contains(QStringLiteral("+2 −1")) && + [&] { + QTextCursor cursor(filesList->document()); + cursor.setPosition(1); + return cursor.charFormat().anchorHref() == + QStringLiteral("codexui-file:0") && + cursor.charFormat().foreground().color() == + QColor(QString::fromLatin1(UiStyle::blue)); + }() && filesStatus && filesStatus->font().capitalization() == QFont::MixedCase && filesStatus->text() == QStringLiteral("running") && filesStatus->property("tone").toString() == QStringLiteral("active"), "file-change cards keep counts below and expose status in the " "header"); - if (filesList) - QMetaObject::invokeMethod(filesList, "linkActivated", Qt::DirectConnection, - Q_ARG(QString, QStringLiteral("codexui-file:0"))); + const qulonglong fileBodyRebuilds = + filesCard->property("fileChangesBodyRebuilds").toULongLong(); + if (filesList) { + QTextCursor retainedFileSelection(filesList->document()); + retainedFileSelection.setPosition(0); + retainedFileSelection.setPosition(QStringLiteral("src/card.cpp").size(), + QTextCursor::KeepAnchor); + filesList->setTextCursor(retainedFileSelection); + } + VisibleCardData fileLifecycle = filesCard->data(); + std::get(fileLifecycle.payload).status = "completed"; + result &= expect( + filesCard->applyPresentation(fileLifecycle) == + PresentationImpact::PaintOnly && + filesCard->property("fileChangesBodyRebuilds").toULongLong() == + fileBodyRebuilds && + filesStatus->text() == QStringLiteral("completed") && + filesList && filesList->textCursor().selectedText() == + QStringLiteral("src/card.cpp"), + "a file-change lifecycle update does not rebuild, reparse, or remeasure " + "the unchanged path list"); + snapshot.sections.front().cards[5] = fileLifecycle; + if (filesList) { + QTextCursor cursor(filesList->document()); + cursor.setPosition(1); + filesList->setTextCursor(cursor); + QKeyEvent activate(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QApplication::sendEvent(filesList, &activate); + } result &= expect( openedFiles.urls.size() == 1 && openedFiles.urls.back().isLocalFile() && openedFiles.urls.back().toLocalFile() == diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 4d3976c..2d0fe4b 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include namespace codexui::codex::middle { @@ -1553,6 +1554,96 @@ bool outsideTextDragDoesNotReenterTheView() { return result; } +bool collapsedLargeCardsSkipBodyProjection() { + FileChangesData changes; + changes.status = "completed"; + changes.cwd = "/workspace"; + changes.changes.reserve(5'000); + for (int index = 0; index < 5'000; ++index) { + changes.changes.push_back({"src/generated/file-" + + std::to_string(index) + ".cpp", + "update", index % 9, index % 4}); + } + VisibleCardData card{ + AuthoritativeItemKey{"virtual-thread", "large-files", "changes"}, + CardKind::FileChanges, + "virtual-thread", + "large-files", + "changes", + std::move(changes)}; + ConversationSnapshot snapshot; + snapshot.threadId = "virtual-thread"; + snapshot.sections.push_back( + {"large-file-section", "large-files", {card}, std::nullopt}); + + ConversationView view; + view.resize(820, 320); + view.show(); + bool result = + expect(view.reconcile(std::move(snapshot)), + "large collapsed file-change fixture reconciles"); + settle(); + static_cast(view.viewport()->grab()); + settle(); + const QModelIndex index = view.conversationModel()->index(0); + ConversationCard *richCard = materializedCard(view, stableKey(card.key)); + const bool bodySkipped = + index.isValid() && view.visualRect(index).height() == 46 && + view.materializedCardCount() <= 1 && richCard && + richCard->property("fileChangesBodyRebuilds").toULongLong() == 0 && + view.property("conversationDelegateDocumentRebuilds").toULongLong() == + 0; + if (!bodySkipped) + std::cerr << "collapsed large card: valid=" << index.isValid() + << " height=" << view.visualRect(index).height() + << " materialized=" << view.materializedCardCount() + << " bodyRebuilds=" + << (richCard ? richCard->property("fileChangesBodyRebuilds") + .toULongLong() + : std::numeric_limits::max()) + << " documents=" + << view.property("conversationDelegateDocumentRebuilds") + .toULongLong() + << '\n'; + result &= expect( + bodySkipped, + "collapsed large cards paint only their header without converting or " + "laying out their body"); + QElapsedTimer expansionTimer; + expansionTimer.start(); + richCard->setCollapsed(false); + const qint64 expansionMicros = expansionTimer.nsecsElapsed() / 1000; + auto *fileList = richCard->findChild( + QStringLiteral("fileChangesList")); + const bool boundedExpansion = + fileList && fileList->blockCount() == 5'000 && + richCard->property("fileChangesBodyRebuilds").toULongLong() == 1 && + expansionMicros < 100'000; + if (!boundedExpansion) + std::cerr << "large file-change expansion us=" << expansionMicros + << " internal=" + << richCard->property("fileChangesBodyBuildMicros").toLongLong() + << " text=" + << (fileList ? fileList->property("fileChangesSetTextMicros") + .toLongLong() + : -1) + << " links=" + << (fileList + ? fileList->property("fileChangesFormatLinksMicros") + .toLongLong() + : -1) + << " measure=" + << (fileList ? fileList->property("fileChangesMeasureMicros") + .toLongLong() + : -1) + << '\n'; + result &= expect( + boundedExpansion, + "expanding a large file-change card creates one block-oriented document " + "without one widget per path"); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -1577,7 +1668,8 @@ int main(int argc, char **argv) { streamingMarkdownReparsesOnlyMutableTail() && passiveMarkdownHoverKeepsLinkSemanticsWithoutAnEditor() && selectionFocusAndOneGesturePromotion() && - outsideTextDragDoesNotReenterTheView(); + outsideTextDragDoesNotReenterTheView() && + collapsedLargeCardsSkipBodyProjection(); if (result) std::cout << "Conversation virtualization tests passed\n"; return result ? EXIT_SUCCESS : EXIT_FAILURE; diff --git a/tests/codex/NodeGraphConversationUiTest.cpp b/tests/codex/NodeGraphConversationUiTest.cpp index d24cc55..e9c9da4 100644 --- a/tests/codex/NodeGraphConversationUiTest.cpp +++ b/tests/codex/NodeGraphConversationUiTest.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -435,7 +436,11 @@ bool fileChangesUseCanonicalWorkspace() { changesState.fields.emplace( "changes", nodegraph::Value::Array{nodegraph::Value(nodegraph::Value::Object{ - {"path", "src/file.cpp"}, {"kind", "update"}})}); + {"path", "src/file.cpp"}, + {"kind", "update"}, + {"additions", std::int64_t{7}}, + {"deletions", std::int64_t{3}}, + {"diff", "+different fallback\n"}})}); changes = write.upsert({NodeKind::Item, "files"}, std::move(changesState)); write.setParent(thread, turn); @@ -451,8 +456,12 @@ bool fileChangesUseCanonicalWorkspace() { return false; const auto *inherited = std::get_if( &snapshot->sections.front().cards.front().payload); - if (!require(inherited && inherited->cwd == "/workspace/thread", - "relative file changes did not inherit the thread workspace")) + if (!require(inherited && inherited->cwd == "/workspace/thread" && + inherited->changes.size() == 1 && + inherited->changes.front().additions == 7 && + inherited->changes.front().deletions == 3, + "relative file changes did not inherit the thread workspace " + "and canonical diff counts")) return false; { From 6ac7bef05483aa075206c3855959e8f5a73462f7 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 11 Sep 2026 00:47:27 +0200 Subject: [PATCH 32/39] Bound conversation accessibility projection --- src/codex/middle/ConversationItemModel.cpp | 70 +++++++++++++++---- src/codex/middle/ConversationPresentation.cpp | 10 ++- tests/codex/ConversationItemModelTest.cpp | 41 +++++++++++ 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/codex/middle/ConversationItemModel.cpp b/src/codex/middle/ConversationItemModel.cpp index 3d0b14b..1c66a96 100644 --- a/src/codex/middle/ConversationItemModel.cpp +++ b/src/codex/middle/ConversationItemModel.cpp @@ -3,8 +3,6 @@ #include "codex/middle/ConversationItemModel.h" #include -#include - #include #include #include @@ -51,6 +49,35 @@ QString boundedAccessibleText(std::string_view value) { return result; } +constexpr qsizetype MaximumAccessibleCharacters = 8192; + +bool appendAccessibleLine(QString &destination, std::string_view value, + bool prependNewline) { + qsizetype remaining = MaximumAccessibleCharacters - destination.size(); + if (prependNewline) { + if (remaining <= 0) + return false; + destination += QLatin1Char('\n'); + --remaining; + } + if (remaining <= 0) + return value.empty(); + + const std::size_t byteCount = + std::min(value.size(), static_cast(remaining) * 4); + QString rendered = + QString::fromUtf8(value.data(), static_cast(byteCount)); + if (rendered.size() > remaining) + rendered.truncate(remaining); + destination += rendered; + return byteCount == value.size(); +} + +void markAccessibleTextTruncated(QString &value) { + value.truncate(MaximumAccessibleCharacters); + value += QStringLiteral("…"); +} + QString accessibleCardText(const VisibleCardData &card) { QString detail = std::visit( [](const auto &payload) -> QString { @@ -70,22 +97,37 @@ QString accessibleCardText(const VisibleCardData &card) { if constexpr (std::is_same_v) return boundedAccessibleText(payload.summary); if constexpr (std::is_same_v) { - QStringList paths; - for (const FileChangeData &change : payload.changes) - paths.push_back(boundedAccessibleText(change.path)); - return paths.join(QLatin1Char('\n')); + QString paths; + bool first = true; + for (const FileChangeData &change : payload.changes) { + if (!appendAccessibleLine(paths, change.path, !first)) { + markAccessibleTextTruncated(paths); + break; + } + first = false; + } + return paths; } if constexpr (std::is_same_v) return QStringLiteral("%1\n%2").arg( boundedAccessibleText(payload.revisedPrompt), boundedAccessibleText(payload.path)); if constexpr (std::is_same_v) { - QStringList lines{boundedAccessibleText(payload.explanation)}; - for (const PlanStepData &step : payload.steps) - lines.push_back(boundedAccessibleText(step.text)); - if (!payload.legacyText.empty()) - lines.push_back(boundedAccessibleText(payload.legacyText)); - return lines.join(QLatin1Char('\n')); + QString lines; + bool complete = appendAccessibleLine(lines, payload.explanation, + false); + for (const PlanStepData &step : payload.steps) { + if (!complete || + !appendAccessibleLine(lines, step.text, true)) { + complete = false; + break; + } + } + if (complete && !payload.legacyText.empty()) + complete = appendAccessibleLine(lines, payload.legacyText, true); + if (!complete) + markAccessibleTextTruncated(lines); + return lines; } if constexpr (std::is_same_v) return boundedAccessibleText(payload.displayDetail); @@ -94,10 +136,8 @@ QString accessibleCardText(const VisibleCardData &card) { return {}; }, card.payload); - constexpr qsizetype MaximumAccessibleCharacters = 8192; if (detail.size() > MaximumAccessibleCharacters) { - detail.truncate(MaximumAccessibleCharacters); - detail += QStringLiteral("…"); + markAccessibleTextTruncated(detail); } const QString label = cardLabel(card.kind); return detail.isEmpty() ? label : label + QStringLiteral("\n") + detail; diff --git a/src/codex/middle/ConversationPresentation.cpp b/src/codex/middle/ConversationPresentation.cpp index 69f4103..c587d58 100644 --- a/src/codex/middle/ConversationPresentation.cpp +++ b/src/codex/middle/ConversationPresentation.cpp @@ -17,6 +17,8 @@ namespace codexui::codex::middle::presentation { namespace { constexpr qsizetype MaximumGenericActivityCharacters = 4096; +constexpr std::size_t MaximumGenericActivityUtf8Bytes = + static_cast(MaximumGenericActivityCharacters) * 4; constexpr QTextDocument::MarkdownFeatures MarkdownFeatures = QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub) | @@ -182,8 +184,12 @@ QString genericActivityTitle(const GenericActivityData &activity) { } QString boundedGenericActivityDetail(const GenericActivityData &activity) { - QString rendered = text(activity.displayDetail); - if (rendered.size() <= MaximumGenericActivityCharacters) + const std::size_t byteCount = std::min( + activity.displayDetail.size(), MaximumGenericActivityUtf8Bytes); + QString rendered = QString::fromUtf8( + activity.displayDetail.data(), static_cast(byteCount)); + if (byteCount == activity.displayDetail.size() && + rendered.size() <= MaximumGenericActivityCharacters) return rendered; rendered.truncate(MaximumGenericActivityCharacters); return rendered + QStringLiteral("\n\n[Activity details truncated]"); diff --git a/tests/codex/ConversationItemModelTest.cpp b/tests/codex/ConversationItemModelTest.cpp index 38ea6d2..da8a6ed 100644 --- a/tests/codex/ConversationItemModelTest.cpp +++ b/tests/codex/ConversationItemModelTest.cpp @@ -487,6 +487,46 @@ bool testHeightIndexIsBoundedAndExact() { return result; } +bool testAccessibilityProjectionStopsAtItsVisibleBound() { + ConversationItemModel model; + VisibleCardData files; + files.key = AuthoritativeItemKey{"accessible-thread", "turn", "files"}; + files.kind = CardKind::FileChanges; + files.threadId = "accessible-thread"; + files.turnId = "turn"; + files.itemId = "files"; + files.payload = FileChangesData{ + "completed", + {{std::string(100'000, 'x'), "update", 1, 1}, + {"must-not-be-projected-after-the-bound", "update", 1, 1}}}; + bool result = require(model.replaceConversation(snapshot({std::move(files)})), + "large accessibility fixture was not accepted"); + const QString fileText = + model.index(0).data(Qt::AccessibleTextRole).toString(); + result &= require(fileText.size() <= 8210 && + fileText.endsWith(QStringLiteral("…")) && + !fileText.contains(QStringLiteral("must-not-be-projected")), + "file accessibility traversed beyond its bounded text"); + + VisibleCardData plan; + plan.key = TurnPlanKey{"accessible-thread", "turn"}; + plan.kind = CardKind::Plan; + plan.threadId = "accessible-thread"; + plan.turnId = "turn"; + plan.payload = PlanData{ + std::string(100'000, 'p'), + {{"must-not-be-projected-after-the-bound", "pending"}}, {}}; + result &= require(model.replaceConversation(snapshot({std::move(plan)})), + "large plan accessibility fixture was not accepted"); + const QString planText = + model.index(0).data(Qt::AccessibleTextRole).toString(); + result &= require(planText.size() <= 8210 && + planText.endsWith(QStringLiteral("…")) && + !planText.contains(QStringLiteral("must-not-be-projected")), + "plan accessibility traversed beyond its bounded text"); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -498,6 +538,7 @@ int main(int argc, char **argv) { result &= testVisibilityAndLargeModelRemainDataOnly(); result &= testBoundedTailAppendKeepsAbsoluteIdentityIndexes(); result &= testHeightIndexIsBoundedAndExact(); + result &= testAccessibilityProjectionStopsAtItsVisibleBound(); if (result) std::cout << "Conversation item model tests passed\n"; return result ? 0 : 1; From abf0cb1ee2842cd07dfd5fa3e863d4e0121cd326 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 11 Sep 2026 00:47:27 +0200 Subject: [PATCH 33/39] Defer collapsed card body projection --- src/codex/middle/ConversationCards.cpp | 193 +++++++++++++++--- src/codex/middle/ConversationCards.h | 6 +- src/codex/middle/ConversationView.cpp | 13 +- src/codex/middle/ConversationView.h | 2 +- tests/codex/ConversationCardsTest.cpp | 39 +++- .../codex/ConversationVirtualizationTest.cpp | 107 +++++++++- tests/codex/ShellIntegrationTest.cpp | 14 +- 7 files changed, 318 insertions(+), 56 deletions(-) diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index e36a733..16d511d 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -581,6 +581,11 @@ QString commandMetadata(const CommandExecutionData &command) { return metadata.join(QStringLiteral(" | ")); } +bool commandHasMetadata(const CommandExecutionData &command) noexcept { + return command.exitCode || !command.cwd.empty() || + command.durationMilliseconds; +} + QString displayChangeKind(std::string_view kind) { if (kind.empty()) return QStringLiteral("Changed"); @@ -1491,11 +1496,13 @@ class ConversationCard::Impl final { Impl(ConversationCard *owner, const VisibleCardData &initial, bool commandInitiallyCollapsed, bool imageInitiallyCollapsed, bool fileChangesInitiallyCollapsed, - std::shared_ptr preparedMarkdownDocument) + std::shared_ptr preparedMarkdownDocument, + std::optional collapsedOverride) : owner(owner), current(initial), - collapsed(initiallyCollapsed(initial.kind, commandInitiallyCollapsed, - imageInitiallyCollapsed, - fileChangesInitiallyCollapsed)), + collapsed(collapsedOverride.value_or(initiallyCollapsed( + initial.kind, commandInitiallyCollapsed, imageInitiallyCollapsed, + fileChangesInitiallyCollapsed))), + deferCollapsedBodyProjection(collapsedOverride.has_value()), preparedMarkdownDocument(std::move(preparedMarkdownDocument)) { owner->setObjectName(QStringLiteral("conversationCard")); owner->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); @@ -1600,8 +1607,7 @@ class ConversationCard::Impl final { before && after && before->command == after->command && before->output == after->output && before->cwd == after->cwd && !before->status.empty() && !after->status.empty() && - !commandMetadata(*before).isEmpty() && - !commandMetadata(*after).isEmpty(); + commandHasMetadata(*before) && commandHasMetadata(*after); } if (becomingAuthoritative) promoteToAuthoritativeUserMessage(); @@ -1622,7 +1628,8 @@ class ConversationCard::Impl final { return PresentationImpact::PaintOnly; } const int previousNaturalHeight = - commandLifecycleOnly ? naturalHeightForCurrentWidth() : -1; + commandLifecycleOnly && !collapsed ? naturalHeightForCurrentWidth() + : -1; std::visit( [this, fileChangesBodyChanged](const auto &payload) { using Payload = std::decay_t; @@ -1647,7 +1654,7 @@ class ConversationCard::Impl final { (!fileChangesBodyChanged || collapsed); const bool geometryChanged = !cappedCommandOutputOnly && !measuredLifecyclePaintOnly && - !fileChangesLifecycleOnly; + !fileChangesLifecycleOnly && !collapsed; if (geometryChanged) owner->updateGeometry(); owner->update(); @@ -1702,10 +1709,18 @@ class ConversationCard::Impl final { void setCollapsed(bool next) { if (collapsed == next) return; - if (!next && current.kind == CardKind::FileChanges) - updateComposition(std::get(current.payload), false, - true); collapsed = next; + if (!collapsed) { + std::visit( + [this](const auto &payload) { + using Payload = std::decay_t; + if constexpr (std::is_same_v) + updateComposition(payload, false, true); + else + updateComposition(payload); + }, + current.payload); + } refreshFoldPresentation(); owner->updateGeometry(); owner->update(); @@ -1837,6 +1852,26 @@ class ConversationCard::Impl final { return std::exchange(preparedMarkdownDocument, {}); } + std::shared_ptr takePreparedVisibleMarkdownDocument() { + if (collapsed && deferCollapsedBodyProjection) { + preparedMarkdownDocument.reset(); + return {}; + } + return takePreparedMarkdownDocument(); + } + + void markBodyProjectionDeferred() { + owner->setProperty("conversationBodyProjectionDeferred", true); + } + + void markBodyProjectionReady() { + if (owner->property("conversationBodyProjectionDeferred").toBool()) + owner->setProperty( + "conversationDeferredBodyBuilds", + owner->property("conversationDeferredBodyBuilds").toULongLong() + 1); + owner->setProperty("conversationBodyProjectionDeferred", false); + } + int markdownContentWidth() const { const QMargins margins = layout->contentsMargins(); return std::max(1, owner->contentsRect().width() - margins.left() - @@ -1850,26 +1885,37 @@ class ConversationCard::Impl final { void createComposition(const UserMessageData &message) { owner->setProperty("messageRole", "user"); title->setText(QStringLiteral("You")); - markdownBody = makeMarkdownView(text(message.text), - takePreparedMarkdownDocument(), - markdownContentWidth(), content); + markdownBody = makeMarkdownView( + collapsed && deferCollapsedBodyProjection ? QString{} + : text(message.text), + takePreparedVisibleMarkdownDocument(), markdownContentWidth(), + content); contentLayout->addWidget(markdownBody); createImageContainer(); updateComposition(message); } void updateComposition(const UserMessageData &message) { + if (collapsed && deferCollapsedBodyProjection) { + markdownBody->setVisible(!message.text.empty()); + images->setVisible(!message.imagePaths.empty()); + markBodyProjectionDeferred(); + return; + } setVisibleMarkdown(markdownBody, text(message.text)); setImages(textList(message.imagePaths)); + markBodyProjectionReady(); } void createComposition(const AgentMessageData &message) { owner->setProperty("messageRole", "agent"); title->setText(QStringLiteral("Codex")); showPhase({}, QStringLiteral("agentMessagePhase")); - markdownBody = makeMarkdownView(text(message.text), - takePreparedMarkdownDocument(), - markdownContentWidth(), content); + markdownBody = makeMarkdownView( + collapsed && deferCollapsedBodyProjection ? QString{} + : text(message.text), + takePreparedVisibleMarkdownDocument(), markdownContentWidth(), + content); contentLayout->addWidget(markdownBody); updateComposition(message); } @@ -1891,7 +1937,13 @@ class ConversationCard::Impl final { setStatusTone(phase, phaseStatus); layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, message.finalAnswer ? 10 : 8); + if (collapsed && deferCollapsedBodyProjection) { + markdownBody->setVisible(!message.text.empty()); + markBodyProjectionDeferred(); + return; + } setVisibleMarkdown(markdownBody, text(message.text)); + markBodyProjectionReady(); } void createComposition(const CommandExecutionData &execution) { @@ -1921,6 +1973,14 @@ class ConversationCard::Impl final { void updateComposition(const CommandExecutionData &execution) { setActiveWork(isActiveStatus(execution.status)); showStatus(text(execution.status), QStringLiteral("commandStatus")); + if (collapsed && deferCollapsedBodyProjection) { + command->setVisible( + hasTextAfterTrimmingTrailingEmptyLines(execution.command)); + output->setVisible(terminalOutputHasVisibleText(execution.output)); + metadata->setVisible(commandHasMetadata(execution)); + markBodyProjectionDeferred(); + return; + } const std::string trimmedCommand = trimTrailingEmptyLines(execution.command); const QString displayCommand = text(trimmedCommand); @@ -1941,14 +2001,17 @@ class ConversationCard::Impl final { output->restoreScrollState({true, 0}); } setVisibleText(metadata, commandMetadata(execution)); + markBodyProjectionReady(); } void createComposition(const AgentActivityData &activity) { title->setText(QStringLiteral("Agent activity")); metadata = makeLabel({}, "meta", content); body = makeLabel({}, "body", content); - detail = makeMarkdownView(text(activity.resultText), - takePreparedMarkdownDocument(), + detail = makeMarkdownView(collapsed && deferCollapsedBodyProjection + ? QString{} + : text(activity.resultText), + takePreparedVisibleMarkdownDocument(), markdownContentWidth(), content); contentLayout->addWidget(metadata); contentLayout->addWidget(body); @@ -1958,22 +2021,44 @@ class ConversationCard::Impl final { void updateComposition(const AgentActivityData &activity) { showStatus(text(activity.status), QStringLiteral("agentActivityStatus")); + if (collapsed && deferCollapsedBodyProjection) { + metadata->setVisible(!activity.tool.empty() || !activity.kind.empty() || + !activity.receivers.empty() || + !activity.model.empty() || + !activity.reasoningEffort.empty() || + !activity.childThreadId.empty() || + !activity.agentPath.empty() || + !activity.senderThreadId.empty()); + body->setVisible(!activity.prompt.empty()); + detail->setVisible(!activity.resultText.empty()); + markBodyProjectionDeferred(); + return; + } setVisibleText(metadata, presentation::agentMetadata(activity)); setVisibleText(body, text(activity.prompt)); setVisibleMarkdown(detail, text(activity.resultText)); + markBodyProjectionReady(); } void createComposition(const ReasoningData &reasoning) { title->setText(QStringLiteral("Reasoning")); - markdownBody = makeMarkdownView(text(reasoning.summary), - takePreparedMarkdownDocument(), + markdownBody = makeMarkdownView(collapsed && deferCollapsedBodyProjection + ? QString{} + : text(reasoning.summary), + takePreparedVisibleMarkdownDocument(), markdownContentWidth(), content); contentLayout->addWidget(markdownBody); updateComposition(reasoning); } void updateComposition(const ReasoningData &reasoning) { + if (collapsed && deferCollapsedBodyProjection) { + markdownBody->setVisible(!reasoning.summary.empty()); + markBodyProjectionDeferred(); + return; + } setVisibleMarkdown(markdownBody, text(reasoning.summary)); + markBodyProjectionReady(); } void createComposition(const FileChangesData &changes) { @@ -1989,6 +2074,11 @@ class ConversationCard::Impl final { bool presentBody) { if (contentChanged) fileChangesBodyReady = false; + if (!presentBody) { + markBodyProjectionDeferred(); + showStatus(text(changes.status), QStringLiteral("fileChangesStatus")); + return; + } if (presentBody && !fileChangesBodyReady) { QElapsedTimer buildTimer; buildTimer.start(); @@ -2009,20 +2099,31 @@ class ConversationCard::Impl final { owner->property("fileChangesBodyRebuilds").toULongLong() + 1); fileChangesBodyReady = true; } + markBodyProjectionReady(); showStatus(text(changes.status), QStringLiteral("fileChangesStatus")); } void createComposition(const PlanData &plan) { title->setText(QStringLiteral("Plan")); - markdownBody = makeMarkdownView(presentation::planMarkdown(plan), - takePreparedMarkdownDocument(), - markdownContentWidth(), content); + markdownBody = makeMarkdownView( + collapsed && deferCollapsedBodyProjection + ? QString{} + : presentation::planMarkdown(plan), + takePreparedVisibleMarkdownDocument(), markdownContentWidth(), + content); contentLayout->addWidget(markdownBody); updateComposition(plan); } void updateComposition(const PlanData &plan) { + if (collapsed && deferCollapsedBodyProjection) { + markdownBody->setVisible(!plan.explanation.empty() || + !plan.steps.empty() || !plan.legacyText.empty()); + markBodyProjectionDeferred(); + return; + } setVisibleMarkdown(markdownBody, presentation::planMarkdown(plan)); + markBodyProjectionReady(); } void createComposition(const ImageGenerationData &image) { @@ -2040,13 +2141,21 @@ class ConversationCard::Impl final { !image.status.empty() || !image.revisedPrompt.empty(); title->setText(generated ? QStringLiteral("Generated image") : QStringLiteral("Image")); + if (collapsed && deferCollapsedBodyProjection) { + body->setVisible(!image.revisedPrompt.empty()); + images->setVisible(!image.path.empty()); + markBodyProjectionDeferred(); + return; + } setVisibleText(body, text(image.revisedPrompt)); setImages(image.path.empty() ? QStringList{} : QStringList{text(image.path)}); + markBodyProjectionReady(); } void createComposition(const GenericActivityData &activity) { metadata = makeLabel({}, "meta", content); + metadata->setObjectName(QStringLiteral("genericActivityMetadata")); contentLayout->addWidget(metadata); updateComposition(activity); } @@ -2054,10 +2163,15 @@ class ConversationCard::Impl final { void updateComposition(const GenericActivityData &activity) { title->setText(presentation::genericActivityTitle(activity)); showStatus(text(activity.status), QStringLiteral("genericActivityStatus")); + if (collapsed && deferCollapsedBodyProjection) { + metadata->setVisible(!activity.displayDetail.empty()); + markBodyProjectionDeferred(); + return; + } metadata->setText( presentation::boundedGenericActivityDetail(activity)); - metadata->setObjectName(QStringLiteral("genericActivityMetadata")); metadata->show(); + markBodyProjectionReady(); } void createComposition(const LocalPromptData &prompt) { @@ -2066,8 +2180,10 @@ class ConversationCard::Impl final { QStringLiteral("QFrame#pendingPromptCard{background:transparent;" "border:1px solid transparent;border-radius:8px;}")); title->setText(QStringLiteral("You")); - markdownBody = makeMarkdownView(text(prompt.prompt), - takePreparedMarkdownDocument(), + markdownBody = makeMarkdownView(collapsed && deferCollapsedBodyProjection + ? QString{} + : text(prompt.prompt), + takePreparedVisibleMarkdownDocument(), markdownContentWidth(), content); metadata = makeLabel({}, "meta", content); contentLayout->addWidget(markdownBody); @@ -2102,8 +2218,15 @@ class ConversationCard::Impl final { } void updateComposition(const LocalPromptData &prompt) { - setVisibleMarkdown(markdownBody, text(prompt.prompt)); - setImages(textList(prompt.imagePaths)); + if (collapsed && deferCollapsedBodyProjection) { + markdownBody->setVisible(!prompt.prompt.empty()); + images->setVisible(!prompt.imagePaths.empty()); + markBodyProjectionDeferred(); + } else { + setVisibleMarkdown(markdownBody, text(prompt.prompt)); + setImages(textList(prompt.imagePaths)); + markBodyProjectionReady(); + } refreshPendingPresentation(); } @@ -2233,6 +2356,7 @@ class ConversationCard::Impl final { std::optional pendingFeedbackDeadlineMs; ImageRibbon *images = nullptr; bool fileChangesBodyReady = false; + bool deferCollapsedBodyProjection = false; std::shared_ptr preparedMarkdownDocument; bool authoritativeTurnActive = false; int turnRootBottomMargin = 10; @@ -2244,14 +2368,16 @@ ConversationCard::ConversationCard(const VisibleCardData &data, QWidget *parent, bool fileChangesInitiallyCollapsed, int initialWidth, std::shared_ptr - markdownDocument) + markdownDocument, + std::optional collapsedOverride) : QFrame(parent) { if (initialWidth > 0) resize(initialWidth, 1); impl_ = std::make_unique(this, data, commandInitiallyCollapsed, imageInitiallyCollapsed, fileChangesInitiallyCollapsed, - std::move(markdownDocument)); + std::move(markdownDocument), + collapsedOverride); } ConversationCard::~ConversationCard() = default; @@ -2413,11 +2539,12 @@ ConversationCard *createConversationCard(const VisibleCardData &data, bool fileChangesInitiallyCollapsed, int initialWidth, std::shared_ptr - markdownDocument) { + markdownDocument, + std::optional collapsedOverride) { return new ConversationCard(data, parent, commandInitiallyCollapsed, imageInitiallyCollapsed, fileChangesInitiallyCollapsed, initialWidth, - std::move(markdownDocument)); + std::move(markdownDocument), collapsedOverride); } } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index 1e03513..de0562d 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -141,7 +141,8 @@ class ConversationCard : public QFrame { bool imageInitiallyCollapsed = true, bool fileChangesInitiallyCollapsed = true, int initialWidth = 0, - std::shared_ptr markdownDocument = {}); + std::shared_ptr markdownDocument = {}, + std::optional collapsedOverride = {}); ~ConversationCard() override; [[nodiscard]] CardKind cardKind() const noexcept; @@ -190,7 +191,8 @@ createConversationCard(const VisibleCardData &data, QWidget *parent = nullptr, bool imageInitiallyCollapsed = true, bool fileChangesInitiallyCollapsed = true, int initialWidth = 0, - std::shared_ptr markdownDocument = {}); + std::shared_ptr markdownDocument = {}, + std::optional collapsedOverride = {}); } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index c865777..8e1c222 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -1183,7 +1183,8 @@ void ConversationView::runStructuralStagePass() { continue; const int width = std::max( 0, viewport()->width() - (location->nested ? 2 * NestedCardIndent : 0)); - ConversationCard *card = createCard(*data, stagingHost_, key, width); + ConversationCard *card = + createCard(*data, stagingHost_, key, width, false); card->setNestedPresentation(location->nested); card->setAuthoritativeTurnActive(location->activeTurn); const int height = measureCard(card, width); @@ -2621,16 +2622,17 @@ std::pair ConversationView::materializationRows() const { ConversationCard *ConversationView::createCard(const VisibleCardData &data, QWidget *parent, const std::string &key, - int width) { + int width, bool collapsed) { auto *delegate = static_cast(itemDelegate()); std::shared_ptr markdownDocument = - delegate->takeMarkdownDocument(key, data, width); + collapsed ? std::shared_ptr{} + : delegate->takeMarkdownDocument(key, data, width); ConversationCard *card = createConversationCard( data, parent, !presentationOptions_.commandsInitiallyExpanded, !presentationOptions_.imagesInitiallyExpanded, !presentationOptions_.fileChangesInitiallyExpanded, width, - std::move(markdownDocument)); + std::move(markdownDocument), collapsed); card->setProperty("conversationAnchorKey", QString::fromStdString(key)); if (const auto collapsed = cardCollapsedStates_.find(key); collapsed != cardCollapsedStates_.end()) @@ -2714,7 +2716,8 @@ ConversationCard *ConversationView::materializeRow(int rowIndex, } else { QElapsedTimer constructionTimer; constructionTimer.start(); - card = createCard(row->card, stagingHost_, row->stableKey, rowWidth(*row)); + card = createCard(row->card, stagingHost_, row->stableKey, rowWidth(*row), + rowCollapsed(*row)); card->setProperty("conversationConstructionMicros", constructionTimer.nsecsElapsed() / 1000); incrementProperty(this, "conversationCardConstructions"); diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index 0f4d594..2896cd4 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -289,7 +289,7 @@ class ConversationView final : public QAbstractItemView { [[nodiscard]] ConversationCard *createCard(const VisibleCardData &data, QWidget *parent, const std::string &key, - int width); + int width, bool collapsed); void setCardCollapsed(const std::string &key, ConversationCard *card, bool collapsed); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index c5213a3..72581f7 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -2277,6 +2277,9 @@ bool testMutableCardsAndCommandOutput() { openedFiles.urls.back().toLocalFile() == QStringLiteral("/workspace/src/card.cpp"), "a relative changed-file link opens from the canonical thread workspace"); + planCard->setCollapsed(false); + commandCard->setCollapsed(false); + spin(); result &= expect( containsLabelText(planCard, QStringLiteral("Keep the card compact")) && containsLabelText(planCard, QStringLiteral("✓ Inspect data")) && @@ -2296,8 +2299,6 @@ bool testMutableCardsAndCommandOutput() { QStringLiteral("[report.pdf](file:///tmp/report.pdf)")), "pending prompts render file links before authoritative replacement"); - commandCard->setCollapsed(false); - spin(); view.verticalScrollBar()->setValue( view.verticalScrollBar()->value() + commandCard->mapTo(view.viewport(), QPoint{}).y() - 8); @@ -2801,17 +2802,30 @@ bool testCardFoldingGeometryAndRetention() { auto *output = dynamic_cast( commandCard->findChild( QStringLiteral("commandOutputView"))); + result &= expect( + output && + !output->toPlainText().contains(QStringLiteral("streamed line 4")) && + commandCard->property("conversationBodyProjectionDeferred").toBool(), + "streaming into a folded command defers its hidden document work"); + view.verticalScrollBar()->setValue(scrollBeforeCommandUpdate); + spin(20); + result &= expect( + commandCard->isCollapsed() && commandCard->height() == commandHeight && + output, + "streaming keeps folded command geometry unchanged"); + result &= expect(setFolded(commandCard, false), + "the updated folded command expands on demand"); result &= spinUntil([&] { return output && output->toPlainText().contains(QStringLiteral("streamed line 4")); }); - view.verticalScrollBar()->setValue(scrollBeforeCommandUpdate); - spin(20); result &= expect( - commandCard->isCollapsed() && commandCard->height() == commandHeight && - output && - output->toPlainText().contains(QStringLiteral("streamed line 4")), - "streaming updates folded content without changing height"); + output && + output->toPlainText().contains(QStringLiteral("streamed line 4")) && + !commandCard->property("conversationBodyProjectionDeferred").toBool(), + "command expansion projects the latest deferred output exactly once"); + result &= expect(setFolded(commandCard, true), + "the command returns to its retained folded state"); const int userHeight = userCard->height(); result &= expect(setFolded(userCard, true), @@ -3243,8 +3257,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { result &= expect( update && reasoning && !update->isHidden() && !reasoning->isHidden() && containsText(update, QStringLiteral("Updated while hidden")) && - containsText(reasoning, - QStringLiteral("Reasoning updated while hidden")) && + reasoning->property("conversationBodyProjectionDeferred").toBool() && firstCommand && firstCommand->isCollapsed() && secondCommand && secondCommand->isCollapsed() && firstImage && !firstImage->isCollapsed() && secondImage && @@ -3253,6 +3266,12 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { secondFileChanges->isCollapsed(), "restoring visibility reveals latest content and preserves existing " "folds"); + result &= expect(setFolded(reasoning, false), + "the restored reasoning card expands on demand"); + result &= expect( + containsText(reasoning, QStringLiteral("Reasoning updated while hidden")) && + !reasoning->property("conversationBodyProjectionDeferred").toBool(), + "expansion projects the latest reasoning retained while hidden"); const AuthoritativeItemKey thirdCommandKey{thread, "turn", "command-3"}; const AuthoritativeItemKey thirdFileChangesKey{thread, "turn", "files-3"}; diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 2d0fe4b..1f6ffe8 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -1644,6 +1644,110 @@ bool collapsedLargeCardsSkipBodyProjection() { return result; } +bool collapsedInteractionDefersEveryHeavyCardBody() { + const std::string large(20'000, 'x'); + const std::string marker = "latest-deferred-body"; + std::vector cards{ + {AuthoritativeItemKey{"deferred", "turn", "user"}, + CardKind::UserMessage, "deferred", "turn", "user", + UserMessageData{large, {}}}, + {AuthoritativeItemKey{"deferred", "turn", "agent"}, + CardKind::AgentMessage, "deferred", "turn", "agent", + AgentMessageData{large, false}}, + {AuthoritativeItemKey{"deferred", "turn", "command"}, + CardKind::CommandExecution, "deferred", "turn", "command", + CommandExecutionData{large, large, "inProgress", "/workspace", {}}}, + {AuthoritativeItemKey{"deferred", "turn", "activity"}, + CardKind::AgentActivity, "deferred", "turn", "activity", + AgentActivityData{"spawn_agent", "inProgress", {}, large, large}}, + {AuthoritativeItemKey{"deferred", "turn", "reasoning"}, + CardKind::Reasoning, "deferred", "turn", "reasoning", + ReasoningData{large}}, + {TurnPlanKey{"deferred", "turn"}, CardKind::Plan, "deferred", "turn", + {}, PlanData{large, {{large, "inProgress"}}, {}}}, + {AuthoritativeItemKey{"deferred", "turn", "image"}, + CardKind::ImageGeneration, "deferred", "turn", "image", + ImageGenerationData{{}, "inProgress", large}}, + {AuthoritativeItemKey{"deferred", "turn", "generic"}, + CardKind::GenericActivity, "deferred", "turn", "generic", + GenericActivityData{"customActivity", "inProgress", large}}, + {LocalPromptKey{912}, CardKind::LocalPrompt, "deferred", "turn", {}, + LocalPromptData{912, large, PromptState::InFlight, 0, {}, {}}}}; + + auto appendMarker = [&marker](VisibleCardData &card) { + std::visit( + [&marker](auto &payload) { + using Payload = std::decay_t; + if constexpr (std::is_same_v) + payload.text += marker; + else if constexpr (std::is_same_v) + payload.text += marker; + else if constexpr (std::is_same_v) + payload.output += marker; + else if constexpr (std::is_same_v) + payload.resultText += marker; + else if constexpr (std::is_same_v) + payload.summary += marker; + else if constexpr (std::is_same_v) + payload.explanation += marker; + else if constexpr (std::is_same_v) + payload.revisedPrompt += marker; + else if constexpr (std::is_same_v) + payload.displayDetail = marker + payload.displayDetail; + else if constexpr (std::is_same_v) + payload.prompt += marker; + }, + card.payload); + }; + auto bodyContains = [&marker](ConversationCard &card) { + const auto markdown = card.findChildren(); + if (std::ranges::any_of(markdown, [&marker](MarkdownTextView *view) { + return view->markdownSource().contains(QString::fromStdString(marker)); + })) + return true; + const auto textEdits = card.findChildren(); + if (std::ranges::any_of(textEdits, [&marker](QTextEdit *view) { + return view->toPlainText().contains(QString::fromStdString(marker)); + })) + return true; + const auto plainEdits = card.findChildren(); + if (std::ranges::any_of(plainEdits, [&marker](QPlainTextEdit *view) { + return view->toPlainText().contains(QString::fromStdString(marker)); + })) + return true; + return std::ranges::any_of( + card.findChildren(), [&marker](QLabel *label) { + return label->property("kind").toString() != QStringLiteral("title") && + label->text().contains(QString::fromStdString(marker)); + }); + }; + + bool result = true; + for (VisibleCardData &data : cards) { + ConversationCard card(data, nullptr, true, true, true, 820, {}, true); + result &= expect( + card.isCollapsed() && + card.property("conversationBodyProjectionDeferred").toBool() && + !bodyContains(card), + "collapsed interaction constructs no hidden heavy body"); + VisibleCardData latest = data; + appendMarker(latest); + result &= expect( + card.applyPresentation(latest) == PresentationImpact::PaintOnly && + card.property("conversationBodyProjectionDeferred").toBool() && + !bodyContains(card), + "collapsed lifecycle updates only the card header surface"); + card.setCollapsed(false); + result &= expect( + !card.property("conversationBodyProjectionDeferred").toBool() && + card.property("conversationDeferredBodyBuilds").toULongLong() == + 1 && + bodyContains(card), + "expansion projects the latest deferred body exactly once"); + } + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -1669,7 +1773,8 @@ int main(int argc, char **argv) { passiveMarkdownHoverKeepsLinkSemanticsWithoutAnEditor() && selectionFocusAndOneGesturePromotion() && outsideTextDragDoesNotReenterTheView() && - collapsedLargeCardsSkipBodyProjection(); + collapsedLargeCardsSkipBodyProjection() && + collapsedInteractionDefersEveryHeavyCardBody(); if (result) std::cout << "Conversation virtualization tests passed\n"; return result ? EXIT_SUCCESS : EXIT_FAILURE; diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 896d82f..91809e2 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -234,10 +234,16 @@ middle::ConversationCard *agentMessageCard(ShellWidget &shell, if (!target.isValid() || visible.isEmpty()) return nullptr; const QPoint position = visible.center(); - QMouseEvent move(QEvent::MouseMove, QPointF(position), QPointF(position), - view->viewport()->mapToGlobal(position), Qt::NoButton, - Qt::NoButton, Qt::NoModifier); - QApplication::sendEvent(view->viewport(), &move); + QMouseEvent press(QEvent::MouseButtonPress, QPointF(position), + QPointF(position), + view->viewport()->mapToGlobal(position), Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(view->viewport(), &press); + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(position), + QPointF(position), + view->viewport()->mapToGlobal(position), Qt::LeftButton, + Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(view->viewport(), &release); QCoreApplication::processEvents(); return findMaterialized(); } From 585f5a5df77d891f80ce799a1b19ff8b5543adb6 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 11 Sep 2026 01:21:03 +0200 Subject: [PATCH 34/39] Document bounded rich card presentation --- docs/qt-virtualized-conversation-view.md | 94 +++++++++++++++++------- docs/ui-ux-internal-api.md | 9 +++ 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/docs/qt-virtualized-conversation-view.md b/docs/qt-virtualized-conversation-view.md index 3f611ae..104ccfe 100644 --- a/docs/qt-virtualized-conversation-view.md +++ b/docs/qt-virtualized-conversation-view.md @@ -164,15 +164,27 @@ records contain only stable key, width, and measured height and therefore cannot become presentation authority. Collapsed cards and text-only resting cards are painted by the item delegate. -Its Markdown document cache is bounded to 128 visible/recent blocks. Hover, -keyboard current-row movement, or a direct press promotes exactly that row to -the established `ConversationCard`, so selection, copying, links, tooltips, -focus, and controls continue to use their existing implementations. Local -pending prompts and expanded command, file, and image surfaces remain real -widgets because their animation, nested scrolling, file actions, and image -controls are intrinsically interactive. Scrolling a promoted editor out of the -bounded overscan stores only its fold and inner-command-scroll state before the -widget is released. +Its Markdown document cache is bounded to 128 visible/recent blocks. Hover and +tooltip hit-testing remain entirely delegate-side; a direct press or keyboard +current-row transition promotes exactly that row to the established +`ConversationCard`, so selection, copying, links, focus, and controls continue +to use their existing implementations without constructing a widget merely for +pointer travel. Local pending prompts and expanded command, file, and image +surfaces remain real widgets because their animation, nested scrolling, file +actions, and image controls are intrinsically interactive. Scrolling a promoted +editor out of the bounded overscan stores only its fold and +inner-command-scroll state before the widget is released. + +Production materialization passes the row's resolved collapse state into the +card factory. A collapsed rich card constructs only its header, disclosure, +copy action, focus surface, and status metadata. Its Markdown parse, command +document, plan/file/activity details, image thumbnail, attachments, and other +hidden body projection are deferred until expansion and then built once from +the latest model value. Updates received while collapsed change only the +visible header/status facts; expansion can therefore never reveal stale hidden +content. Direct standalone `ConversationCard` construction retains the +established eager behavior for callers that do not supply an initial collapse +state. A canonical Turn is still flat in the model, but not visually flattened. The view paints the continuous outer You surface from the root row through the last @@ -268,10 +280,10 @@ index. | Content/state | Presentation | Reason | | --- | --- | --- | -| Resting user text without images | passive delegate; promoted on hover, current-row focus, or press | Fast history scrolling while preserving selection, copy, context menu, tooltips, and keyboard interaction on demand. | +| Resting user text without images | passive delegate; promoted on press or keyboard current-row focus | Fast history scrolling while preserving selection, copy, context menu, tooltips, and keyboard interaction on demand; hover remains widget-free. | | Resting Markdown/final answer | passive `QTextDocument` delegate with a 128-document bound; promoted on interaction | Preserves Markdown appearance while preventing document count from scaling with history. | | Resting reasoning, update, plan, agent activity, and generic tool/activity cards | passive delegate while their current state is noninteractive or collapsed | These rows need text, status, disclosure, and Turn hierarchy but no continuously live editor. | -| Any collapsed completed card | passive delegate | Disclosure can promote exactly the pointed row; no hidden subtree is retained. | +| Any collapsed completed card | passive delegate; header-only real card after explicit promotion | Disclosure can promote exactly the pointed row; even then, no hidden body subtree or document is created until expansion. | | Local optimistic prompt | real visible `ConversationCard` | Delayed sweep animation, recovery, and authoritative morph are live behavior. | | Expanded/running command output | real visible `ConversationCard` and `CommandOutputView` | Requires nested scrolling, tail-follow state, selection/copy, streaming output, and completion controls. | | Expanded file changes and images/attachments | real visible `ConversationCard` | Requires file/image activation, hover/cursor behavior, and rich child controls. | @@ -305,9 +317,9 @@ releases their editors without deleting model identity. | Approval controls | Existing request surface remains a real widget outside passive conversation painting and carries the exact request target. | Accept/reject/review shaping passes; live rejection displayed exact facts and created no file. | | User-input requests | Existing embedded request card and modal remain real widgets with authored input retained until exact response. | Validation/cancel/submit tests pass; live Plan-mode Alpha submission completed authoritatively. | | Expand/collapse state | Fold state is keyed by stable row; root fold sets nested extents to zero and releases invisible editors. | Card/root folding, automatic preferences, anchor preservation, and rematerialization pass. | -| Hover, cursor, tooltip, context menu | Delegate hit-testing promotes only the pointed row, then existing widget semantics take over. | Pointer forwarding, disclosure/copy ordering, link cursor, menus, and tooltip tests pass. | +| Hover, cursor, tooltip, context menu | Delegate hit-testing supplies cursor and tooltip without promotion; press materializes only the pointed row before forwarding the exact interaction. | Widget-free hover, pointer forwarding, disclosure/copy ordering, link cursor, menus, and tooltip tests pass. | | Keyboard navigation and visible focus | Qt current index is stable identity; focused rich editor remains materialized and is scrolled into view. | Tab/Backtab, arrows, activation, modal return, visible focus, and no unrelated focus jump pass. | -| Accessibility | Model roles expose row names/structure; promoted controls retain their established accessible names and focus behavior. | Row, control, dialog, image/link, and nested-scroll accessibility assertions pass. | +| Accessibility | Model roles expose row names/structure through an 8,192-character bounded projection; promoted controls retain their established accessible names and focus behavior. | Row, control, dialog, image/link, nested-scroll, and large plan/file accessibility bounds pass. | | Paused scroll and exact anchoring | Anchor is stable row key plus exact vertical pixel offset and horizontal value; height deltas above it are applied through the index. | Height change, insertion, tail arrival, selection, Load 80, and steering preserve both axes. | | Follow latest | Tail is followed only when already following; user wheel/slider activity changes to paused mode. | Arrival/completion at tail and manual pause/resume scenarios pass without blank-card exposure. | | Atomic selection and paging | Passive rows require no construction; initially visible rich rows stage behind the old complete surface/loading cover. | Initial long selection and Load 80 expose one completed frame with bounded event-loop work. | @@ -324,7 +336,10 @@ with zero model, section, or height rebuild; pinned-root prefix trimming; paused/following tail behavior; atomic selection and paging; prompt/steering acknowledgment; command completion; selection/copy; links, files, images, folds, focus, accessibility; heterogeneous -cards; inactive panes; and bounded event-loop passes without idle spin. +cards; inactive panes; bounded 4,096-character generic detail and +8,192-character accessibility projection; header-only construction for every +collapsed heavy card kind; current-value expansion after hidden streaming; and +bounded event-loop passes without idle spin. The final persistent Debug build passes all 19 native suites under Xvfb/xcb. The independently reused integrated ASan/UBSan build also passes 19/19 with no @@ -333,8 +348,8 @@ passes 5/5 with no race report; Qt itself is not run under TSan because the system Qt libraries are not instrumented. `npm run release --prefix web` passes 85/85 WebUI tests, the 10,000-item profile, the Vite production build, Chromium responsive/focus qualification, and relocatable artifact verification. -The current profile reports 45.77 ms hydration, 34.69 ms projection, and -9.40 ms for 2,000 streaming deltas. +The current profile reports 44.81 ms hydration, 35.17 ms projection, and +9.12 ms for 2,000 streaming deltas. ## Final performance measurements @@ -343,25 +358,30 @@ and benchmark as the baseline. Values below are medians. | Loaded rows | Initial reveal | Conversation cards | Descendant QWidgets | Peak resident memory | 240-position sweep | Mean sweep position | One bounded tail append | | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| 320 | 11 ms | 0 | 8 | 78,092 KiB | 313.8 ms | 1.31 ms | 0.45 ms | -| 1,280 | 33 ms | 0 | 8 | 78,500 KiB | 355.6 ms | 1.48 ms | 0.51 ms | -| 10,000 | 259 ms | 0 | 8 | 99,164 KiB | 448.4 ms | 1.87 ms | 0.48 ms | +| 320 | 12 ms | 0 | 8 | 78,136 KiB | 417.8 ms | 1.74 ms | 0.49 ms | +| 1,280 | 33 ms | 0 | 8 | 78,620 KiB | 468.6 ms | 1.95 ms | 0.58 ms | +| 10,000 | 269 ms | 0 | 8 | 99,036 KiB | 679.1 ms | 2.83 ms | 0.72 ms | The old 320/1,280-row initial reveal was 733/4,040 ms with 4,209/16,809 descendant widgets and 125,324/281,616 KiB peak RSS. At 1,280 rows the final initial reveal is approximately 122 times faster and uses approximately 72% less peak resident memory. Four times the loaded history now changes the -scroll sweep by approximately 13.3%, while widget count remains exactly eight; -10,000 passive rows still create zero `ConversationCard` widgets. The original -baseline did not record process CPU counters separately, so the directly -comparable CPU-time proxy is the single-threaded initial-reveal and scroll-sweep -wall time above rather than a fabricated percentage. +scroll sweep by approximately 12.2%, while widget count remains exactly eight; +10,000 passive rows still create zero `ConversationCard` widgets. Absolute xcb +sweep time varies with the shared Xvfb host and delegate rasterization, so the +architectural result is the bounded per-position cost and fixed widget count, +not a claim that every synthetic sweep is faster than QWidget blitting. The +original baseline did not record process CPU counters separately, so the +directly comparable CPU-time proxy is the single-threaded initial-reveal and +scroll-sweep wall time above rather than a fabricated percentage. The bounded append column measures the complete synchronous view operation, including exact anchor/follow restoration and visible materialization. Every sample reported zero model-index rebuilds and zero section-range rebuilds; the -0.45–0.51 ms spread from 320 through 10,000 rows demonstrates that loaded -history is not traversed. +sub-millisecond medians from 320 through 10,000 rows demonstrate that loaded +history is not traversed. Peak RSS was measured in a forked benchmark child so +the kernel high-water counter did not inherit the long-lived command harness's +unrelated resident set. During a 60-fps live 1,200-line command interval with continuous outer scrolling, mean decoded-frame luminance deltas were 2.211289 in Conversation, 0.000012 in @@ -412,6 +432,28 @@ movies and compact contact sheets are under completed without blank reservation, ending with the exact authoritative response `FINAL BOUNDED TAIL VERIFIED.` +After the command/update/file/image/accessibility hardening, the same Debug +binary was qualified again on display `:98` without restarting the bridge. +Additional evidence is under +`../../build/codexui-adapter-qualification/capture/qt-stall-final/`: + +- `long-thread-and-folding.mp4`: current-binary thread switching, repeated + long-history scrolling, and fold/materialization interaction; +- `streaming-current-binary.mp4`: a real 800-line command with continuous outer + scrolling, running-to-completed transition, final answer, nested output + scrolling, and collapse/expand; +- `steering-current-binary.mp4`: a real 1,200-line command, manual outer + scrolling during output, steering admission under the same active Turn, and + one authoritative final answer containing `STEERING ACKNOWLEDGED`; +- `steering-admitted.png` and `steering-completed.png`: exact ordering and + lifecycle endpoints for that steering run. + +The settled current-binary process advanced zero scheduler ticks during a +five-second idle sample. Its qualification log contains no warning, error, +assertion, timeout, sanitizer, or crash diagnostic. The user separately +qualified the same fixes against their real running session and reported the +interaction smooth. + The movies supplement deterministic geometry and interaction assertions; lossy video alone cannot prove a sub-frame timing bound. No source, remote, GitHub, WebUI behavior, transport, thread, or graph-ownership change was made for the diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index bccd27c..c792702 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -264,6 +264,15 @@ follow/pause anchors, fold state, text selections, focus/current-row identity, nested command-output scroll state, and presentation options by stable row key. Shell retains only the selected canonical graph target. +Passive-row hover, cursor, and tooltip hit-testing stay in the delegate and do +not materialize a card. A press or keyboard current-row transition may create +the one required editor. When production materialization starts collapsed, the +real card initially contains only its header/control/status surface; hidden +Markdown, command output, plan/file/activity detail, image, attachment, and +other body projection is deferred until expansion and is built from the latest +row value. Accessible model detail is bounded to 8,192 characters without +first traversing or converting an unbounded plan or file-change collection. + Thread/ownership contract: Qt-main only. Passive historical rows have no QWidget or placeholder. QObject parentage owns only the rich cards currently inside the viewport plus one viewport of bounded overscan and temporary hidden From 2fa5481f1e30666e5c1bde89204221355c4f1c6d Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Fri, 11 Sep 2026 18:34:54 +0200 Subject: [PATCH 35/39] Improve thread workflow and stabilize conversation UI - restore and wire thread sorting with fast background reconciliation - add quick and configurable forks with stable numbered names - preserve thread names, activity state, prompt newlines, and turn ordering - improve thread metadata presentation and remove redundant refresh control - stabilize command output sizing and follow-tail behavior - make Markdown selection and Ctrl+C reliable - extend native and browser parity coverage and documentation --- CMakeLists.txt | 1 + docs/native-ui-ux-qualification-inventory.md | 11 +- docs/ui-behavior.md | 116 +++++-- docs/ui-ux-internal-api.md | 66 +++- src/codex/ClientRuntime.cpp | 173 +++++++++- src/codex/ForkNaming.h | 93 +++++ src/codex/NewThreadDialog.cpp | 33 +- src/codex/NewThreadDialog.h | 4 + src/codex/ShellWidget.cpp | 178 ++++++++-- src/codex/middle/ConversationCards.cpp | 125 +++++-- src/codex/middle/ConversationCards.h | 15 +- src/codex/middle/ConversationPresentation.cpp | 106 ++++++ src/codex/middle/ConversationPresentation.h | 4 + src/codex/middle/ConversationView.cpp | 13 +- src/codex/middle/ThreadPane.cpp | 317 ++++++++++-------- src/codex/middle/ThreadPane.h | 10 +- src/codex/nodegraph/Messages.h | 1 + src/codex/nodegraph/ProtocolUpdater.cpp | 27 +- src/codex/nodegraph/WorkerLogic.cpp | 159 ++++++++- src/codex/nodegraph/WorkerLogic.h | 10 +- src/codex/ui/NodeGraphUiAdapter.cpp | 52 ++- src/codex/ui/UiViewState.h | 6 +- tests/codex/ClientRuntimeDispatchTest.cpp | 255 +++++++++++++- tests/codex/ConversationCardsTest.cpp | 222 +++++++++--- .../codex/ConversationVirtualizationTest.cpp | 21 +- tests/codex/NodeGraphThreadPaneUiTest.cpp | 180 +++++++++- tests/codex/NodeGraphUiAdapterTest.cpp | 17 +- tests/codex/ShellIntegrationTest.cpp | 179 +++++++++- tests/codex/nodegraph/ProtocolUpdaterTest.cpp | 51 +++ tests/codex/nodegraph/WorkerLogicTest.cpp | 114 +++++-- web/src/app/App.tsx | 89 +++-- web/src/app/BrowserFrontendSession.ts | 262 ++++++++++++++- web/src/conversation/PromptCoordinator.ts | 8 +- web/src/presentation/PresentationModel.ts | 29 +- web/src/styles.css | 12 +- web/tests/browser-session-parity.test.mjs | 269 +++++++++++++-- web/tests/card-copy.test.mjs | 18 +- web/tests/model-parity.test.mjs | 8 +- web/tests/qualification.test.mjs | 18 + web/tests/responsive-layout.test.mjs | 9 +- 40 files changed, 2781 insertions(+), 500 deletions(-) create mode 100644 src/codex/ForkNaming.h diff --git a/CMakeLists.txt b/CMakeLists.txt index a10e4f1..6d69dad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,7 @@ set( src/codex/GitDiffProvider.h src/codex/FileSelectionDialog.cpp src/codex/FileSelectionDialog.h + src/codex/ForkNaming.h src/codex/MainWindow.cpp src/codex/MainWindow.h src/codex/NewThreadDialog.cpp diff --git a/docs/native-ui-ux-qualification-inventory.md b/docs/native-ui-ux-qualification-inventory.md index 15a3006..fa22fa2 100644 --- a/docs/native-ui-ux-qualification-inventory.md +++ b/docs/native-ui-ux-qualification-inventory.md @@ -63,9 +63,9 @@ with fixed screen geometry and pixel-difference regions. | Rows | Name/preview/ID fallback, status dot, hover, selected state, badges/tooltips and exact canonical state vocabulary match the legacy appearance | | Selection | Left click changes selection once; background activity never steals it; selecting a child keeps its root visible; removal clears selection safely | | Hierarchy | Root/child ownership, expansion/collapse, deep indentation, late parent, fork reassignment and child removal retain complete reachable topology | -| Sorting | Recent, Created, Last changed and natural Alphanumeric orders; missing timestamps; local prompt ticks; non-sort field changes never scan/reorder | -| Local draft row | New Thread insertion, animation, promotion to provider ID, failure, abandon-empty, second-create guard and later-navigation preservation use one stable row | -| Context menu | Right-click targets the pointed row without selection, holds hover while open, dismisses without click-through, and exposes correct reload/rename/fork/archive/delete state | +| Sorting | Exactly Alphanumeric, Created, and Recent; natural-number titles; newest-first timestamps; missing timestamps last; fast DB-only first page, automatic background file reconciliation, cursor-guarded near-end paging; Recent admission promotion, acknowledgement confirmation, rejection rollback, and criterion-specific re-sorting | +| Local draft row | New Thread insertion, chosen-name preservation, prompt-bound animation, promotion to provider ID, failure, abandon-empty, second-create guard and later-navigation preservation use one stable row | +| Context menu | Right-click targets the pointed row without selection, holds hover while open, dismisses without click-through, and exposes correct reload/rename/Quick fork/Fork with options/archive/delete state; advanced fork fields and hierarchical chosen names reach the exact action | | Incremental update | Name/status/tooltips patch only the affected row and repaint only its rectangle; no whole-list update suppression | | Atomic topology | Insert/remove/reparent/reorder computes a complete target before commit and never exposes partial ordering | | Large list | Visible rows plus two-row overscan only; bounded scans complete under unrelated revisions; scroll position and expansion remain stable | @@ -135,7 +135,10 @@ For each type verify: direction, title anchoring, keyboard focus and no geometry change for paint-only state; - Copy availability, exact plain/Markdown content, 0.5-second check feedback, - reduced-motion behavior, overlay placement and no header movement; + reduced-motion behavior, overlay placement, uniformly narrow phase-to-Copy + spacing and no header movement; +- authored single newlines remain visible in normal and steering You cards + while copied Markdown, blank lines, and fenced code remain exact; - retained text truncation notice and copied disclosure at the byte bound; - attachment order, encoded filenames, bounded image ribbon, horizontal scrolling without vertical growth, missing-image accessibility and modeless diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index e3b3e02..7a6b300 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -68,30 +68,47 @@ bottom or is owned by the user. meaningful thread-scoped protocol traffic in either direction advances it immediately. Selection-driven `thread/read` and `thread/resume` hydration, global connection traffic, and catalog traffic do not count as activity. - Live traffic does not alter thread ordering. Only local prompt admission - advances the thread node's local effective activity fields used by the - `Recent` and `Last changed` comparators; these values are not persisted by - CodexUI. + Live traffic does not alter thread ordering. A local prompt that starts a + turn owns a separate client-local turn-order value; it is not persisted by + CodexUI and never rewrites provider `updatedAt` or `recencyAt`. - The visible sidebar order contains confirmed root threads only. Minimal thread placeholders created by scoped protocol traffic remain retained but invisible until an explicit list, read, resume, create, or fork admits them as roots. A valued `parentThreadId` immediately assigns structural child ownership, so child threads never flash in the root list while later agent correlation is pending. -- The sidebar sorts all visible rows by a user-selected criterion. `Recent` is - the default and uses the app-server's provider-defined `recencyAt` value, - newest first. `Created` uses `createdAt` newest first, and `Last changed` - uses `updatedAt` newest first. `Alphanumeric` sorts displayed titles - case-insensitively with natural number ordering, so 2 precedes 10 and titles - beginning with numbers precede other titles. Timestamp values that are not - available sort after timestamped threads. The directions are fixed; the UI - does not provide a separate ascending/descending control. -- Admitting a prompt immediately advances its root thread group's effective - `updatedAt` and `recencyAt`, so the ordinary timestamp comparator moves it to - the first position under `Recent` and `Last changed`. Rapid prompts receive - strictly increasing timestamp ticks. Stale provider timestamps cannot move - a locally newer thread backwards; newer provider timestamps reconcile - naturally. `Created` and `Alphanumeric` remain unaffected. +- The sidebar sort control exposes exactly `Alphanumeric`, `Created`, and + `Recent`; `Recent` is the default. `Alphanumeric` compares displayed titles + case-insensitively with natural-number ordering (2 before 10) and places + number-leading titles first. `Created` uses app-server `createdAt`, newest + first. `Recent` orders root thread groups by the start/admission of their + newest turn, newest first. Missing timestamps sort last and canonical IDs + break exact ties. The directions are fixed; there is no separate direction + control. +- `createdAt` and `recencyAt` first become available to CodexUI when an + app-server thread descriptor from `thread/list`, `thread/read`, + `thread/start`, `thread/fork`, or a thread notification contains them. The + app server advances `recencyAt` when a turn starts. `updatedAt` instead + describes stored thread changes; it is still retained for activity display + but deliberately has no `Last changed` sort option. +- Thread discovery explicitly asks for `recency_at`, descending, with a + bounded page size. Startup first requests one DB-only page and paints it + immediately. CodexUI then requests the same first page with app-server file + reconciliation enabled in the background; that scan repairs missing or + stale database metadata and replaces the authoritative paging cursor without + blocking initial presentation. There is no manual Repair action. Near the + end of the visible list, each scroll threshold follows one repaired + `nextCursor` page at a time. Thus the catalog becomes complete on demand + without making every startup wait for every persisted thread file. +- Admitting a prompt that starts a turn immediately assigns a monotonic local + turn-order value, re-sorts `Recent`, and promotes its root thread group. The `turn/start` + response does not carry a refreshed thread `recencyAt`, so successful + acknowledgement re-evaluates the list and confirms that local value without + a visible jump. Definitive rejection removes it and restores the previous + order. `Created` and `Alphanumeric` re-sort only when their own keys change. + Steering an already active turn does not change turn order. Stale provider + timestamps cannot undo a confirmed local turn order, and unrelated traffic + never changes it. - The Plan inspector preserves app-server step states while the owning turn is active. If a stale step still reports `inProgress` after its owning turn or thread becomes terminal, the display reconciles that step to `completed`, @@ -100,8 +117,9 @@ bottom or is owned by the user. - Each visible thread is presented as a compact card. Its status indicator is part of that card, and hover and selection strengthen the same card surface instead of introducing a separate row treatment. The Sort and Transport - controls use the same centered chevron and compact text-to-indicator spacing - as the prompt settings. + controls share the compact centered-chevron treatment. Hover details expose + the effective `Recent turn`, provider `Created`, and synthetic `Last + activity` times; missing provider values are reported as Unknown. - A left click selects a thread and changes the displayed conversation. A right click opens actions for the pointed-to card without changing the selected thread or displayed conversation. That card retains its hover @@ -114,11 +132,33 @@ bottom or is owned by the user. captures the workspace, optional name, instructions, and ephemeral state. In the browser, the workspace is an app-server-local path entered as text; browser file pickers cannot truthfully select an arbitrary server directory. +- `thread/start` does not accept the chosen display name, so creation retains + that name as a local overlay while the draft ID is replaced by the + authoritative thread ID, then sends `thread/name/set`. Blank, stale, or + mismatched provider payloads never expose the ID as the title. The overlay + retires only when the matching name acknowledgement arrives. +- The thread menu exposes `Quick fork` and `Fork with options…`. Quick fork + inherits the source context. Fork with options opens the same workspace, + name, base-instructions, developer-instructions, and temporary-thread fields + as New thread; the generated name is editable. +- Automatic fork names preserve lineage and choose the first unused direct + child number. Repeated forks of `Original` become `Original (fork 1)`, + `Original (fork 2)`, and so on. Forking `Original (fork 1)` produces + `Original (fork 1.1)`; further nesting appends another component. +- `thread/fork` accepts the context overrides but no name. After it returns, + CodexUI applies the chosen fork name as a local overlay before the new row is + presented and sends that name through `thread/name/set`. Mismatched or stale + provider names cannot replace the chosen display name; the matching name + acknowledgement retires the overlay. - Background thread activity, list refreshes, reconnects, and creation by another frontend never change the user's selected thread. Completion of a locally started creation likewise selects the returned thread only while its optimistic draft remains visibly selected; later navigation is preserved while the draft's queued prompts continue independently. +- A successful `thread/fork` result is already a loaded, event-subscribed + thread. CodexUI selects it without inserting a redundant `thread/read` or + `thread/resume` gate, so its first prompt can proceed directly to + `turn/start`. - Selecting a thread hydrates it once per bridge connection even when the discovery result already contains an active turn. The full read is merged into the thread's current graph nodes, so live Plan and Agents state @@ -135,9 +175,10 @@ events and Enter used to confirm an active input-method composition never submit a prompt. Auto-repeat is consumed instead of inserting an accidental newline. Send and Steer are enabled only when admission is available and the draft contains non-whitespace text. Focus uses the canonical blue composer -border without changing its geometry. The legacy submission contract trims -leading and trailing whitespace once; whitespace and blank lines inside the -trimmed prompt remain unchanged. +border without changing its geometry. Submission retains the editor's exact +logical text, including leading and trailing whitespace and every internal +blank line; whitespace-only drafts remain inadmissible. Platform-native CRLF +or CR editor input is represented canonically as LF logical line breaks. Submitting a prompt creates a client-local pending prompt card at the bottom of the destination thread immediately. The card begins with the calm blue @@ -158,6 +199,11 @@ stable prompt row's fixed one-second admission deadline is the sole animation start trigger; the correlated `turn/start` or `turn/steer` result is the successful stop trigger. Unrelated worker updates cannot start, stop, or restart the sweep. +The destination thread card uses that same pending-prompt lifecycle: it begins +the same blue sweep at the same one-second deadline and stops on the same exact +`turn/start` or `turn/steer` result. Multiple pending prompts keep the thread +card active until none is awaiting acknowledgement. Unrelated thread or +catalog traffic cannot start or stop it. The timer controls only whether pending feedback is visible; it never acknowledges or promotes the prompt. If the authoritative app-server item arrives before or after the result, it inherits the pending card's stable visual @@ -234,8 +280,12 @@ flight remain attached to that draft. When creation succeeds, all pending prompts move to the returned stable thread ID and are dispatched in order. Authoritative user-message text is rendered as Markdown through the same safe -`MarkdownNoHTML` path as agent messages. The locally admitted prompt remains a -plain-text transitional card until its authoritative item arrives. +`MarkdownNoHTML` path as agent messages. Every authored logical newline remains +a visible line break in both a normal and steering You card, including the +empty visual row created by two consecutive newlines. Paragraph structure, +fenced code, and the exact retained Markdown source remain unchanged. The +locally admitted transitional card uses the same line-preserving projection, +so acknowledgement does not change its line layout. Generated-image items show the app-server-saved image as a bounded thumbnail. Selecting it opens the shared non-modal image viewer; encoded image data is @@ -286,7 +336,9 @@ growth rules. Every card with copyable content places a backgroundless copy icon at the right of its header, immediately before the disclosure chevron with the canonical -compact 4 px action gap. Both icons share one vertical center; tooltip and +compact 4 px action gap. A preceding phase or lifecycle label uses the same +narrow zero-layout-gap relationship to Copy in every card family. Both icons +share one vertical center; tooltip and accessible text provide the action label. Copy remains available while that card is collapsed; contentless cards omit it. Markdown cards copy their exact retained source as both plain clipboard text and `text/markdown`, never @@ -530,8 +582,12 @@ The card's visible label is **Command execution**. Command execution output boxes are created only when output contains printable, non-whitespace text after terminal control sequences are ignored; empty, whitespace-only, and ANSI/control-only output has no output surface. A shown box -has no non-content minimum height, grows from zero to a maximum of 220 pixels, -and exposes a styled vertical scrollbar only when content exceeds that limit. +has no non-content minimum height and grows as an integer number of terminal +line heights plus a symmetric 4-pixel vertical inset. Its maximum is the +greatest such height within 220 pixels, and it exposes a styled vertical +scrollbar only when content exceeds that limit. The pixel-scrolled viewport +ends at the final populated row, so initial output, streaming overflow, +follow-tail, and completion never expose a synthetic empty row below it. The command surface uses the same content-height behavior with its existing 90-pixel maximum. Trailing empty lines are omitted from both displayed texts. Executed command text opens at its beginning and never follows its bottom; @@ -543,7 +599,9 @@ Streaming output, completion status, and metadata update the retained outer Command execution card in place; they do not replace it. Output follows its bottom while already at the bottom. A manual upward scroll pauses following until the user returns to the bottom. Each output card retains its own -follow/pause position across in-place output updates. +follow/pause position across in-place output updates. Follow-tail is reapplied +after text, viewport geometry, or completion-state settlement, so a late layout +pass cannot leave a following surface above its final populated row. When retained stream text exceeds its canonical byte budget, the card shows an explicit omitted-byte notice followed by the newest retained tail. The same notice is included when copying the card, so bounded history is never presented diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md index c792702..005032b 100644 --- a/docs/ui-ux-internal-api.md +++ b/docs/ui-ux-internal-api.md @@ -48,7 +48,7 @@ concrete graph-cutover requirement. | Surface | Established behavior | Current status | | --- | --- | --- | -| `ThreadPane::Actions` | Emits New, Refresh, Hide, Select, Reload, Rename, Fork, Archive toggle, and Remove exactly once using the row's canonical string ID. | Compatible. Shell resolves the visible ID to the exact current `NodeRef` before admission. | +| `ThreadPane::Actions` | Emits New, Refresh, Hide, Select, Reload, Rename, Quick fork, Fork with options, Archive toggle, and Remove exactly once using the row's canonical string ID. | Compatible. Shell resolves the visible ID to the exact current `NodeRef` before admission. | | `ThreadPane::refresh` | Consumes one complete hierarchy snapshot; retains expansion, selection, sort choice, optimistic rows, hover/context state; identical effective rows do no work. | Compatible after restoring provider/controller gating, effective activity time, unreachable-root retention, and ordered child relations. | | optimistic thread methods | Begin one draft row, promote it without replacing its visual identity, mark failure, and remove only on confirmation/abandonment. | Compatible. Promotion is correlated to the admitted creation prompt rather than guessed from later payload fields. | | `ConversationView::reconcile` | Consumes one complete `ConversationSnapshot`; keys mutate compatible cards in place; one Turn section owns one opening You card and all nested cards; identical snapshots are a no-op. | Compatible after restoring encoded section keys, canonical root pinning, stable prompt aliasing, and the identical-snapshot early return. | @@ -149,7 +149,7 @@ always means “no coherent value was available now”, never “render empty” ### `middle::ThreadPane` `ThreadPane` owns the sidebar's QWidgets, selected-row rendering, expanded -thread IDs, current sort criterion, optimistic row animation, context-menu +thread IDs, current sort criterion, pending-prompt animation, context-menu state, and row comparison values. Thread/ownership contract: Qt-main only; QObject parenting owns every row and @@ -157,16 +157,16 @@ popup. The pane owns no graph references or provider state. Callbacks may enter shell code synchronously, so all caller graph guards must already be released. - `ThreadPane(parent)` constructs the established sidebar and restores its - persisted local sort/expansion behavior. + local expansion behavior. The sort control contains exactly Alphanumeric, + Created, and Recent, with Recent selected initially. - `setActions(Actions)` replaces the callback bundle. Missing callbacks make the corresponding gesture a no-op; callbacks execute without graph locks. - `refresh(snapshot)` compares a complete DTO with the last effective rendered list. It patches/reorders only as required, preserves local expansion and context state, and does nothing for an identical effective list. It never initiates hydration or provider operations itself. -- `beginOptimisticThread(id, title, cwd)` inserts one locally animated draft - row using the supplied stable provisional ID without changing canonical - graph authority. +- `beginOptimisticThread(id, title, cwd)` inserts one local draft row using the + supplied stable provisional ID without changing canonical graph authority. - `promoteOptimisticThread(draftId, authoritativeId)` changes the row's action identity in place and preserves its selection/animation/position. - `confirmOptimisticThread(threadId)` removes only the matching optimistic @@ -175,29 +175,54 @@ shell code synchronously, so all caller graph guards must already be released. failure presentation so recovery/navigation remains possible. - `isOptimisticThread(threadId)` is a side-effect-free membership query used only by shell correlation logic. -- `setSortCriterion(criterion)` changes the local ordering rule, persists it, - and reconciles the current snapshot once. +- `setSortCriterion(criterion)` selects one of Alphanumeric, Created, and + Recent and reconciles the current snapshot once. Alphanumeric is natural, + case-insensitive title order; Created and Recent are newest-first with + missing timestamps last. - `currentSortCriterion()` returns that local rule without triggering work. - `visiblySelectedThreadId()` returns the ID of the row the user currently sees as selected. Outbound prompt routing must use this value, not a stale shell selection. -- `Actions::select/reload/rename/fork/toggleArchive/remove` carry exactly the - pointed row ID. `Actions::newThread/refresh/hide` carry no inferred target. +- `Actions::select/reload/rename/fork/forkWithOptions/toggleArchive/remove` + carry exactly the pointed row ID. `Actions::newThread/refresh/loadMore/hide` + carry no inferred target. `loadMore` is requested only at the bounded + near-list-end threshold; runtime single-flight and cursor guards decide + whether a provider request is required. | Method | Parameters / return | Preconditions and observable effect | | --- | --- | --- | | constructor | optional QWidget `parent` | Constructs one empty pane; restores only local settings. Performs no callback. | | `setActions` | replacement `Actions` value | Post: later gestures use only this bundle. Does not replay a gesture. | -| `refresh` | complete `ThreadListSnapshot` by const reference | Snapshot remains valid for the call only. Post: rendered hierarchy/selection equals its effective value plus local expansion/sort/optimistic rows. Identical effective input performs no row work. | +| `refresh` | complete `ThreadListSnapshot` by const reference | Snapshot remains valid for the call only. Post: rendered hierarchy/selection equals its effective value plus the selected ordering, local expansion, and optimistic rows. Identical effective input performs no row work. | | `beginOptimisticThread` | provisional `id`, display `title`, `cwd` | `id` must be nonempty and process-locally unique. Duplicate begin updates no canonical graph state. | | `promoteOptimisticThread` | exact `draftId`, exact `authoritativeId` | If draft is absent, no-op. Post: callbacks and visible selection use authoritative ID without replacing unrelated rows. | | `confirmOptimisticThread` | current provisional/promoted `threadId` | Removes only the matching overlay; canonical row remains. | | `failOptimisticThread` | exact optimistic ID | Marks only that overlay failed and keeps it recoverable/selectable as defined by UI behavior. | | `isOptimisticThread` | ID; returns bool | Pure local query. | -| `setSortCriterion` | enum value | Reorders roots atomically using local snapshot and persists choice. Child order/hierarchy is retained. | +| `setSortCriterion` | enum value | Reorders root groups atomically from the retained snapshot. Child hierarchy remains intact. | | `currentSortCriterion` | no parameters; returns enum | Pure local query. | | `visiblySelectedThreadId` | no parameters; returns canonical/provisional string | Empty when no visible row is selected. This is the outbound routing source of truth. | +Thread catalog startup is two-stage. The runtime requests one bounded +`recency_at` descending page with `useStateDbOnly=true`, publishes it, then +schedules an automatic first-page request with `useStateDbOnly=false`. The +second request lets app-server reconcile persisted session files into its +database without delaying the first usable sidebar. Its `nextCursor` becomes +the paging authority. `LoadMoreThreads` consumes at most one cursor page per +near-end request using the repaired database, rejects repeated cursors, and +merges rows through the same graph-backed list projection so selection, +hierarchy, optimistic names, pending animation, and current sorting survive. +A provider-generation change invalidates the timer, cursor, and in-flight +cycle together. No manual repair command is exposed. + +`suggestForkName(sourceTitle, existingTitles)` is toolkit-independent and +returns the first unused direct descendant of the source's parsed fork +lineage. `NewThreadDialog` accepts either Create or Fork purpose; Fork reuses +the complete creation form with a prefilled, editable suggested name. The +client-only `requestedName` is removed before `thread/fork`, applied as a local +overlay to the returned thread, and synchronized by a separate +`thread/name/set` request. + ### `middle::ConversationItemModel` `ConversationItemModel` is the thin `QAbstractListModel` indexing surface for @@ -441,6 +466,13 @@ internal image dialog, callback registry, or file-opening cache is retained. whether effective content/geometry changed. `CommandOutputView` alone owns its inner wheel/follow state; restoring it must not move the outer conversation. +`presentation::userMessageMarkdown` is a presentation-only projection. It +turns soft newlines in authored user text into visible Markdown line breaks +while leaving blank lines, existing hard breaks, indented code, and fenced code +intact. `MarkdownTextView::markdownSource()` and card copy continue to expose +the original canonical source. Normal and steering user messages use this same +path in both rich widgets and passive delegate documents. + | Method | Parameters / return | Preconditions and observable effect | | --- | --- | --- | | `data` | returns const DTO reference | Reference is valid until next successful apply or destruction; caller must not retain it across reconciliation. | @@ -652,10 +684,12 @@ fields belong in adapter control metadata instead. Each `ThreadListRow` carries canonical ID, display title fallback, cwd, status, created/updated/recency values, effective last activity, pending count, -archive state, and ordered children. Effective last activity is the maximum of -provider activity, update/recency, and admitted local prompt activity. The -widget, not the adapter, owns sorting, expansion, optimistic animation, -selection visuals, context menus, and row QWidget identity. +archive state, pending-prompt acknowledgement state/deadline, and ordered +children. Effective last activity is the maximum of provider activity, +update/recency, and admitted local prompt activity. The adapter folds confirmed +and optimistic turn order into effective `recencyAt`; the widget owns the +Alphanumeric, Created, and Recent comparators, expansion, pending-prompt +animation, selection visuals, context menus, and row QWidget identity. ### Conversation diff --git a/src/codex/ClientRuntime.cpp b/src/codex/ClientRuntime.cpp index c925de9..cb2ac99 100644 --- a/src/codex/ClientRuntime.cpp +++ b/src/codex/ClientRuntime.cpp @@ -303,6 +303,7 @@ runtimeActionDiagnosticSubject(nodegraph::RuntimeActionKind kind) { using enum nodegraph::RuntimeActionKind; switch (kind) { case RefreshThreads: + case LoadMoreThreads: return "thread/list"; case CreateThread: return "thread/start"; @@ -814,11 +815,13 @@ struct RequestOutcome final { std::string error; std::string threadId; std::string turnId; + std::string nextCursor; }; void identifyResultEntities(RequestOutcome &outcome) { outcome.threadId = valueString(outcome.payload, "threadId"); outcome.turnId = valueString(outcome.payload, "turnId"); + outcome.nextCursor = valueString(outcome.payload, "nextCursor"); if (const nodegraph::Value *thread = valueMember(outcome.payload, "thread")) { if (const nodegraph::Value::Object *object = thread->asObject()) if (outcome.threadId.empty()) @@ -975,6 +978,12 @@ int runClientRuntime(Configuration &configuration, nodegraph::NodeGraph &graph, std::unordered_map promptsWaitingForHydration; bool threadListPending = false; + bool threadListRepairPending = false; + bool threadListLoadMoreRequested = false; + std::uint64_t threadListCycle = 0; + std::string threadListNextCursor; + std::unordered_set threadListSeenCursors; + nlohmann::json threadListBaseParameters = nlohmann::json::object(); bool modelListPending = false; bool permissionProfilesPending = false; @@ -990,6 +999,12 @@ int runClientRuntime(Configuration &configuration, nodegraph::NodeGraph &graph, resumedPromptAdmissions.clear(); promptsWaitingForHydration.clear(); threadListPending = false; + threadListRepairPending = false; + threadListLoadMoreRequested = false; + ++threadListCycle; + threadListNextCursor.clear(); + threadListSeenCursors.clear(); + threadListBaseParameters = nlohmann::json::object(); modelListPending = false; permissionProfilesPending = false; }; @@ -1615,16 +1630,119 @@ int runClientRuntime(Configuration &configuration, nodegraph::NodeGraph &graph, return result; }; - const auto requestThreadList = [&](nlohmann::json parameters) { - if (threadListPending) - return; + const auto normalizedThreadListParameters = [](nlohmann::json parameters) { + parameters.erase("cursor"); + parameters.erase("useStateDbOnly"); + parameters["sortKey"] = "recency_at"; + parameters["sortDirection"] = "desc"; + parameters["limit"] = 100; + return parameters; + }; + + const auto requestThreadListPage = + [&](nlohmann::json parameters, + std::function completed) { threadListPending = true; dispatchRequest( sdk, std::move(parameters), workerLogic, {}, - [&threadListPending, &showNotice](RequestOutcome outcome) { + [&, completed = std::move(completed)](RequestOutcome outcome) mutable { threadListPending = false; - if (!outcome.ok) + completed(std::move(outcome)); + }); + }; + + std::function requestMoreThreads; + requestMoreThreads = [&] { + if (threadListPending || threadListRepairPending) { + threadListLoadMoreRequested = true; + return; + } + if (threadListNextCursor.empty()) + return; + const std::string cursor = threadListNextCursor; + if (!threadListSeenCursors.insert(cursor).second) { + threadListNextCursor.clear(); + threadListLoadMoreRequested = false; + return; + } + const std::uint64_t cycle = threadListCycle; + nlohmann::json parameters = threadListBaseParameters; + parameters["useStateDbOnly"] = true; + parameters["cursor"] = cursor; + requestThreadListPage( + std::move(parameters), [&, cycle, cursor](RequestOutcome outcome) { + if (cycle != threadListCycle) + return; + if (!outcome.ok) { + threadListSeenCursors.erase(cursor); + threadListLoadMoreRequested = false; showNotice(outcome.error); + return; + } + threadListNextCursor = std::move(outcome.nextCursor); + if (threadListLoadMoreRequested) { + threadListLoadMoreRequested = false; + requestMoreThreads(); + } + }); + }; + + const auto requestThreadListRepair = [&](std::uint64_t cycle) { + if (cycle != threadListCycle || threadListPending) + return; + threadListRepairPending = true; + nlohmann::json parameters = threadListBaseParameters; + parameters["useStateDbOnly"] = false; + requestThreadListPage( + std::move(parameters), [&, cycle](RequestOutcome outcome) { + if (cycle != threadListCycle) + return; + threadListRepairPending = false; + if (!outcome.ok) { + showNotice(outcome.error); + if (threadListLoadMoreRequested) { + threadListLoadMoreRequested = false; + requestMoreThreads(); + } + return; + } + threadListNextCursor = std::move(outcome.nextCursor); + if (threadListLoadMoreRequested) { + threadListLoadMoreRequested = false; + requestMoreThreads(); + } + }); + }; + + const auto requestThreadList = [&](nlohmann::json parameters) { + if (threadListPending) + return; + const std::uint64_t cycle = ++threadListCycle; + threadListRepairPending = true; + threadListLoadMoreRequested = false; + threadListNextCursor.clear(); + threadListSeenCursors.clear(); + threadListBaseParameters = + normalizedThreadListParameters(std::move(parameters)); + nlohmann::json fastParameters = threadListBaseParameters; + fastParameters["useStateDbOnly"] = true; + requestThreadListPage( + std::move(fastParameters), [&, cycle](RequestOutcome outcome) { + if (cycle != threadListCycle) + return; + if (!outcome.ok) { + showNotice(outcome.error); + } else { + threadListNextCursor = std::move(outcome.nextCursor); + } + const nodegraph::WorkerGenerations expectedGenerations = + workerLogic.generations(); + static_cast(core::timer::Timer::singleshotTimer( + [&, cycle, expectedGenerations] { + if (workerLogic.generations() == expectedGenerations) + requestThreadListRepair(cycle); + }, + utils::Timeval({0, 50000}))); }); }; @@ -2224,6 +2342,15 @@ int runClientRuntime(Configuration &configuration, nodegraph::NodeGraph &graph, return; } } + std::string requestedForkName; + if (action.kind == Fork) { + const nodegraph::Value *nameValue = + valueMember(action.payload, "requestedName"); + if (const std::string *name = nameValue ? nameValue->asString() + : nullptr) + requestedForkName = *name; + action.payload.erase("requestedName"); + } nlohmann::json parameters = jsonObject(std::move(action.payload)); parameters["threadId"] = threadId; const nodegraph::NodeRef target = std::move(action.target); @@ -2235,22 +2362,47 @@ int runClientRuntime(Configuration &configuration, nodegraph::NodeGraph &graph, dispatchRequest( sdk, std::move(parameters), workerLogic, target, completed); else if (action.kind == Fork) - dispatchRequest( + dispatchRequestHandled( sdk, std::move(parameters), workerLogic, target, - [&](RequestOutcome outcome) { + [](const nodegraph::ProtocolRequestId &) {}, + [&, requestedForkName](RequestOutcome outcome, + nodegraph::DecodedMessage decoded) { if (!outcome.ok) { + static_cast( + workerLogic.applyDetailed(std::move(decoded))); showNotice(outcome.error); return; } std::string forkId = std::move(outcome.threadId); - if (forkId.empty()) + if (forkId.empty()) { + static_cast( + workerLogic.applyDetailed(std::move(decoded))); + showNotice("Thread fork returned no thread identifier"); return; + } + static_cast(workerLogic.completeFork( + std::move(decoded), forkId, requestedForkName)); nodegraph::NodeRef fork = currentNode({nodegraph::NodeKind::Thread, forkId}); if (!fork) return; + if (!requestedForkName.empty()) { + dispatchRequest< + codex::generated::client_requests::ThreadSetName>( + sdk, + nlohmann::json{{"threadId", forkId}, + {"name", requestedForkName}}, + workerLogic, fork, + [&showNotice](RequestOutcome renameOutcome) { + if (!renameOutcome.ok) + showNotice(renameOutcome.error); + }); + } + // thread/fork returns a live, subscribed thread with its copied + // history. Treat that result as hydrated: issuing thread/read + // followed by thread/resume here can strand the first prompt + // behind a redundant resume when provider liveness is stale. static_cast(workerLogic.selectThread(fork)); - hydrateThread(fork); }); else if (action.kind == Archive) dispatchRequest( @@ -2505,6 +2657,9 @@ int runClientRuntime(Configuration &configuration, nodegraph::NodeGraph &graph, case RefreshThreads: requestThreadList(jsonObject(std::move(action.payload))); return; + case LoadMoreThreads: + requestMoreThreads(); + return; case CreateThread: { nodegraph::PromptTransition transition = workerLogic.admitFirstPrompt( std::move(action), threadActivityAt("thread/start"), diff --git a/src/codex/ForkNaming.h b/src/codex/ForkNaming.h new file mode 100644 index 0000000..db962e8 --- /dev/null +++ b/src/codex/ForkNaming.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_FORKNAMING_H +#define CODEXUI_CODEX_FORKNAMING_H + +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace detail { + +struct ForkNameParts final { + std::string base; + std::vector lineage; +}; + +inline ForkNameParts parseForkName(std::string_view title) { + constexpr std::string_view Marker = " (fork "; + if (!title.ends_with(')')) + return {std::string(title), {}}; + const std::size_t marker = title.rfind(Marker); + if (marker == std::string_view::npos || marker == 0) + return {std::string(title), {}}; + + std::vector lineage; + std::string_view remaining = title.substr( + marker + Marker.size(), title.size() - marker - Marker.size() - 1); + while (!remaining.empty()) { + const std::size_t separator = remaining.find('.'); + const std::string_view component = remaining.substr(0, separator); + std::uint64_t number = 0; + const auto [end, error] = std::from_chars( + component.data(), component.data() + component.size(), number); + if (component.empty() || component.front() == '0' || error != std::errc{} || + end != component.data() + component.size() || number == 0) + return {std::string(title), {}}; + lineage.push_back(number); + if (separator == std::string_view::npos) + break; + remaining.remove_prefix(separator + 1); + } + return {std::string(title.substr(0, marker)), std::move(lineage)}; +} + +} // namespace detail + +inline std::string +suggestForkName(std::string_view sourceTitle, + std::span existingThreadTitles) { + detail::ForkNameParts source = detail::parseForkName(sourceTitle); + if (source.base.empty()) + source.base = "Thread"; + + std::unordered_set directChildren; + for (const std::string &title : existingThreadTitles) { + const detail::ForkNameParts candidate = detail::parseForkName(title); + if (candidate.base != source.base || + candidate.lineage.size() != source.lineage.size() + 1) + continue; + bool hasParentLineage = true; + for (std::size_t index = 0; index < source.lineage.size(); ++index) { + if (candidate.lineage[index] != source.lineage[index]) { + hasParentLineage = false; + break; + } + } + if (hasParentLineage) + directChildren.insert(candidate.lineage.back()); + } + + std::uint64_t next = 1; + while (directChildren.contains(next)) + ++next; + source.lineage.push_back(next); + + std::string result = source.base + " (fork "; + for (std::size_t index = 0; index < source.lineage.size(); ++index) { + if (index != 0) + result.push_back('.'); + result += std::to_string(source.lineage[index]); + } + result.push_back(')'); + return result; +} + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_FORKNAMING_H diff --git a/src/codex/NewThreadDialog.cpp b/src/codex/NewThreadDialog.cpp index 6c839c2..a39641b 100644 --- a/src/codex/NewThreadDialog.cpp +++ b/src/codex/NewThreadDialog.cpp @@ -45,19 +45,33 @@ QWidget *field(QString caption, QWidget *control) { } // namespace NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) + : NewThreadDialog( + NewThreadDraft{std::move(initialWorkspace), {}, {}, {}, false}, + Purpose::Create, parent) {} + +NewThreadDialog::NewThreadDialog(NewThreadDraft initialDraft, Purpose purpose, + QWidget *parent) : QDialog(parent) { setModal(true); - setWindowTitle(QStringLiteral("New thread")); + const bool forFork = purpose == Purpose::Fork; + const QString heading = forFork ? QStringLiteral("Fork with options") + : QStringLiteral("New thread"); + setWindowTitle(heading); setMinimumSize(540, 480); auto *root = new QVBoxLayout(this); root->setContentsMargins(24, 22, 24, 20); root->setSpacing(14); - root->addWidget(label(QStringLiteral("New thread"), "heading")); - root->addWidget( - label(QStringLiteral("Set the thread context. Model, access, reasoning, " - "and style remain in the upcoming-turn controls."), - "muted")); + root->addWidget(label(heading, "heading")); + root->addWidget(label( + forFork + ? QStringLiteral("Adjust the copied thread context. Model, access, " + "reasoning, and style remain in the upcoming-turn " + "controls.") + : QStringLiteral( + "Set the thread context. Model, access, reasoning, and " + "style remain in the upcoming-turn controls."), + "muted")); auto *scroll = new QScrollArea; scroll->setWidgetResizable(true); @@ -68,7 +82,7 @@ NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) form->setContentsMargins(0, 2, 8, 2); form->setSpacing(16); - workspace = new QLineEdit(std::move(initialWorkspace)); + workspace = new QLineEdit(std::move(initialDraft.workspace)); workspace->setPlaceholderText(QDir::homePath()); auto *workspaceRow = new QWidget; auto *workspaceLayout = new QHBoxLayout(workspaceRow); @@ -82,6 +96,7 @@ NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) name = new QLineEdit; name->setPlaceholderText(QStringLiteral("Optional thread name")); + name->setText(std::move(initialDraft.name)); form->addWidget(field(QStringLiteral("Name"), name)); baseInstructions = new QPlainTextEdit; @@ -89,6 +104,7 @@ NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) QStringLiteral("Optional base instructions")); baseInstructions->setMaximumHeight(110); baseInstructions->setProperty("kind", "dialogEditor"); + baseInstructions->setPlainText(std::move(initialDraft.baseInstructions)); form->addWidget(field(QStringLiteral("Base instructions"), baseInstructions)); developerInstructions = new QPlainTextEdit; @@ -96,6 +112,8 @@ NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) QStringLiteral("Optional developer instructions")); developerInstructions->setMaximumHeight(110); developerInstructions->setProperty("kind", "dialogEditor"); + developerInstructions->setPlainText( + std::move(initialDraft.developerInstructions)); form->addWidget( field(QStringLiteral("Developer instructions"), developerInstructions)); @@ -104,6 +122,7 @@ NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) auto *ephemeralLayout = new QVBoxLayout(ephemeralSurface); ephemeralLayout->setContentsMargins(12, 10, 12, 10); ephemeral = new QCheckBox(QStringLiteral("Temporary thread")); + ephemeral->setChecked(initialDraft.ephemeral); ephemeralLayout->addWidget(ephemeral); ephemeralLayout->addWidget( label(QStringLiteral( diff --git a/src/codex/NewThreadDialog.h b/src/codex/NewThreadDialog.h index ec47ed8..520b1e3 100644 --- a/src/codex/NewThreadDialog.h +++ b/src/codex/NewThreadDialog.h @@ -23,7 +23,11 @@ struct NewThreadDraft { class NewThreadDialog final : public QDialog { public: + enum class Purpose { Create, Fork }; + explicit NewThreadDialog(QString initialWorkspace, QWidget *parent = nullptr); + NewThreadDialog(NewThreadDraft initialDraft, Purpose purpose, + QWidget *parent = nullptr); [[nodiscard]] NewThreadDraft draft() const; diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index bc31abb..591ca11 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -4,6 +4,7 @@ #include "codex/ConnectionDialog.h" #include "codex/FileSelectionDialog.h" +#include "codex/ForkNaming.h" #include "codex/FrontendSession.h" #include "codex/NewThreadDialog.h" #include "codex/PendingRequestDialog.h" @@ -110,6 +111,25 @@ std::string graphString(const nodegraph::Value *value) { return value && value->asString() ? *value->asString() : std::string{}; } +const ui::ThreadListRow *threadRowById( + const std::vector &rows, std::string_view id) { + for (const ui::ThreadListRow &row : rows) { + if (row.id == id) + return &row; + if (const ui::ThreadListRow *found = threadRowById(row.children, id)) + return found; + } + return nullptr; +} + +void collectThreadTitles(const std::vector &rows, + std::vector &titles) { + for (const ui::ThreadListRow &row : rows) { + titles.push_back(row.title); + collectThreadTitles(row.children, titles); + } +} + bool fieldChanged(const nodegraph::NodeGraph::ReadAccess &read, const nodegraph::NodeRef &node, std::string_view field, std::uint64_t revision) { @@ -140,20 +160,24 @@ threadPaneRoute(const nodegraph::GraphChanged &change, nodegraph::NodeKind::Thread}) ? ThreadPaneRoute{true, true, {}} : ThreadPaneRoute{}; - constexpr std::array Fields{"name", - "title", - "cwd", - "workspace", - "status", - "createdAt", - "updatedAt", - "recencyAt", - "lastActivityAt", - "localActivityAt", - "localPromptActivityAt", - "pendingInteractionCount", - "hydrationState", - "archived"}; + constexpr std::array Fields{ + "name", + "localNameOverlay", + "title", + "preview", + "cwd", + "workspace", + "status", + "createdAt", + "updatedAt", + "recencyAt", + "lastActivityAt", + "localActivityAt", + "localPromptActivityAt", + "confirmedLocalPromptActivityAt", + "pendingInteractionCount", + "hydrationState", + "archived"}; ThreadPaneRoute route; for (const nodegraph::NodeRef &node : change.affected) { if (!node || !read->contains(node)) @@ -163,6 +187,19 @@ threadPaneRoute(const nodegraph::GraphChanged &change, return {true, true, {}}; continue; } + if (node->id().kind == nodegraph::NodeKind::Item) { + const auto state = read->state(node); + if (!state || graphString(graphField(*state, "type")) != "localPrompt") + continue; + nodegraph::NodeRef owner = read->parent(node); + while (owner && owner->id().kind != nodegraph::NodeKind::Thread) + owner = read->parent(owner); + if (owner && std::ranges::find(route.rows, owner) == route.rows.end()) { + route.affected = true; + route.rows.push_back(std::move(owner)); + } + continue; + } if (node->id().kind != nodegraph::NodeKind::Thread) continue; const bool presentationChanged = @@ -176,13 +213,16 @@ threadPaneRoute(const nodegraph::GraphChanged &change, const bool sortChanged = (sortCriterion == middle::ThreadPane::SortCriterion::Alphanumeric && (fieldChanged(*read, node, "name", change.revision) || - fieldChanged(*read, node, "title", change.revision))) || + fieldChanged(*read, node, "localNameOverlay", change.revision) || + fieldChanged(*read, node, "title", change.revision) || + fieldChanged(*read, node, "preview", change.revision))) || (sortCriterion == middle::ThreadPane::SortCriterion::Created && fieldChanged(*read, node, "createdAt", change.revision)) || - (sortCriterion == middle::ThreadPane::SortCriterion::LastChanged && - fieldChanged(*read, node, "updatedAt", change.revision)) || (sortCriterion == middle::ThreadPane::SortCriterion::Recency && - fieldChanged(*read, node, "recencyAt", change.revision)); + (fieldChanged(*read, node, "recencyAt", change.revision) || + fieldChanged(*read, node, "localPromptActivityAt", change.revision) || + fieldChanged(*read, node, "confirmedLocalPromptActivityAt", + change.revision))); if (read->structureChangedRevision(node) == change.revision || sortChanged || fieldChanged(*read, node, "archived", change.revision)) return {true, true, {}}; @@ -1072,6 +1112,11 @@ struct ShellWidget::Impl final { pendingRequest(const std::string &requestKey = {}, bool *busy = nullptr); void hydrateSelectedThreadIfNeeded(nodegraph::NodeRef thread); void beginNewThreadDialog(); + [[nodiscard]] std::optional + suggestedForkDraft(const nodegraph::NodeRef &thread) const; + void forkThread(const nodegraph::NodeRef &thread, NewThreadDraft draft, + bool includeOptions); + void beginForkThreadDialog(const nodegraph::NodeRef &thread); void renameThreadDialog(const nodegraph::NodeRef &thread); void confirmDeleteThread(const nodegraph::NodeRef &thread); [[nodiscard]] bool submitPrompt(QString prompt, @@ -1318,6 +1363,11 @@ void ShellWidget::Impl::connectUi() { {nodegraph::RuntimeActionKind::RefreshThreads}, QStringLiteral("Thread refresh was not admitted; try again."))); }; + threadActions.loadMore = [this] { + static_cast(sendRuntimeAction( + {nodegraph::RuntimeActionKind::LoadMoreThreads}, + QStringLiteral("More threads could not be requested; try again."))); + }; threadActions.hide = [this] { middleRegion->showSidebar(false); }; threadActions.select = [this](const std::string &id) { if (id == DraftThreadId && newThreadDraft) { @@ -1356,10 +1406,16 @@ void ShellWidget::Impl::connectUi() { const nodegraph::NodeRef thread = threadById(id); if (!thread) return; - nodegraph::NodeAction action{thread, nodegraph::NodeActionKind::Fork}; - static_cast(sendNodeAction( - std::move(action), - QStringLiteral("Thread fork was not admitted; try again."))); + const std::optional draft = suggestedForkDraft(thread); + if (!draft) { + showNotice(QStringLiteral("Thread state is busy; try Quick fork again.")); + return; + } + forkThread(thread, *draft, false); + }; + threadActions.forkWithOptions = [this](const std::string &id) { + if (const nodegraph::NodeRef thread = threadById(id)) + beginForkThreadDialog(thread); }; threadActions.toggleArchive = [this](const std::string &id) { const nodegraph::NodeRef thread = threadById(id); @@ -3023,6 +3079,81 @@ void ShellWidget::Impl::beginNewThreadDialog() { render(); } +std::optional ShellWidget::Impl::suggestedForkDraft( + const nodegraph::NodeRef &thread) const { + if (!thread || thread->id().kind != nodegraph::NodeKind::Thread) + return std::nullopt; + const std::optional snapshot = uiAdapter.threads({}); + if (!snapshot) + return std::nullopt; + const ui::ThreadListRow *source = + threadRowById(snapshot->roots, thread->id().canonical); + if (!source) + return std::nullopt; + + std::vector titles; + collectThreadTitles(snapshot->roots, titles); + NewThreadDraft draft; + draft.workspace = text(source->cwd); + draft.name = text(suggestForkName(source->title, titles)); + + if (auto read = session.nodeGraph().tryRead(); + read && read->contains(thread) && !read->removed(thread)) { + const std::shared_ptr state = read->state(thread); + draft.baseInstructions = + text(graphString(graphField(*state, "baseInstructions"))); + draft.developerInstructions = + text(graphString(graphField(*state, "developerInstructions"))); + draft.ephemeral = graphBool(graphField(*state, "ephemeral")); + } + return draft; +} + +void ShellWidget::Impl::forkThread(const nodegraph::NodeRef &thread, + NewThreadDraft draft, + bool includeOptions) { + if (!thread || thread->id().kind != nodegraph::NodeKind::Thread) + return; + nodegraph::NodeAction action{thread, nodegraph::NodeActionKind::Fork}; + const QString requestedName = draft.name.trimmed(); + if (!requestedName.isEmpty()) + action.payload.emplace("requestedName", utf8(requestedName)); + if (includeOptions) { + const QString workspace = draft.workspace.trimmed(); + if (!workspace.isEmpty()) + action.payload.emplace("cwd", utf8(workspace)); + const QString baseInstructions = draft.baseInstructions.trimmed(); + if (!baseInstructions.isEmpty()) + action.payload.emplace("baseInstructions", utf8(baseInstructions)); + const QString developerInstructions = + draft.developerInstructions.trimmed(); + if (!developerInstructions.isEmpty()) + action.payload.emplace("developerInstructions", + utf8(developerInstructions)); + action.payload.emplace("ephemeral", draft.ephemeral); + } + static_cast(sendNodeAction( + std::move(action), + QStringLiteral("Thread fork was not admitted; try again."))); +} + +void ShellWidget::Impl::beginForkThreadDialog( + const nodegraph::NodeRef &thread) { + std::optional draft = suggestedForkDraft(thread); + if (!draft) { + showNotice(QStringLiteral( + "Thread state is busy; try Fork with options again.")); + return; + } + NewThreadDialog dialog(*draft, NewThreadDialog::Purpose::Fork, owner); + if (dialog.exec() != QDialog::Accepted) + return; + NewThreadDraft selected = dialog.draft(); + if (selected.name.trimmed().isEmpty()) + selected.name = draft->name; + forkThread(thread, std::move(selected), true); +} + void ShellWidget::Impl::renameThreadDialog(const nodegraph::NodeRef &thread) { if (!thread || thread->id().kind != nodegraph::NodeKind::Thread) return; @@ -3081,8 +3212,7 @@ void ShellWidget::Impl::confirmDeleteThread(const nodegraph::NodeRef &thread) { bool ShellWidget::Impl::submitPrompt(QString prompt, std::vector attachments) { - prompt = prompt.trimmed(); - if (prompt.isEmpty()) + if (prompt.trimmed().isEmpty()) return false; TurnSettingsWidget *settings = middleRegion->composer().turnSettings(); std::vector ownedAttachments; diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 16d511d..09e26ae 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -58,11 +59,13 @@ namespace { constexpr int MaximumCommandOutputHeight = 220; constexpr int MaximumCommandTextHeight = 90; constexpr int CommandTextPadding = 7; +constexpr int CommandOutputHorizontalPadding = 7; +constexpr int CommandOutputVerticalPadding = 4; constexpr int PendingAnimationIntervalMilliseconds = 32; constexpr qint64 PendingHalfCycleMilliseconds = 850; constexpr int ThumbnailMaximumWidth = 280; constexpr int ThumbnailMaximumHeight = 180; -constexpr int CardHeaderActionSpacing = 4; +constexpr int CardHeaderActionSpacing = 0; constexpr int CopyMorphDurationMilliseconds = 160; constexpr int CopyCheckHoldMilliseconds = 500; constexpr int MarkdownBottomPaintGuard = 4; @@ -514,9 +517,10 @@ QLabel *makeLabel(const QString &value, const char *kind = "body", MarkdownTextView *makeMarkdownView( const QString &value, std::shared_ptr preparedDocument, - int initialWidth, QWidget *parent = nullptr) { + int initialWidth, QWidget *parent = nullptr, + bool preserveSoftLineBreaks = false) { return new MarkdownTextView(value, std::move(preparedDocument), initialWidth, - parent); + parent, preserveSoftLineBreaks); } bool setVisibleText(QLabel *label, const QString &text) { @@ -928,10 +932,11 @@ bool presentationEquals(const VisibleCardData &left, MarkdownTextView::MarkdownTextView( const QString &markdown, std::shared_ptr preparedDocument, int initialWidth, - QWidget *parent) + QWidget *parent, bool preserveSoftLineBreaks) : QTextBrowser(parent), document_(preparedDocument ? preparedDocument - : std::make_shared()) { + : std::make_shared()), + preserveSoftLineBreaks_(preserveSoftLineBreaks) { setObjectName(QStringLiteral("markdownTextView")); setProperty("kind", "body"); setStyleSheet(QStringLiteral( @@ -940,6 +945,11 @@ MarkdownTextView::MarkdownTextView( setFrameShape(QFrame::NoFrame); setContentsMargins(0, 0, 0, 0); setReadOnly(true); + setTextInteractionFlags(Qt::TextSelectableByMouse | + Qt::TextSelectableByKeyboard | + Qt::LinksAccessibleByMouse | + Qt::LinksAccessibleByKeyboard); + setFocusPolicy(Qt::StrongFocus); setOpenExternalLinks(true); setOpenLinks(true); setLineWrapMode(QTextEdit::WidgetWidth); @@ -964,8 +974,12 @@ MarkdownTextView::MarkdownTextView( configureDocument(); if (preparedDocument) { markdown_ = markdown; + renderedMarkdown_ = preserveSoftLineBreaks_ + ? presentation::userMessageMarkdown(markdown_) + : markdown_; markdownTail_ = - presentation::markdownTailState(*document_, QStringView(markdown_)); + presentation::markdownTailState(*document_, + QStringView(renderedMarkdown_)); setProperty("markdownSource", markdown_); } else { setContent(markdown); @@ -993,16 +1007,20 @@ void MarkdownTextView::configureDocument() { bool MarkdownTextView::setContent(const QString &markdown) { if (markdown_ == markdown) return false; + const QString rendered = preserveSoftLineBreaks_ + ? presentation::userMessageMarkdown(markdown) + : markdown; const QTextCursor retainedCursor = textCursor(); const bool retainedSelection = retainedCursor.hasSelection(); const int retainedPosition = retainedCursor.position(); const int retainedAnchor = retainedCursor.anchor(); if (!presentation::appendMarkdownDocument( - *document_, QStringView(markdown_), QStringView(markdown), + *document_, QStringView(renderedMarkdown_), QStringView(rendered), markdownTail_)) { - presentation::replaceMarkdownDocument(*document_, markdown, markdownTail_); + presentation::replaceMarkdownDocument(*document_, rendered, markdownTail_); } markdown_ = markdown; + renderedMarkdown_ = rendered; setProperty("markdownSource", markdown_); if (retainedSelection) { const int maximum = std::max(0, document_->characterCount() - 1); @@ -1067,6 +1085,25 @@ QSize MarkdownTextView::sizeHint() const { QSize MarkdownTextView::minimumSizeHint() const { return {0, 0}; } +void MarkdownTextView::keyPressEvent(QKeyEvent *event) { + if (event && event->matches(QKeySequence::Copy) && hasSelectedText()) { + // QTextBrowser owns the platform copy semantics. Remove only the + // presentation-only glyph used to keep authored blank prompt lines + // visible; it is not part of the user's canonical text. + copy(); + if (QClipboard *clipboard = QApplication::clipboard()) { + QString text = clipboard->text(); + if (text.contains(QChar(0x200B))) { + text.remove(QChar(0x200B)); + clipboard->setText(text); + } + } + event->accept(); + return; + } + QTextBrowser::keyPressEvent(event); +} + void MarkdownTextView::refreshPreferredHeight(int documentWidth) const { if (preferredDocumentWidth_ == documentWidth && preferredHeight_ > 0) return; @@ -1228,20 +1265,28 @@ bool ContentSizedTextView::contentHeightCapped() const noexcept { } CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) - : QPlainTextEdit(parent) { + : QTextEdit(parent) { setReadOnly(true); + setAcceptRichText(false); setMinimumHeight(0); - setMaximumHeight(MaximumCommandOutputHeight); - setLineWrapMode(QPlainTextEdit::WidgetWidth); + setLineWrapMode(QTextEdit::WidgetWidth); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - document()->setDocumentMargin(CommandTextPadding); + setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + document()->setDocumentMargin(0); setProperty("kind", "code"); setObjectName(QStringLiteral("commandOutputView")); - setStyleSheet(QStringLiteral( - "QPlainTextEdit#commandOutputView{background:#111827;color:#e5e7eb;" - "border-radius:6px;}")); + setStyleSheet( + QStringLiteral("QTextEdit#commandOutputView{background:#111827;" + "color:#e5e7eb;border-radius:6px;padding:%1px %2px;}") + .arg(CommandOutputVerticalPadding) + .arg(CommandOutputHorizontalPadding)); + ensurePolished(); + const int lineHeight = std::max(1, fontMetrics().lineSpacing()); + const int contentBudget = + MaximumCommandOutputHeight - 2 * CommandOutputVerticalPadding; + const int maximumRows = std::max(1, contentBudget / lineHeight); + setMaximumHeight(2 * CommandOutputVerticalPadding + maximumRows * lineHeight); connect(verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) { @@ -1311,19 +1356,20 @@ bool CommandOutputView::retainsWheelGesture(QWheelEvent *event) { } QSize CommandOutputView::sizeHint() const { - QSize result = QPlainTextEdit::sizeHint(); + QSize result = QTextEdit::sizeHint(); result.setHeight(preferredHeight_); return result; } QSize CommandOutputView::minimumSizeHint() const { - QSize result = QPlainTextEdit::minimumSizeHint(); + QSize result = QTextEdit::minimumSizeHint(); result.setHeight(0); return result; } CommandOutputView::ScrollState CommandOutputView::scrollState() const { - return {followsLatest_, preservedScrollValue_}; + return {followsLatest_, followsLatest_ ? preservedScrollValue_ + : verticalScrollBar()->value()}; } bool CommandOutputView::followsLatest() const noexcept { @@ -1338,8 +1384,11 @@ bool CommandOutputView::setOutput(const QString &output) { QElapsedTimer commitTimer; commitTimer.start(); const QString displayOutput = trimmedTrailingLines(output); - if (currentOutput_ == displayOutput) + if (currentOutput_ == displayOutput) { + settleScroll(); + scheduleScrollSettlement(); return false; + } const bool retainedHeightIsCapped = isHeightCapped(); const bool retainedFollow = followsLatest_; @@ -1373,6 +1422,7 @@ bool CommandOutputView::setOutput(const QString &output) { viewport()->update(); } settleScroll(); + scheduleScrollSettlement(); setProperty("lastOutputCommitMicros", commitTimer.nsecsElapsed() / 1000); return true; } @@ -1383,7 +1433,7 @@ bool CommandOutputView::outputRequiresMaximumHeight( return false; const int lineHeight = std::max(1, fontMetrics().lineSpacing()); const int availableHeight = - std::max(1, maximumHeight() - 2 * frameWidth()); + std::max(1, maximumHeight() - 2 * CommandOutputVerticalPadding); const int requiredLines = availableHeight / lineHeight + 1; const int availableWidth = std::max(1, viewport()->width()); int visualLines = 0; @@ -1410,15 +1460,16 @@ bool CommandOutputView::measureAtCurrentWidth(bool notifyParent) { if (outputRequiresMaximumHeight(currentOutput_)) return setPreferredContentHeight(maximumHeight(), notifyParent); - qreal contentHeight = 2.0 * document()->documentMargin(); + qreal contentHeight = 2 * CommandOutputVerticalPadding; for (QTextBlock block = document()->begin(); block.isValid(); block = block.next()) { - contentHeight += blockBoundingRect(block).height(); - if (contentHeight + 2 * frameWidth() >= maximumHeight()) + if (block.layout()) + contentHeight += block.layout()->boundingRect().height(); + if (contentHeight >= maximumHeight()) return setPreferredContentHeight(maximumHeight(), notifyParent); } return setPreferredContentHeight( - 2 * frameWidth() + static_cast(std::ceil(contentHeight)), + static_cast(std::ceil(contentHeight)), notifyParent); } @@ -1434,22 +1485,27 @@ bool CommandOutputView::setPreferredContentHeight(int height, } void CommandOutputView::resizeEvent(QResizeEvent *event) { - QPlainTextEdit::resizeEvent(event); + QTextEdit::resizeEvent(event); if (outputRequiresMaximumHeight(currentOutput_)) { static_cast(setPreferredContentHeight(maximumHeight(), true)); setProperty("boundedOutputMeasurements", property("boundedOutputMeasurements").toULongLong() + 1); + settleScroll(); + scheduleScrollSettlement(); return; } static_cast(measureAtCurrentWidth(true)); setProperty("fullOutputMeasurements", property("fullOutputMeasurements").toULongLong() + 1); + settleScroll(); + scheduleScrollSettlement(); } void CommandOutputView::restoreScrollState(const ScrollState &state) { followsLatest_ = state.followsLatest; preservedScrollValue_ = std::max(0, state.value); settleScroll(); + scheduleScrollSettlement(); } void CommandOutputView::wheelEvent(QWheelEvent *event) { @@ -1464,7 +1520,7 @@ void CommandOutputView::wheelEvent(QWheelEvent *event) { if (atBoundary) event->accept(); else - QPlainTextEdit::wheelEvent(event); + QTextEdit::wheelEvent(event); preservedScrollValue_ = bar->value(); followsLatest_ = isAtBottom(); } @@ -1487,6 +1543,16 @@ void CommandOutputView::settleScroll() { settlingScroll_ = false; } +void CommandOutputView::scheduleScrollSettlement() { + if (scrollSettlementPending_) + return; + scrollSettlementPending_ = true; + QTimer::singleShot(0, this, [this] { + scrollSettlementPending_ = false; + settleScroll(); + }); +} + bool CommandOutputView::isAtBottom() const { return verticalScrollBar()->value() >= verticalScrollBar()->maximum(); } @@ -1524,6 +1590,7 @@ class ConversationCard::Impl final { disclosure = new CardDisclosureButton(header); headerLayout->addWidget(title, 1); headerLayout->addWidget(copy, 0, Qt::AlignRight | Qt::AlignVCenter); + headerLayout->addSpacing(4); headerLayout->addWidget(disclosure, 0, Qt::AlignRight | Qt::AlignVCenter); layout->addWidget(header); @@ -1889,7 +1956,7 @@ class ConversationCard::Impl final { collapsed && deferCollapsedBodyProjection ? QString{} : text(message.text), takePreparedVisibleMarkdownDocument(), markdownContentWidth(), - content); + content, true); contentLayout->addWidget(markdownBody); createImageContainer(); updateComposition(message); @@ -2184,7 +2251,7 @@ class ConversationCard::Impl final { ? QString{} : text(prompt.prompt), takePreparedVisibleMarkdownDocument(), - markdownContentWidth(), content); + markdownContentWidth(), content, true); metadata = makeLabel({}, "meta", content); contentLayout->addWidget(markdownBody); contentLayout->addWidget(metadata); diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index de0562d..3cf8f22 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -17,6 +17,7 @@ class QLabel; class QPaintEvent; +class QKeyEvent; class QResizeEvent; class QTimer; class QTextDocument; @@ -35,7 +36,8 @@ class MarkdownTextView final : public QTextBrowser { const QString &markdown, std::shared_ptr preparedDocument = {}, int initialWidth = 0, - QWidget *parent = nullptr); + QWidget *parent = nullptr, + bool preserveSoftLineBreaks = false); ~MarkdownTextView() override; bool setContent(const QString &markdown); @@ -49,13 +51,18 @@ class MarkdownTextView final : public QTextBrowser { [[nodiscard]] QSize sizeHint() const override; [[nodiscard]] QSize minimumSizeHint() const override; +protected: + void keyPressEvent(QKeyEvent *event) override; + private: void configureDocument(); void refreshPreferredHeight(int documentWidth) const; std::shared_ptr document_; QString markdown_; + QString renderedMarkdown_; presentation::MarkdownTailState markdownTail_; + bool preserveSoftLineBreaks_ = false; mutable int preferredDocumentWidth_ = 0; mutable int preferredHeight_ = 0; }; @@ -85,7 +92,9 @@ class ContentSizedTextView : public QTextEdit { bool wheelGestureOwned_ = false; }; -class CommandOutputView final : public QPlainTextEdit { +class CommandOutputView final : public QTextEdit { + Q_OBJECT + public: struct ScrollState { bool followsLatest = true; @@ -116,12 +125,14 @@ class CommandOutputView final : public QPlainTextEdit { [[nodiscard]] bool measureAtCurrentWidth(bool notifyParent); [[nodiscard]] bool setPreferredContentHeight(int height, bool notifyParent); void settleScroll(); + void scheduleScrollSettlement(); [[nodiscard]] bool isAtBottom() const; [[nodiscard]] bool outputRequiresMaximumHeight(const QString &output) const; bool followsLatest_ = true; bool programmaticScroll_ = false; bool settlingScroll_ = false; + bool scrollSettlementPending_ = false; bool userScrollActive_ = false; bool wheelGestureActive_ = false; bool wheelGestureDecided_ = false; diff --git a/src/codex/middle/ConversationPresentation.cpp b/src/codex/middle/ConversationPresentation.cpp index c587d58..2a75be3 100644 --- a/src/codex/middle/ConversationPresentation.cpp +++ b/src/codex/middle/ConversationPresentation.cpp @@ -124,6 +124,112 @@ QString statusLabel(std::string_view status) { return text(codexui::codex::displayStatus(status)); } +QString userMessageMarkdown(QStringView source) { + QString rendered; + rendered.reserve(source.size() + source.count(QLatin1Char('\n')) * 3); + + // Markdown treats an empty source line as a paragraph separator and Qt's + // Markdown layout consequently paints the two adjacent paragraphs without + // the authored empty row. For ordinary prose, keep every editor line in one + // paragraph with explicit hard breaks and give empty lines an invisible + // layout glyph. The canonical source remains untouched on the view and is + // still used for copy and protocol correlation. + if (simpleMarkdownParagraphs(source)) { + qsizetype lineStart = 0; + while (lineStart <= source.size()) { + qsizetype lineEnd = source.indexOf(QLatin1Char('\n'), lineStart); + const bool hasNewline = lineEnd >= 0; + if (!hasNewline) + lineEnd = source.size(); + QStringView line = source.sliced(lineStart, lineEnd - lineStart); + if (!line.isEmpty() && line.back() == QLatin1Char('\r')) + line.chop(1); + rendered += line; + if (line.trimmed().isEmpty()) + rendered += QChar(0x200B); + if (hasNewline) { + if (!line.endsWith(QLatin1Char('\\')) && + !line.endsWith(QLatin1StringView(" "))) + rendered += QLatin1StringView(" "); + rendered += QLatin1Char('\n'); + } + if (!hasNewline) + break; + lineStart = lineEnd + 1; + } + return rendered; + } + + bool fenced = false; + QChar fenceMarker; + qsizetype fenceLength = 0; + qsizetype lineStart = 0; + while (lineStart <= source.size()) { + qsizetype lineEnd = source.indexOf(QLatin1Char('\n'), lineStart); + const bool hasNewline = lineEnd >= 0; + if (!hasNewline) + lineEnd = source.size(); + QStringView line = source.sliced(lineStart, lineEnd - lineStart); + if (!line.isEmpty() && line.back() == QLatin1Char('\r')) + line.chop(1); + + qsizetype indentation = 0; + while (indentation < line.size() && indentation < 4 && + line.at(indentation) == QLatin1Char(' ')) + ++indentation; + const bool indentedCode = + indentation >= 4 || line.startsWith(QLatin1Char('\t')); + const QChar marker = indentation < line.size() ? line.at(indentation) + : QChar{}; + qsizetype markerLength = 0; + if (indentation <= 3 && + (marker == QLatin1Char('`') || marker == QLatin1Char('~'))) { + while (indentation + markerLength < line.size() && + line.at(indentation + markerLength) == marker) + ++markerLength; + } + const bool opensFence = !fenced && markerLength >= 3; + const bool closesFence = + fenced && marker == fenceMarker && markerLength >= fenceLength && + line.sliced(indentation + markerLength).trimmed().isEmpty(); + const bool fenceLine = opensFence || closesFence; + const bool insideFence = fenced || opensFence; + + rendered += line; + if (hasNewline) { + qsizetype nextEnd = source.indexOf(QLatin1Char('\n'), lineEnd + 1); + if (nextEnd < 0) + nextEnd = source.size(); + QStringView next = source.sliced(lineEnd + 1, nextEnd - lineEnd - 1); + if (!next.isEmpty() && next.back() == QLatin1Char('\r')) + next.chop(1); + const bool currentBlank = line.trimmed().isEmpty(); + const bool nextBlank = next.trimmed().isEmpty(); + const bool alreadyHardBreak = + line.endsWith(QLatin1Char('\\')) || + line.endsWith(QLatin1StringView(" ")); + if (!insideFence && !fenceLine && !indentedCode && !currentBlank && + !nextBlank && !alreadyHardBreak) + rendered += QLatin1StringView(" "); + rendered += QLatin1Char('\n'); + } + + if (opensFence) { + fenced = true; + fenceMarker = marker; + fenceLength = markerLength; + } else if (closesFence) { + fenced = false; + fenceMarker = QChar{}; + fenceLength = 0; + } + if (!hasNewline) + break; + lineStart = lineEnd + 1; + } + return rendered; +} + QString planMarkdown(const PlanData &plan) { if (!plan.legacyText.empty()) return text(plan.legacyText); diff --git a/src/codex/middle/ConversationPresentation.h b/src/codex/middle/ConversationPresentation.h index da27c33..cc2332b 100644 --- a/src/codex/middle/ConversationPresentation.h +++ b/src/codex/middle/ConversationPresentation.h @@ -26,6 +26,10 @@ struct MarkdownTailState { // Pure display-value helpers shared by the passive delegate and the rich card // editor. They own no state and do not decide which renderer a row uses. [[nodiscard]] QString statusLabel(std::string_view status); +// User-authored prompt newlines are intentional visual line breaks. Preserve +// them in the Markdown presentation without changing the canonical source +// retained for copy or protocol reconciliation. +[[nodiscard]] QString userMessageMarkdown(QStringView source); [[nodiscard]] QString planMarkdown(const PlanData &plan); [[nodiscard]] QString agentMetadata(const AgentActivityData &activity); [[nodiscard]] QString fileChangesText(const FileChangesData &changes); diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 8e1c222..863e4f4 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -243,7 +243,9 @@ PassivePresentation passivePresentation(const VisibleCardData &card, result.border = QColor(QStringLiteral("#b7cff9")); result.titleColor = QColor(QStringLiteral("#415882")); if (includeBlocks) - result.blocks.push_back({text(payload.text), true, false}); + result.blocks.push_back( + {presentation::userMessageMarkdown(text(payload.text)), true, + false}); } else if constexpr (std::is_same_v) { result.title = QStringLiteral("Codex"); result.status = payload.finalAnswer ? QStringLiteral("final answer") @@ -3550,6 +3552,15 @@ void ConversationView::mousePressEvent(QMouseEvent *event) { QWidget *target = card->childAt(cardPosition); if (!target) target = card; + if (event->button() == Qt::LeftButton) { + for (QWidget *candidate = target; candidate && candidate != card; + candidate = candidate->parentWidget()) { + if (auto *markdown = qobject_cast(candidate)) { + markdown->setFocus(Qt::MouseFocusReason); + break; + } + } + } const QPoint localPosition = target->mapFrom(viewport(), viewportPosition); QMouseEvent forwarded(event->type(), QPointF(localPosition), event->scenePosition(), event->globalPosition(), diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index f18f012..b6bb9b3 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -3,6 +3,7 @@ #include "codex/middle/ThreadPane.h" #include "codex/UiStatus.h" +#include "codex/middle/MiddleTypes.h" #include "codex/ui/UiStyle.h" #include @@ -20,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +41,8 @@ constexpr int ExpandedRole = Qt::UserRole + 4; constexpr int ParentIdRole = Qt::UserRole + 5; constexpr int OptimisticRole = Qt::UserRole + 6; constexpr int OptimisticFailedRole = Qt::UserRole + 7; +constexpr int AwaitingPromptRole = Qt::UserRole + 8; +constexpr int PromptAdmittedAtRole = Qt::UserRole + 9; constexpr int ChildIndent = 16; constexpr int DisclosureWidth = 16; constexpr int DisclosureExtent = 24; @@ -106,7 +110,9 @@ class ThreadItemDelegate final : public QStyledItemDelegate { if (index.data(ContextMenuRole).toBool()) effective.state |= QStyle::State_MouseOver; QStyledItemDelegate::paint(painter, effective, index); - if (!index.data(OptimisticRole).toBool()) + const bool optimistic = index.data(OptimisticRole).toBool(); + const bool awaitingPrompt = index.data(AwaitingPromptRole).toBool(); + if (!optimistic && !awaitingPrompt) return; const QRectF bounds = QRectF(option.rect).adjusted(1.0, 4.0, -1.0, -4.0); @@ -114,16 +120,23 @@ class ThreadItemDelegate final : public QStyledItemDelegate { painter->save(); painter->setRenderHint(QPainter::Antialiasing); painter->setBrush( - failed ? QColor(QString::fromLatin1(UiStyle::redSurface)) - : QColor(QString::fromLatin1(UiStyle::orangeSurface))); - painter->setPen(QPen(failed ? QColor(QString::fromLatin1(UiStyle::redBorder)) - : QColor(QString::fromLatin1(UiStyle::orangeBorderStrong)), - 1.0)); + failed ? QColor(QString::fromLatin1(UiStyle::redSurface)) + : awaitingPrompt ? QColor(QString::fromLatin1(UiStyle::blueSurface)) + : QColor(QString::fromLatin1(UiStyle::orangeSurface))); + painter->setPen( + QPen(failed ? QColor(QString::fromLatin1(UiStyle::redBorder)) + : awaitingPrompt + ? QColor(QString::fromLatin1(UiStyle::blueBorderStrong)) + : QColor(QString::fromLatin1(UiStyle::orangeBorderStrong)), + awaitingPrompt ? 1.5 : 1.0)); painter->drawRoundedRect(bounds, 8.0, 8.0); - if (!failed) { - constexpr qint64 HalfCycleMilliseconds = 900; - const qint64 phase = - QDateTime::currentMSecsSinceEpoch() % (2 * HalfCycleMilliseconds); + const qint64 admittedAt = index.data(PromptAdmittedAtRole).toLongLong(); + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const bool animate = awaitingPrompt && admittedAt > 0 && + now >= admittedAt + PendingAnimationDelayMilliseconds; + if (animate) { + constexpr qint64 HalfCycleMilliseconds = 850; + const qint64 phase = now % (2 * HalfCycleMilliseconds); const qreal position = phase <= HalfCycleMilliseconds ? qreal(phase) / HalfCycleMilliseconds : qreal(2 * HalfCycleMilliseconds - phase) / @@ -131,9 +144,9 @@ class ThreadItemDelegate final : public QStyledItemDelegate { const qreal center = bounds.left() + position * bounds.width(); const qreal radius = std::max(24.0, bounds.width() * 0.22); QLinearGradient sweep(center - radius, 0.0, center + radius, 0.0); - sweep.setColorAt(0.0, QColor(220, 164, 90, 0)); - sweep.setColorAt(0.5, QColor(236, 188, 112, 105)); - sweep.setColorAt(1.0, QColor(220, 164, 90, 0)); + sweep.setColorAt(0.0, QColor(47, 111, 235, 0)); + sweep.setColorAt(0.5, QColor(117, 160, 239, 105)); + sweep.setColorAt(1.0, QColor(47, 111, 235, 0)); QPainterPath clip; clip.addRoundedRect(bounds, 8.0, 8.0); painter->setClipPath(clip); @@ -281,13 +294,11 @@ QString activityText(const std::optional ×tamp) { : activity.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); } -std::optional timestampFor(const ui::ThreadListRow &thread, - ThreadPane::SortCriterion criterion) { - if (criterion == ThreadPane::SortCriterion::Created) - return thread.createdAt; - if (criterion == ThreadPane::SortCriterion::LastChanged) - return thread.updatedAt; - return thread.recencyAt; +std::optional +timestampFor(const ui::ThreadListRow &thread, + ThreadPane::SortCriterion criterion) { + return criterion == ThreadPane::SortCriterion::Created ? thread.createdAt + : thread.recencyAt; } const ui::ThreadListRow *findThread(const ui::ThreadListRow &row, @@ -301,8 +312,8 @@ const ui::ThreadListRow *findThread(const ui::ThreadListRow &row, return nullptr; } -const ui::ThreadListRow *findThread( - const std::vector &roots, std::string_view id) { +const ui::ThreadListRow *findThread(const std::vector &roots, + std::string_view id) { for (const ui::ThreadListRow &root : roots) { if (const ui::ThreadListRow *found = findThread(root, id)) return found; @@ -371,15 +382,17 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { auto *create = new QPushButton(QStringLiteral("+ New thread")); create->setObjectName(QStringLiteral("threadNewButton")); create->setFixedHeight(36); - create->setStyleSheet(QStringLiteral( - "QPushButton{background:#ffffff;color:%1;border:1px solid %2;" - "border-radius:8px;text-align:left;padding-left:14px;font-weight:600;}" - "QPushButton:hover{background:%3;border-color:%1;}" - "QPushButton:disabled{background:#f6f8fb;color:#98a2b3;" - "border-color:#d7dee8;}") - .arg(QString::fromLatin1(UiStyle::blue), - QString::fromLatin1(UiStyle::blueBorder), - QString::fromLatin1(UiStyle::blueSelected))); + create->setStyleSheet( + QStringLiteral( + "QPushButton{background:#ffffff;color:%1;border:1px solid %2;" + "border-radius:8px;text-align:left;padding-left:14px;font-weight:600;" + "}" + "QPushButton:hover{background:%3;border-color:%1;}" + "QPushButton:disabled{background:#f6f8fb;color:#98a2b3;" + "border-color:#d7dee8;}") + .arg(QString::fromLatin1(UiStyle::blue), + QString::fromLatin1(UiStyle::blueBorder), + QString::fromLatin1(UiStyle::blueSelected))); connect(create, &QPushButton::clicked, this, [this] { if (actions.newThread) actions.newThread(); @@ -389,14 +402,6 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { auto *toolbar = new QHBoxLayout; toolbar->setContentsMargins(4, 0, 4, 6); - auto *refresh = new QPushButton(QStringLiteral("Refresh")); - refresh->setProperty("kind", "subtle"); - refresh->setFixedHeight(28); - connect(refresh, &QPushButton::clicked, this, [this] { - if (actions.refresh) - actions.refresh(); - }); - toolbar->addWidget(refresh); toolbar->addStretch(); sortButton = new UiStyle::ChevronToolButton; sortButton->setObjectName(QStringLiteral("threadSortButton")); @@ -418,7 +423,6 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { }; addSortAction(QStringLiteral("Alphanumeric"), SortCriterion::Alphanumeric); addSortAction(QStringLiteral("Created"), SortCriterion::Created); - addSortAction(QStringLiteral("Last changed"), SortCriterion::LastChanged); QAction *recent = addSortAction(QStringLiteral("Recent"), SortCriterion::Recency); recent->setChecked(true); @@ -440,27 +444,24 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { optimisticAnimation->setObjectName( QStringLiteral("optimisticThreadAnimation")); optimisticAnimation->setInterval(32); - connect(optimisticAnimation, &QTimer::timeout, list, [this] { - if (std::ranges::any_of( - optimisticThreads, - [](const OptimisticThread &thread) { return !thread.failed; })) - list->viewport()->update(); - }); + connect(optimisticAnimation, &QTimer::timeout, list, + [this] { list->viewport()->update(); }); list->setSelectionMode(QAbstractItemView::SingleSelection); list->setContextMenuPolicy(Qt::CustomContextMenu); list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); list->setTextElideMode(Qt::ElideRight); - list->setStyleSheet(QStringLiteral( - "QListWidget#threadList{background:transparent;border:0;outline:0;}" - "QListWidget#threadList::item{min-height:30px;background:#ffffff;" - "border:1px solid #d7dee8;border-radius:8px;margin:3px 0;" - "padding:2px 8px;color:#344054;}" - "QListWidget#threadList::item:hover{background:#f1f5fb;" - "border-color:#b9c4d2;}" - "QListWidget#threadList::item:selected{background:%1;" - "border-color:%2;color:#1d2633;font-weight:600;}") - .arg(QString::fromLatin1(UiStyle::blueSelected), - QString::fromLatin1(UiStyle::blueBorder))); + list->setStyleSheet( + QStringLiteral( + "QListWidget#threadList{background:transparent;border:0;outline:0;}" + "QListWidget#threadList::item{min-height:30px;background:#ffffff;" + "border:1px solid #d7dee8;border-radius:8px;margin:3px 0;" + "padding:2px 8px;color:#344054;}" + "QListWidget#threadList::item:hover{background:#f1f5fb;" + "border-color:#b9c4d2;}" + "QListWidget#threadList::item:selected{background:%1;" + "border-color:%2;color:#1d2633;font-weight:600;}") + .arg(QString::fromLatin1(UiStyle::blueSelected), + QString::fromLatin1(UiStyle::blueBorder))); connect(list, &QListWidget::itemSelectionChanged, this, [this] { if (actions.select) { const std::string id = visiblySelectedThreadId(); @@ -470,6 +471,8 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { }); connect(list, &QListWidget::customContextMenuRequested, this, [this](const QPoint &position) { showContextMenu(position); }); + connect(list->verticalScrollBar(), &QScrollBar::valueChanged, this, + [this] { requestMoreNearListEnd(); }); layout->addWidget(list); } @@ -483,7 +486,6 @@ void ThreadPane::beginOptimisticThread(std::string id, std::string title, optimisticThreads.insert( optimisticThreads.begin(), OptimisticThread{std::move(id), std::move(title), std::move(cwd), false}); - optimisticAnimation->start(); visibleSnapshot.reset(); } @@ -509,10 +511,6 @@ void ThreadPane::confirmOptimisticThread(const std::string &threadId) { std::erase_if(optimisticThreads, [&threadId](const OptimisticThread &thread) { return thread.id == threadId; }); - if (std::ranges::none_of( - optimisticThreads, - [](const OptimisticThread &thread) { return !thread.failed; })) - optimisticAnimation->stop(); visibleSnapshot.reset(); } @@ -522,10 +520,6 @@ void ThreadPane::failOptimisticThread(const std::string &threadId) { if (optimistic == optimisticThreads.end()) return; optimistic->failed = true; - if (std::ranges::none_of( - optimisticThreads, - [](const OptimisticThread &thread) { return !thread.failed; })) - optimisticAnimation->stop(); visibleSnapshot.reset(); } @@ -553,31 +547,21 @@ ThreadPane::SortCriterion ThreadPane::currentSortCriterion() const noexcept { void ThreadPane::updateSortButton() { if (!sortButton) return; - QString label; + QString selected; switch (sortCriterion) { case SortCriterion::Alphanumeric: - label = QStringLiteral("A–Z"); + selected = QStringLiteral("Alphanumeric"); break; case SortCriterion::Created: - label = QStringLiteral("Created"); - break; - case SortCriterion::LastChanged: - label = QStringLiteral("Changed"); + selected = QStringLiteral("Created"); break; case SortCriterion::Recency: - label = QStringLiteral("Recent"); + selected = QStringLiteral("Recent"); break; } - sortButton->setText(QStringLiteral("Sort: %1").arg(label)); + sortButton->setText(QStringLiteral("Sort: %1").arg(selected)); for (QAction *action : sortButton->menu()->actions()) - action->setChecked(action->text() == - (sortCriterion == SortCriterion::Alphanumeric - ? QStringLiteral("Alphanumeric") - : sortCriterion == SortCriterion::Created - ? QStringLiteral("Created") - : sortCriterion == SortCriterion::LastChanged - ? QStringLiteral("Last changed") - : QStringLiteral("Recent"))); + action->setChecked(action->text() == selected); } void ThreadPane::sortRootThreads(std::vector &rows) const { @@ -588,8 +572,7 @@ void ThreadPane::sortRootThreads(std::vector &rows) const { collator.setIgnorePunctuation(true); collator.setNumericMode(true); std::sort(rows.begin(), rows.end(), - [&](const ui::ThreadListRow &left, - const ui::ThreadListRow &right) { + [&](const ui::ThreadListRow &left, const ui::ThreadListRow &right) { if (sortCriterion == SortCriterion::Alphanumeric) { const QString leftTitle = text(left.title).trimmed(); const QString rightTitle = text(right.title).trimmed(); @@ -617,6 +600,29 @@ void ThreadPane::sortRootThreads(std::vector &rows) const { }); } +void ThreadPane::updateAnimationTimer() { + const bool active = + visibleSnapshot && + std::ranges::any_of(visibleSnapshot->rows, + [](const RenderedThreadRow &row) { + return row.awaitingPromptAcknowledgement; + }); + if (active && !optimisticAnimation->isActive()) + optimisticAnimation->start(); + else if (!active) + optimisticAnimation->stop(); +} + +void ThreadPane::requestMoreNearListEnd() { + if (!actions.loadMore || !list || list->count() == 0) + return; + const QScrollBar *scroll = list->verticalScrollBar(); + const int threshold = std::max(48, scroll->pageStep() / 2); + if (scroll->maximum() == 0 || + scroll->value() >= scroll->maximum() - threshold) + actions.loadMore(); +} + void ThreadPane::appendVisibleThread( RenderedThreadList &snapshot, const ui::ThreadListRow &thread, const std::string &parentId, std::size_t depth, @@ -625,10 +631,12 @@ void ThreadPane::appendVisibleThread( return; const bool hasChildren = !thread.children.empty(); const bool expanded = hasChildren && expandedThreads.contains(thread.id); - snapshot.rows.push_back( - {thread.id, thread.title, thread.cwd, thread.status, - thread.lastActivityAt, parentId, - thread.pending, depth, hasChildren, expanded}); + snapshot.rows.push_back({thread.id, thread.title, thread.cwd, thread.status, + thread.createdAt, thread.recencyAt, + thread.lastActivityAt, parentId, thread.pending, + depth, hasChildren, expanded, false, false, + thread.awaitingPromptAcknowledgement, + thread.pendingPromptAdmittedAtMs}); if (!expanded) return; for (const ui::ThreadListRow &child : thread.children) @@ -698,20 +706,27 @@ bool ThreadPane::applyRowPresentation(const ui::ThreadListRow &row) { retained->updatedAt = row.updatedAt; retained->recencyAt = row.recencyAt; retained->lastActivityAt = row.lastActivityAt; + retained->pendingPromptAdmittedAtMs = row.pendingPromptAdmittedAtMs; retained->pending = row.pending; + retained->awaitingPromptAcknowledgement = row.awaitingPromptAcknowledgement; retained->archived = row.archived; const auto visible = std::ranges::find_if( - visibleSnapshot->rows, - [&row](const RenderedThreadRow &candidate) { return candidate.id == row.id; }); + visibleSnapshot->rows, [&row](const RenderedThreadRow &candidate) { + return candidate.id == row.id; + }); if (visible == visibleSnapshot->rows.end()) return true; RenderedThreadRow next = *visible; next.title = row.title; next.cwd = row.cwd; next.status = row.status; + next.createdAt = row.createdAt; + next.recencyAt = row.recencyAt; next.lastActivityAt = row.lastActivityAt; next.pending = row.pending; + next.awaitingPromptAcknowledgement = row.awaitingPromptAcknowledgement; + next.pendingPromptAdmittedAtMs = row.pendingPromptAdmittedAtMs; if (*visible == next) return true; *visible = next; @@ -722,36 +737,44 @@ bool ThreadPane::applyRowPresentation(const ui::ThreadListRow &row) { QListWidgetItem *item = found->second; const QString title = text(next.title); const QString status = text(displayStatus(next.status)); - QStringList accessibleParts{ - title, status, QStringLiteral("level %1").arg(next.depth + 1)}; + QStringList accessibleParts{title, status, + QStringLiteral("level %1").arg(next.depth + 1)}; if (next.hasChildren) accessibleParts.push_back(next.expanded ? QStringLiteral("expanded") : QStringLiteral("collapsed")); const QString accessible = accessibleParts.join(", "); if (item->data(Qt::AccessibleTextRole).toString() != accessible) item->setData(Qt::AccessibleTextRole, accessible); - QStringList details{title, - QStringLiteral("Workspace: %1").arg( - next.cwd.empty() ? QStringLiteral("Unknown") - : text(next.cwd)), - QStringLiteral("Status: %1").arg(status), - QStringLiteral("Last activity: %1") - .arg(activityText(next.lastActivityAt))}; + QStringList details{ + title, + QStringLiteral("Workspace: %1") + .arg(next.cwd.empty() ? QStringLiteral("Unknown") : text(next.cwd)), + QStringLiteral("Status: %1").arg(status), + QStringLiteral("Recent turn: %1").arg(activityText(next.recencyAt)), + QStringLiteral("Created: %1").arg(activityText(next.createdAt)), + QStringLiteral("Last activity: %1") + .arg(activityText(next.lastActivityAt))}; if (!next.parentId.empty()) { const ui::ThreadListRow *parent = findThread(currentSnapshot->roots, next.parentId); - details.push_back(QStringLiteral("Parent: %1").arg( - parent && !parent->title.empty() ? text(parent->title) - : text(next.parentId))); + details.push_back(QStringLiteral("Parent: %1") + .arg(parent && !parent->title.empty() + ? text(parent->title) + : text(next.parentId))); } const QString tooltip = details.join(QLatin1Char('\n')); if (item->toolTip() != tooltip) item->setToolTip(tooltip); + item->setData(AwaitingPromptRole, next.awaitingPromptAcknowledgement); + item->setData( + PromptAdmittedAtRole, + static_cast(next.pendingPromptAdmittedAtMs.value_or(0))); updateRow(list->itemWidget(item), next.id, next.title, next.status, next.pending, next.depth, next.hasChildren, next.expanded, next.optimistic, next.optimisticFailed); if (QWidget *rowWidget = list->itemWidget(item)) rowWidget->update(); + updateAnimationTimer(); setProperty("targetedRowPresentationUpdates", property("targetedRowPresentationUpdates").toULongLong() + 1); return true; @@ -785,6 +808,8 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { thread->title, thread->cwd, thread->status, + thread->createdAt, + thread->recencyAt, thread->lastActivityAt, {}, 0, @@ -792,7 +817,9 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { false, false, true, - optimisticThread.failed}); + optimisticThread.failed, + thread->awaitingPromptAcknowledgement, + thread->pendingPromptAdmittedAtMs}); } else { next.rows.push_back({optimisticThread.id, optimisticThread.title, @@ -800,6 +827,8 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { {}, {}, {}, + {}, + {}, 0, 0, false, @@ -816,11 +845,12 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { const bool retainedOrder = visibleSnapshot && visibleSnapshot->rows.size() == next.rows.size() && - std::equal(visibleSnapshot->rows.begin(), visibleSnapshot->rows.end(), - next.rows.begin(), [](const RenderedThreadRow &before, - const RenderedThreadRow &after) { - return before.id == after.id; - }); + std::equal( + visibleSnapshot->rows.begin(), visibleSnapshot->rows.end(), + next.rows.begin(), + [](const RenderedThreadRow &before, const RenderedThreadRow &after) { + return before.id == after.id; + }); if (retainedOrder) { const RenderedThreadList previous = *visibleSnapshot; visibleSnapshot = std::move(next); @@ -845,19 +875,22 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { const QString accessible = accessibleParts.join(", "); if (item->data(Qt::AccessibleTextRole).toString() != accessible) item->setData(Qt::AccessibleTextRole, accessible); - QStringList details{title, - QStringLiteral("Workspace: %1").arg( - row.cwd.empty() ? QStringLiteral("Unknown") - : text(row.cwd)), - QStringLiteral("Status: %1").arg(status), - QStringLiteral("Last activity: %1").arg( - activityText(row.lastActivityAt))}; + QStringList details{ + title, + QStringLiteral("Workspace: %1") + .arg(row.cwd.empty() ? QStringLiteral("Unknown") : text(row.cwd)), + QStringLiteral("Status: %1").arg(status), + QStringLiteral("Recent turn: %1").arg(activityText(row.recencyAt)), + QStringLiteral("Created: %1").arg(activityText(row.createdAt)), + QStringLiteral("Last activity: %1") + .arg(activityText(row.lastActivityAt))}; if (!row.parentId.empty()) { const ui::ThreadListRow *parent = findThread(currentSnapshot->roots, row.parentId); - details.push_back(QStringLiteral("Parent: %1").arg( - parent && !parent->title.empty() ? text(parent->title) - : text(row.parentId))); + details.push_back(QStringLiteral("Parent: %1") + .arg(parent && !parent->title.empty() + ? text(parent->title) + : text(row.parentId))); } const QString tooltip = details.join(QLatin1Char('\n')); if (item->toolTip() != tooltip) @@ -868,6 +901,10 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { item->setData(ParentIdRole, text(row.parentId)); item->setData(OptimisticRole, row.optimistic); item->setData(OptimisticFailedRole, row.optimisticFailed); + item->setData(AwaitingPromptRole, row.awaitingPromptAcknowledgement); + item->setData( + PromptAdmittedAtRole, + static_cast(row.pendingPromptAdmittedAtMs.value_or(0))); updateRow(list->itemWidget(item), row.id, row.title, row.status, row.pending, row.depth, row.hasChildren, row.expanded, row.optimistic, row.optimisticFailed); @@ -884,6 +921,7 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { } } list->blockSignals(false); + updateAnimationTimer(); return; } @@ -957,19 +995,22 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { : QStringLiteral("collapsed")); item->setData(Qt::DisplayRole, {}); item->setData(Qt::AccessibleTextRole, accessibleParts.join(", ")); - QStringList details{title, - QStringLiteral("Workspace: %1").arg( - row.cwd.empty() ? QStringLiteral("Unknown") - : text(row.cwd)), - QStringLiteral("Status: %1").arg(status), - QStringLiteral("Last activity: %1").arg( - activityText(row.lastActivityAt))}; + QStringList details{ + title, + QStringLiteral("Workspace: %1") + .arg(row.cwd.empty() ? QStringLiteral("Unknown") : text(row.cwd)), + QStringLiteral("Status: %1").arg(status), + QStringLiteral("Recent turn: %1").arg(activityText(row.recencyAt)), + QStringLiteral("Created: %1").arg(activityText(row.createdAt)), + QStringLiteral("Last activity: %1") + .arg(activityText(row.lastActivityAt))}; if (!row.parentId.empty()) { const ui::ThreadListRow *parent = findThread(currentSnapshot->roots, row.parentId); - details.push_back(QStringLiteral("Parent: %1").arg( - parent && !parent->title.empty() ? text(parent->title) - : text(row.parentId))); + details.push_back(QStringLiteral("Parent: %1") + .arg(parent && !parent->title.empty() + ? text(parent->title) + : text(row.parentId))); } item->setToolTip(details.join(QLatin1Char('\n'))); item->setData(DepthRole, static_cast(row.depth)); @@ -978,6 +1019,10 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { item->setData(ParentIdRole, text(row.parentId)); item->setData(OptimisticRole, row.optimistic); item->setData(OptimisticFailedRole, row.optimisticFailed); + item->setData(AwaitingPromptRole, row.awaitingPromptAcknowledgement); + item->setData( + PromptAdmittedAtRole, + static_cast(row.pendingPromptAdmittedAtMs.value_or(0))); updateRow(list->itemWidget(item), row.id, row.title, row.status, row.pending, row.depth, row.hasChildren, row.expanded, row.optimistic, row.optimisticFailed); @@ -989,6 +1034,7 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { } list->setUpdatesEnabled(true); list->blockSignals(false); + updateAnimationTimer(); } std::string ThreadPane::visiblySelectedThreadId() const { @@ -1030,10 +1076,16 @@ void ThreadPane::showContextMenu(const QPoint &position) { if (actions.rename) actions.rename(id); }); - QAction *fork = menu->addAction(QStringLiteral("Fork"), this, [this, id] { - if (actions.fork) - actions.fork(id); - }); + QAction *fork = + menu->addAction(QStringLiteral("Quick fork"), this, [this, id] { + if (actions.fork) + actions.fork(id); + }); + QAction *forkWithOptions = menu->addAction( + QStringLiteral("Fork with options…"), this, [this, id] { + if (actions.forkWithOptions) + actions.forkWithOptions(id); + }); QAction *archive = menu->addAction(thread->archived ? QStringLiteral("Unarchive") : QStringLiteral("Archive"), @@ -1049,6 +1101,7 @@ void ThreadPane::showContextMenu(const QPoint &position) { reload->setEnabled(providerReady); rename->setEnabled(canControl); fork->setEnabled(canControl); + forkWithOptions->setEnabled(canControl); archive->setEnabled(canControl); remove->setEnabled(canControl); menu->popup(list->viewport()->mapToGlobal(position)); diff --git a/src/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h index aeed13b..1efbef0 100644 --- a/src/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -26,16 +26,18 @@ namespace middle { class ThreadPane final : public QFrame { public: - enum class SortCriterion { Alphanumeric, Created, LastChanged, Recency }; + enum class SortCriterion { Alphanumeric, Created, Recency }; struct Actions { std::function newThread; std::function refresh; + std::function loadMore; std::function hide; std::function select; std::function reload; std::function rename; std::function fork; + std::function forkWithOptions; std::function toggleArchive; std::function remove; }; @@ -62,6 +64,8 @@ class ThreadPane final : public QFrame { std::string title; std::string cwd; std::string status; + std::optional createdAt; + std::optional recencyAt; std::optional lastActivityAt; std::string parentId; std::size_t pending = 0; @@ -70,6 +74,8 @@ class ThreadPane final : public QFrame { bool expanded = false; bool optimistic = false; bool optimisticFailed = false; + bool awaitingPromptAcknowledgement = false; + std::optional pendingPromptAdmittedAtMs; bool operator==(const RenderedThreadRow &) const = default; }; @@ -88,6 +94,8 @@ class ThreadPane final : public QFrame { }; void updateSortButton(); void sortRootThreads(std::vector &rows) const; + void updateAnimationTimer(); + void requestMoreNearListEnd(); void appendVisibleThread(RenderedThreadList &snapshot, const ui::ThreadListRow &thread, const std::string &parentId, std::size_t depth, diff --git a/src/codex/nodegraph/Messages.h b/src/codex/nodegraph/Messages.h index 6a86223..42a3713 100644 --- a/src/codex/nodegraph/Messages.h +++ b/src/codex/nodegraph/Messages.h @@ -88,6 +88,7 @@ struct NodeAction final { enum class RuntimeActionKind : std::uint8_t { RefreshThreads, + LoadMoreThreads, CreateThread, Connect, Disconnect, diff --git a/src/codex/nodegraph/ProtocolUpdater.cpp b/src/codex/nodegraph/ProtocolUpdater.cpp index d17ad54..0be6947 100644 --- a/src/codex/nodegraph/ProtocolUpdater.cpp +++ b/src/codex/nodegraph/ProtocolUpdater.cpp @@ -1148,8 +1148,7 @@ std::vector replaceAuthoritativeChildren( std::unordered_set submittedNodes; submittedNodes.reserve(authoritative.size()); for (const NodeRef &node : existing) - if (node && finalNodes.contains(node.get()) && - retainsSubmittedSlot(node)) + if (node && finalNodes.contains(node.get()) && retainsSubmittedSlot(node)) submittedNodes.insert(node.get()); if (!submittedNodes.empty()) { @@ -2673,8 +2672,14 @@ void ProtocolUpdater::applyGraphUpdate( const std::string id = addressedId(message.payload, NodeKind::Thread); if (!id.empty()) { NodeRef thread = write.upsert({NodeKind::Thread, id}); - if (const Value *name = member(message.payload, "threadName")) + if (const Value *name = member(message.payload, "threadName")) { write.setField(thread, "name", *name); + const std::string confirmedName = canonicalValue(name); + const std::string localName = canonicalValue( + member(write.state(thread)->fields, "localNameOverlay")); + if (!confirmedName.empty() && confirmedName == localName) + write.eraseField(thread, "localNameOverlay"); + } } return; } @@ -3144,6 +3149,13 @@ NodeRef ProtocolUpdater::ingestThread( write.fieldChangedRevision(thread, field) <= *preserveChangesAfter; }; mergeObject(write, thread, object, "turns", preserveChangesAfter); + if (const Value *providerName = member(object, "name")) { + const std::string confirmedName = canonicalValue(providerName); + const std::string localName = + canonicalValue(member(write.state(thread)->fields, "localNameOverlay")); + if (!confirmedName.empty() && confirmedName == localName) + write.eraseField(thread, "localNameOverlay"); + } if (const Value *projectId = member(object, "projectId"); projectId && acceptsField("projectId")) @@ -3239,9 +3251,8 @@ ProtocolUpdater::ingestTurn(NodeGraph::WriteAccess &write, const std::vector existing = mergeExistingTail(write.children(turn), write.related(turn, RelationKind::TurnRootItem)); - write.replaceChildren( - turn, replaceAuthoritativeChildren(write, turn, std::move(order), - existing)); + write.replaceChildren(turn, replaceAuthoritativeChildren( + write, turn, std::move(order), existing)); } else if (!order.empty()) write.replaceChildren( turn, mergeExistingTail(std::move(order), write.children(turn))); @@ -3281,8 +3292,8 @@ ProtocolUpdater::ingestItem(NodeGraph::WriteAccess &write, if (!claimsTurnRoot) { const std::vector prompts = write.related(item, RelationKind::PromptMaterialization); - claimsTurnRoot = std::ranges::any_of( - prompts, [&write, &roots](const NodeRef &prompt) { + claimsTurnRoot = + std::ranges::any_of(prompts, [&write, &roots](const NodeRef &prompt) { if (std::ranges::find(roots, prompt) == roots.end()) return false; const Value *dispatch = diff --git a/src/codex/nodegraph/WorkerLogic.cpp b/src/codex/nodegraph/WorkerLogic.cpp index f527192..bee232e 100644 --- a/src/codex/nodegraph/WorkerLogic.cpp +++ b/src/codex/nodegraph/WorkerLogic.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -30,6 +31,20 @@ std::uint64_t unsignedField(const NodeState &state, std::string_view name) { return number ? *number : 0; } +std::optional integerField(const NodeState &state, + std::string_view name) { + const Value *value = field(state, name); + if (!value) + return std::nullopt; + if (const auto *number = value->asInt64()) + return *number; + if (const auto *number = value->asUInt64(); + number && *number <= static_cast( + std::numeric_limits::max())) + return static_cast(*number); + return std::nullopt; +} + const Value *objectField(const Value::Object &object, std::string_view name) { const auto found = object.find(name); return found == object.end() ? nullptr : &found->second; @@ -379,6 +394,28 @@ ChannelSendStatus WorkerLogic::threadHydration(const NodeRef &thread, return publish(std::move(change)); } +ChannelSendStatus WorkerLogic::completeFork(DecodedMessage result, + std::string threadId, + std::string chosenName) { + GraphChange change; + { + auto write = graph_.write(); + static_cast(updater_.applyInto(write, result)); + const NodeRef thread = + write.find({NodeKind::Thread, std::move(threadId)}); + if (thread) { + if (!chosenName.empty()) { + write.setField(thread, "localNameOverlay", Value(chosenName)); + write.setField(thread, "name", Value(std::move(chosenName))); + } + updateThreadHydration(write, thread, "ready", {}); + } + change = write.finish(); + } + forgetRemoved(change); + return publish(std::move(change)); +} + ChannelSendStatus WorkerLogic::completeThreadHydration(DecodedMessage result, const NodeRef &thread, std::string state, @@ -768,8 +805,12 @@ PromptTransition WorkerLogic::admit(PendingPrompt pending, write.relate(runtime, RelationKind::PendingPrompt, pending.localPrompt); write.relate(pending.thread, RelationKind::PendingPrompt, pending.localPrompt); - if (activityAt) - advancePromptActivity(write, pending.thread, *activityAt); + if (activityAt && startsTurn) { + const std::int64_t promptActivityAt = + advancePromptActivity(write, pending.thread, *activityAt); + write.setField(pending.localPrompt, "sortActivityAt", + Value(promptActivityAt)); + } if (!invalidTarget) { const NodeRef ownerThread = pending.thread; promptQueues_[pending.thread.get()].emplace_back(std::move(pending)); @@ -788,9 +829,10 @@ PromptTransition WorkerLogic::admit(PendingPrompt pending, return {publish(std::move(change)), std::move(command)}; } -void WorkerLogic::advancePromptActivity(NodeGraph::WriteAccess &write, - const NodeRef &target, - std::int64_t proposedActivityAt) { +std::int64_t +WorkerLogic::advancePromptActivity(NodeGraph::WriteAccess &write, + const NodeRef &target, + std::int64_t proposedActivityAt) { std::int64_t activityAt = proposedActivityAt; const auto retainMaximum = [&activityAt](const Value *value) { if (!value) @@ -826,6 +868,66 @@ void WorkerLogic::advancePromptActivity(NodeGraph::WriteAccess &write, write.related(thread, RelationKind::ThreadOwner); thread = owners.empty() ? NodeRef{} : owners.front(); } + return activityAt; +} + +void WorkerLogic::recomputePromptActivity(NodeGraph::WriteAccess &write) { + std::unordered_map activityByThread; + std::unordered_map threads; + for (const NodeRef &thread : write.orderedNodes()) { + if (!thread || thread->id().kind != NodeKind::Thread) + continue; + threads.emplace(thread.get(), thread); + const auto state = write.state(thread); + if (const auto confirmed = + integerField(*state, "confirmedLocalPromptActivityAt")) + activityByThread.insert_or_assign(thread.get(), *confirmed); + for (const NodeRef &prompt : + write.related(thread, RelationKind::PendingPrompt)) { + if (!prompt || prompt->id().kind != NodeKind::Item) + continue; + const auto promptState = write.state(prompt); + if (stringField(*promptState, "type") != "localPrompt") + continue; + const Value *startsTurn = field(*promptState, "startsTurn"); + const bool *beginsTurn = startsTurn ? startsTurn->asBool() : nullptr; + if (!beginsTurn || !*beginsTurn) + continue; + const std::string dispatch = stringField(*promptState, "dispatchState"); + if (dispatch == "failed" || dispatch == "uncertain") + continue; + const auto activity = integerField(*promptState, "sortActivityAt"); + if (!activity) + continue; + auto [found, inserted] = + activityByThread.emplace(thread.get(), *activity); + if (!inserted && *activity > found->second) + found->second = *activity; + } + } + + for (const auto &[rawThread, activity] : + std::vector>( + activityByThread.begin(), activityByThread.end())) { + NodeRef thread = threads.at(rawThread); + std::unordered_set visited; + while (thread && visited.insert(thread.get()).second) { + auto [found, inserted] = activityByThread.emplace(thread.get(), activity); + if (!inserted && activity > found->second) + found->second = activity; + const std::vector owners = + write.related(thread, RelationKind::ThreadOwner); + thread = owners.empty() ? NodeRef{} : owners.front(); + } + } + + for (const auto &[rawThread, thread] : threads) { + const auto activity = activityByThread.find(rawThread); + if (activity == activityByThread.end()) + write.eraseField(thread, "localPromptActivityAt"); + else + write.setField(thread, "localPromptActivityAt", Value(activity->second)); + } } NodeRef WorkerLogic::activeTurn(NodeGraph::WriteAccess &write, @@ -876,9 +978,20 @@ WorkerLogic::takeNextPrompt(NodeGraph::WriteAccess &write, write.setField(command.localPrompt, "startsTurn", Value(false)); write.setField(command.localPrompt, "expectedTurnId", Value(command.expectedTurnId)); + write.eraseField(command.localPrompt, "sortActivityAt"); } else { write.setField(command.localPrompt, "startsTurn", Value(true)); write.eraseField(command.localPrompt, "expectedTurnId"); + const auto promptState = write.state(command.localPrompt); + if (!integerField(*promptState, "sortActivityAt")) { + if (const auto admittedAtMs = + integerField(*promptState, "admittedAtMs")) { + const std::int64_t activityAt = advancePromptActivity( + write, thread, *admittedAtMs / 1000); + write.setField(command.localPrompt, "sortActivityAt", + Value(activityAt)); + } + } } } write.setField(command.localPrompt, "dispatchState", Value("dispatching")); @@ -968,10 +1081,16 @@ bool WorkerLogic::attachCreatedThread(NodeGraph::WriteAccess &write, updateThreadHydration(write, authoritative, "ready", {}); for (const std::string_view key : {std::string_view("localActivityAt"), - std::string_view("localPromptActivityAt")}) { + std::string_view("localPromptActivityAt"), + std::string_view("confirmedLocalPromptActivityAt")}) { if (const Value *value = field(*draftState, key)) write.setField(authoritative, std::string(key), *value); } + if (const std::string chosenName = stringField(*draftState, "name"); + !chosenName.empty()) { + write.setField(authoritative, "localNameOverlay", Value(chosenName)); + write.setField(authoritative, "name", Value(chosenName)); + } const std::vector draftChildren = write.children(draft); for (const NodeRef &child : draftChildren) @@ -1109,6 +1228,24 @@ std::optional WorkerLogic::completePrompt( const bool startsTurn = startsTurnValue && startsTurnValue->asBool() && *startsTurnValue->asBool(); + if (accepted && startsTurn) { + if (const auto activity = integerField(*promptState, "sortActivityAt")) { + NodeRef owner = thread; + std::unordered_set visited; + while (owner && visited.insert(owner.get()).second) { + const auto state = write.state(owner); + const auto previous = + integerField(*state, "confirmedLocalPromptActivityAt"); + if (!previous || *activity > *previous) + write.setField(owner, "confirmedLocalPromptActivityAt", + Value(*activity)); + const std::vector owners = + write.related(owner, RelationKind::ThreadOwner); + owner = owners.empty() ? NodeRef{} : owners.front(); + } + } + } + if (uiMaterialized) { NodeRef provisionalTurn = write.parent(localPrompt); write.remove(localPrompt); @@ -1176,8 +1313,8 @@ std::optional WorkerLogic::completePrompt( write.related(turn, RelationKind::TurnRootItem); if (roots.empty() || std::ranges::find(roots, localPrompt) != roots.end()) { - const std::array root{ - materializedItem ? materializedItem : localPrompt}; + const std::array root{materializedItem ? materializedItem + : localPrompt}; write.replaceRelated(turn, RelationKind::TurnRootItem, root); } } @@ -1224,9 +1361,11 @@ std::optional WorkerLogic::completePrompt( } } } + std::optional next; if (accepted || !thread->id().canonical.starts_with("local-thread:")) - return takeNextPrompt(write, thread); - return std::nullopt; + next = takeNextPrompt(write, thread); + recomputePromptActivity(write); + return next; } PromptTransition WorkerLogic::failPrompt(const NodeRef &localPrompt, diff --git a/src/codex/nodegraph/WorkerLogic.h b/src/codex/nodegraph/WorkerLogic.h index 9e7c745..865c3c3 100644 --- a/src/codex/nodegraph/WorkerLogic.h +++ b/src/codex/nodegraph/WorkerLogic.h @@ -89,6 +89,9 @@ class WorkerLogic final { [[nodiscard]] ChannelSendStatus threadHydration(const NodeRef &thread, std::string state, std::string error = {}); + [[nodiscard]] ChannelSendStatus completeFork(DecodedMessage result, + std::string threadId, + std::string chosenName); [[nodiscard]] ChannelSendStatus completeThreadHydration(DecodedMessage result, const NodeRef &thread, std::string state, std::string error = {}); @@ -182,9 +185,10 @@ class WorkerLogic final { const NodeRef &thread) const; void resetProviderDerived(NodeGraph::WriteAccess &write, std::string_view reason); - void advancePromptActivity(NodeGraph::WriteAccess &write, - const NodeRef &thread, - std::int64_t proposedActivityAt); + [[nodiscard]] std::int64_t + advancePromptActivity(NodeGraph::WriteAccess &write, const NodeRef &thread, + std::int64_t proposedActivityAt); + void recomputePromptActivity(NodeGraph::WriteAccess &write); void forgetPrompt(const NodeRef &localPrompt); NodeGraph &graph_; diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp index a08f992..33b59d2 100644 --- a/src/codex/ui/NodeGraphUiAdapter.cpp +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -129,6 +129,36 @@ std::optional graphInteger(const nodegraph::Value *value) { return std::nullopt; } +struct PendingPromptPresentation { + bool awaitingAcknowledgement = false; + std::optional admittedAtMs; +}; + +PendingPromptPresentation +pendingPromptPresentation(nodegraph::NodeGraph::ReadAccess &read, + const nodegraph::NodeRef &thread) { + PendingPromptPresentation result; + for (const nodegraph::NodeRef &prompt : + read.related(thread, nodegraph::RelationKind::PendingPrompt)) { + if (!prompt || prompt->id().kind != nodegraph::NodeKind::Item) + continue; + const auto state = read.state(prompt); + if (!state || graphString(graphField(*state, "type")) != "localPrompt") + continue; + const std::string dispatch = + graphString(graphField(*state, "dispatchState")); + if (dispatch != "queued" && dispatch != "dispatching" && + dispatch != "inFlight") + continue; + result.awaitingAcknowledgement = true; + const auto admittedAt = graphInteger(graphField(*state, "admittedAtMs")); + if (admittedAt && + (!result.admittedAtMs || *admittedAt < *result.admittedAtMs)) + result.admittedAtMs = admittedAt; + } + return result; +} + std::vector graphStrings(const nodegraph::Value *value) { std::vector result; const auto *array = value ? value->asArray() : nullptr; @@ -872,7 +902,9 @@ NodeGraphUiAdapter::threadRow(const nodegraph::NodeRef &thread) const { }; ThreadListRow row; row.id = thread->id().canonical; - row.title = graphString(graphField(*state, "name")); + row.title = graphString(graphField(*state, "localNameOverlay")); + if (row.title.empty()) + row.title = graphString(graphField(*state, "name")); if (row.title.empty()) row.title = graphString(graphField(*state, "preview")); if (row.title.empty()) @@ -882,6 +914,9 @@ NodeGraphUiAdapter::threadRow(const nodegraph::NodeRef &thread) const { row.createdAt = timestamp("createdAt"); row.updatedAt = timestamp("updatedAt"); row.recencyAt = timestamp("recencyAt"); + if (const auto local = timestamp("localPromptActivityAt"); + local && (!row.recencyAt || *local > *row.recencyAt)) + row.recencyAt = local; for (const std::string_view field : { std::string_view("lastActivityAt"), std::string_view("updatedAt"), std::string_view("recencyAt"), @@ -893,6 +928,10 @@ NodeGraphUiAdapter::threadRow(const nodegraph::NodeRef &thread) const { } row.pending = graphSize(graphField(*state, "pendingInteractionCount")).value_or(0); + const PendingPromptPresentation prompt = + pendingPromptPresentation(*read, thread); + row.awaitingPromptAcknowledgement = prompt.awaitingAcknowledgement; + row.pendingPromptAdmittedAtMs = prompt.admittedAtMs; row.archived = graphBool(graphField(*state, "archived")); return row; } @@ -961,7 +1000,9 @@ NodeGraphUiAdapter::threads(const nodegraph::NodeRef &selectedThread) const { if (!state) return row; row.id = node->id().canonical; - row.title = graphString(graphField(*state, "name")); + row.title = graphString(graphField(*state, "localNameOverlay")); + if (row.title.empty()) + row.title = graphString(graphField(*state, "name")); if (row.title.empty()) row.title = graphString(graphField(*state, "preview")); if (row.title.empty()) @@ -971,6 +1012,9 @@ NodeGraphUiAdapter::threads(const nodegraph::NodeRef &selectedThread) const { row.createdAt = timestamp(*state, "createdAt"); row.updatedAt = timestamp(*state, "updatedAt"); row.recencyAt = timestamp(*state, "recencyAt"); + if (const auto local = timestamp(*state, "localPromptActivityAt"); + local && (!row.recencyAt || *local > *row.recencyAt)) + row.recencyAt = local; for (const std::string_view field : { std::string_view("lastActivityAt"), std::string_view("updatedAt"), std::string_view("recencyAt"), @@ -983,6 +1027,10 @@ NodeGraphUiAdapter::threads(const nodegraph::NodeRef &selectedThread) const { } row.pending = graphSize(graphField(*state, "pendingInteractionCount")) .value_or(0); + const PendingPromptPresentation prompt = + pendingPromptPresentation(*read, node); + row.awaitingPromptAcknowledgement = prompt.awaitingAcknowledgement; + row.pendingPromptAdmittedAtMs = prompt.admittedAtMs; row.archived = graphBool(graphField(*state, "archived")); std::unordered_set localChildren; for (const nodegraph::RelationKind kind : diff --git a/src/codex/ui/UiViewState.h b/src/codex/ui/UiViewState.h index e87c448..6672643 100644 --- a/src/codex/ui/UiViewState.h +++ b/src/codex/ui/UiViewState.h @@ -15,8 +15,8 @@ namespace codexui::codex::ui { enum class InspectorProjection { All, Plan, Agents, Changes, Requests, State }; -// Toolkit-neutral inputs for the concrete thread-list renderer. Expansion, -// sorting, and optimistic rows deliberately remain local to that renderer. +// Toolkit-neutral inputs for the concrete thread-list renderer. Expansion +// and optimistic rows deliberately remain local to that renderer. struct ThreadListRow { std::string id; std::string title; @@ -26,7 +26,9 @@ struct ThreadListRow { std::optional updatedAt; std::optional recencyAt; std::optional lastActivityAt; + std::optional pendingPromptAdmittedAtMs; std::size_t pending = 0; + bool awaitingPromptAcknowledgement = false; bool archived = false; std::vector children; diff --git a/tests/codex/ClientRuntimeDispatchTest.cpp b/tests/codex/ClientRuntimeDispatchTest.cpp index 2e67245..eb9170f 100644 --- a/tests/codex/ClientRuntimeDispatchTest.cpp +++ b/tests/codex/ClientRuntimeDispatchTest.cpp @@ -472,7 +472,7 @@ bool establishProvider(UnixBridge &bridge, RunningRuntime &runtime) { return false; std::unordered_map methods; - for (int count = 0; count != 3; ++count) { + for (int count = 0; count != 4; ++count) { std::optional request = bridge.receiveAppServer(); if (!request || !request->contains("id")) return false; @@ -480,6 +480,16 @@ bool establishProvider(UnixBridge &bridge, RunningRuntime &runtime) { ++methods[method]; nlohmann::json result{{"data", nlohmann::json::array()}}; if (method == "thread/list") { + const bool firstThreadPage = methods[method] == 1; + expect(request->at("params").value("sortKey", std::string{}) == + "recency_at" && + request->at("params").value("sortDirection", std::string{}) == + "desc" && + request->at("params").value("limit", 0) == 100 && + request->at("params").value("useStateDbOnly", false) == + firstThreadPage, + "initial thread hydration requests a fast DB page followed by " + "the repair page"); result["data"].push_back(listedThread()); result["nextCursor"] = nullptr; } @@ -488,7 +498,7 @@ bool establishProvider(UnixBridge &bridge, RunningRuntime &runtime) { } return methods == std::unordered_map{ - {"thread/list", 1}, + {"thread/list", 2}, {"model/list", 1}, {"permissionProfile/list", 1}} && static_cast( @@ -591,6 +601,8 @@ void protocolDiagnosticsPreserveMetadataWithoutPayloads( secretRequest->value("method", std::string{}) == "thread/list", "secret-error fixture receives the direct request"); if (secretRequest) { + expect(secretRequest->at("params").value("useStateDbOnly", false), + "the foreground diagnostic request uses the fast DB path"); expect(bridge.replyError(*secretRequest, -32042, "Bearer sk-runtime-secret eyJabc.def.ghi"), "secret-shaped error reaches the runtime"); @@ -618,6 +630,18 @@ void protocolDiagnosticsPreserveMetadataWithoutPayloads( } expect(redactedSecretError, "credential-shaped protocol error detail is redacted"); + const std::optional repairRequest = + bridge.receiveAppServer(); + expect(repairRequest && + repairRequest->value("method", std::string{}) == + "thread/list" && + !repairRequest->at("params").value("useStateDbOnly", true), + "a failed fast DB request still falls back to repair scanning"); + if (repairRequest) + expect(bridge.reply(*repairRequest, + {{"data", nlohmann::json::array()}, + {"nextCursor", nullptr}}), + "repair fallback completes after the fast-path failure"); } const auto readAuthority = [&](bool insertInterveningFrame) { @@ -1138,6 +1162,143 @@ void remainingUiCommandFamiliesUseExactWirePaths(UnixBridge &bridge, runtime.drainNotifications(); } +void firstPromptAfterForkStartsANewTurn(UnixBridge &bridge, + RunningRuntime &runtime) { + const NodeRef source = + findNode(runtime.graph(), {NodeKind::Thread, "runtime-thread"}); + expect(static_cast(source), + "successful fork coverage has a stable source thread"); + if (!source) + return; + + NodeAction fork{source, NodeActionKind::Fork}; + fork.payload = {{"requestedName", Value("Runtime thread (fork 1)")}, + {"cwd", Value("/fork-workspace")}, + {"baseInstructions", Value("Fork base")}, + {"developerInstructions", Value("Fork developer")}, + {"ephemeral", Value(true)}}; + expect(sendAction(runtime.channels(), std::move(fork)), + "successful fork enters the typed worker mailbox"); + std::optional request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "thread/fork" && + request->at("params").value("threadId", std::string{}) == + "runtime-thread" && + request->at("params").value("cwd", std::string{}) == + "/fork-workspace" && + request->at("params").value("baseInstructions", std::string{}) == + "Fork base" && + request->at("params").value("developerInstructions", + std::string{}) == + "Fork developer" && + request->at("params").value("ephemeral", false) && + !request->at("params").contains("requestedName") && + !request->at("params").contains("name"), + "successful fork sends adjustable options but keeps its chosen name out of thread/fork"); + if (!request) + return; + const nlohmann::json forkedThread{ + {"id", "runtime-fork"}, + {"name", "Runtime fork"}, + {"forkedFromId", "runtime-thread"}, + {"status", "idle"}, + {"createdAt", 3}, + {"updatedAt", 4}, + {"recencyAt", 4}, + {"turns", nlohmann::json::array()}}; + expect(bridge.reply(*request, {{"thread", forkedThread}}), + "successful fork result is delivered"); + + NodeRef forked; + expect(waitUntil([&] { + forked = + findNode(runtime.graph(), {NodeKind::Thread, "runtime-fork"}); + if (!forked) + return false; + auto read = runtime.graph().tryRead(); + if (!read) + return false; + const auto state = read->state(forked); + const auto overlay = state->fields.find("localNameOverlay"); + return overlay != state->fields.end() && + overlay->second.asString() && + *overlay->second.asString() == "Runtime thread (fork 1)"; + }), + "successful fork immediately applies its chosen local name"); + expect(static_cast(forked), + "successful fork retains its authoritative thread node"); + if (!forked) + return; + request = bridge.receiveAppServer(); + expect(request && + request->value("method", std::string{}) == "thread/name/set" && + request->at("params").value("threadId", std::string{}) == + "runtime-fork" && + request->at("params").value("name", std::string{}) == + "Runtime thread (fork 1)", + "the fork name is synchronized through thread/name/set"); + if (request) + expect(bridge.reply(*request, nlohmann::json::object()), + "the fork rename acknowledgement is delivered"); + NodeAction prompt{forked, NodeActionKind::SubmitPrompt}; + prompt.promptText = "Answer after a successful fork"; + expect(sendAction(runtime.channels(), std::move(prompt)), + "the first fork prompt enters the typed worker mailbox"); + request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "turn/start" && + request->at("params").value("threadId", std::string{}) == + "runtime-fork", + "the first fork prompt starts immediately without a redundant read or resume"); + if (!request) + return; + expect(bridge.reply(*request, + {{"turn", {{"id", "runtime-fork-turn"}, + {"status", "inProgress"}, + {"items", nlohmann::json::array()}}}}), + "the first fork prompt acknowledgement is delivered"); + expect(bridge.appServerNotification( + "turn/completed", + {{"threadId", "runtime-fork"}, + {"turn", {{"id", "runtime-fork-turn"}, + {"status", "completed"}, + {"items", nlohmann::json::array()}}}}), + "the fork turn reaches an authoritative terminal state"); + runtime.drainNotifications(); +} + +void ordinaryThreadPromptStillStartsAndCompletes(UnixBridge &bridge, + RunningRuntime &runtime) { + const NodeRef thread = + findNode(runtime.graph(), {NodeKind::Thread, "runtime-thread"}); + expect(static_cast(thread), + "ordinary prompt coverage has a stable thread"); + if (!thread) + return; + NodeAction prompt{thread, NodeActionKind::SubmitPrompt}; + prompt.promptText = "Answer an ordinary thread prompt"; + expect(sendAction(runtime.channels(), std::move(prompt)), + "an ordinary prompt enters the typed worker mailbox"); + std::optional request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "turn/start" && + request->at("params").value("threadId", std::string{}) == + "runtime-thread", + "an ordinary thread prompt reaches turn/start"); + if (!request) + return; + expect(bridge.reply(*request, + {{"turn", {{"id", "ordinary-runtime-turn"}, + {"status", "inProgress"}, + {"items", nlohmann::json::array()}}}}), + "the ordinary prompt acknowledgement is delivered"); + expect(bridge.appServerNotification( + "turn/completed", + {{"threadId", "runtime-thread"}, + {"turn", {{"id", "ordinary-runtime-turn"}, + {"status", "completed"}, + {"items", nlohmann::json::array()}}}}), + "the ordinary prompt reaches an authoritative terminal state"); + runtime.drainNotifications(); +} + void runtimeRefreshActionsHaveExactRequestCardinality(UnixBridge &bridge, RunningRuntime &runtime) { RuntimeAction refresh{RuntimeActionKind::RefreshThreads}; @@ -1146,15 +1307,65 @@ void runtimeRefreshActionsHaveExactRequestCardinality(UnixBridge &bridge, "thread refresh enters the worker mailbox"); std::optional request = bridge.receiveAppServer(); expect(request && request->value("method", std::string{}) == "thread/list" && - request->at("params").value("limit", 0) == 17, - "thread refresh emits exactly the requested typed operation"); + request->at("params").value("limit", 0) == 100 && + request->at("params").value("sortKey", std::string{}) == + "recency_at" && + request->at("params").value("sortDirection", std::string{}) == + "desc" && + request->at("params").value("useStateDbOnly", false) && + !request->at("params").contains("cursor"), + "thread refresh emits the fast DB-only Recent first page"); if (request) expect(bridge.reply(*request, {{"data", nlohmann::json::array({listedThread()})}, - {"nextCursor", nullptr}}), - "thread refresh response is delivered"); + {"nextCursor", "discarded-fast-cursor"}}), + "fast thread refresh page is delivered immediately"); + request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "thread/list" && + request->at("params").value("limit", 0) == 100 && + request->at("params").value("sortKey", std::string{}) == + "recency_at" && + request->at("params").value("sortDirection", std::string{}) == + "desc" && + !request->at("params").value("useStateDbOnly", true) && + !request->at("params").contains("cursor"), + "thread refresh follows with one scan-and-repair first page"); + if (request) + expect(bridge.reply(*request, + {{"data", nlohmann::json::array({listedThread()})}, + {"nextCursor", "recent-page-2"}}), + "thread repair page establishes the reconciled pagination cursor"); + RuntimeAction loadMore{RuntimeActionKind::LoadMoreThreads}; + expect(sendAction(runtime.channels(), std::move(loadMore)), + "scroll pagination enters the worker mailbox"); + request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "thread/list" && + request->at("params").value("limit", 0) == 100 && + request->at("params").value("sortKey", std::string{}) == + "recency_at" && + request->at("params").value("sortDirection", std::string{}) == + "desc" && + request->at("params").value("useStateDbOnly", false) && + request->at("params").value("cursor", std::string{}) == + "recent-page-2", + "scroll pagination follows the repaired cursor through the DB-only path"); + if (request) + expect(bridge.replyError(*request, -32000, "temporary paging failure"), + "a transient page failure is delivered"); + RuntimeAction retryLoadMore{RuntimeActionKind::LoadMoreThreads}; + expect(sendAction(runtime.channels(), std::move(retryLoadMore)), + "a failed page cursor can be retried"); + request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "thread/list" && + request->at("params").value("cursor", std::string{}) == + "recent-page-2", + "retry preserves the failed page cursor"); + if (request) + expect(bridge.reply(*request, {{"data", nlohmann::json::array()}, + {"nextCursor", nullptr}}), + "the retried additional page is delivered"); expect(!bridge.receiveAppServer(100ms), - "one refresh action does not duplicate thread/list"); + "pagination stops after the requested final page"); RuntimeAction catalogs{RuntimeActionKind::RefreshCatalogs}; catalogs.payload = {{"cwd", Value("/tmp/runtime-dispatch")}}; @@ -1197,16 +1408,30 @@ void failedWakeUsesBoundedWorkerRecovery(UnixBridge &bridge, std::optional request = bridge.receiveAppServer(1500ms); expect(request && request->value("method", std::string{}) == "thread/list" && - request->at("params").value("limit", 0) == 9, + request->at("params").value("limit", 0) == 100 && + request->at("params").value("sortKey", std::string{}) == + "recency_at" && + request->at("params").value("sortDirection", std::string{}) == + "desc" && + request->at("params").value("useStateDbOnly", false), "the existing worker thread consumes an unwoken action within its " - "bounded recovery interval"); + "bounded recovery interval using fixed Recent ordering"); if (request) expect(bridge.reply(*request, {{"data", nlohmann::json::array({listedThread()})}, {"nextCursor", nullptr}}), "the timeout-delivered action completes normally"); + request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "thread/list" && + !request->at("params").value("useStateDbOnly", true), + "the admitted refresh schedules exactly one repair stage"); + if (request) + expect(bridge.reply(*request, + {{"data", nlohmann::json::array({listedThread()})}, + {"nextCursor", nullptr}}), + "the repair stage completes after wake recovery"); expect(!bridge.receiveAppServer(150ms), - "wake recovery never retries the non-idempotent queue payload"); + "wake recovery never duplicates either loading stage"); runtime.drainNotifications(); } @@ -1214,8 +1439,7 @@ void idleWorkerSleepsBetweenWakeRecoveryChecks(RunningRuntime &runtime) { const auto before = runtime.workerCpuTime(); std::this_thread::sleep_for(350ms); const auto after = runtime.workerCpuTime(); - expect(before && after && *after >= *before && - *after - *before < 100ms, + expect(before && after && *after >= *before && *after - *before < 100ms, "an idle worker rearms its mailbox timeout instead of zero-timeout " "polling"); } @@ -1553,7 +1777,7 @@ void workerRevalidatesCurrentAuthorityAndRetainsResponses( expect(bridge.setProviderGeneration(2), "a new provider generation reaches the runtime"); std::size_t refreshes = 0; - while (refreshes != 3) { + while (refreshes != 4) { const std::optional refresh = bridge.receiveAppServer(); if (!refresh) break; @@ -1563,7 +1787,7 @@ void workerRevalidatesCurrentAuthorityAndRetainsResponses( if (bridge.reply(*refresh, std::move(result))) ++refreshes; } - expect(refreshes == 3 && generationInput && waitUntil([&] { + expect(refreshes == 4 && generationInput && waitUntil([&] { std::optional read = runtime.graph().tryRead(); if (!read) @@ -1815,6 +2039,9 @@ int main(int argc, char **argv) { codexui::codex::directNodeActionsUseOneCorrelatedRequest(bridge, runtime); codexui::codex::remainingUiCommandFamiliesUseExactWirePaths(bridge, runtime); + codexui::codex::ordinaryThreadPromptStillStartsAndCompletes(bridge, + runtime); + codexui::codex::firstPromptAfterForkStartsANewTurn(bridge, runtime); codexui::codex::runtimeRefreshActionsHaveExactRequestCardinality(bridge, runtime); codexui::codex::failedWakeUsesBoundedWorkerRecovery(bridge, runtime); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 72581f7..679e964 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -901,6 +901,13 @@ QToolButton *copyButton(ConversationCard *card) { : nullptr; } +bool usesNarrowPhaseCopySpacing(ConversationCard *card, QLabel *phase) { + QToolButton *copy = copyButton(card); + return phase && copy && phase->parentWidget() == copy->parentWidget() && + phase->parentWidget()->layout()->spacing() == 0 && + copy->geometry().left() - phase->geometry().right() - 1 == 0; +} + QRect paintedDisclosureBounds(QToolButton *button) { if (!button) return {}; @@ -1311,9 +1318,8 @@ bool testPausedExpandedCommandStaysPainted() { result &= expect(setFolded(commandCard, false), "completed command is expanded before incoming cards"); QPointer outputView = - commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) + commandCard ? commandCard->findChild( + QStringLiteral("commandOutputView")) : nullptr; if (outputView && outputView->verticalScrollBar()->maximum() > 0) { outputView->verticalScrollBar()->setValue( @@ -2016,7 +2022,7 @@ bool testCardCopyControls() { button->text().isEmpty() && button->height() == fold->height() && std::abs(copyInk.center().y() - foldInk.center().y()) <= 1 && foldInk.left() - copyInk.right() - 1 <= 14 && - button->parentWidget()->layout()->spacing() == 4, + button->parentWidget()->layout()->spacing() == 0, "copy and disclosure are backgroundless, vertically aligned, and " "use canonical compact spacing"); } @@ -2058,6 +2064,84 @@ bool testCardCopyControls() { return result; } +bool testUserMessageLineBreakPresentation() { + const std::string thread = "line-breaks"; + const QString source = + QStringLiteral("First authored line\n\nThird authored line"); + ConversationCard card(VisibleCardData{ + AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, + thread, "turn", "user", + UserMessageData{source.toStdString(), {}}}); + card.resize(520, 160); + card.show(); + spin(); + + MarkdownTextView *body = card.findChild(); + bool result = expect( + body && body->markdownSource() == source && + body->sharedDocument()->toPlainText() == + QStringLiteral("First authored line\n\u200B\nThird authored line") && + body->sharedDocument()->blockCount() == 3, + "an authoritative turn You card displays the empty row authored by two " + "newlines"); + + card.setNestedPresentation(true); + spin(); + result &= expect( + body && body->markdownSource() == source && + body->sharedDocument()->blockCount() == 3, + "an authoritative steering You card keeps the same authored blank row"); + + QApplication::clipboard()->clear(); + copyButton(&card)->click(); + result &= expect( + QApplication::clipboard()->text() == source && + QApplication::clipboard()->mimeData()->data("text/markdown") == + source.toUtf8(), + "newline presentation does not alter copied prompt Markdown"); + + QApplication::clipboard()->clear(); + body->setSelection(0, body->sharedDocument()->characterCount() - 1); + body->setFocus(Qt::OtherFocusReason); + QKeyEvent selectionCopy(QEvent::KeyPress, Qt::Key_C, Qt::ControlModifier); + QApplication::sendEvent(body, &selectionCopy); + result &= expect( + QApplication::clipboard()->text() == source, + "Ctrl+C omits the presentation-only blank-line marker from a prompt " + "selection"); + + const CardKey localKey = LocalPromptKey{91}; + ConversationCard pending(VisibleCardData{ + localKey, CardKind::LocalPrompt, thread, "turn", {}, + LocalPromptData{91, source.toStdString(), PromptState::InFlight, {}, {}}}); + pending.resize(520, 160); + pending.show(); + spin(); + const VisibleCardData acknowledged{ + localKey, CardKind::UserMessage, thread, "turn", "user", + UserMessageData{source.toStdString(), {}}}; + result &= expect(pending.apply(acknowledged), + "a pending prompt promotes in place on acknowledgement"); + MarkdownTextView *promoted = pending.findChild(); + result &= expect( + promoted && promoted->markdownSource() == source && + promoted->sharedDocument()->blockCount() == 3, + "local-to-authoritative promotion retains the authored blank row"); + + const QString fenced = QStringLiteral( + "Before\nAfter\n\n```text\ninside\ncode\n```\n\nDone"); + const QString rendered = presentation::userMessageMarkdown(fenced); + result &= expect( + rendered == QStringLiteral( + "Before \nAfter\n\n```text\ninside\ncode\n```\n\nDone"), + "prompt newline projection preserves fenced code and paragraph breaks"); + result &= expect( + presentation::userMessageMarkdown(source) == + QStringLiteral("First authored line \n\u200B \nThird authored line"), + "plain prompt projection represents an empty source line explicitly"); + return result; +} + bool testMutableCardsAndCommandOutput() { const QString originalStyleSheet = qApp->styleSheet(); qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); @@ -2156,9 +2240,8 @@ bool testMutableCardsAndCommandOutput() { }; auto *commandCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "command"}})]; - auto *output = dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))); + auto *output = commandCard->findChild( + QStringLiteral("commandOutputView")); auto *commandText = dynamic_cast( commandCard->findChild(QStringLiteral("commandTextView"))); auto *commandStatus = @@ -2203,6 +2286,7 @@ bool testMutableCardsAndCommandOutput() { agentPhase->text() == QStringLiteral("update") && agentPhase->property("tone").toString() == QStringLiteral("active") && agentPhase->font().weight() == QFont::Normal && + usesNarrowPhaseCopySpacing(agentCardWidget, agentPhase) && agentPhase->parentWidget()->layout()->indexOf(agentPhase) < agentPhase->parentWidget()->layout()->indexOf( copyButton(agentCardWidget)), @@ -2388,10 +2472,14 @@ bool testMutableCardsAndCommandOutput() { "cwd, and duration below output"); result &= expect(!output->isHidden() && output->minimumHeight() == 0 && - output->maximumHeight() == 220 && + output->maximumHeight() <= 220 && + (output->maximumHeight() - 8) % + output->fontMetrics().lineSpacing() == + 0 && output->toPlainText().endsWith(QStringLiteral("visible")) && output->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, - "visible output trims empty lines and grows with the 220px cap"); + "visible output trims empty lines and grows by whole rows within " + "the 220px cap"); QString longOutput; for (int line = 0; line < 80; ++line) @@ -2714,6 +2802,7 @@ bool testCardFoldingGeometryAndRetention() { cardTitle(steeringCard) == QStringLiteral("You") && steeringPhase && steeringPhase->text() == QStringLiteral("steering · pending") && steeringPhase->font().weight() == QFont::Normal && + usesNarrowPhaseCopySpacing(steeringCard, steeringPhase) && steeringPhase->parentWidget()->layout()->indexOf(steeringPhase) < steeringPhase->parentWidget()->layout()->indexOf( copyButton(steeringCard)) && @@ -2799,9 +2888,8 @@ bool testCardFoldingGeometryAndRetention() { spin(40); result &= expect(applyConversation(view, snapshot), "folded command accepts a streamed content update"); - auto *output = dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))); + auto *output = commandCard->findChild( + QStringLiteral("commandOutputView")); result &= expect( output && !output->toPlainText().contains(QStringLiteral("streamed line 4")) && @@ -3330,9 +3418,9 @@ bool testInitialCommandGeometrySettlement() { ConversationCard *commandCard = card(view, stableKey(command.key)); result &= expect(setFolded(commandCard, false), "initially folded command can be expanded for inspection"); - auto *outputView = commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) + auto *outputView = commandCard + ? commandCard->findChild( + QStringLiteral("commandOutputView")) : nullptr; result &= expect(commandCard && outputView && !outputView->isHidden() && outputView->height() < outputView->maximumHeight(), @@ -3589,9 +3677,9 @@ bool testBottomAnchoredCommandOutputGrowth() { commandCard ? commandCard->findChild(QStringLiteral("commandStatus")) : nullptr; - auto *output = commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) + auto *output = commandCard + ? commandCard->findChild( + QStringLiteral("commandOutputView")) : nullptr; result &= expect(commandCard && metadata && metadata->isHidden() && status && output && output->isHidden() && view.isAtBottom() && @@ -3615,10 +3703,30 @@ bool testBottomAnchoredCommandOutputGrowth() { }); const int cardBottomAfter = commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())).y(); + QTextCursor initialEnd(output->document()); + initialEnd.movePosition(QTextCursor::End); + const int initialBottomGap = + output->viewport()->height() - output->cursorRect(initialEnd).bottom(); + qreal initialLineHeight = 0; + for (QTextBlock block = output->document()->begin(); block.isValid(); + block = block.next()) + if (block.layout()) + initialLineHeight += block.layout()->boundingRect().height(); result &= expect(!output->isHidden() && output->height() > 2 * 20 && output->height() == output->sizeHint().height() && + output->height() == + 8 + static_cast(std::ceil(initialLineHeight)) && + (output->maximumHeight() - 8) % + output->fontMetrics().lineSpacing() == + 0 && + initialBottomGap <= + output->document()->documentMargin() + 2 && + !output->document()->lastBlock().text().isEmpty() && + output->verticalScrollBar()->value() == + output->verticalScrollBar()->maximum() && cardBottomAfter == cardBottomBefore && view.isAtBottom(), - "multiline output takes its needed height and grows upward"); + "multiline output uses complete text rows with symmetric " + "padding, no synthetic trailing row, and grows upward"); QString cappedOutput; for (int line = 0; line < 80; ++line) @@ -3627,10 +3735,10 @@ bool testBottomAnchoredCommandOutputGrowth() { result &= expect(applyConversation(view, snapshot), "live output reaches its cap"); spinUntil([&] { - return output->height() == 220 && + return output->height() == output->maximumHeight() && output->verticalScrollBar()->maximum() > 0; }); - if (!(output->height() == 220 && + if (!(output->height() == output->maximumHeight() && output->verticalScrollBar()->maximum() > 0 && commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())) .y() == cardBottomBefore)) @@ -3651,11 +3759,20 @@ bool testBottomAnchoredCommandOutputGrowth() { .toString() .toStdString() << '\n'; + QTextCursor cappedEnd(output->document()); + cappedEnd.movePosition(QTextCursor::End); + const int cappedBottomGap = + output->viewport()->height() - output->cursorRect(cappedEnd).bottom(); result &= expect( - output->height() == 220 && output->verticalScrollBar()->maximum() > 0 && + output->height() == output->maximumHeight() && + output->verticalScrollBar()->maximum() > 0 && cappedBottomGap <= 2 && + !output->document()->lastBlock().text().isEmpty() && + output->verticalScrollBar()->value() == + output->verticalScrollBar()->maximum() && commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())) .y() == cardBottomBefore, - "capped output keeps its scrollbar and fixed card bottom"); + "capped output follows its final populated row and keeps its scrollbar " + "and fixed card bottom"); const qulonglong geometryBeforeAppend = view.property("conversationGeometryPasses").toULongLong(); @@ -3670,7 +3787,10 @@ bool testBottomAnchoredCommandOutputGrowth() { "capped output accepts another streaming append"); spin(); result &= expect( - retainedCommand == commandCard && output->height() == 220 && + retainedCommand == commandCard && + output->height() == output->maximumHeight() && + output->verticalScrollBar()->value() == + output->verticalScrollBar()->maximum() && view.property("conversationGeometryPasses").toULongLong() == geometryBeforeAppend && commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())) @@ -3680,6 +3800,19 @@ bool testBottomAnchoredCommandOutputGrowth() { result &= expect(output->textCursor().selectedText() == selectionBeforeAppend, "append-only command streaming preserves output text " "selection"); + live.status = "completed"; + result &= expect(applyConversation(view, snapshot), + "the live command reaches completion"); + spin(); + QTextCursor completedEnd(output->document()); + completedEnd.movePosition(QTextCursor::End); + result &= expect( + output->viewport()->height() - output->cursorRect(completedEnd).bottom() <= + 2 && + !output->document()->lastBlock().text().isEmpty() && + output->verticalScrollBar()->value() == + output->verticalScrollBar()->maximum(), + "command completion retains follow-tail without a synthetic output row"); return result; } @@ -3706,11 +3839,10 @@ bool testCommandOutputStateAcrossNavigation() { ConversationCard *commandCard = card(view, stableKey(command.key)); bool result = expect(setFolded(commandCard, false), "navigation command expands from its compact default"); - auto *initialOutput = commandCard - ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) - : nullptr; + auto *initialOutput = + commandCard ? commandCard->findChild( + QStringLiteral("commandOutputView")) + : nullptr; result &= expect(initialOutput && initialOutput->verticalScrollBar()->maximum() > 0, "navigation test has independently scrollable output"); @@ -3721,9 +3853,9 @@ bool testCommandOutputStateAcrossNavigation() { applyConversation(view, commandThread); spin(); commandCard = card(view, stableKey(command.key)); - initialOutput = commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) + initialOutput = commandCard + ? commandCard->findChild( + QStringLiteral("commandOutputView")) : nullptr; result &= expect(initialOutput && initialOutput->followsLatest() && initialOutput->verticalScrollBar()->value() == @@ -3749,11 +3881,10 @@ bool testCommandOutputStateAcrossNavigation() { applyConversation(view, commandThread); spin(); commandCard = card(view, stableKey(command.key)); - auto *restoredOutput = commandCard - ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) - : nullptr; + auto *restoredOutput = + commandCard ? commandCard->findChild( + QStringLiteral("commandOutputView")) + : nullptr; result &= expect(restoredOutput && !restoredOutput->followsLatest() && restoredOutput->verticalScrollBar()->value() == pausedValue && @@ -4310,9 +4441,8 @@ bool testFocusedGraphCardSurvivesViewportReconciliation() { bool result = expect(commandReady && setFolded(commandCard, false), "the focus-pinning fixture exposes its command output"); QPointer output = - commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) + commandCard ? commandCard->findChild( + QStringLiteral("commandOutputView")) : nullptr; if (!commandCard || !output) return false; @@ -4442,9 +4572,8 @@ bool testGraphRootFoldSuppressesAndRestoresChildExtent() { rootCard && commandCard && setFolded(commandCard, false), "the root-fold fixture materializes an expanded child"); QPointer output = - commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) + commandCard ? commandCard->findChild( + QStringLiteral("commandOutputView")) : nullptr; if (!rootCard || !commandCard || !output) return false; @@ -4473,9 +4602,9 @@ bool testGraphRootFoldSuppressesAndRestoresChildExtent() { view.verticalScrollBar()->maximum() == expandedExtent; }); commandCard = card(view, stableKey(command.key)); - output = commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) + output = commandCard + ? commandCard->findChild( + QStringLiteral("commandOutputView")) : nullptr; const auto childStateAfter = commandCard ? commandCard->commandOutputScrollState() : std::nullopt; @@ -7496,6 +7625,7 @@ int main(int argc, char **argv) { result &= testThreadLocalScrollAndComposerExtent(); result &= testPromptAdmissionFollowOwnership(); result &= testCardCopyControls(); + result &= testUserMessageLineBreakPresentation(); result &= testMutableCardsAndCommandOutput(); result &= testCardFoldingGeometryAndRetention(); result &= testPresentationOptionsRetainCardsAndInitialFolding(); diff --git a/tests/codex/ConversationVirtualizationTest.cpp b/tests/codex/ConversationVirtualizationTest.cpp index 1f6ffe8..486deaf 100644 --- a/tests/codex/ConversationVirtualizationTest.cpp +++ b/tests/codex/ConversationVirtualizationTest.cpp @@ -1320,12 +1320,10 @@ bool largeIncomingCommandUsesBoundedFinalWidthLayout() { const qint64 appendMicros = timer.nsecsElapsed() / 1000; settle(); ConversationCard *commandCard = materializedCard(view, key); - CommandOutputView *commandOutput = commandCard - ? dynamic_cast( - commandCard->findChild( - QStringLiteral( - "commandOutputView"))) - : nullptr; + CommandOutputView *commandOutput = + commandCard ? commandCard->findChild( + QStringLiteral("commandOutputView")) + : nullptr; result &= expect( commandOutput && commandOutput->viewport()->width() > 500 && commandOutput->property("boundedOutputMeasurements").toULongLong() >= @@ -1551,6 +1549,17 @@ bool outsideTextDragDoesNotReenterTheView() { settle(); result &= expect(body->hasSelectedText(), "dragging from outside update glyphs selects text"); + const QString selected = body->selectedText(); + result &= expect(body->hasFocus(), + "a real Markdown drag leaves its editor focused"); + QApplication::clipboard()->clear(); + if (QWidget *focused = QApplication::focusWidget()) { + QKeyEvent copy(QEvent::KeyPress, Qt::Key_C, Qt::ControlModifier); + QApplication::sendEvent(focused, ©); + } + result &= expect(!selected.isEmpty() && + QApplication::clipboard()->text() == selected, + "a real Markdown drag copies with the next Ctrl+C"); return result; } diff --git a/tests/codex/NodeGraphThreadPaneUiTest.cpp b/tests/codex/NodeGraphThreadPaneUiTest.cpp index bdaea28..d698b14 100644 --- a/tests/codex/NodeGraphThreadPaneUiTest.cpp +++ b/tests/codex/NodeGraphThreadPaneUiTest.cpp @@ -1,10 +1,17 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT +#include "codex/ForkNaming.h" #include "codex/middle/ThreadPane.h" #include "codex/ui/NodeGraphUiAdapter.h" #include +#include +#include #include +#include +#include +#include +#include #include #include @@ -19,6 +26,25 @@ bool require(bool condition, const char *message) { return false; } +bool forkNamesPreserveTheirLineage() { + const std::vector titles{ + "Original", "Original (fork 1)", "Original (fork 2)", + "Original (fork 1.1)", "Original (fork 1.3)", + "Original (fork 1.1.1)"}; + return require(suggestForkName("Original", titles) == + "Original (fork 3)", + "a repeated root fork did not use the next root number") && + require(suggestForkName("Original (fork 1)", titles) == + "Original (fork 1.2)", + "a fork of a fork lost its parent lineage") && + require(suggestForkName("Original (fork 1.1)", titles) == + "Original (fork 1.1.2)", + "a nested fork did not use its own next child number") && + require(suggestForkName("Separate", titles) == + "Separate (fork 1)", + "an unrelated thread did not start at fork 1"); +} + bool selectedChildRetainsRootAndCanonicalIdentity() { nodegraph::NodeGraph graph; nodegraph::NodeRef root; @@ -72,12 +98,164 @@ bool selectedChildRetainsRootAndCanonicalIdentity() { "selecting a child removed its root row"); } +bool sortingAndPromptAnimationAreFixed() { + middle::ThreadPane pane; + pane.resize(300, 500); + ui::ThreadListSnapshot snapshot; + ui::ThreadListRow older; + older.id = "older"; + older.title = "20 tasks"; + older.createdAt = 500; + older.updatedAt = 900; + older.recencyAt = 10; + ui::ThreadListRow recent; + recent.id = "recent"; + recent.title = "Alpha"; + recent.createdAt = 1; + recent.updatedAt = 2; + recent.recencyAt = 30; + ui::ThreadListRow missing; + missing.id = "missing"; + missing.title = "2 tasks"; + snapshot.roots = {older, recent, missing}; + + pane.refresh(snapshot); + pane.show(); + QApplication::processEvents(); + auto *list = pane.findChild(QStringLiteral("threadList")); + auto *sortButton = + pane.findChild(QStringLiteral("threadSortButton")); + if (!require(list && list->count() == 3, "thread list missing") || + !require(sortButton && sortButton->menu(), "thread sort control missing") || + !require(list->item(0)->data(Qt::UserRole).toString() == + QStringLiteral("recent"), + "thread rows are not ordered by recencyAt newest first")) + return false; + if (!require(list->item(0)->toolTip().contains( + QStringLiteral("Recent turn:")) && + list->item(0)->toolTip().contains( + QStringLiteral("Created:")), + "thread hover omits provider recent-turn or creation time")) + return false; + + QStringList sortLabels; + for (QAction *action : sortButton->menu()->actions()) + sortLabels.push_back(action->text()); + if (!require(sortLabels == QStringList{QStringLiteral("Alphanumeric"), + QStringLiteral("Created"), + QStringLiteral("Recent")}, + "thread sort menu does not expose exactly the three contracts")) + return false; + + pane.setSortCriterion(middle::ThreadPane::SortCriterion::Alphanumeric); + QApplication::processEvents(); + if (!require(list->item(0)->data(Qt::UserRole).toString() == + QStringLiteral("missing"), + "alphanumeric order is not numeric-aware") || + !require(list->item(1)->data(Qt::UserRole).toString() == + QStringLiteral("older"), + "alphanumeric order did not place 20 after 2") || + !require(list->item(2)->data(Qt::UserRole).toString() == + QStringLiteral("recent"), + "alphanumeric order did not place letters after numeric titles")) + return false; + + pane.setSortCriterion(middle::ThreadPane::SortCriterion::Created); + QApplication::processEvents(); + if (!require(list->item(0)->data(Qt::UserRole).toString() == + QStringLiteral("older"), + "Created order is not newest first") || + !require(list->item(1)->data(Qt::UserRole).toString() == + QStringLiteral("recent"), + "Created order did not keep older dated rows before missing") || + !require(list->item(2)->data(Qt::UserRole).toString() == + QStringLiteral("missing"), + "Created order did not put missing timestamps last")) + return false; + + pane.setSortCriterion(middle::ThreadPane::SortCriterion::Recency); + QApplication::processEvents(); + + snapshot.roots[0].recencyAt = 31; + snapshot.roots[0].awaitingPromptAcknowledgement = true; + snapshot.roots[0].pendingPromptAdmittedAtMs = + QDateTime::currentMSecsSinceEpoch() - 1500; + pane.refresh(snapshot); + QApplication::processEvents(); + auto *animation = + pane.findChild(QStringLiteral("optimisticThreadAnimation")); + QWidget *row = list->itemWidget(list->item(0)); + auto *title = + row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; + if (!require(list->item(0)->data(Qt::UserRole).toString() == + QStringLiteral("older"), + "new turn admission did not promote its thread") || + !require(title && title->text() == QStringLiteral("20 tasks"), + "promotion substituted a canonical ID for the chosen name") || + !require(animation && animation->isActive(), + "thread-card animation did not follow the pending prompt")) + return false; + + snapshot.roots[1].status = "active"; + pane.refresh(snapshot); + QApplication::processEvents(); + if (!require(animation->isActive(), + "unrelated thread traffic stopped pending prompt animation")) + return false; + + snapshot.roots[0].awaitingPromptAcknowledgement = false; + snapshot.roots[0].pendingPromptAdmittedAtMs.reset(); + pane.refresh(snapshot); + QApplication::processEvents(); + row = list->itemWidget(list->item(0)); + title = + row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; + return require(!animation->isActive(), + "prompt acknowledgement did not stop thread-card animation") && + require(title && title->text() == QStringLiteral("20 tasks"), + "prompt acknowledgement changed the chosen name"); +} + +bool pagingFollowsTheVisibleListEnd() { + middle::ThreadPane pane; + pane.resize(300, 500); + int loadMoreRequests = 0; + middle::ThreadPane::Actions actions; + actions.loadMore = [&] { ++loadMoreRequests; }; + pane.setActions(std::move(actions)); + + ui::ThreadListSnapshot longSnapshot; + for (int index = 0; index < 80; ++index) { + ui::ThreadListRow row; + row.id = "long-" + std::to_string(index); + row.title = row.id; + row.recencyAt = 1000 - index; + longSnapshot.roots.push_back(std::move(row)); + } + pane.refresh(longSnapshot); + pane.show(); + QApplication::processEvents(); + auto *list = pane.findChild(QStringLiteral("threadList")); + if (!require(list && list->verticalScrollBar()->maximum() > 0, + "long thread page did not produce a scroll range")) + return false; + loadMoreRequests = 0; + list->verticalScrollBar()->setValue(list->verticalScrollBar()->maximum()); + QApplication::processEvents(); + return require(loadMoreRequests > 0, + "scrolling to the visible list end did not request the next " + "thread page"); +} + } // namespace } // namespace codexui::codex int main(int argc, char **argv) { QApplication application(argc, argv); - if (!codexui::codex::selectedChildRetainsRootAndCanonicalIdentity()) + if (!codexui::codex::forkNamesPreserveTheirLineage() || + !codexui::codex::selectedChildRetainsRootAndCanonicalIdentity() || + !codexui::codex::sortingAndPromptAnimationAreFixed() || + !codexui::codex::pagingFollowsTheVisibleListEnd()) return EXIT_FAILURE; std::cout << "NodeGraph ThreadPane UI tests passed\n"; return EXIT_SUCCESS; diff --git a/tests/codex/NodeGraphUiAdapterTest.cpp b/tests/codex/NodeGraphUiAdapterTest.cpp index 6d357cd..e93ae01 100644 --- a/tests/codex/NodeGraphUiAdapterTest.cpp +++ b/tests/codex/NodeGraphUiAdapterTest.cpp @@ -404,7 +404,8 @@ bool preservesReadinessActivityAndAuthoritativeBudgetSemantics() { static_cast(write.upsert({NodeKind::Connection, "connection"}, std::move(connection))); NodeState threadState; - threadState.fields = {{"name", "Compatibility thread"}, + threadState.fields = {{"name", "Provider thread"}, + {"localNameOverlay", "Chosen thread"}, {"updatedAt", std::int64_t{4}}, {"recencyAt", std::int64_t{6}}, {"lastActivityAt", std::int64_t{5}}, @@ -421,9 +422,12 @@ bool preservesReadinessActivityAndAuthoritativeBudgetSemantics() { itemState("provider-item", "agentMessage", "provider")); write.setParent(turn, provider); NodeState local = itemState("local-item", "localPrompt", "local"); + local.fields.emplace("dispatchState", "inFlight"); + local.fields.emplace("admittedAtMs", std::int64_t{1234}); const NodeRef localPrompt = write.upsert({NodeKind::Item, "local-item"}, std::move(local)); write.setParent(turn, localPrompt); + write.relate(thread, nodegraph::RelationKind::PendingPrompt, localPrompt); write.relate(runtime, nodegraph::RelationKind::RootThread, thread); static_cast(write.finish()); } @@ -438,9 +442,16 @@ bool preservesReadinessActivityAndAuthoritativeBudgetSemantics() { require(threads && !threads->providerReady && !threads->canControl, "disconnected transport exposed ready thread controls") && require(threads && threads->roots.size() == 1 && + threads->roots.front().title == "Chosen thread" && + threads->roots.front().recencyAt == + std::optional{8} && threads->roots.front().lastActivityAt == - std::optional{9}, - "canonical effective thread activity changed"); + std::optional{9} && + threads->roots.front().awaitingPromptAcknowledgement && + threads->roots.front().pendingPromptAdmittedAtMs == + std::optional{1234}, + "chosen-name, Recent, activity, or prompt lifecycle " + "projection changed"); } bool preservesThreadRootsAndExactChildTargets() { diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 91809e2..7ff1149 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -1066,8 +1067,8 @@ void graphBackedShellPreservesDraftsAndPrompts(Configuration &configuration) { auto *editor = shell.findChild( QStringLiteral("upcomingPromptEditor")); - const QString exact = QStringLiteral(" graph prompt stays exact "); - const QString trimmed = exact.trimmed(); + const QString exact = + QStringLiteral(" graph prompt stays exact\n\nincluding blank lines\n\n"); require(submit(editor, exact), "the real composer emits its submit action"); std::vector actions = takeQtMessages(channels); NodeAction prompt; @@ -1081,10 +1082,10 @@ void graphBackedShellPreservesDraftsAndPrompts(Configuration &configuration) { } require(promptCount == 1 && prompt.target && prompt.target->id() == NodeId{NodeKind::Thread, "shell-thread"} && - prompt.promptText == trimmed.toStdString() && editor && + prompt.promptText == exact.toStdString() && editor && editor->toPlainText().isEmpty(), - "the composer emits one typed prompt with legacy whitespace " - "normalization"); + "the composer emits one typed prompt without removing authored " + "blank lines"); PromptTransition transition = worker.admitPrompt(std::move(prompt)); const NodeRef localPrompt = @@ -1093,11 +1094,11 @@ void graphBackedShellPreservesDraftsAndPrompts(Configuration &configuration) { localPrompt != nullptr, "the worker turns an admitted action into the one shared prompt node"); require(spinUntil([&] { - return localPromptCard(shell, trimmed.toStdString()) != nullptr; + return localPromptCard(shell, exact.toStdString()) != nullptr; }), "the visible existing card renders directly from the prompt node"); middle::ConversationCard *card = - localPromptCard(shell, trimmed.toStdString()); + localPromptCard(shell, exact.toStdString()); QTimer *pendingAnimation = card ? card->findChild(QStringLiteral("pendingAnimationTimer")) : nullptr; @@ -1121,8 +1122,18 @@ void graphBackedShellPreservesDraftsAndPrompts(Configuration &configuration) { spin(40); require( editor->toPlainText() == QStringLiteral("unsent editor draft") && - localPromptCard(shell, trimmed.toStdString()) == card, + localPromptCard(shell, exact.toStdString()) == card && + pendingAnimation->isActive(), "unrelated graph updates preserve local editor text and card identity"); + + auto *threadAnimation = + shell.findChild(QStringLiteral("optimisticThreadAnimation")); + require(threadAnimation && threadAnimation->isActive(), + "the selected thread card shares the Turn/You pending animation"); + static_cast(worker.completePrompt(localPrompt, true, {}, "shell-turn")); + spin(60); + require(!pendingAnimation->isActive() && !threadAnimation->isActive(), + "the same prompt acknowledgement stops both card animations"); } void initialHydrationUsesTheEstablishedBoundedWindow( @@ -1635,6 +1646,119 @@ void reloadAndReconnectHydrationStayExplicit(Configuration &configuration) { "a recreated selected thread is read once without resending prompts"); } +void forkActionsExposeLineageAndAdvancedOptions(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + makeReady(worker); + applyThread(worker, "fork-source", "Original (fork 1)"); + applyThread(worker, "existing-child", "Original (fork 1.1)"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require(spinUntil([&] { return threadItem(list, "fork-source"); }), + "fork fixture appears in the real thread list"); + static_cast(takeQtMessages(channels)); + + const auto openAction = [&](QStringView label) -> QAction * { + const QPoint point = + list->visualItemRect(threadItem(list, "fork-source")).center(); + QMetaObject::invokeMethod(list, "customContextMenuRequested", + Qt::DirectConnection, Q_ARG(QPoint, point)); + auto *menu = qobject_cast(QApplication::activePopupWidget()); + if (!menu) + return nullptr; + for (QAction *action : menu->actions()) + if (action && action->text() == label) + return action; + return nullptr; + }; + + QAction *quick = openAction(u"Quick fork"); + require(quick && openAction(u"Fork with options…"), + "thread context menu exposes Quick fork and Fork with options"); + if (QWidget *popup = QApplication::activePopupWidget()) + popup->close(); + quick = openAction(u"Quick fork"); + if (quick) + quick->trigger(); + std::vector messages = takeQtMessages(channels); + const NodeAction *quickFork = nullptr; + for (const QtToWorkerMessage &message : messages) { + const auto *action = std::get_if(&message); + if (action && action->kind == NodeActionKind::Fork) + quickFork = action; + } + const auto quickName = quickFork + ? quickFork->payload.find("requestedName") + : Value::Object::const_iterator{}; + require(quickFork && quickName != quickFork->payload.end() && + quickName->second.asString() && + *quickName->second.asString() == "Original (fork 1.2)" && + !quickFork->payload.contains("cwd"), + "Quick fork sends only the correct next nested chosen name"); + + bool suggestedNameVisible = false; + QAction *advanced = openAction(u"Fork with options…"); + QTimer::singleShot(0, [&] { + auto *dialog = qobject_cast(QApplication::activeModalWidget()); + if (!dialog) + return; + const auto lineEdits = dialog->findChildren(); + const auto plainEdits = dialog->findChildren(); + QLineEdit *workspace = nullptr; + QLineEdit *name = nullptr; + for (QLineEdit *edit : lineEdits) { + if (edit->text() == QStringLiteral("/tmp")) + workspace = edit; + else + name = edit; + } + suggestedNameVisible = + name && name->text() == QStringLiteral("Original (fork 1.2)"); + if (workspace) + workspace->setText(QStringLiteral("/adjusted-workspace")); + if (name) + name->setText(QStringLiteral("Chosen advanced fork")); + if (plainEdits.size() >= 2) { + plainEdits[0]->setPlainText(QStringLiteral("Adjusted base")); + plainEdits[1]->setPlainText(QStringLiteral("Adjusted developer")); + } + if (auto *ephemeral = dialog->findChild()) + ephemeral->setChecked(true); + dialog->accept(); + }); + if (advanced) + advanced->trigger(); + messages = takeQtMessages(channels); + const NodeAction *advancedFork = nullptr; + for (const QtToWorkerMessage &message : messages) { + const auto *action = std::get_if(&message); + if (action && action->kind == NodeActionKind::Fork) + advancedFork = action; + } + const auto hasString = [&](std::string_view key, std::string_view value) { + if (!advancedFork) + return false; + const auto found = advancedFork->payload.find(key); + return found != advancedFork->payload.end() && found->second.asString() && + *found->second.asString() == value; + }; + const auto ephemeral = advancedFork + ? advancedFork->payload.find("ephemeral") + : Value::Object::const_iterator{}; + require(advanced && suggestedNameVisible && advancedFork && + hasString("requestedName", "Chosen advanced fork") && + hasString("cwd", "/adjusted-workspace") && + hasString("baseInstructions", "Adjusted base") && + hasString("developerInstructions", "Adjusted developer") && + ephemeral != advancedFork->payload.end() && + ephemeral->second.asBool() && *ephemeral->second.asBool(), + "Fork with options prefills lineage and sends every editable field"); +} + void backgroundGraphChangesDoNotRefreshSelectedConversation( Configuration &configuration) { FrontendSession session(configuration); @@ -2262,12 +2386,18 @@ void optimisticDraftUsesOneTypedCreateAction(Configuration &configuration) { makeReady(worker); spin(40); + const QString chosenName = QStringLiteral("Chosen UI name"); auto *newThread = shell.findChild(QStringLiteral("threadNewButton")); - QTimer::singleShot(0, &shell, [] { + QTimer::singleShot(0, &shell, [chosenName] { if (auto *dialog = - qobject_cast(QApplication::activeModalWidget())) + qobject_cast(QApplication::activeModalWidget())) { + for (QLineEdit *editor : dialog->findChildren()) { + if (editor->placeholderText() == QStringLiteral("Optional thread name")) + editor->setText(chosenName); + } dialog->accept(); + } }); require(newThread != nullptr, "the existing New thread control is available"); if (!newThread) @@ -2278,8 +2408,14 @@ void optimisticDraftUsesOneTypedCreateAction(Configuration &configuration) { auto *list = shell.findChild(QStringLiteral("threadList")); QListWidgetItem *draft = threadItem(list, "draft:new-thread"); QListWidgetItem *const stableDraft = draft; - require(draft && list->currentItem() == draft, - "the local optimistic draft is selected without a mirror model"); + QWidget *draftRow = draft ? list->itemWidget(draft) : nullptr; + QLabel *draftTitle = + draftRow + ? draftRow->findChild(QStringLiteral("threadTitle")) + : nullptr; + require(draft && list->currentItem() == draft && draftTitle && + draftTitle->text() == chosenName, + "the local optimistic draft is selected with its chosen UI name"); static_cast(worker.connectionSettings( {{"selected", Value("unix")}, {"endpoint", Value("local")}})); @@ -2323,8 +2459,10 @@ void optimisticDraftUsesOneTypedCreateAction(Configuration &configuration) { graphDraft && list && list->currentItem() == stableDraft && list->currentItem()->data(Qt::UserRole).toString().toStdString() == graphDraft->id().canonical && - localPromptCard(shell, promptText.toStdString()), - "the same optimistic row hands off to the selected shared graph draft"); + localPromptCard(shell, promptText.toStdString()) && + draftTitle->text() == chosenName, + "the same optimistic row and chosen name hand off to the shared graph " + "draft"); if (!transition.command) return; @@ -2339,8 +2477,10 @@ void optimisticDraftUsesOneTypedCreateAction(Configuration &configuration) { threadPane = dynamic_cast(ancestor); require(threadItem(list, "created-thread") == stableDraft && list->currentItem() == stableDraft && threadPane && - threadPane->isOptimisticThread("created-thread"), - "the same row is promoted from local to canonical identity"); + threadPane->isOptimisticThread("created-thread") && + draftTitle->text() == chosenName, + "the same row and chosen name survive promotion to the canonical " + "thread identity"); static_cast( worker.completePrompt(localPrompt, true, {}, "created-turn")); @@ -2355,9 +2495,11 @@ void optimisticDraftUsesOneTypedCreateAction(Configuration &configuration) { !threadPane->isOptimisticThread("created-thread") && acceptedCard && !acceptedCard->property("pendingFeedbackVisible").toBool() && - acceptedAnimation && !acceptedAnimation->isActive(), + acceptedAnimation && !acceptedAnimation->isActive() && + draftTitle->text() == chosenName, "the exact prompt result confirms the canonical row without " - "replacing its widget item and stops optimistic feedback"); + "replacing its widget item or chosen name, and stops optimistic " + "feedback"); } void emptyOptimisticDraftIsAbandonedOnThreadSelection( @@ -3439,6 +3581,7 @@ int main(int argc, char **argv) { threadSwitchStagesTheCompleteReplacement(*configuration); inactiveThreadNeverReactivatesAStaleTurn(*configuration); reloadAndReconnectHydrationStayExplicit(*configuration); + forkActionsExposeLineageAndAdvancedOptions(*configuration); backgroundGraphChangesDoNotRefreshSelectedConversation(*configuration); optimisticDraftUsesOneTypedCreateAction(*configuration); emptyOptimisticDraftIsAbandonedOnThreadSelection(*configuration); diff --git a/tests/codex/nodegraph/ProtocolUpdaterTest.cpp b/tests/codex/nodegraph/ProtocolUpdaterTest.cpp index f1194e8..3369173 100644 --- a/tests/codex/nodegraph/ProtocolUpdaterTest.cpp +++ b/tests/codex/nodegraph/ProtocolUpdaterTest.cpp @@ -3950,6 +3950,56 @@ void correlatedThreadReadsPreserveOnlyInterveningLiveState() { } } +void chosenNameOverlaySurvivesUntilMatchingAcknowledgement() { + NodeGraph graph; + ProtocolUpdater updater(graph); + { + auto write = graph.write(); + NodeState state; + state.fields = {{"name", Value("Chosen name")}, + {"localNameOverlay", Value("Chosen name")}}; + static_cast( + write.upsert({NodeKind::Thread, "named-thread"}, std::move(state))); + static_cast(write.finish()); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("named-thread")}, + {"name", Value("Provider default")}})}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/name/updated", + std::nullopt, + Value::Object{{"threadId", Value("named-thread")}, + {"threadName", Value("Out-of-order name")}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "named-thread"}); + const Value *overlay = field(read->state(thread), "localNameOverlay"); + require(overlay && overlay->asString() && + *overlay->asString() == "Chosen name", + "provider handoff or mismatched notification dropped the chosen " + "name overlay"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/name/updated", + std::nullopt, + Value::Object{{"threadId", Value("named-thread")}, + {"threadName", Value("Chosen name")}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "named-thread"}); + const auto state = read->state(thread); + const Value *name = field(state, "name"); + require(field(state, "localNameOverlay") == nullptr && name && + name->asString() && *name->asString() == "Chosen name", + "matching app-server acknowledgement did not retire the local " + "name overlay cleanly"); + } +} + void authoritativeReplacementRetiresItemsAndPreservesLocalTail() { NodeGraph graph; ProtocolUpdater updater(graph); @@ -4200,6 +4250,7 @@ int main() { deletionUnlinksWholeGraph(); largeThreadDeletionIsNearLinear(); correlatedThreadReadsPreserveOnlyInterveningLiveState(); + chosenNameOverlaySurvivesUntilMatchingAcknowledgement(); authoritativeReplacementRetiresItemsAndPreservesLocalTail(); rollbackAndRevertReplaceAuthoritativeHistory(); diff --git a/tests/codex/nodegraph/WorkerLogicTest.cpp b/tests/codex/nodegraph/WorkerLogicTest.cpp index 8ddd639..c7d402a 100644 --- a/tests/codex/nodegraph/WorkerLogicTest.cpp +++ b/tests/codex/nodegraph/WorkerLogicTest.cpp @@ -714,7 +714,9 @@ void threadActivityAndPromptOrderingStayInTheGraph() { } NodeAction first{child, NodeActionKind::SubmitPrompt}; first.promptText = "promote child root"; - static_cast(logic.admitPrompt(std::move(first), 20)); + PromptTransition admitted = logic.admitPrompt(std::move(first), 20); + const NodeRef localPrompt = + admitted.command ? admitted.command->localPrompt : NodeRef{}; static_cast(takeWorkerMessages(channels)); { auto read = graph.tryRead(); @@ -727,6 +729,15 @@ void threadActivityAndPromptOrderingStayInTheGraph() { "propagates to its visible root group"); } + static_cast(logic.failPrompt(localPrompt, "turn/start rejected")); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + require(field(read->state(child), "localPromptActivityAt") == nullptr && + field(read->state(parent), "localPromptActivityAt") == nullptr, + "a rejected turn admission removes its optimistic Recent value"); + } + static_cast(logic.applyDetailed( {DecodedMessageKind::ServerNotification, "turn/started", @@ -739,12 +750,11 @@ void threadActivityAndPromptOrderingStayInTheGraph() { static_cast(takeWorkerMessages(channels)); { auto read = graph.tryRead(); - require( - signedFieldEquals(read->state(child), "localActivityAt", 40) && - signedFieldEquals(read->state(parent), "localActivityAt", 40) && - signedFieldEquals(read->state(parent), "localPromptActivityAt", 31), - "meaningful decoded traffic advances heading activity without " - "rewriting prompt ordering state"); + require(signedFieldEquals(read->state(child), "localActivityAt", 40) && + signedFieldEquals(read->state(parent), "localActivityAt", 40) && + field(read->state(parent), "localPromptActivityAt") == nullptr, + "meaningful decoded traffic advances heading activity without " + "creating prompt ordering state"); } static_cast(logic.applyDetailed( @@ -794,7 +804,7 @@ void localPromptsAreGraphNodesAndDispatchPerThread() { const char *const textStorage = first.promptText.data(); const std::uint8_t *const bytesStorage = first.attachments.front().bytes->data(); - PromptTransition admitted = logic.admitPrompt(std::move(first)); + PromptTransition admitted = logic.admitPrompt(std::move(first), 10, 10'000); require(admitted.command && admitted.command->kind == PromptCommandKind::StartTurn && admitted.command->thread == thread && @@ -840,7 +850,7 @@ void localPromptsAreGraphNodesAndDispatchPerThread() { queued.target = thread; queued.kind = NodeActionKind::SubmitPrompt; queued.promptText = "second exact prompt"; - PromptTransition second = logic.admitPrompt(std::move(queued)); + PromptTransition second = logic.admitPrompt(std::move(queued), 11, 11'000); require(!second.command, "a second prompt for the same thread remains queued while one " "request is in flight"); @@ -851,7 +861,7 @@ void localPromptsAreGraphNodesAndDispatchPerThread() { parallel.kind = NodeActionKind::SubmitPrompt; parallel.promptText = "independent prompt"; PromptTransition independentAdmission = - logic.admitPrompt(std::move(parallel)); + logic.admitPrompt(std::move(parallel), 12, 12'000); require(independentAdmission.command && independentAdmission.command->thread == independent, "different threads dispatch independently"); @@ -872,12 +882,21 @@ void localPromptsAreGraphNodesAndDispatchPerThread() { PromptTransition completed = logic.completePrompt(firstPrompt, true, {}, "authoritative-turn"); + bool steeringKeptFirstTurnOrder = false; + if (completed.command) { + auto read = graph.tryRead(); + steeringKeptFirstTurnOrder = + field(read->state(completed.command->localPrompt), "sortActivityAt") == + nullptr && + signedFieldEquals(read->state(thread), "localPromptActivityAt", 10); + } require(completed.command && completed.command->kind == PromptCommandKind::SteerTurn && completed.command->expectedTurnId == "authoritative-turn" && - completed.command->promptText == "second exact prompt", + completed.command->promptText == "second exact prompt" && + steeringKeptFirstTurnOrder, "a successful request releases exactly the next same-thread prompt " - "as a steer command"); + "as a steer command without treating steering as a newer turn"); static_cast(takeWorkerMessages(channels)); static_cast(logic.apply( @@ -904,8 +923,8 @@ void localPromptsAreGraphNodesAndDispatchPerThread() { read->state(firstPrompt)->status == NodeStatus::Running && stringFieldEquals(read->state(firstPrompt), "dispatchState", "awaitingMaterialization") && - boolFieldEquals(read->state(firstPrompt), - "showPendingAnimation", false), + boolFieldEquals(read->state(firstPrompt), "showPendingAnimation", + false), "matching authoritative clientId directly relates the user item " "to its active local visual identity and transfers canonical " "turn-root ownership without overriding the retained UI deadline"); @@ -1544,7 +1563,8 @@ void firstPromptCreatesAndMigratesOneDraftThread() { {"turnStart", Value(Value::Object{{"approvalPolicy", Value("on-request")}})}, {"requestedName", Value("Named locally")}}; - PromptTransition admitted = logic.admitFirstPrompt(std::move(action)); + PromptTransition admitted = + logic.admitFirstPrompt(std::move(action), 100, 100'000); require(admitted.command && admitted.command->kind == PromptCommandKind::CreateThread && admitted.command->requestedName == "Named locally" && @@ -1572,9 +1592,10 @@ void firstPromptCreatesAndMigratesOneDraftThread() { require(draft && draft->id().canonical.starts_with("local-thread:") && read->related(runtime, RelationKind::RootThread).front() == draft && - read->parent(read->parent(localPrompt)) == draft, + read->parent(read->parent(localPrompt)) == draft && + stringFieldEquals(read->state(draft), "name", "Named locally"), "the first prompt is immediately renderable under one draft " - "thread and provisional turn"); + "thread with its chosen name and provisional turn"); } static_cast(logic.apply( @@ -1592,23 +1613,48 @@ void firstPromptCreatesAndMigratesOneDraftThread() { { auto read = graph.tryRead(); const NodeRef actual = read->find({NodeKind::Thread, "created-thread"}); - require(admitted.command->kind == PromptCommandKind::StartTurn && - admitted.command->thread == actual && - admitted.command->options.contains("approvalPolicy") && - !read->find(draft->id()) && - read->parent(read->parent(localPrompt)) == actual && - read->related(actual, RelationKind::PendingPrompt) == - std::vector{localPrompt} && - std::ranges::any_of( - migrationMessages, - [&](const auto &message) { - const UiEffect *effect = std::get_if(&message); - return effect && - effect->kind == UiEffectKind::SelectThread && - effect->target == std::optional(actual); - }), - "migration removes the draft shell, selects the canonical thread, " - "and changes the retained command to turn/start"); + require( + admitted.command->kind == PromptCommandKind::StartTurn && + admitted.command->thread == actual && + admitted.command->options.contains("approvalPolicy") && + !read->find(draft->id()) && + read->parent(read->parent(localPrompt)) == actual && + read->related(actual, RelationKind::PendingPrompt) == + std::vector{localPrompt} && + stringFieldEquals(read->state(actual), "name", "Named locally") && + stringFieldEquals(read->state(actual), "localNameOverlay", + "Named locally") && + std::ranges::any_of( + migrationMessages, + [&](const auto &message) { + const UiEffect *effect = std::get_if(&message); + return effect && effect->kind == UiEffectKind::SelectThread && + effect->target == std::optional(actual); + }), + "migration removes the draft shell, preserves the chosen name, " + "selects the canonical thread, and changes the retained command " + "to turn/start"); + } + static_cast( + logic.completePrompt(localPrompt, true, {}, "created-turn")); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const NodeRef actual = read->find({NodeKind::Thread, "created-thread"}); + const Value *sortActivity = + field(read->state(localPrompt), "sortActivityAt"); + const std::int64_t *timestamp = + sortActivity ? sortActivity->asInt64() : nullptr; + require(timestamp && + signedFieldEquals(read->state(actual), + "confirmedLocalPromptActivityAt", + *timestamp) && + signedFieldEquals(read->state(actual), "localPromptActivityAt", + *timestamp) && + stringFieldEquals(read->state(actual), "localNameOverlay", + "Named locally"), + "turn acknowledgement confirms Recent without dropping the " + "chosen-name overlay"); } NodeGraph failedGraph; diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index b983b41..bf726cf 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -119,12 +119,16 @@ function storedConversationPresentation(): ConversationPresentationOptions { }; } -export function lastActivityText(timestamp: number, now = new Date()): string { +function threadTimestampText(timestamp: number, now = new Date()): string { const activity = new Date(timestamp * 1000); const sameDate = activity.getFullYear() === now.getFullYear() && activity.getMonth() === now.getMonth() && activity.getDate() === now.getDate(); const time = activity.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit", second: "2-digit"}); - return `Last activity: ${sameDate ? time : `${activity.toLocaleDateString()} ${time}`}`; + return sameDate ? time : `${activity.toLocaleDateString()} ${time}`; +} + +export function lastActivityText(timestamp: number, now = new Date()): string { + return `Last activity: ${threadTimestampText(timestamp, now)}`; } function persistConversationPresentation(options: ConversationPresentationOptions): void { @@ -143,7 +147,7 @@ function effectivePlanStepStatus(stepStatus: string, turnStatus: string, threadS return outcome === "completed" ? "completed" : outcome === "failed" ? "failed" : outcome === "interrupted" ? "interrupted" : stepStatus; } -function ThreadPane({session, revision, onRequestNewThread, drawer = false, paneRef, onClose}: {session: BrowserFrontendSession; revision: number; onRequestNewThread: () => void} & DrawerPaneProps) { +function ThreadPane({session, revision, onRequestNewThread, onRequestForkWithOptions, drawer = false, paneRef, onClose}: {session: BrowserFrontendSession; revision: number; onRequestNewThread: () => void; onRequestForkWithOptions: (threadId: string) => void} & DrawerPaneProps) { void revision; const snapshot = session.getSnapshot(); const selected = snapshot.selectedThreadId || (snapshot.newThreadIntent ? "__codexui_new_thread__" : ""); @@ -151,6 +155,10 @@ function ThreadPane({session, revision, onRequestNewThread, drawer = false, pane const [sortCriterion, setSortCriterion] = useState("recent"); const [contextMenu, setContextMenu] = useState<{threadId: string; x: number; y: number; trigger: HTMLElement} | null>(null); const contextMenuRef = useRef(null); + const requestMoreNearEnd = (list: HTMLDivElement) => { + if (list.scrollHeight - list.scrollTop - list.clientHeight <= Math.max(48, list.clientHeight / 2)) + session.loadMoreThreads(); + }; useEffect(() => { if (!contextMenu) return; const menu = contextMenuRef.current; @@ -199,14 +207,20 @@ function ThreadPane({session, revision, onRequestNewThread, drawer = false, pane if (!thread && !optimistic) return null; const status = classifyStatus(thread?.status ?? ""); const hasChildren = (thread?.childThreadOrder.length ?? 0) > 0; - const optimisticClass = optimistic ? ` optimistic-${optimistic.state}` : ""; - const title = thread?.title || optimistic?.title || id; + const promptAnimating = session.threadPromptAnimating(id); + const optimisticClass = optimistic?.state === "failed" ? " optimistic-failed" + : promptAnimating ? " prompt-awaiting" + : optimistic ? ` optimistic-${optimistic.state}` : ""; + const title = optimistic?.title || thread?.title || id; const detail = optimistic ? optimistic.state === "failed" ? "not created" : optimistic.state === "confirmed" ? "created" : "creating" : thread?.cwd || thread?.preview || id; const statusText = optimistic ? detail : displayStatus(thread?.status ?? ""); const parentId = session.model.childOwnership(id)?.parentThreadId; + const recentAt = session.threadRecentAt(id); const hoverDetails = [title, `Workspace: ${thread?.cwd || optimistic?.cwd || "Unknown"}`, `Status: ${statusText}`, - `Last activity: ${thread?.lastActivityAt === undefined ? "Unknown" : lastActivityText(thread.lastActivityAt).replace(/^Last activity: /u, "")}`, + `Recent turn: ${recentAt === undefined ? "Unknown" : threadTimestampText(recentAt)}`, + `Created: ${thread?.createdAt === undefined ? "Unknown" : threadTimestampText(thread.createdAt)}`, + `Last activity: ${thread?.lastActivityAt === undefined ? "Unknown" : threadTimestampText(thread.lastActivityAt)}`, ...(parentId ? [`Parent: ${session.model.thread(parentId)?.title || parentId}`] : [])]; const accessibleDetails = hoverDetails.join(", "); return
×}
-
+
requestMoreNearEnd(event.currentTarget)}> {snapshot.optimisticThreads.map(thread => renderThread(thread.id, 0))} {session.threadOrder(sortCriterion).filter(id => !snapshot.optimisticThreads.some(thread => thread.id === id)).map(id => renderThread(id, 0))}
- {contextThread && contextMenu &&
- + +
} ; } -export function NewThreadDialog({initialWorkspace, onCancel, onContinue}: {initialWorkspace: string; onCancel: () => void; onContinue: (draft: NewThreadDraft) => void}) { +export function NewThreadDialog({initialWorkspace, initialDraft, purpose = "create", onCancel, onContinue}: {initialWorkspace: string; initialDraft?: NewThreadDraft; purpose?: "create" | "fork"; onCancel: () => void; onContinue: (draft: NewThreadDraft) => void}) { const dialog = useRef(null); const workspaceInput = useRef(null); - const [workspace, setWorkspace] = useState(initialWorkspace); - const [name, setName] = useState(""); - const [baseInstructions, setBaseInstructions] = useState(""); - const [developerInstructions, setDeveloperInstructions] = useState(""); - const [ephemeral, setEphemeral] = useState(false); + const [workspace, setWorkspace] = useState(initialDraft?.workspace ?? initialWorkspace); + const [name, setName] = useState(initialDraft?.name ?? ""); + const [baseInstructions, setBaseInstructions] = useState(initialDraft?.baseInstructions ?? ""); + const [developerInstructions, setDeveloperInstructions] = useState(initialDraft?.developerInstructions ?? ""); + const [ephemeral, setEphemeral] = useState(initialDraft?.ephemeral ?? false); const [error, setError] = useState(""); useBrowserLayoutEffect(() => { const previous = typeof document === "undefined" ? null : document.activeElement as HTMLElement | null; @@ -279,8 +295,9 @@ export function NewThreadDialog({initialWorkspace, onCancel, onContinue}: {initi if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } }; - return
-

New thread

Set thread context. Upcoming-turn controls retain model, reasoning, access, and style.

+ const title = purpose === "fork" ? "Fork with options" : "New thread"; + return
+

{title}

{purpose === "fork" ? "Adjust the copied thread context." : "Set thread context."} Upcoming-turn controls retain model, reasoning, access, and style.

@@ -308,6 +325,25 @@ function SafeMarkdown({text}: {text: string}) { }}>{text}
; } +export function userMessageMarkdownText(text: string): string { + const lines = text.split("\n"); + const structuralMarkdown = lines.some(rawLine => { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + const indentation = line.match(/^ */u)?.[0].length ?? 0; + const content = line.slice(indentation); + return indentation >= 4 || /^(?:#{1,6}(?:\s|$)|>|```|~~~|[-*+]\s|\d+[.)]\s|\[)/u.test(content) + || content.includes("|"); + }); + if (structuralMarkdown) return text; + return lines.map((rawLine, index) => { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + const blankMarker = line.trim() === "" ? "\u200B" : ""; + if (index === lines.length - 1) return `${line}${blankMarker}`; + const hardBreak = line.endsWith("\\") || line.endsWith(" ") ? "" : " "; + return `${line}${blankMarker}${hardBreak}`; + }).join("\n"); +} + export interface CardCopyContent {text: string; markdown: boolean} function joinCopyText(parts: string[]): string { @@ -447,7 +483,7 @@ export function Card({card, active, collapsed, onToggle, onCopy, nested, turnCon let phaseLabel = ""; if (card.kind === "userMessage") { const data = card.payload as UserMessageData; title = "You"; phaseLabel = nestedCard ? "steering" : ""; - body = <>; + body = <>; } else if (card.kind === "localPrompt") { const data = card.payload as LocalPromptData; title = data.state === "failed" ? "Not sent" : "You"; phaseLabel = nestedCard && data.state !== "failed" ? "steering" : ""; body = <>
{data.prompt}
{data.error &&
{data.error}
}; @@ -971,6 +1007,7 @@ export function App({session}: {session: BrowserFrontendSession}) { const responsiveMode = useResponsiveMode(); const [drawer, setDrawer] = useState<"threads" | "inspector" | null>(null); const [newThreadDialog, setNewThreadDialog] = useState(false); + const [forkWithOptionsThreadId, setForkWithOptionsThreadId] = useState(null); const shell = useRef(null); const threadTrigger = useRef(null); const inspectorTrigger = useRef(null); @@ -980,12 +1017,17 @@ export function App({session}: {session: BrowserFrontendSession}) { const threadsOverlay = responsiveMode === "mobile"; const inspectorOverlay = responsiveMode !== "desktop"; const activeDrawer = drawer === "threads" && threadsOverlay || drawer === "inspector" && inspectorOverlay ? drawer : null; - const modalOpen = Boolean(activeDrawer || newThreadDialog); + const modalOpen = Boolean(activeDrawer || newThreadDialog || forkWithOptionsThreadId); const closeDrawer = () => setDrawer(null); const requestNewThread = () => { closeDrawer(); setNewThreadDialog(true); }; + const requestForkWithOptions = (threadId: string) => { closeDrawer(); setForkWithOptionsThreadId(threadId); }; const createNewThreadDraft = (draft: NewThreadDraft) => { session.beginNewThread(draft); setNewThreadDialog(false); closeDrawer(); }; + const forkThreadWithOptions = (draft: NewThreadDraft) => { + if (forkWithOptionsThreadId) session.forkThread(forkWithOptionsThreadId, draft); + setForkWithOptionsThreadId(null); closeDrawer(); + }; useEffect(() => setDrawer(null), [responsiveMode]); useBrowserLayoutEffect(() => { for (const region of shell.current?.querySelectorAll("[data-modal-background]") ?? []) @@ -1035,7 +1077,7 @@ export function App({session}: {session: BrowserFrontendSession}) { {snapshot.notice &&
{snapshot.notice}
}
- {!threadsOverlay && } + {!threadsOverlay && } {!inspectorOverlay && }
@@ -1044,8 +1086,9 @@ export function App({session}: {session: BrowserFrontendSession}) { Powered by SNode.C
Status:{globalStatus}
{activeDrawer &&
; } diff --git a/web/src/app/BrowserFrontendSession.ts b/web/src/app/BrowserFrontendSession.ts index 9dab994..a3f5f62 100644 --- a/web/src/app/BrowserFrontendSession.ts +++ b/web/src/app/BrowserFrontendSession.ts @@ -77,12 +77,36 @@ export interface OptimisticThreadSnapshot { readonly state: "awaiting" | "failed" | "confirmed"; } -export type ThreadSortCriterion = "alphanumeric" | "created" | "updated" | "recent"; +export type ThreadSortCriterion = "alphanumeric" | "created" | "recent"; const threadTitleCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base", ignorePunctuation: true, }); +function forkNameParts(title: string): {base: string; lineage: number[]} { + const match = /^(.*) \(fork ([1-9]\d*(?:\.[1-9]\d*)*)\)$/u.exec(title); + if (!match) return {base: title, lineage: []}; + const lineage = match[2]!.split(".").map(Number); + if (lineage.some(component => !Number.isSafeInteger(component))) + return {base: title, lineage: []}; + return {base: match[1]!, lineage}; +} + +export function suggestForkName(sourceTitle: string, existingThreadTitles: readonly string[]): string { + const source = forkNameParts(sourceTitle); + const base = source.base || "Thread"; + const directChildren = new Set(); + for (const title of existingThreadTitles) { + const candidate = forkNameParts(title); + if (candidate.base !== base || candidate.lineage.length !== source.lineage.length + 1) continue; + if (source.lineage.every((component, index) => candidate.lineage[index] === component)) + directChildren.add(candidate.lineage.at(-1)!); + } + let next = 1; + while (directChildren.has(next)) ++next; + return `${base} (fork ${[...source.lineage, next].join(".")})`; +} + type HydrationState = "notHydrated" | "inFlight" | "hydrated" | "failed"; interface ThreadRuntimeState { hydration: HydrationState; @@ -129,6 +153,14 @@ export class BrowserFrontendSession { private reconnectAfterDetach = false; private lifecycleEpoch = 0; private catalogHydrationKey = ""; + private threadListInFlight = false; + private threadListInFlightEpoch = -1; + private threadListRepairPending = false; + private threadListLoadMoreRequested = false; + private threadListNextCursor = ""; + private readonly threadListSeenCursors = new Set(); + private threadListCycle = 0; + private threadListRepairTimer: ReturnType | undefined; private bridgeUrl: string; private readonly createWebSocket: WebSocketFactory | undefined; private snapshot: BrowserSessionSnapshot; @@ -232,6 +264,8 @@ export class BrowserFrontendSession { this.connection.dispose(); this.transport = undefined; for (const timer of this.pendingAnimationTimers.values()) clearTimeout(timer); this.pendingAnimationTimers.clear(); + if (this.threadListRepairTimer) clearTimeout(this.threadListRepairTimer); + this.threadListRepairTimer = undefined; if (this.noticeTimer) clearTimeout(this.noticeTimer); this.noticeTimer = undefined; } @@ -282,12 +316,49 @@ export class BrowserFrontendSession { this.publish(); } threadVisualKey(threadId: string): string { return this.threadVisualKeys.get(threadId) ?? threadId; } + threadPromptAnimating(threadId: string): boolean { + const now = Date.now(); + return this.prompts.submissions(threadId).some(submission => + (submission.state === "queued" || submission.state === "inFlight") + && now - submission.admittedAtMilliseconds >= PendingAnimationDelayMilliseconds); + } + threadRecentAt(threadId: string): number | undefined { + let latest: number | undefined; + const visited = new Set(); + const visit = (id: string) => { + if (visited.has(id)) return; + visited.add(id); + const thread = this.model.thread(id); + for (const timestamp of [thread?.recencyAt, thread?.localPromptActivityAt]) + if (timestamp !== undefined && (latest === undefined || timestamp > latest)) latest = timestamp; + for (const submission of this.prompts.submissions(id)) + if (submission.startsTurn && (submission.state === "queued" || submission.state === "inFlight") + && submission.sortActivityAt !== undefined + && (latest === undefined || submission.sortActivityAt > latest)) latest = submission.sortActivityAt; + for (const childId of thread?.childThreadOrder ?? []) visit(childId); + }; + visit(threadId); + return latest; + } + private nextPromptActivityAt(nowMilliseconds: number): number { + let activityAt = Math.floor(nowMilliseconds / 1000); + for (const id of this.model.threadIds()) { + const thread = this.model.thread(id); + for (const timestamp of [thread?.recencyAt, thread?.localPromptActivityAt]) + if (timestamp !== undefined && timestamp >= activityAt) activityAt = timestamp + 1; + for (const submission of this.prompts.submissions(id)) + if ((submission.state === "queued" || submission.state === "inFlight") + && submission.sortActivityAt !== undefined && submission.sortActivityAt >= activityAt) + activityAt = submission.sortActivityAt + 1; + } + return activityAt; + } threadOrder(criterion: ThreadSortCriterion = "recent"): readonly string[] { const order = this.model.threadOrder().filter(id => this.model.childOwnership(id) === undefined); const timestamp = (threadId: string) => { const thread = this.model.thread(threadId); - return criterion === "created" ? thread?.createdAt - : criterion === "updated" ? thread?.updatedAt : thread?.recencyAt; + if (criterion === "created") return thread?.createdAt; + return this.threadRecentAt(threadId); }; order.sort((leftId, rightId) => { const left = this.model.thread(leftId); const right = this.model.thread(rightId); @@ -322,8 +393,8 @@ export class BrowserFrontendSession { loadMore(): void { /* Default parity window is sufficient until viewport pausing is introduced. */ } async submitPrompt(prompt: string, attachments: AttachmentDraft[] = [], turnOptions: JsonObject = {}, threadOptions: JsonObject = {}): Promise { - const canonicalPrompt = promptWithFileLinks(prompt.trim(), attachments); - if (canonicalPrompt === "") return false; + if (prompt.trim() === "") return false; + const canonicalPrompt = promptWithFileLinks(prompt, attachments); if (!this.canSubmit()) { this.setNotice("Codex is not ready for a controlled turn. Your message was not sent."); return false; @@ -334,12 +405,13 @@ export class BrowserFrontendSession { if (!this.newThreadIntent) { this.setNotice("Select a thread or choose New thread before sending."); return false; } destination = DraftThreadId; thread = undefined; } + const admittedAt = Date.now(); + const activeTurnId = destination === DraftThreadId ? undefined : this.activeTurnId(destination); const submissionId = this.prompts.admit(destination, canonicalPrompt, attachments, turnOptions, thread, - destination === DraftThreadId ? undefined : this.activeTurnId(destination), Date.now()); + activeTurnId, admittedAt, activeTurnId === undefined ? this.nextPromptActivityAt(admittedAt) : undefined); this.schedulePendingAnimation(submissionId); if (destination !== DraftThreadId) { this.threadRuntime(destination); - this.model.notePromptActivity(destination, Math.floor(Date.now() / 1000)); } this.publish(); if (destination === DraftThreadId) { @@ -367,7 +439,6 @@ export class BrowserFrontendSession { this.optimisticThreads = this.optimisticThreads.map(thread => thread.id === DraftThreadId ? { ...thread, id, - title: stringMember(createdThread, "name") || thread.title, cwd: stringMember(createdThread, "cwd") || thread.cwd, } : thread); const draftStillSelected = this.selectedThreadId === "" && this.newThreadIntent; @@ -377,7 +448,10 @@ export class BrowserFrontendSession { runtime.hydration = "hydrated"; runtime.operationReady = true; const requestedName = threadDraft?.name ?? ""; this.newThreadDraft = undefined; - if (requestedName !== "") this.renameThread(id, requestedName); + if (requestedName !== "") { + this.model.setThreadTitleLocally(id, requestedName); + this.renameThread(id, requestedName); + } this.publish(); } queueMicrotask(() => this.dispatchNextPrompt(destination)); @@ -392,17 +466,58 @@ export class BrowserFrontendSession { return this.pendingUserOperations.has(`${action}:${threadId}`); } requestThreads(): void { - void this.performUserOperation("threads.refresh", "threads.list", {}, "Refresh threads", false); + const key = "threads.refresh:"; + if (this.pendingUserOperations.has(key) + || (this.threadListInFlight && this.threadListInFlightEpoch === this.lifecycleEpoch)) return; + if (!this.providerReady()) { + this.setNotice("Refresh threads is unavailable until Codex is ready."); + return; + } + this.pendingUserOperations.add(key); + this.publish(); + void this.requestInitialThreadPage(true).then(response => { + this.pendingUserOperations.delete(key); + if (!response.ok && !response.stale) + this.setNotice(`Refresh threads failed: ${this.errorMessage(response)}`); + else this.publish(); + }); } renameThread(threadId: string, name: string): void { void this.performUserOperation("thread.rename", "thread.rename", {threadId, name}, "Rename thread"); } reloadThread(threadId: string): void { this.readThread(threadId, true); } - forkThread(threadId: string): void { - this.performUserOperation("thread.fork", "thread.fork", {threadId}, "Fork thread")?.then(response => { + forkDraft(threadId: string): NewThreadDraft { + const source = this.model.thread(threadId); + return { + workspace: source?.cwd ?? "", + name: suggestForkName(source?.title || threadId, this.model.threadTitles()), + baseInstructions: stringMember(source?.raw, "baseInstructions"), + developerInstructions: stringMember(source?.raw, "developerInstructions"), + ephemeral: source?.raw.ephemeral === true, + }; + } + forkThread(threadId: string, draft?: NewThreadDraft): void { + const suggested = this.forkDraft(threadId); + const requestedName = draft?.name.trim() || suggested.name; + const parameters: JsonObject = {threadId}; + if (draft) { + if (draft.workspace.trim() !== "") parameters.cwd = draft.workspace.trim(); + if (draft.baseInstructions.trim() !== "") parameters.baseInstructions = draft.baseInstructions.trim(); + if (draft.developerInstructions.trim() !== "") + parameters.developerInstructions = draft.developerInstructions.trim(); + parameters.ephemeral = draft.ephemeral; + } + this.performUserOperation("thread.fork", "thread.fork", parameters, "Fork thread")?.then(response => { const thread = isObject(response.data) ? member(response.data, "thread", {}) : {}; const id = stringMember(thread, "id"); - if (response.ok && id !== "") this.selectThread(id); + if (response.ok && id !== "") { + this.model.setThreadTitleLocally(id, requestedName); + const runtime = this.threadRuntime(id); + runtime.hydration = "hydrated"; + runtime.operationReady = true; + this.renameThread(id, requestedName); + this.selectThread(id); + } else if (response.ok) this.setNotice("Fork thread failed: no thread was returned."); }); } @@ -464,6 +579,15 @@ export class BrowserFrontendSession { } private invalidateProviderWork(): void { ++this.lifecycleEpoch; + ++this.threadListCycle; + if (this.threadListRepairTimer) clearTimeout(this.threadListRepairTimer); + this.threadListRepairTimer = undefined; + this.threadListInFlight = false; + this.threadListInFlightEpoch = -1; + this.threadListRepairPending = false; + this.threadListLoadMoreRequested = false; + this.threadListNextCursor = ""; + this.threadListSeenCursors.clear(); this.catalogHydrationKey = ""; this.resolvingRequests.clear(); for (const [threadId, runtime] of this.runtimeByThread) { @@ -554,7 +678,8 @@ export class BrowserFrontendSession { const key = `${connection.generation}:${connection.providerGeneration}`; if (this.catalogHydrationKey !== key) { this.catalogHydrationKey = key; - this.request("threads.list", {}); this.request("models.list", {}); this.request("permission-profiles.list", {}); + void this.requestInitialThreadPage(); + this.request("models.list", {}); this.request("permission-profiles.list", {}); } const queued = new Set(this.prompts.queuedThreadIds()); if (this.selectedThreadId !== "") queued.add(this.selectedThreadId); @@ -592,6 +717,105 @@ export class BrowserFrontendSession { ? {ok: false, stale: true} : Object.hasOwn(response, "result") ? {ok: true, data: response.result} : {ok: false, error: response.error}), acceptResult)); } + private threadListParameters(useStateDbOnly: boolean, cursor = ""): JsonObject { + return { + sortKey: "recency_at", sortDirection: "desc", limit: 100, useStateDbOnly, + ...(cursor === "" ? {} : {cursor}), + }; + } + private async requestInitialThreadPage(force = false): Promise { + const epoch = this.lifecycleEpoch; + if (this.threadListInFlight && this.threadListInFlightEpoch === epoch) return {ok: true}; + if (this.threadListRepairPending && !force) return {ok: true}; + const cycle = ++this.threadListCycle; + if (this.threadListRepairTimer) clearTimeout(this.threadListRepairTimer); + this.threadListRepairTimer = undefined; + this.threadListRepairPending = true; + this.threadListLoadMoreRequested = false; + this.threadListNextCursor = ""; + this.threadListSeenCursors.clear(); + this.threadListInFlight = true; + this.threadListInFlightEpoch = epoch; + let response: OperationResponse; + try { + response = await this.requestPromise("threads.list", this.threadListParameters(true), + () => epoch === this.lifecycleEpoch && cycle === this.threadListCycle); + } finally { + if (this.threadListInFlightEpoch === epoch && cycle === this.threadListCycle) { + this.threadListInFlight = false; + this.threadListInFlightEpoch = -1; + } + } + if (response.stale || epoch !== this.lifecycleEpoch || cycle !== this.threadListCycle) + return response; + if (response.ok) this.threadListNextCursor = stringMember(response.data, "nextCursor"); + this.threadListRepairTimer = setTimeout(() => { + this.threadListRepairTimer = undefined; + void this.repairThreadList(epoch, cycle); + }, 16); + return response; + } + private async repairThreadList(epoch: number, cycle: number): Promise { + if (this.disposed || epoch !== this.lifecycleEpoch || cycle !== this.threadListCycle) + return; + this.threadListInFlight = true; + this.threadListInFlightEpoch = epoch; + const response = await this.requestPromise("threads.list", this.threadListParameters(false), + () => epoch === this.lifecycleEpoch && cycle === this.threadListCycle); + if (epoch !== this.lifecycleEpoch || cycle !== this.threadListCycle) return; + this.threadListInFlight = false; + this.threadListInFlightEpoch = -1; + this.threadListRepairPending = false; + if (!response.ok || response.stale) { + if (!response.stale) this.setNotice(`Thread reconciliation failed: ${this.errorMessage(response)}`); + if (this.threadListLoadMoreRequested) { + this.threadListLoadMoreRequested = false; + this.loadMoreThreads(); + } + return; + } + this.threadListNextCursor = stringMember(response.data, "nextCursor"); + this.publish(); + if (this.threadListLoadMoreRequested) { + this.threadListLoadMoreRequested = false; + this.loadMoreThreads(); + } + } + loadMoreThreads(): void { + if (!this.providerReady()) return; + if (this.threadListInFlight || this.threadListRepairPending) { + this.threadListLoadMoreRequested = true; + return; + } + const cursor = this.threadListNextCursor; + if (cursor === "" || this.threadListSeenCursors.has(cursor)) { + if (this.threadListSeenCursors.has(cursor)) this.threadListNextCursor = ""; + return; + } + this.threadListSeenCursors.add(cursor); + const epoch = this.lifecycleEpoch; + const cycle = this.threadListCycle; + this.threadListInFlight = true; + this.threadListInFlightEpoch = epoch; + void this.requestPromise("threads.list", this.threadListParameters(true, cursor), + () => epoch === this.lifecycleEpoch && cycle === this.threadListCycle).then(response => { + if (epoch !== this.lifecycleEpoch || cycle !== this.threadListCycle) return; + this.threadListInFlight = false; + this.threadListInFlightEpoch = -1; + if (!response.ok || response.stale) { + this.threadListSeenCursors.delete(cursor); + this.threadListLoadMoreRequested = false; + if (!response.stale) this.setNotice(`Loading more threads failed: ${this.errorMessage(response)}`); + return; + } + this.threadListNextCursor = stringMember(response.data, "nextCursor"); + this.publish(); + if (this.threadListLoadMoreRequested) { + this.threadListLoadMoreRequested = false; + this.loadMoreThreads(); + } + }); + } private performUserOperation(keyAction: string, action: string, parameters: JsonObject, failureContext: string, requiresControl = true): Promise | undefined { const threadId = stringMember(parameters, "threadId"); @@ -608,7 +832,7 @@ export class BrowserFrontendSession { this.pendingUserOperations.delete(key); if (!response.ok && !response.stale) this.setNotice(`${failureContext} failed: ${this.errorMessage(response)}`); - else this.publish(); + else this.schedulePublish(); }); return operation; } @@ -632,6 +856,9 @@ export class BrowserFrontendSession { } const dispatch = this.prompts.beginNext(threadId, this.activeTurnId(threadId)); if (!dispatch) return; + const submission = this.prompts.submission(threadId, dispatch.id); + if (submission?.startsTurn && submission.sortActivityAt === undefined) + submission.sortActivityAt = this.nextPromptActivityAt(Date.now()); this.dispatchPrompt(dispatch); } private dispatchPrompt(dispatch: PromptDispatch): void { @@ -655,7 +882,10 @@ export class BrowserFrontendSession { if (runtime) runtime.operationReady = true; const turn = isObject(response.data) ? member(response.data, "turn", {}) : {}; const turnId = stringMember(turn, "id") || undefined; - const startsTurn = this.prompts.submission(dispatch.threadId, dispatch.id)?.startsTurn === true; + const submission = this.prompts.submission(dispatch.threadId, dispatch.id); + const startsTurn = submission?.startsTurn === true; + if (startsTurn && submission?.sortActivityAt !== undefined) + this.model.notePromptActivity(dispatch.threadId, submission.sortActivityAt); this.prompts.acknowledge(dispatch.threadId, dispatch.id, turnId); if (startsTurn && turnId !== undefined) { if (runtime) runtime.provisionalActiveTurnId = turnId; diff --git a/web/src/conversation/PromptCoordinator.ts b/web/src/conversation/PromptCoordinator.ts index 37f0e90..487a88d 100644 --- a/web/src/conversation/PromptCoordinator.ts +++ b/web/src/conversation/PromptCoordinator.ts @@ -8,6 +8,7 @@ export interface PromptSubmission { attachments: AttachmentDraft[]; turnOptions: Record; state: PromptState; admittedAtMilliseconds: number; error: string; admissionAnchor?: AuthoritativeItemKey; admissionAtStart: boolean; startsTurn: boolean; expectedTurnId?: string; materializedItem?: AuthoritativeItemKey; + sortActivityAt?: number; } export interface PromptDispatch { id: number; threadId: string; clientUserMessageId: string; prompt: string; attachments: AttachmentDraft[]; @@ -95,13 +96,15 @@ export class PromptCoordinator { private nextAdmissionOrdinal = 1; admit(threadId: string, prompt: string, attachments: AttachmentDraft[], turnOptions: Record, - authoritativeThread: ThreadPresentation | undefined, activeTurnId: string | undefined, now: number): number { + authoritativeThread: ThreadPresentation | undefined, activeTurnId: string | undefined, now: number, + sortActivityAt?: number): number { const submission: PromptSubmission = { id: this.nextSubmissionId++, admissionOrdinal: this.nextAdmissionOrdinal++, threadId, clientUserMessageId: `codexui-${now}-${this.nextSubmissionId - 1}`, prompt, attachments: structuredClone(attachments), turnOptions: structuredClone(turnOptions), state: "queued", admittedAtMilliseconds: now, error: "", admissionAtStart: false, startsTurn: activeTurnId === undefined, }; + if (activeTurnId === undefined && sortActivityAt !== undefined) submission.sortActivityAt = sortActivityAt; if (activeTurnId !== undefined) submission.expectedTurnId = activeTurnId; if (authoritativeThread) { const items = indexAuthoritativeItems(threadId, authoritativeThread); @@ -122,7 +125,8 @@ export class PromptCoordinator { next.admissionAtStart = next.admissionAnchor === undefined; next.state = "inFlight"; next.startsTurn = activeTurnId === undefined; - if (activeTurnId === undefined) delete next.expectedTurnId; else next.expectedTurnId = activeTurnId; + if (activeTurnId === undefined) delete next.expectedTurnId; + else { next.expectedTurnId = activeTurnId; delete next.sortActivityAt; } const dispatch: PromptDispatch = { id: next.id, threadId: next.threadId, clientUserMessageId: next.clientUserMessageId, prompt: next.prompt, attachments: structuredClone(next.attachments), turnOptions: structuredClone(next.turnOptions), diff --git a/web/src/presentation/PresentationModel.ts b/web/src/presentation/PresentationModel.ts index 341c265..e111403 100644 --- a/web/src/presentation/PresentationModel.ts +++ b/web/src/presentation/PresentationModel.ts @@ -69,6 +69,8 @@ export interface ThreadPresentation { createdAt?: number; updatedAt?: number; recencyAt?: number; + localPromptActivityAt?: number; + localNameOverlay?: string; lastActivityAt?: number; commandCwds: string[]; changedPaths: string[]; @@ -398,7 +400,16 @@ export class PresentationModel { } threadOrder(): readonly string[] { return this.orderedThreads; } + threadIds(): readonly string[] { return [...this.threads.keys()]; } + threadTitles(): readonly string[] { return [...this.threads.values()].map(thread => thread.title); } thread(threadId: string): ThreadPresentation | undefined { return this.threads.get(threadId); } + setThreadTitleLocally(threadId: string, title: string): void { + const thread = this.threads.get(threadId); + if (thread && title.trim() !== "") { + thread.localNameOverlay = title; + thread.title = title; + } + } noteThreadActivity(threadId: string, timestamp: number): void { if (!Number.isSafeInteger(timestamp)) return; let current = threadId; @@ -415,12 +426,7 @@ export class PresentationModel { } } notePromptActivity(threadId: string, timestamp: number): void { - for (const thread of this.threads.values()) { - if (thread.updatedAt !== undefined && thread.updatedAt >= timestamp) - timestamp = thread.updatedAt + 1; - if (thread.recencyAt !== undefined && thread.recencyAt >= timestamp) - timestamp = thread.recencyAt + 1; - } + if (!Number.isSafeInteger(timestamp)) return; let current = threadId; const visited = new Set(); while (current !== "" && !visited.has(current)) { @@ -429,8 +435,8 @@ export class PresentationModel { if (!thread) break; if (thread.lastActivityAt === undefined || timestamp > thread.lastActivityAt) thread.lastActivityAt = timestamp; - thread.updatedAt = timestamp; - thread.recencyAt = timestamp; + if (thread.localPromptActivityAt === undefined || timestamp > thread.localPromptActivityAt) + thread.localPromptActivityAt = timestamp; const ownership = this.childOwnerships.get(current); if (!ownership) break; current = ownership.parentThreadId; @@ -567,8 +573,9 @@ export class PresentationModel { if (type === "thread.name.changed") { const thread = this.threads.get(stringMember(scope, "threadId")); if (thread && isObject(data) && typeof data.name === "string") { - thread.title = data.name; thread.raw.name = data.name; + if (thread.localNameOverlay === data.name) delete thread.localNameOverlay; + thread.title = thread.localNameOverlay ?? data.name; } return; } @@ -742,7 +749,9 @@ export class PresentationModel { result.raw = replaceTurns ? threadFields : mergePreservingCompleteness(result.raw, threadFields) as JsonObject; const name = stringMember(raw, "name"); const preview = stringMember(raw, "preview"); - if (name !== "") result.title = name; + if (name !== "" && result.localNameOverlay === name) delete result.localNameOverlay; + if (result.localNameOverlay !== undefined) result.title = result.localNameOverlay; + else if (name !== "") result.title = name; else if (preview !== "") result.title = preview.slice(0, 80); else if (result.title === "") result.title = id.slice(0, 12); if (preview !== "") result.preview = preview; diff --git a/web/src/styles.css b/web/src/styles.css index c00f7ff..790d6bd 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -19,7 +19,7 @@ button { color: inherit; } .brand small { color: #667085; font-size: 10px; margin-top: 4px; } .workspace-breadcrumb { min-width: 0; color: #667085; font-size: 13px; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .top-actions { display: flex; align-items: center; min-width: 0; gap: 10px; margin-left: auto; } -.subtle-button, .connection-control button, .refresh-button, .load-more { border: 1px solid #d7dee8; background: #fff; border-radius: 8px; padding: 7px 11px; cursor: pointer; } +.subtle-button, .connection-control button, .load-more { border: 1px solid #d7dee8; background: #fff; border-radius: 8px; padding: 7px 11px; cursor: pointer; } .connection-control { display: flex; align-items: center; gap: 6px; } .connection-control input { width: 210px; border: 1px solid #d7dee8; border-radius: 8px; padding: 7px 9px; color: #4c566a; } .status-dot { width: 9px; height: 9px; border-radius: 50%; background: #a6afbd; flex: none; box-shadow: 0 0 0 3px #a6afbd18; } @@ -44,7 +44,8 @@ h1, h2, h3, p { margin: 0; } .thread-row-wrap { position: relative; display: flex; align-items: center; border: 1px solid transparent; border-radius: 9px; } .thread-row-wrap:hover, .thread-row-wrap.context-open { background: #fff; border-color: #e0e5ed; } .thread-row-wrap.selected { background: #e8eefc; border-color: #cad6f5; } -.thread-row-wrap.optimistic-awaiting { background: linear-gradient(100deg, #fff7e8, #fce7c2, #fff7e8); background-size: 200% 100%; border-color: #dca45a; animation: awaiting 1.8s linear infinite; } +.thread-row-wrap.optimistic-awaiting { background: #fff7e8; border-color: #dca45a; } +.thread-row-wrap.prompt-awaiting { background: linear-gradient(100deg, #eaf2ff, #75a0ef69, #eaf2ff); background-size: 200% 100%; border-color: #79a0d7; animation: awaiting 1.7s linear infinite; } .thread-row-wrap.optimistic-failed { background: #fff0f2; border-color: #efb8c0; } .thread-row-wrap.optimistic-confirmed { background: #fff; border-color: #d7dee8; } .tree-toggle { width: 24px; height: 28px; padding: 0; border: 0; background: transparent; color: #65738a; font-size: 17px; cursor: pointer; } @@ -64,7 +65,6 @@ h1, h2, h3, p { margin: 0; } .thread-context-menu button:disabled { opacity: .45; cursor: default; } .thread-context-menu .danger { color: #b93647; } @media (hover: none) { .thread-menu-trigger { visibility: visible; } } -.refresh-button { margin: 8px; color: #667085; background: transparent; } .conversation-pane { --composer-overlay-height: 112px; position: relative; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); background: #f2f5f9; } .conversation-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 26px 14px; background: #fff; border-bottom: 1px solid #e0e5ed; } .conversation-title { flex: 1 1 auto; min-width: 0; } @@ -98,7 +98,7 @@ h1, h2, h3, p { margin: 0; } .conversation-card > header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; color: #667085; } .conversation-card > header span { color: #38445a; font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .07em; } .conversation-card > header small { color: #667085; font-size: 9px; } -.card-meta { display: flex; align-items: center; gap: 0; }.conversation-card > header .card-phase { margin-right: 4px; font-weight: 400; letter-spacing: 0; text-transform: none; }.conversation-card > header .card-phase.update { color: #285fca; }.conversation-card > header .card-phase.final { color: #176b45; }.conversation-card > header .card-phase.steering { color: #146f73; }.card-meta button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: #667085; cursor: pointer; }.card-meta button:hover, .card-meta button:focus-visible { color: #1d2633; }.card-copy-control { position: relative; display: inline-flex; }.card-meta .card-copy-button svg { width: 14px; height: 14px; transform: translateX(4px); fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; }.card-copy-button .copy-glyph, .card-copy-button .check-glyph { transform-box: fill-box; transform-origin: center; transition: opacity 160ms ease, transform 160ms ease; }.card-copy-button .copy-glyph { opacity: 1; transform: scale(1); }.card-copy-button .check-glyph { opacity: 0; transform: scale(.72); }.card-copy-button.copied { color: #176b45; }.card-copy-button.copied .copy-glyph { opacity: 0; transform: scale(.72); }.card-copy-button.copied .check-glyph { opacity: 1; transform: scale(1); }.conversation-card > header .card-copy-overlay { position: absolute; z-index: 4; top: 50%; right: calc(100% + 4px); transform: translateY(-50%); padding: 4px 7px; border: 1px solid #344054; border-radius: 6px; background: #1d2633; color: #fff; box-shadow: 0 2px 6px #17203324; font-size: 10px; font-weight: 600; line-height: 1.3; letter-spacing: 0; text-transform: none; white-space: nowrap; pointer-events: none; }.conversation-card > header .card-copy-overlay.failed { border-color: #982f3d; background: #982f3d; }.card-meta .card-fold-button svg { width: 14px; height: 14px; transform: translateX(2px); fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.card-meta { display: flex; align-items: center; gap: 0; }.conversation-card > header .card-phase { margin-right: 0; font-weight: 400; letter-spacing: 0; text-transform: none; }.conversation-card > header .card-phase.update { color: #285fca; }.conversation-card > header .card-phase.final { color: #176b45; }.conversation-card > header .card-phase.steering { color: #146f73; }.card-meta button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: #667085; cursor: pointer; }.card-meta button:hover, .card-meta button:focus-visible { color: #1d2633; }.card-copy-control { position: relative; display: inline-flex; }.card-meta .card-copy-button svg { width: 14px; height: 14px; transform: translateX(4px); fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; stroke-linejoin: round; }.card-copy-button .copy-glyph, .card-copy-button .check-glyph { transform-box: fill-box; transform-origin: center; transition: opacity 160ms ease, transform 160ms ease; }.card-copy-button .copy-glyph { opacity: 1; transform: scale(1); }.card-copy-button .check-glyph { opacity: 0; transform: scale(.72); }.card-copy-button.copied { color: #176b45; }.card-copy-button.copied .copy-glyph { opacity: 0; transform: scale(.72); }.card-copy-button.copied .check-glyph { opacity: 1; transform: scale(1); }.conversation-card > header .card-copy-overlay { position: absolute; z-index: 4; top: 50%; right: calc(100% + 4px); transform: translateY(-50%); padding: 4px 7px; border: 1px solid #344054; border-radius: 6px; background: #1d2633; color: #fff; box-shadow: 0 2px 6px #17203324; font-size: 10px; font-weight: 600; line-height: 1.3; letter-spacing: 0; text-transform: none; white-space: nowrap; pointer-events: none; }.conversation-card > header .card-copy-overlay.failed { border-color: #982f3d; background: #982f3d; }.card-meta .card-fold-button svg { width: 14px; height: 14px; transform: translateX(2px); fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } .conversation-card > header .card-phase.status.active { color: #285fca; }.conversation-card > header .card-phase.status.success { color: #176b45; }.conversation-card > header .card-phase.status.warning { color: #8a5208; }.conversation-card > header .card-phase.status.danger { color: #982f3d; } .conversation-card.collapsed > header { margin-bottom: 0; } .conversation-card.userMessage, .conversation-card.localPrompt { background: #eaf2ff; border-color: #bfd3f9; } @@ -122,9 +122,9 @@ h1, h2, h3, p { margin: 0; } .card-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.55; } .image-ribbon { display: flex; align-items: center; gap: 8px; box-sizing: border-box; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 4px; border: 1px solid #d7dee8; border-radius: 6px; background: #111827; }.image-ribbon > code { flex: 0 0 auto; } .markdown-text { font-size: 14px; } -.safe-markdown { font-size: 14px; line-height: 1.58; overflow-wrap: anywhere; }.safe-markdown p { margin: 0 0 9px; }.safe-markdown > :last-child { margin-bottom: 0; }.safe-markdown h1, .safe-markdown h2, .safe-markdown h3, .safe-markdown h4 { margin: 14px 0 7px; line-height: 1.3; }.safe-markdown h1 { font-size: 19px; }.safe-markdown h2 { font-size: 17px; }.safe-markdown h3 { font-size: 15px; }.safe-markdown h4 { font-size: 14px; }.safe-markdown ul, .safe-markdown ol { margin: 7px 0; padding-left: 24px; }.safe-markdown li + li { margin-top: 3px; }.safe-markdown blockquote { margin: 8px 0; padding: 2px 10px; border-left: 3px solid #c8d2e1; color: #667085; }.safe-markdown table { display: block; width: max-content; max-width: 100%; overflow-x: auto; border-collapse: collapse; }.safe-markdown th, .safe-markdown td { border: 1px solid #d7dee8; padding: 5px 8px; text-align: left; }.safe-markdown th { background: #f4f6f9; }.safe-markdown a { color: #2956bd; }.safe-markdown :not(pre) > code { padding: 1px 4px; border-radius: 4px; background: #edf1f7; color: #b2354c; font-size: .9em; }.markdown-image-reference { color: #667085; font-style: italic; } +.safe-markdown { font-size: 14px; line-height: 1.58; overflow-wrap: anywhere; }.conversation-card.userMessage .safe-markdown { white-space: pre-wrap; }.safe-markdown p { margin: 0 0 9px; }.safe-markdown > :last-child { margin-bottom: 0; }.safe-markdown h1, .safe-markdown h2, .safe-markdown h3, .safe-markdown h4 { margin: 14px 0 7px; line-height: 1.3; }.safe-markdown h1 { font-size: 19px; }.safe-markdown h2 { font-size: 17px; }.safe-markdown h3 { font-size: 15px; }.safe-markdown h4 { font-size: 14px; }.safe-markdown ul, .safe-markdown ol { margin: 7px 0; padding-left: 24px; }.safe-markdown li + li { margin-top: 3px; }.safe-markdown blockquote { margin: 8px 0; padding: 2px 10px; border-left: 3px solid #c8d2e1; color: #667085; }.safe-markdown table { display: block; width: max-content; max-width: 100%; overflow-x: auto; border-collapse: collapse; }.safe-markdown th, .safe-markdown td { border: 1px solid #d7dee8; padding: 5px 8px; text-align: left; }.safe-markdown th { background: #f4f6f9; }.safe-markdown a { color: #2956bd; }.safe-markdown :not(pre) > code { padding: 1px 4px; border-radius: 4px; background: #edf1f7; color: #b2354c; font-size: .9em; }.markdown-image-reference { color: #667085; font-style: italic; } .command-line, .command-output, .conversation-card pre { display: block; max-height: 220px; overflow: auto; border-radius: 7px; background: #111827; color: #d6deeb; padding: 10px 12px; font-family: "SFMono-Regular", Consolas, monospace; font-size: 11px; white-space: pre-wrap; } -.conversation-card pre.command-line { max-height: 90px; }.command-line code, .command-output code { font: inherit; white-space: inherit; } +.conversation-card pre.command-line { max-height: 90px; }.conversation-card pre.command-output { box-sizing: border-box; max-height: 212px; padding: 4px 12px; line-height: 17px; }.command-line code, .command-output code { font: inherit; white-space: inherit; } .conversation-card > small, .card-status { display: block; margin-top: 8px; color: #667085; }.card-status.success { color: #178857; }.card-status.warning { color: #a86815; }.card-status.danger { color: #bd3445; }.card-status.active { color: #315ccf; } .activity-line { display: flex; align-items: center; gap: 8px; color: #667085; font-size: 13px; } .activity-line i { width: 14px; height: 14px; border: 2px solid #bac7e7; border-top-color: #315ccf; border-radius: 50%; animation: spin .8s linear infinite; } diff --git a/web/tests/browser-session-parity.test.mjs b/web/tests/browser-session-parity.test.mjs index 520d968..4ba5627 100644 --- a/web/tests/browser-session-parity.test.mjs +++ b/web/tests/browser-session-parity.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import {BrowserFrontendSession} from "../dist/app/BrowserFrontendSession.js"; +import {BrowserFrontendSession, suggestForkName} from "../dist/app/BrowserFrontendSession.js"; import {cardKeys, result, stableKey} from "../dist/index.js"; class FakeSocket { @@ -102,6 +102,8 @@ test("user thread operations are single-flight and report failures", async () => assert.equal(session.operationPending("thread.rename", "thread-1"), false); assert.match(session.getSnapshot().notice, /Rename thread failed: rename denied/u); + respond(socket, requests(socket, "thread/list").at(-1), {data: [], nextCursor: null}); + await Promise.resolve(); await Promise.resolve(); const listsBefore = requests(socket, "thread/list").length; session.requestThreads(); session.requestThreads(); assert.equal(requests(socket, "thread/list").length, listsBefore + 1); @@ -114,6 +116,122 @@ test("user thread operations are single-flight and report failures", async () => session.dispose(); }); +test("thread catalog loads fast, repairs in the background, and pages on demand", async () => { + const socket = new FakeSocket(); + const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); + session.connect(); socket.open(); await readyProvider(socket, "thread-pages"); + + const first = requests(socket, "thread/list").at(-1); + assert.deepEqual(first.payload.params, + {sortKey: "recency_at", sortDirection: "desc", limit: 100, useStateDbOnly: true}); + respond(socket, first, {data: [{id: "newest", recencyAt: 30}], nextCursor: "discarded-fast-cursor"}); + await waitForPublish(); + const repair = requests(socket, "thread/list").at(-1); + assert.notEqual(repair, first); + assert.deepEqual(repair.payload.params, + {sortKey: "recency_at", sortDirection: "desc", limit: 100, useStateDbOnly: false}); + respond(socket, repair, {data: [ + {id: "repaired", recencyAt: 20}, {id: "newest", recencyAt: 30}, + ], nextCursor: "older-page"}); + await Promise.resolve(); await Promise.resolve(); + session.loadMoreThreads(); + const older = requests(socket, "thread/list").at(-1); + assert.notEqual(older, repair); + assert.deepEqual(older.payload.params, { + sortKey: "recency_at", sortDirection: "desc", limit: 100, + useStateDbOnly: true, cursor: "older-page", + }); + respond(socket, older, {data: [{id: "oldest", recencyAt: 10}], nextCursor: null}); + await Promise.resolve(); await Promise.resolve(); + + assert.deepEqual(session.threadOrder(), ["newest", "repaired", "oldest"]); + session.dispose(); +}); + +test("thread paging falls back after repair failure and retries transient page errors", async () => { + const socket = new FakeSocket(); + const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); + session.connect(); socket.open(); await readyProvider(socket, "thread-page-recovery"); + + const first = requests(socket, "thread/list").at(-1); + respond(socket, first, {data: [{id: "newest", recencyAt: 30}], nextCursor: "fast-page"}); + await waitForPublish(); + const repair = requests(socket, "thread/list").at(-1); + reject(socket, repair, "repair unavailable"); + await Promise.resolve(); await Promise.resolve(); + + session.loadMoreThreads(); + const page = requests(socket, "thread/list").at(-1); + assert.equal(page.payload.params.cursor, "fast-page", + "failed reconciliation retains the DB-only fast cursor"); + reject(socket, page, "temporary paging failure"); + await Promise.resolve(); await Promise.resolve(); + + session.loadMoreThreads(); + const retry = requests(socket, "thread/list").at(-1); + assert.notEqual(retry, page); + assert.equal(retry.payload.params.cursor, "fast-page", + "a failed page does not consume its retry cursor"); + respond(socket, retry, {data: [{id: "older", recencyAt: 20}], nextCursor: null}); + await Promise.resolve(); await Promise.resolve(); + assert.deepEqual(session.threadOrder(), ["newest", "older"]); + session.dispose(); +}); + +test("the first prompt after a successful fork starts without redundant hydration", async () => { + const socket = new FakeSocket(); + const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); + session.connect(); socket.open(); await readyProvider(socket, "fork-first-prompt"); + respond(socket, requests(socket, "thread/list").at(-1), {data: [ + {id: "fork-source", preview: "Fork source", status: {type: "idle"}}, + ], nextCursor: null}); + await Promise.resolve(); await Promise.resolve(); + + session.forkThread("fork-source"); + const fork = requests(socket, "thread/fork").at(-1); + assert.ok(fork); + assert.deepEqual(fork.payload.params, {threadId: "fork-source"}, + "Quick fork changes no copied thread options and never sends a name field"); + const observedForkTitles = []; + const unsubscribe = session.subscribe(() => { + const title = session.model.thread("fork-result")?.title; + if (title !== undefined) observedForkTitles.push(title); + }); + respond(socket, fork, {thread: { + id: "fork-result", forkedFromId: "fork-source", preview: "Fork result", + status: {type: "idle"}, turns: [], + }}); + await Promise.resolve(); await Promise.resolve(); + + assert.equal(session.getSnapshot().selectedThreadId, "fork-result"); + assert.equal(session.model.thread("fork-result")?.title, "Fork source (fork 1)", + "the automatic chosen name replaces the provider title immediately"); + assert.deepEqual([...new Set(observedForkTitles)], ["Fork source (fork 1)"], + "no published frame exposes the provider title or thread ID"); + unsubscribe(); + const rename = requests(socket, "thread/name/set").at(-1); + assert.ok(rename); + assert.deepEqual(rename.payload.params, + {threadId: "fork-result", name: "Fork source (fork 1)"}); + respond(socket, rename, {}); + socket.receive(appserver({jsonrpc: "2.0", method: "thread/name/updated", params: { + threadId: "fork-result", threadName: "Fork source (fork 1)", + }})); + assert.equal(session.model.thread("fork-result")?.title, "Fork source (fork 1)", + "the chosen name survives its app-server acknowledgement"); + assert.equal(requests(socket, "thread/read").length, 0, + "thread/fork already returns the loaded and subscribed thread"); + assert.equal(await session.submitPrompt("answer this fork"), true); + await Promise.resolve(); await Promise.resolve(); + const start = requests(socket, "turn/start").at(-1); + assert.ok(start); + assert.equal(start.payload.params.threadId, "fork-result"); + assert.equal(start.payload.params.input[0].text, "answer this fork"); + assert.equal(requests(socket, "thread/resume").length, 0, + "the fork prompt is never gated behind a redundant resume"); + session.dispose(); +}); + test("browser session uses the C++ action routing and preserves prompt-response order", async () => { const socket = new FakeSocket(); const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); @@ -133,15 +251,18 @@ test("browser session uses the C++ action routing and preserves prompt-response assert.deepEqual(read.payload.params, {threadId: "thread-1", includeTurns: true}); respond(socket, read, {thread: {id: "thread-1", preview: "Browser parity", cwd: "/workspace", status: {type: "idle"}, turns: []}}); - assert.equal(await session.submitPrompt("new prompt"), true); + const authoredPrompt = " first authored line\n\nthird authored line\n\n"; + assert.equal(await session.submitPrompt(authoredPrompt), true); await Promise.resolve(); const start = requests(socket, "turn/start").at(-1); assert.ok(start); assert.equal(start.payload.params.threadId, "thread-1"); - assert.equal(start.payload.params.input[0].text, "new prompt"); + assert.equal(start.payload.params.input[0].text, authoredPrompt); assert.match(start.payload.params.clientUserMessageId, /^codexui-/u); assert.equal(session.conversation().sections[0].cards[0].payload.showPendingAnimation, false, "newly admitted prompts begin without motion"); + assert.equal(session.conversation().sections[0].cards[0].payload.prompt, authoredPrompt, + "the optimistic card retains all authored blank lines"); await new Promise(resolve => setTimeout(resolve, 1050)); assert.equal(session.conversation().sections[0].cards[0].payload.showPendingAnimation, true, "the session republishes delayed feedback after one second"); @@ -155,7 +276,7 @@ test("browser session uses the C++ action routing and preserves prompt-response socket.receive(appserver({jsonrpc: "2.0", method: "item/started", params: { threadId: "thread-1", turnId: "turn-1", item: { id: "user-1", type: "userMessage", clientId: start.payload.params.clientUserMessageId, - content: [{type: "text", text: "new prompt"}], + content: [{type: "text", text: authoredPrompt}], }, }})); respond(socket, start, {turn: {id: "turn-1", status: "inProgress"}}); @@ -169,6 +290,8 @@ test("browser session uses the C++ action routing and preserves prompt-response assert.equal(session.model.connection().providerState, "ready"); assert.equal(session.conversation().sections[0].cards[0].kind, "userMessage", "correlated acknowledgement materializes without a post-ack timer"); + assert.equal(session.conversation().sections[0].cards[0].payload.text, authoredPrompt, + "the acknowledged card retains all authored blank lines"); session.dispose(); }); @@ -269,6 +392,9 @@ test("new threads retain one optimistic row through first-turn acknowledgment", respond(socket, create, {thread: {id: "created-thread", name: "Created thread", cwd: "/workspace", status: {type: "idle"}}}); assert.equal(await submitted, true); await Promise.resolve(); + assert.equal(session.getSnapshot().optimisticThreads[0]?.title, "Named draft", + "the canonical thread handoff never substitutes the provider name or UUID"); + assert.equal(session.model.thread("created-thread")?.title, "Named draft"); const rename = requests(socket, "thread/name/set").at(-1); assert.equal(rename?.payload.params.name, "Named draft"); respond(socket, rename, {}); @@ -286,6 +412,14 @@ test("new threads retain one optimistic row through first-turn acknowledgment", assert.notEqual(session.getSnapshot().optimisticThreads[0]?.state, "awaiting"); assert.equal(session.threadVisualKey("created-thread"), draft?.visualKey, "canonical styling retains the optimistic row's React identity"); + assert.equal(session.model.thread("created-thread")?.title, "Named draft", + "turn acknowledgement leaves the chosen name intact"); + socket.receive(appserver({jsonrpc: "2.0", method: "thread/name/updated", params: { + threadId: "created-thread", threadName: "Named draft", + }})); + assert.equal(session.model.thread("created-thread")?.localNameOverlay, undefined); + assert.equal(session.model.thread("created-thread")?.title, "Named draft", + "matching name acknowledgement retires the overlay without changing the title"); session.dispose(); }); @@ -315,55 +449,79 @@ test("new-thread completion preserves later navigation and explicit drafts start session.dispose(); }); -test("thread ordering is numeric-first and naturally alphanumeric", () => { +test("thread ordering exposes Alphanumeric, Created, and Recent contracts", () => { const session = new BrowserFrontendSession("ws://bridge.test/", () => new FakeSocket()); session.model.applyEvent(result(96, 1, "threads.list", "list", true, {threads: [ - {id: "beta", name: "Beta"}, {id: "ten", name: "10 tasks"}, - {id: "alpha", name: "alpha"}, {id: "two", name: "2 tasks"}, + {id: "missing", name: "2 tasks", createdAt: 100, updatedAt: 1000}, + {id: "old", name: "20 tasks", recencyAt: 10, createdAt: 200, updatedAt: 2000}, + {id: "new", name: "Alpha", recencyAt: 30, createdAt: 1, updatedAt: 2}, ]}, "merge")); - assert.deepEqual(session.threadOrder("alphanumeric"), ["two", "ten", "alpha", "beta"]); + assert.deepEqual(session.threadOrder("recent"), ["new", "old", "missing"]); + assert.deepEqual(session.threadOrder("created"), ["old", "missing", "new"]); + assert.deepEqual(session.threadOrder("alphanumeric"), ["missing", "old", "new"]); session.dispose(); }); -test("thread ordering uses newest creation time and leaves missing values last", () => { +test("a new turn in a child thread promotes its root Recent group", () => { const session = new BrowserFrontendSession("ws://bridge.test/", () => new FakeSocket()); session.model.applyEvent(result(97, 1, "threads.list", "list", true, {threads: [ - {id: "missing"}, {id: "old", createdAt: 10}, {id: "new", createdAt: 30}, + {id: "newer-root", name: "Newer", recencyAt: 30}, + {id: "older-root", name: "Older", recencyAt: 20}, + {id: "child", name: "Child", parentThreadId: "older-root", recencyAt: 10}, ]}, "merge")); - assert.deepEqual(session.threadOrder("created"), ["new", "old", "missing"]); + assert.deepEqual(session.threadOrder(), ["newer-root", "older-root"]); + session.prompts.admit("child", "child turn", [], {}, session.model.thread("child"), undefined, 40_000, 31); + assert.deepEqual(session.threadOrder(), ["older-root", "newer-root"]); session.dispose(); }); -test("thread ordering uses prompt activity as natural newest update time", async () => { +test("fork names preserve root and nested lineage without collisions", () => { + const titles = ["Original", "Original (fork 1)", "Original (fork 2)", + "Original (fork 1.1)", "Original (fork 1.3)", "Original (fork 1.1.1)"]; + assert.equal(suggestForkName("Original", titles), "Original (fork 3)"); + assert.equal(suggestForkName("Original (fork 1)", titles), "Original (fork 1.2)"); + assert.equal(suggestForkName("Original (fork 1.1)", titles), "Original (fork 1.1.2)"); + assert.equal(suggestForkName("Separate", titles), "Separate (fork 1)"); +}); + +test("Fork with options sends adjustable fork fields and names separately", async () => { const socket = new FakeSocket(); const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); - session.connect(); socket.open(); await readyProvider(socket, "updated-promotion"); - session.model.applyEvent(result(98, 1, "threads.list", "list", true, {threads: [ - {id: "old", updatedAt: 10}, {id: "new", updatedAt: 30}, - ]}, "merge")); - assert.deepEqual(session.threadOrder("updated"), ["new", "old"]); - const realNow = Date.now; - try { - Date.now = () => 40_000; - session.selectThread("old"); await session.submitPrompt("change old thread"); - assert.deepEqual(session.threadOrder("updated"), ["old", "new"]); - Date.now = () => 40_000; - session.selectThread("new"); await session.submitPrompt("change new thread"); - assert.deepEqual(session.threadOrder("updated"), ["new", "old"], - "the prior locally changed thread retains its timestamp behind the latest one"); - assert.deepEqual(session.threadOrder("created"), ["new", "old"], "activity does not affect Created"); - } finally { Date.now = realNow; } + session.connect(); socket.open(); await readyProvider(socket, "fork-options"); + respond(socket, requests(socket, "thread/list").at(-1), {data: [ + {id: "source", name: "Original (fork 1)", cwd: "/old"}, + {id: "child", name: "Original (fork 1.1)", cwd: "/old"}, + ], nextCursor: null}); + await Promise.resolve(); await Promise.resolve(); + assert.equal(session.forkDraft("source").name, "Original (fork 1.2)"); + + session.forkThread("source", { + workspace: "/new", name: "Chosen fork", baseInstructions: "Base", + developerInstructions: "Developer", ephemeral: true, + }); + const fork = requests(socket, "thread/fork").at(-1); + assert.deepEqual(fork.payload.params, { + threadId: "source", cwd: "/new", baseInstructions: "Base", + developerInstructions: "Developer", ephemeral: true, + }); + respond(socket, fork, {thread: {id: "advanced", name: "Provider name", cwd: "/new"}}); + await Promise.resolve(); await Promise.resolve(); + assert.equal(session.model.thread("advanced")?.title, "Chosen fork"); + assert.deepEqual(requests(socket, "thread/name/set").at(-1).payload.params, + {threadId: "advanced", name: "Chosen fork"}); session.dispose(); }); -test("thread ordering retains every locally prompted thread by natural recency", async () => { +test("Recent promotes on turn admission, reverts rejection, confirms acknowledgement, and shares prompt animation", async () => { const socket = new FakeSocket(); const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); session.connect(); socket.open(); await readyProvider(socket, "promotion"); - session.model.applyEvent(result(100, 1, "threads.list", "list", true, {threads: [ - {id: "older", recencyAt: 10}, {id: "recent", recencyAt: 30}, - ]}, "merge")); + respond(socket, requests(socket, "thread/list").at(-1), {data: [ + {id: "older", recencyAt: 10, updatedAt: 12, status: {type: "idle"}}, + {id: "recent", recencyAt: 30, updatedAt: 32, status: {type: "idle"}}, + ], nextCursor: null}); + await Promise.resolve(); await Promise.resolve(); assert.deepEqual(session.threadOrder(), ["recent", "older"], "Recent is explicit newest-first ordering"); @@ -371,22 +529,61 @@ test("thread ordering retains every locally prompted thread by natural recency", try { Date.now = () => 40_000; session.selectThread("older"); + respond(socket, requests(socket, "thread/read").at(-1), {thread: { + id: "older", recencyAt: 10, updatedAt: 12, status: {type: "idle"}, turns: [], + }}); + await Promise.resolve(); await Promise.resolve(); await session.submitPrompt("use older thread"); + await Promise.resolve(); + const rejectedStart = requests(socket, "turn/start").at(-1); assert.deepEqual(session.threadOrder(), ["older", "recent"], "the admitted prompt updates its thread recency immediately"); + assert.equal(session.threadPromptAnimating("older"), false, + "the thread card and Turn/You card share the calm delay"); + Date.now = () => 41_001; + assert.equal(session.threadPromptAnimating("older"), true, + "the thread card begins motion at the Turn/You animation deadline"); + reject(socket, rejectedStart, "prompt rejected"); + await Promise.resolve(); await Promise.resolve(); + assert.deepEqual(session.threadOrder(), ["recent", "older"], + "a rejected admission removes its optimistic recency"); + assert.equal(session.threadPromptAnimating("older"), false, + "the exact failed prompt stops both animations"); Date.now = () => 40_000; + await session.submitPrompt("confirm older thread"); + await Promise.resolve(); + const acceptedOlder = requests(socket, "turn/start").at(-1); + respond(socket, acceptedOlder, {turn: {id: "older-turn", status: "inProgress"}}); + await Promise.resolve(); await Promise.resolve(); + assert.deepEqual(session.threadOrder(), ["older", "recent"], + "acknowledgement confirms the admitted turn order"); + assert.equal(session.model.thread("older").recencyAt, 10, + "client confirmation preserves app-server recency as provider data"); + assert.equal(session.model.thread("older").updatedAt, 12, + "turn ordering never rewrites last-changed data"); + session.selectThread("recent"); - await session.submitPrompt("use recent thread"); + respond(socket, requests(socket, "thread/read").at(-1), {thread: { + id: "recent", recencyAt: 30, updatedAt: 32, status: {type: "idle"}, turns: [], + }}); + await Promise.resolve(); await Promise.resolve(); + await session.submitPrompt("confirm recent thread"); + await Promise.resolve(); assert.deepEqual(session.threadOrder(), ["recent", "older"], - "a later prompt does not erase the prior thread's local recency"); + "same-clock admissions still follow their monotonic admission order"); + respond(socket, requests(socket, "turn/start").at(-1), + {turn: {id: "recent-turn", status: "inProgress"}}); + await Promise.resolve(); await Promise.resolve(); + assert.deepEqual(session.threadOrder(), ["recent", "older"]); } finally { Date.now = realNow; } session.model.applyEvent(result(101, 1, "thread.read", "read", true, {thread: {id: "older", recencyAt: 11}}, "merge", {threadId: "older"})); assert.deepEqual(session.threadOrder(), ["recent", "older"], "stale authoritative recency cannot undo newer local activity"); - assert.equal(session.model.thread("older").recencyAt, 40); + assert.equal(session.model.thread("older").recencyAt, 11); + assert.ok(session.model.thread("older").localPromptActivityAt > 30); session.dispose(); }); @@ -423,7 +620,7 @@ test("thread activity preserves provider time during hydration and advances for "meaningful thread responses advance local activity"); assert.equal(session.model.thread("tracked").updatedAt, 20); assert.equal(session.model.thread("tracked").recencyAt, 30, - "non-prompt traffic does not reorder Recent or Last changed"); + "non-prompt traffic does not reorder Recent or rewrite provider timestamps"); session.model.thread("tracked").lastActivityAt = 1; const beforeInbound = Math.floor(Date.now() / 1000); diff --git a/web/tests/card-copy.test.mjs b/web/tests/card-copy.test.mjs index 52ed312..3ba9206 100644 --- a/web/tests/card-copy.test.mjs +++ b/web/tests/card-copy.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import {createElement} from "react"; import {renderToStaticMarkup} from "react-dom/server"; -import {Card, InspectorAgentCard, cardCopyContent} from "../dist/app/App.js"; +import {Card, InspectorAgentCard, cardCopyContent, userMessageMarkdownText} from "../dist/app/App.js"; function itemCard(kind, itemId, payload) { return { @@ -103,6 +103,22 @@ test("nested user messages expose the steering identity", () => { assert.doesNotMatch(markup, /[·•]\s*steering/u); }); +test("normal and steering You cards retain authored prompt line breaks", () => { + const source = "First authored line\n\nThird authored line"; + const user = itemCard("userMessage", "multiline", { + text: source, imagePaths: [], + }); + for (const nestedCard of [false, true]) { + const markup = renderToStaticMarkup(createElement(Card, { + card: user, active: nestedCard, collapsed: false, nestedCard, + onToggle() {}, + })); + assert.match(markup, /First authored line[\s\S]*[\s\S]*\u200B[\s\S]*[\s\S]*Third authored line/u); + } + assert.deepEqual(cardCopyContent(user), {text: source, markdown: true}); + assert.equal(userMessageMarkdownText(source), "First authored line \n\u200B \nThird authored line"); +}); + test("local prompt sweep is rendered only after delayed feedback activates", () => { const card = showPendingAnimation => itemCard("localPrompt", "local", { submissionId: 8, prompt: "pending", state: "inFlight", diff --git a/web/tests/model-parity.test.mjs b/web/tests/model-parity.test.mjs index fe27f4c..4610b97 100644 --- a/web/tests/model-parity.test.mjs +++ b/web/tests/model-parity.test.mjs @@ -183,9 +183,11 @@ test("C++ child ownership, correlation, replacement, and removal invariants", () assert.deepEqual(model.threadOrder(), ["parent", "second-root"]); assert.deepEqual(model.childOwnership("child-one"), {parentThreadId: "parent", agentId: "spawn-one"}); model.notePromptActivity("grandchild", 100); - assert.equal(model.thread("grandchild").recencyAt, 100); - assert.equal(model.thread("child-one").recencyAt, 100); - assert.equal(model.thread("parent").recencyAt, 100); + assert.equal(model.thread("grandchild").localPromptActivityAt, 100); + assert.equal(model.thread("child-one").localPromptActivityAt, 100); + assert.equal(model.thread("parent").localPromptActivityAt, 100); + assert.equal(model.thread("grandchild").recencyAt, undefined, + "local confirmation does not rewrite the provider's recency value"); model.applyEvent(event(5, 1, "agents.activity.upsert", {activity: { id: "peer", type: "subAgentActivity", kind: "interacted", agentPath: "/root/child-two", agentThreadId: "child-two", diff --git a/web/tests/qualification.test.mjs b/web/tests/qualification.test.mjs index b1a40db..5af87c0 100644 --- a/web/tests/qualification.test.mjs +++ b/web/tests/qualification.test.mjs @@ -56,6 +56,9 @@ test("server-rendered shell exposes keyboard and landmark semantics", () => { assert.match(markup, /aria-label="New command cards start expanded"/u); assert.match(markup, /aria-label="New image cards start expanded"/u); assert.match(markup, /aria-label="Message Codex"/u); + assert.match(markup, /aria-label="Thread sort order"/u); + assert.match(markup, /