diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 6fc193a..fb9c5ad 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -49,12 +49,15 @@ 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. - The same live activity advances the presentation model's effective - `updatedAt` and `recencyAt` values, while the provider payload remains - retained as received. These local values are not persisted by CodexUI. -- Once selected, a hydrated thread remains visible in the sidebar for the - session even when it is outside the ordinary top-level thread ordering; an - authoritative removal still removes it. + Live traffic does not alter thread ordering. Only local prompt admission + advances the presentation model's effective `updatedAt` and `recencyAt`; + these local values are not persisted by CodexUI. +- 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` @@ -109,7 +112,12 @@ the same admission path as the Send button. Shift+Enter always inserts a new line, including when Control or Meta is also held; Control+Enter and Meta+Enter remain submission aliases, while Alt+Enter does not submit. Auto-repeated Enter events and Enter used to confirm an active input-method composition never -submit a prompt. +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. Whitespace is used only for admission +validation: the exact authored text, including intentional leading and trailing +space and blank lines, is passed to the submission path unchanged. 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 @@ -213,6 +221,8 @@ updates remain neutral and identify their phase in the header. Process cards also remain neutral so they support rather than dominate the primary exchange. Their lifecycle status is a normal-weight lowercase value at the right of the header, immediately before Copy, and uses canonical semantic state colors. +An `imageView` item is completed once it materializes; generated-image and +fallback activity cards retain any lifecycle state supplied by the app-server. Thread rows, conversation metadata, Inspector entries, and process cards share the same vocabulary: `running`, `completed`, `failed`, `interrupted`, `pending`, and `not loaded`. Command exit @@ -271,6 +281,8 @@ Web thread refresh, rename, fork, archive, and delete actions are single-flight. Mutation controls require current controller readiness, remain disabled while their operation is pending, and report operation failures through the canonical notice surface. +Transient notices overlay the conversation workspace in native and WebUI. Their +appearance, timeout, and dismissal never resize or reposition message content. Folding is an explicit geometry transaction. Collapsing keeps the selected title row fixed while the natural scroll range permits and shifts following @@ -337,28 +349,40 @@ re-enable following. Composer contraction is the explicit exception: after its trailing space is removed, CodexUI recomputes whether the resulting clamped position is the new bottom. -The complete center region is wheel- and touchpad-scroll sensitive. Wheel -events over non-scrollable center chrome and the horizontal splitter handles -are forwarded to the message view. Command text and output retain a gesture -that started while they could scroll; only a fresh gesture begun at their -current boundary is handed to the conversation. +The complete unobscured center region is wheel- and touchpad-scroll sensitive. +Wheel events over non-scrollable conversation chrome and the horizontal +splitter handles are forwarded to the message view. A gesture begun in the +prompt editor or turn-settings surface is always consumed by that composer +region and never scrolls the conversation behind it. Command text and output +retain a gesture that started while they could scroll; only a fresh gesture +begun at their current boundary is handed to the conversation. ## Composer geometry The upcoming-turn controls are anchored to the bottom of the center pane. The prompt editor starts at one line, grows upward for multiline input, and stops at -its configured maximum height, after which it scrolls internally. +its configured maximum height, after which it scrolls internally. While all +content fits, its hidden scrollbar is clamped to the top so a fully visible +multiline draft cannot be displaced by a trailing blank-line offset. The +compact-to-multiline transition is decided by an invisible `QTextLayout` using +the editor's exact compact content width, including its document margins. The +live document is never resized for measurement, and its height is updated only +after the grid switch, so the first wrapping character moves directly into the +expanded grid without an intermediate row. The message-view layout reserves only the composer's canonical height. When prompt text, attachments, settings, or attention controls increase that height, the composer grows upward as an overlay: the viewport keeps its normal geometry and may be partly covered. An equal logical trailing extent is added to the scrollable conversation content so the final card can still be moved to the -overlay boundary. The conversation owns no permanent bottom padding; the moving -composer uses the canonical Changes-tab treatment of 8 px space, a standard -divider extending 10 px beyond the adjacent content on each side, and another -8 px space. This boundary remains identical whether the conversation is at its -bottom or paused higher in history. +overlay boundary. The visible conversation scrollbar track is inset by the same +extra height, so its lower endpoint remains at the uncovered message boundary +rather than disappearing beneath the composer. Its value, range, and anchoring +semantics remain those of the full conversation. The conversation owns no +permanent bottom padding; the moving composer uses the canonical Changes-tab +treatment of 8 px space, a standard divider extending 10 px beyond the adjacent +content on each side, and another 8 px space. This boundary remains identical +whether the conversation is at its bottom or paused higher in history. Growing this extent preserves the current scrollbar value and does not move the messages automatically. Reaching its new maximum re-enables bottom-follow for @@ -377,6 +401,8 @@ 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. 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; +tail-following belongs only to the streaming output surface. Their wrapped content height is measured at the final viewport width during the outer layout transaction. While the conversation follows its bottom, streaming output growth holds the card bottom and metadata in place and expands upward. diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index e4a2825..0926c87 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -406,10 +406,6 @@ void PresentationModel::noteThreadActivity(const std::string &threadId, break; ThreadPresentation &thread = iterator->second; retainActivity(thread, timestamp); - if (!thread.updatedAt || timestamp > *thread.updatedAt) - thread.updatedAt = timestamp; - if (!thread.recencyAt || timestamp > *thread.recencyAt) - thread.recencyAt = timestamp; const auto ownership = childOwnerships.find(current); if (ownership == childOwnerships.end()) break; @@ -426,7 +422,21 @@ void PresentationModel::notePromptActivity(const std::string &threadId, if (thread.recencyAt && *thread.recencyAt >= timestamp) timestamp = *thread.recencyAt + 1; } - noteThreadActivity(threadId, timestamp); + std::string current = threadId; + std::unordered_set visited; + while (!current.empty() && visited.insert(current).second) { + const auto iterator = threads.find(current); + if (iterator == threads.end()) + break; + ThreadPresentation &thread = iterator->second; + retainActivity(thread, timestamp); + thread.updatedAt = timestamp; + thread.recencyAt = timestamp; + const auto ownership = childOwnerships.find(current); + if (ownership == childOwnerships.end()) + break; + current = ownership->second.parentThreadId; + } } void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { @@ -655,7 +665,7 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { if (authority == "none" || authority == "remove") return; nlohmann::json minimal{{"id", threadId}}; - upsertThread(minimal, false); + upsertThread(minimal, false, false); threadIterator = threads.find(threadId); if (threadIterator == threads.end()) return; @@ -864,11 +874,8 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, } auto [iterator, inserted] = threads.try_emplace(id); ThreadPresentation &result = iterator->second; - if (inserted) { + if (inserted) result.id = id; - if (prependNewThread) - orderedThreads.insert(orderedThreads.begin(), id); - } const std::string previousThreadStatus = result.status; std::unordered_map terminalTurnStatuses; if (replaceTurns) { @@ -908,11 +915,32 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, retainActivity(result, *result.recencyAt); result.archived = boolValue(raw, "archived", result.archived); + if (raw.contains("parentThreadId")) { + const std::string parentThreadId = stringValue(raw, "parentThreadId"); + if (!parentThreadId.empty()) + retainStructuralOwnership(id, parentThreadId); + else { + const auto ownership = childOwnerships.find(id); + if (ownership != childOwnerships.end() && + ownership->second.agentId.empty()) + releaseChildOwnership(id, false); + } + } + if (prependNewThread && !childOwnerships.contains(id) && + std::find(orderedThreads.begin(), orderedThreads.end(), id) == + orderedThreads.end()) + orderedThreads.insert(orderedThreads.begin(), id); + const auto turns = raw.find("turns"); if (turns != raw.end() && turns->is_array()) { std::vector previouslyOwnedChildren; if (replaceTurns) { - previouslyOwnedChildren = result.childThreadOrder; + for (const std::string &childThreadId : result.childThreadOrder) { + const auto ownership = childOwnerships.find(childThreadId); + if (ownership != childOwnerships.end() && + !ownership->second.agentId.empty()) + previouslyOwnedChildren.push_back(childThreadId); + } for (const std::string &childThreadId : previouslyOwnedChildren) releaseChildOwnership(childThreadId, false); result.turnOrder.clear(); @@ -1184,6 +1212,32 @@ void PresentationModel::assignChildOwnership(ThreadPresentation &parent, synchronizeOwningAgent(childThreadId); } +void PresentationModel::retainStructuralOwnership( + const std::string &childThreadId, const std::string &parentThreadId) { + if (childThreadId.empty() || parentThreadId.empty() || + childThreadId == parentThreadId) + return; + const auto existing = childOwnerships.find(childThreadId); + if (existing != childOwnerships.end() && + existing->second.parentThreadId == parentThreadId) + return; + if (existing != childOwnerships.end()) + releaseChildOwnership(childThreadId, false); + + auto [parent, parentInserted] = threads.try_emplace(parentThreadId); + if (parentInserted) + parent->second.id = parentThreadId; + auto [child, childInserted] = threads.try_emplace(childThreadId); + if (childInserted) + child->second.id = childThreadId; + childOwnerships[childThreadId] = {parentThreadId, {}}; + if (std::find(parent->second.childThreadOrder.begin(), + parent->second.childThreadOrder.end(), childThreadId) == + parent->second.childThreadOrder.end()) + parent->second.childThreadOrder.push_back(childThreadId); + std::erase(orderedThreads, childThreadId); +} + void PresentationModel::releaseChildOwnership(const std::string &childThreadId, bool promoteToRoot) { const std::string releasedChildId = childThreadId; diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index c8f954b..402aa77 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -150,6 +150,8 @@ class PresentationModel final { void assignChildOwnership(ThreadPresentation &parent, AgentPresentation &agent, const std::string &childThreadId, bool live); + void retainStructuralOwnership(const std::string &childThreadId, + const std::string &parentThreadId); void releaseChildOwnership(const std::string &childThreadId, bool promoteToRoot); void synchronizeOwningAgent(const std::string &childThreadId, diff --git a/src/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp index a7e1f23..b1ef34c 100644 --- a/src/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -186,6 +186,7 @@ ComposerPane::ComposerPane(QWidget *anchor) promptEditor_ = new codexui::ExpandingPromptEditor(composerBody_); sendButton_ = new QPushButton(QStringLiteral("Send"), composerBody_); + sendButton_->setObjectName(QStringLiteral("composerSendButton")); sendButton_->setProperty("kind", "primary"); sendButton_->setToolTip(QStringLiteral("Send prompt (Enter)")); sendButton_->setFixedSize(62, ControlHeight); @@ -204,9 +205,15 @@ ComposerPane::ComposerPane(QWidget *anchor) connect(promptEditor_, &codexui::ExpandingPromptEditor::submitRequested, this, [this] { submitDraft(); }); connect(promptEditor_, &QPlainTextEdit::textChanged, this, [this] { + refreshSubmissionEnabled(); refreshAdaptiveLayout(); synchronizeGeometry(); }); + connect(promptEditor_, &codexui::ExpandingPromptEditor::focusStateChanged, + this, [this](bool focused) { + composer_->setProperty("focused", focused); + repolish(composer_); + }); connect(promptEditor_, &codexui::ExpandingPromptEditor::editorHeightChanged, this, [this](int) { refreshAdaptiveLayout(); @@ -222,6 +229,7 @@ ComposerPane::ComposerPane(QWidget *anchor) }); refreshAttachments(); + refreshSubmissionEnabled(); synchronizeGeometry(); QTimer::singleShot(0, this, [this] { // The compact reserve is measured only after the splitter has assigned @@ -306,7 +314,8 @@ void ComposerPane::setCanSubmit(bool canSubmit) { // Admission never locks or greys the editor; independent prompts may be // entered while earlier submissions await their real app-server callback. promptEditor_->setEnabled(true); - sendButton_->setEnabled(canSubmit); + canSubmit_ = canSubmit; + refreshSubmissionEnabled(); attachmentButton_->setEnabled(canSubmit); for (QPushButton *button : attachmentPanel_->findChildren()) button->setEnabled(true); @@ -395,8 +404,9 @@ bool ComposerPane::eventFilter(QObject *watched, QEvent *event) { } void ComposerPane::submitDraft() { - const QString prompt = promptEditor_->toPlainText().trimmed(); - if (prompt.isEmpty() || !sendButton_->isEnabled() || !actions_.submit) + const QString prompt = promptEditor_->toPlainText(); + if (prompt.trimmed().isEmpty() || !sendButton_->isEnabled() || + !actions_.submit) return; std::vector attachments = attachments_; if (actions_.submit(prompt, std::move(attachments))) @@ -502,4 +512,9 @@ void ComposerPane::refreshActionStyle() { repolish(sendButton_); } +void ComposerPane::refreshSubmissionEnabled() { + sendButton_->setEnabled( + canSubmit_ && !promptEditor_->toPlainText().trimmed().isEmpty()); +} + } // namespace codexui::codex::middle diff --git a/src/codex/middle/ComposerPane.h b/src/codex/middle/ComposerPane.h index 39dc21d..60f3c39 100644 --- a/src/codex/middle/ComposerPane.h +++ b/src/codex/middle/ComposerPane.h @@ -81,6 +81,7 @@ class ComposerPane final : public QWidget { void refreshAttachments(); void refreshAdaptiveLayout(); void refreshActionStyle(); + void refreshSubmissionEnabled(); QWidget *anchor_ = nullptr; QWidget *reserve_ = nullptr; @@ -108,6 +109,7 @@ class ComposerPane final : public QWidget { int canonicalHeight_ = 0; int extraHeight_ = 0; bool activeTurn_ = false; + bool canSubmit_ = false; bool expanded_ = false; bool synchronizing_ = false; bool canonicalCaptureEnabled_ = false; diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 33f76a6..ee39da6 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -59,7 +59,7 @@ constexpr int ViewerMaximumImageExtent = 4096; constexpr qsizetype MaximumGenericActivityCharacters = 4096; constexpr int CardHeaderActionSpacing = 4; constexpr int CopyMorphDurationMilliseconds = 160; -constexpr int CopyCheckHoldMilliseconds = 1500; +constexpr int CopyCheckHoldMilliseconds = 500; QString text(std::string_view value) { return QString::fromUtf8(value.data(), static_cast(value.size())); @@ -1403,6 +1403,7 @@ class ConversationCard::Impl final { title->setText(activity.type.empty() ? QStringLiteral("Activity") : UiStyle::humanizeLabel(text(activity.type))); + showStatus(text(activity.status), QStringLiteral("genericActivityStatus")); metadata->setText(boundedGenericActivity(activity.raw)); metadata->setObjectName(QStringLiteral("genericActivityMetadata")); metadata->show(); diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp index 4653ae3..14e73e4 100644 --- a/src/codex/middle/ConversationProjection.cpp +++ b/src/codex/middle/ConversationProjection.cpp @@ -27,6 +27,15 @@ std::string stringValue(const nlohmann::json &object, const char *key) { : std::string{}; } +std::string statusValue(const nlohmann::json &object) { + const auto status = object.find("status"); + if (status == object.end()) + return {}; + if (status->is_string()) + return status->get(); + return stringValue(*status, "type"); +} + std::optional integerValue(const nlohmann::json &object, const char *key) { if (!object.is_object()) @@ -209,7 +218,8 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, const std::string type = stringValue(item, "type"); VisibleCardData result{std::move(visualKey), CardKind::GenericActivity, identity.threadId, identity.turnId, - identity.itemId, GenericActivityData{type, item}}; + identity.itemId, + GenericActivityData{type, item, statusValue(item)}}; if (type == "userMessage") { result.kind = CardKind::UserMessage; @@ -295,7 +305,10 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, revisedPrompt = stringValue(item, "revised_prompt"); result.kind = CardKind::ImageGeneration; result.payload = - ImageGenerationData{path, stringValue(item, "status"), revisedPrompt}; + ImageGenerationData{path, + type == "imageView" ? "completed" + : stringValue(item, "status"), + revisedPrompt}; } else if (type == "plan") { const std::string plan = withTruncationNotice( messageText(item), omittedTextBytes(presentation, "text"), "plan text", diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index 1428af7..d833198 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -610,6 +610,13 @@ void ConversationView::setTrailingSpaceHeight(int height) { mode_ = Mode::Paused; } trailingSpaceHeight_ = height; + QScrollBar *conversationScrollBar = verticalScrollBar(); + conversationScrollBar->setProperty("composerBottomInset", height); + conversationScrollBar->setStyleSheet( + height == 0 + ? QString{} + : QStringLiteral("QScrollBar:vertical{margin:2px 2px %1px 2px;}") + .arg(height + 2)); recomputeGeometry(); if (mode_ == Mode::Following) setScrollValue(verticalScrollBar()->maximum()); @@ -809,11 +816,16 @@ void ConversationView::recomputeGeometry() { int cardWidth) { if (!card || !card->layout()) return; + cardWidth = std::max(0, cardWidth); card->setMinimumHeight(0); + // Retained rich text is created and nested in one transaction. Establish + // its real width before measuring so QLabel cannot reuse pre-nesting + // document geometry until a later streamed update. + card->resize(cardWidth, card->height()); card->layout()->invalidate(); + card->layout()->setGeometry(card->contentsRect()); activateCard(card); card->updateGeometry(); - cardWidth = std::max(0, cardWidth); const int cardHeight = card->layout()->hasHeightForWidth() ? card->layout()->heightForWidth(cardWidth) + @@ -822,6 +834,7 @@ void ConversationView::recomputeGeometry() { card->setMinimumHeight(cardHeight); card->resize(cardWidth, cardHeight); card->layout()->setGeometry(card->contentsRect()); + activateCard(card); }; for (const auto &[key, card] : cards_) { static_cast(key); @@ -853,8 +866,15 @@ void ConversationView::recomputeGeometry() { Qt::FindDirectChildrenOnly); if (!nested || !nested->layout()) continue; - if (card->layout()) + 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( @@ -872,7 +892,7 @@ void ConversationView::recomputeGeometry() { nested->updateGeometry(); nested->layout()->invalidate(); nested->layout()->activate(); - settleCardHeight(card, card->width()); + settleCardHeight(card, cardWidth); } for (const auto &[key, section] : sections_) { static_cast(key); diff --git a/src/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp index b52524b..b88405b 100644 --- a/src/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -293,11 +294,16 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { noticeTimer->stop(); noticeBar->hide(); }); - contentLayout->addWidget(noticeBar); - - conversationView = new ConversationView; + auto *conversationLayer = new QWidget(content); + conversationLayer->setObjectName(QStringLiteral("conversationLayer")); + auto *conversationLayerLayout = new QGridLayout(conversationLayer); + conversationLayerLayout->setContentsMargins(0, 0, 0, 0); + conversationLayerLayout->setSpacing(0); + conversationView = new ConversationView(conversationLayer); applyConversationPresentationOptions(); - contentLayout->addWidget(conversationView, 1); + conversationLayerLayout->addWidget(conversationView, 0, 0); + conversationLayerLayout->addWidget(noticeBar, 0, 0, Qt::AlignTop); + contentLayout->addWidget(conversationLayer, 1); composerPane = new ComposerPane(conversationRegion); composerPane->setExtraOverlayHeightAction( [this](int height) { conversationView->setTrailingSpaceHeight(height); }); @@ -413,6 +419,7 @@ void MiddleRegionWidget::showNotice(QString message, bool error) { widget->update(); } noticeBar->show(); + noticeBar->raise(); noticeTimer->start(error ? 10000 : 6000); } @@ -463,6 +470,18 @@ bool MiddleRegionWidget::routeScrollEvent(QObject *watched, QEvent *event) { return false; auto *wheel = static_cast(event); + if (inCenter && + (target == composerPane || composerPane->isAncestorOf(target))) { + // Scrollable composer children receive their own native event. If they + // decline it at a boundary, the first non-scrollable composer ancestor + // consumes the propagated event instead of leaking it to Conversation. + for (QWidget *ancestor = target; ancestor && ancestor != composerPane; + ancestor = ancestor->parentWidget()) + if (qobject_cast(ancestor)) + return false; + wheel->accept(); + return true; + } if (inCenter) { if (target == conversationView || target == conversationView->viewport() || conversationView->isAncestorOf(target)) { diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 6334dcc..8d42253 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -153,6 +153,7 @@ struct PlanData { struct GenericActivityData { std::string type; nlohmann::json raw = nlohmann::json::object(); + std::string status; bool operator==(const GenericActivityData &) const = default; }; diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index 5f314ca..e1a1843 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -223,16 +223,18 @@ void updateRow(QWidget *row, const std::string &threadId, titleText.prepend(QStringLiteral("! ")); title->setText(titleText); const PresentationStatus classified = classifyStatus(threadStatus); - QString color = QStringLiteral("#cacccf"); + QString color = QString::fromLatin1(UiStyle::threadInactive); if (optimistic) color = optimisticFailed ? QStringLiteral("#c43d4d") : QStringLiteral("#d17b16"); else if (requestCount != 0) - color = QStringLiteral("#a85d0c"); + color = QString::fromLatin1(UiStyle::orange); else if (classified.kind == StatusKind::Active) - color = QStringLiteral("#2f6feb"); + color = QString::fromLatin1(UiStyle::blue); + else if (classified.kind == StatusKind::Completed) + color = QString::fromLatin1(UiStyle::green); else if (classified.kind == StatusKind::Failed) - color = QStringLiteral("#c43d4d"); + color = QString::fromLatin1(UiStyle::red); dot->setStyleSheet( QStringLiteral("background:%1;border-radius:5px;").arg(color)); } diff --git a/src/codex/ui/ExpandingPromptEditor.cpp b/src/codex/ui/ExpandingPromptEditor.cpp index c7de6b1..b8caf51 100644 --- a/src/codex/ui/ExpandingPromptEditor.cpp +++ b/src/codex/ui/ExpandingPromptEditor.cpp @@ -7,10 +7,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -37,7 +39,12 @@ ExpandingPromptEditor::ExpandingPromptEditor(QWidget *parent) "3px 2px;}")); connect(this, &QPlainTextEdit::textChanged, this, - &ExpandingPromptEditor::remeasure); + &ExpandingPromptEditor::scheduleRemeasure); + connect(verticalScrollBar(), &QScrollBar::valueChanged, this, + [this](int value) { + if (!contentScrollable && value != verticalScrollBar()->minimum()) + verticalScrollBar()->setValue(verticalScrollBar()->minimum()); + }); } bool ExpandingPromptEditor::requiresExpandedLayout(int widgetWidth) const { @@ -48,13 +55,17 @@ bool ExpandingPromptEditor::requiresExpandedLayout(int widgetWidth) const { return true; const int viewportReduction = std::max(0, width() - viewport()->width()); - const qreal lineWidth = std::max(1, widgetWidth - viewportReduction); + const qreal contentWidth = + std::max(1, widgetWidth - viewportReduction - + 2 * document()->documentMargin()); QTextLayout layout(content, font()); - layout.setTextOption(document()->defaultTextOption()); + QTextOption option = document()->defaultTextOption(); + option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + layout.setTextOption(option); layout.beginLayout(); QTextLine firstLine = layout.createLine(); if (firstLine.isValid()) - firstLine.setLineWidth(lineWidth); + firstLine.setLineWidth(contentWidth); const bool wraps = layout.createLine().isValid(); layout.endLayout(); return wraps; @@ -91,7 +102,12 @@ void ExpandingPromptEditor::keyPressEvent(QKeyEvent *event) { return; } - if (!modifiers.testFlag(Qt::AltModifier) && !event->isAutoRepeat()) { + if (event->isAutoRepeat()) { + event->accept(); + return; + } + + if (!modifiers.testFlag(Qt::AltModifier)) { emit submitRequested(); event->accept(); return; @@ -99,6 +115,14 @@ void ExpandingPromptEditor::keyPressEvent(QKeyEvent *event) { QPlainTextEdit::keyPressEvent(event); } +void ExpandingPromptEditor::wheelEvent(QWheelEvent *event) { + if (contentScrollable) + QPlainTextEdit::wheelEvent(event); + // Composer-originated scrolling belongs to the prompt editor even at its + // boundary. It must never chain into the conversation behind the overlay. + event->accept(); +} + void ExpandingPromptEditor::resizeEvent(QResizeEvent *event) { QPlainTextEdit::resizeEvent(event); scheduleRemeasure(); @@ -127,9 +151,11 @@ void ExpandingPromptEditor::remeasure() { const int documentHeight = static_cast(std::ceil(laidOutHeight)) + 10; const int wanted = std::clamp(documentHeight, compactHeight(), maximumEditorHeight); - setVerticalScrollBarPolicy(wanted >= maximumEditorHeight - ? Qt::ScrollBarAsNeeded - : Qt::ScrollBarAlwaysOff); + contentScrollable = documentHeight > maximumEditorHeight; + setVerticalScrollBarPolicy(contentScrollable ? Qt::ScrollBarAsNeeded + : Qt::ScrollBarAlwaysOff); + if (!contentScrollable) + verticalScrollBar()->setValue(verticalScrollBar()->minimum()); if (wanted == currentContentHeight) return; currentContentHeight = wanted; diff --git a/src/codex/ui/ExpandingPromptEditor.h b/src/codex/ui/ExpandingPromptEditor.h index 2fd3370..6e98cfa 100644 --- a/src/codex/ui/ExpandingPromptEditor.h +++ b/src/codex/ui/ExpandingPromptEditor.h @@ -9,6 +9,7 @@ class QFocusEvent; class QInputMethodEvent; class QKeyEvent; class QResizeEvent; +class QWheelEvent; namespace codexui { @@ -36,6 +37,7 @@ class ExpandingPromptEditor final : public QPlainTextEdit void inputMethodEvent(QInputMethodEvent* event) override; void keyPressEvent(QKeyEvent* event) override; void resizeEvent(QResizeEvent* event) override; + void wheelEvent(QWheelEvent* event) override; private: void scheduleRemeasure(); @@ -43,6 +45,7 @@ class ExpandingPromptEditor final : public QPlainTextEdit int maximumEditorHeight = compactHeight(); int currentContentHeight = compactHeight(); + bool contentScrollable = false; bool preeditActive = false; bool remeasureScheduled = false; }; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index dbf9c96..37200bb 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -150,6 +150,7 @@ QString applicationStyleSheet() { QPushButton:disabled, QToolButton:disabled { color: #98a2b3; background: #f6f8fb; border-color: #d7dee8; } QPushButton[kind="primary"] { background: #2f6feb; border-color: #2f6feb; color: white; } QPushButton[kind="primary"]:hover { background: #285fca; border-color: #285fca; } + QPushButton[kind="primary"]:disabled { color: #98a2b3; background: #f6f8fb; border-color: #d7dee8; } QPushButton[kind="history"] { background: #e5eeff; border-color: #bfd3f9; color: #285fca; } QPushButton[kind="history"]:hover { background: #d8e7ff; border-color: #9ebcf3; } QPushButton[kind="request"] { background: #fff6df; border-color: #e5c77d; color: #8a5208; } @@ -157,6 +158,7 @@ QString applicationStyleSheet() { QPushButton[kind="steer"] { background: #167b80; border-color: #167b80; color: white; } QPushButton[kind="steer"]:hover { background: #126b70; border-color: #126b70; color: white; } QPushButton[kind="steer"]:pressed { background: #0f595d; border-color: #0f595d; color: white; } + QPushButton[kind="steer"]:disabled { color: #98a2b3; background: #f6f8fb; border-color: #d7dee8; } QPushButton[kind="cancel"] { background: #eef1f5; border-color: #c8d0dc; color: #475467; } QPushButton[kind="cancel"]:hover { background: #e3e8ef; border-color: #aeb8c6; } QPushButton[kind="subtle"], QToolButton[kind="subtle"] { @@ -256,7 +258,7 @@ QString applicationStyleSheet() { QFrame#conversationNoticeBar[tone="danger"] { background: #fff0f2; border: 1px solid #efb8c0; border-radius: 7px; } QWidget#composerOverlay { background: #f6f8fb; } QFrame[kind="composer"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } - QFrame[kind="composer"][focused="true"] { border: 2px solid #2f6feb; } + QFrame[kind="composer"][focused="true"] { border-color: #2f6feb; } QPlainTextEdit, QTextEdit { background: transparent; border: 0; diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index 0ead6a2..59eebbd 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -22,6 +22,7 @@ inline constexpr auto dividerStrong = "#b9c4d2"; inline constexpr auto primary = "#1d2633"; inline constexpr auto secondary = "#667085"; inline constexpr auto placeholder = "#98a2b3"; +inline constexpr auto threadInactive = "#cacccf"; inline constexpr auto blue = "#2f6feb"; inline constexpr auto blueHover = "#285fca"; inline constexpr auto blueSelected = "#e5eeff"; diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index d20fc90..2ba2ada 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -15,6 +15,7 @@ #include "codex/ui/UiViewProjection.h" #include +#include #include #include #include @@ -50,6 +51,7 @@ #include #include #include +#include #include namespace codexui::codex::middle { @@ -150,8 +152,9 @@ bool testPromptKeyboardSubmission() { result &= expect(submissions == 4, "Alt+Enter does not submit a prompt"); resetDraft(); sendPromptKey(editor, Qt::Key_Return, Qt::ControlModifier, true); - result &= expect(submissions == 4, - "an auto-repeated Enter chord does not submit a prompt"); + result &= expect(submissions == 4 && + editor.toPlainText() == QStringLiteral("draft"), + "an auto-repeated Enter chord neither submits nor inserts"); resetDraft(); QInputMethodEvent preedit(QStringLiteral("candidate"), {}); @@ -166,6 +169,50 @@ bool testPromptKeyboardSubmission() { sendPromptKey(editor, Qt::Key_Return); result &= expect(submissions == 5, "Enter submits again after IME composition completes"); + + editor.setPlainText(QStringLiteral("one\ntwo\nthree\nfour")); + QCoreApplication::processEvents(); + editor.verticalScrollBar()->setValue(editor.verticalScrollBar()->maximum()); + result &= expect( + editor.verticalScrollBarPolicy() == Qt::ScrollBarAlwaysOff && + editor.verticalScrollBar()->value() == + editor.verticalScrollBar()->minimum(), + "a fully visible multiline draft has no hidden empty-line scroll tail"); + + editor.clear(); + editor.resize(280, codexui::ExpandingPromptEditor::compactHeight()); + QCoreApplication::processEvents(); + const int compactWidth = 170; + QString boundary; + while (boundary.size() < 100 && + !editor.requiresExpandedLayout(compactWidth)) { + boundary += QLatin1Char('W'); + editor.setPlainText(boundary); + } + const QString beforeBoundary = boundary.chopped(1); + editor.setPlainText(beforeBoundary); + const bool beforeExpands = editor.requiresExpandedLayout(compactWidth); + editor.setPlainText(boundary); + QCoreApplication::processEvents(); + const qreal liveWidth = editor.document()->textWidth(); + int liveLayoutChanges = 0; + const QMetaObject::Connection layoutConnection = QObject::connect( + editor.document()->documentLayout(), + &QAbstractTextDocumentLayout::documentSizeChanged, &editor, + [&liveLayoutChanges] { ++liveLayoutChanges; }); + const bool boundaryExpands = editor.requiresExpandedLayout(compactWidth); + QObject::disconnect(layoutConnection); + result &= expect(!beforeBoundary.isEmpty(), + "the compact probe discovers a nonempty wrap boundary"); + result &= expect(!beforeExpands, + "the character before the wrap boundary remains compact"); + result &= expect(boundaryExpands, + "the first wrapped character enters multiline mode"); + result &= expect(editor.document()->textWidth() == liveWidth, + "compact layout probing preserves the live document width"); + result &= expect( + liveLayoutChanges == 0, + "compact layout probing does not relay out the visible document"); return result; } @@ -427,6 +474,28 @@ bool testOverlayGeometryAndRegionRouting() { spin(20); const QRect viewGeometry = view.geometry(); const QRect viewportGeometry = view.viewport()->geometry(); + auto *notice = region.findChild( + QStringLiteral("conversationNoticeBar")); + auto *dismissNotice = + notice ? notice->findChild() : nullptr; + region.showNotice(QStringLiteral("Transient interaction notice"), false); + spin(10); + const auto regionRect = [®ion](QWidget *widget) { + return QRect(widget->mapTo(®ion, QPoint()), widget->size()); + }; + result &= expect( + notice && notice->isVisible() && dismissNotice && + view.geometry() == viewGeometry && + view.viewport()->geometry() == viewportGeometry && + regionRect(notice).intersects(regionRect(&view)), + "transient interaction notice overlays without shifting messages"); + if (dismissNotice) + dismissNotice->click(); + spin(10); + result &= expect(notice && notice->isHidden() && + view.geometry() == viewGeometry && + view.viewport()->geometry() == viewportGeometry, + "dismissing the notice preserves message geometry"); const int canonical = region.composer().canonicalReserveHeight(); result &= expect(canonical > 0 && @@ -443,6 +512,8 @@ bool testOverlayGeometryAndRegionRouting() { composerSurface = frame; } TurnSettingsWidget *settings = region.composer().turnSettings(); + auto *sendButton = region.composer().findChild( + QStringLiteral("composerSendButton")); const auto overlayRect = [&](QWidget *widget) { return QRect(widget->mapTo(®ion.composer(), QPoint()), widget->size()); }; @@ -494,6 +565,33 @@ bool testOverlayGeometryAndRegionRouting() { QStringLiteral("QLabel[tone=\"success\"]")) && stableComposerGeometry(), "compact composer has an opaque surface and canonical section gaps"); + region.composer().setCanSubmit(true); + result &= expect(sendButton && !sendButton->isEnabled(), + "an empty prompt cannot activate Send"); + region.composer().promptEditor()->setPlainText(QStringLiteral("draft")); + spin(10); + result &= expect(sendButton && sendButton->isEnabled(), + "non-blank input activates Send when admission is ready"); + region.composer().promptEditor()->setFocus(); + spin(10); + result &= expect(composerSurface->property("focused").toBool(), + "prompt focus activates the canonical composer focus state"); + QString submittedPrompt; + ComposerPane::Actions exactSubmission; + exactSubmission.submit = [&submittedPrompt]( + QString prompt, + std::vector) { + submittedPrompt = std::move(prompt); + return false; + }; + region.composer().setActions(std::move(exactSubmission)); + const QString exactPrompt = QStringLiteral(" indented Markdown\n\n"); + region.composer().promptEditor()->setPlainText(exactPrompt); + QMetaObject::invokeMethod(region.composer().promptEditor(), + "submitRequested", Qt::DirectConnection); + result &= expect(submittedPrompt == exactPrompt, + "submission validates whitespace without rewriting it"); + region.composer().clearDraft(); view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); spin(10); result &= expect(finalCardBottom() == view.viewport()->height(), @@ -525,6 +623,12 @@ bool testOverlayGeometryAndRegionRouting() { const int extra = region.composer().extraOverlayHeight(); result &= expect(extra > 0 && view.trailingSpaceHeight() == extra, "prompt growth is mirrored by exact trailing scroll space"); + result &= expect( + view.verticalScrollBar()->property("composerBottomInset").toInt() == + extra && + view.verticalScrollBar()->styleSheet().contains( + QStringLiteral("margin:2px 2px %1px 2px").arg(extra + 2)), + "prompt growth shortens the visible message scrollbar track"); view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); spin(10); result &= expect( @@ -553,6 +657,10 @@ bool testOverlayGeometryAndRegionRouting() { result &= expect( region.composer().extraOverlayHeight() == 0 && view.trailingSpaceHeight() == 0 && view.geometry() == viewGeometry && + view.verticalScrollBar() + ->property("composerBottomInset") + .toInt() == 0 && + view.verticalScrollBar()->styleSheet().isEmpty() && view.viewport()->geometry() == viewportGeometry && finalCardBottom() == view.viewport()->height() && stableComposerGeometry() && settingsToEditorGap() == compactEditorGap, @@ -597,6 +705,35 @@ bool testOverlayGeometryAndRegionRouting() { result &= expect(region.composer().promptEditor()->toPlainText().isEmpty(), "successful local admission clears the draft exactly once"); + QString oversizedPrompt; + for (int line = 0; line < 30; ++line) + oversizedPrompt += QStringLiteral("scroll-owned prompt line %1\n").arg(line); + region.composer().promptEditor()->setPlainText(oversizedPrompt); + spin(20); + auto *promptScroll = region.composer().promptEditor()->verticalScrollBar(); + promptScroll->setValue(promptScroll->minimum()); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum() / 2); + const int conversationBeforePromptWheel = + view.verticalScrollBar()->value(); + QWheelEvent promptRoute = + wheelFor(region.composer().promptEditor(), 120, Qt::ScrollBegin); + result &= expect( + !region.routeScrollEvent(region.composer().promptEditor(), &promptRoute), + "the prompt editor retains its own wheel origin"); + QWheelEvent promptNative = + wheelFor(region.composer().promptEditor(), 120, Qt::ScrollUpdate); + QCoreApplication::sendEvent(region.composer().promptEditor(), &promptNative); + result &= expect( + view.verticalScrollBar()->value() == conversationBeforePromptWheel, + "prompt overscroll cannot move the conversation"); + QWheelEvent settingsWheel = wheelFor(settings, 120, Qt::ScrollBegin); + result &= expect(region.routeScrollEvent(settings, &settingsWheel) && + view.verticalScrollBar()->value() == + conversationBeforePromptWheel, + "settings-originated scrolling is consumed locally"); + region.composer().clearDraft(); + spin(20); + QWheelEvent overLeftHandle = wheelFor(splitter->handle(1), 180); result &= expect(region.routeScrollEvent(splitter->handle(1), &overLeftHandle) && @@ -750,6 +887,48 @@ bool testThreadSelectionProjection() { return result; } +bool testThreadRuntimeStatusColors() { + PresentationModel model; + const std::vector> statuses{ + {"thread-not-loaded", "notLoaded"}, + {"thread-completed", "idle"}, + {"thread-running", "active"}, + {"thread-failed", "systemError"}, + }; + std::uint64_t sequence = 1; + for (const auto &[id, status] : statuses) { + model.applyEvent(presentation::event( + sequence++, 1, "thread.upsert", + {{"thread", {{"id", id}, {"name", id}, + {"status", {{"type", status}}}}}}, + presentation::Authority::Merge, {{"threadId", id}})); + } + + ThreadPane pane; + refresh(pane, model, "thread-completed"); + auto *list = pane.findChild(QStringLiteral("threadList")); + const std::vector> expected{ + {"thread-not-loaded", UiStyle::threadInactive}, + {"thread-completed", UiStyle::green}, + {"thread-running", UiStyle::blue}, + {"thread-failed", UiStyle::red}, + }; + bool result = true; + for (const auto &[id, color] : expected) { + QListWidgetItem *item = threadItem(list, id); + QWidget *row = item && list ? list->itemWidget(item) : nullptr; + auto *dot = row ? row->findChild( + QStringLiteral("threadStatusDot")) + : nullptr; + const std::string message = + id + " uses its canonical app-server runtime-state color"; + result &= expect( + dot && dot->styleSheet().contains(QString::fromLatin1(color)), + message.c_str()); + } + return result; +} + bool testIncrementalThreadSettings() { TurnSettingsWidget settings; const nlohmann::json models = @@ -1219,8 +1398,9 @@ bool testThreadLastActivityRetention() { presentation::Authority::Merge, {{"threadId", "tracked"}})); thread = model.thread("tracked"); result &= - expect(thread && thread->lastActivityAt == 40, - "stale provider hydration cannot replace newer live activity"); + expect(thread && thread->lastActivityAt == 40 && + thread->updatedAt == 20 && thread->recencyAt == 35, + "live protocol traffic updates activity without rewriting sort keys"); return result; } @@ -2226,6 +2406,7 @@ int main(int argc, char **argv) { bool result = testPromptKeyboardSubmission(); result &= testOverlayGeometryAndRegionRouting(); result &= testThreadSelectionProjection(); + result &= testThreadRuntimeStatusColors(); result &= testThreadHierarchyExpansionAndNavigation(); result &= testIncrementalThreadSettings(); result &= testThreadAlphanumericSort(); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 833c09e..015e0f2 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -1232,12 +1233,18 @@ bool testMutableCardsAndCommandOutput() { }), "pending prompts render file links before authoritative replacement"); + commandCard->setCollapsed(false); + spin(); auto &cards = snapshot.sections.front().cards; std::get(cards[0].payload).text += " updated"; auto &agent = std::get(cards[1].payload); agent.text += " updated"; agent.finalAnswer = true; auto &command = std::get(cards[2].payload); + QString longCommand; + for (int line = 0; line < 30; ++line) + longCommand += QStringLiteral("command argument line %1\n").arg(line); + command.command = utf8(longCommand); command.output = utf8(QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible\n\n \t")); command.status = "completed"; @@ -1265,6 +1272,11 @@ bool testMutableCardsAndCommandOutput() { commandCard->height() == immediateCommandHeight && output->sizeHint().height() == immediatePreferredOutputHeight, "command output has no delayed outer geometry settlement"); + result &= expect( + commandText->verticalScrollBar()->maximum() > 0 && + commandText->verticalScrollBar()->value() == + commandText->verticalScrollBar()->minimum(), + "long executed-command text opens at its beginning"); for (const auto &value : cards) result &= expect(card(view, stableKey(value.key)) == identities[stableKey(value.key)], @@ -2103,6 +2115,86 @@ bool testRootlessFinalAnswerGeometrySettlement() { return result; } +bool testRetainedNestedFinalAnswerGeometrySettlement() { + const QString originalStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); + const std::string thread = "retained-nested-final-answer"; + const VisibleCardData prompt{ + AuthoritativeItemKey{thread, "turn", "prompt"}, + CardKind::UserMessage, + thread, + "turn", + "prompt", + UserMessageData{"Please provide the complete retained report.", {}}}; + QString markdown = QStringLiteral( + "The retained report contains enough Markdown to require its final " + "nested width before height calculation.\n\n" + "Its complete list must remain inside the final-answer border:\n\n"); + for (int index = 1; index <= 14; ++index) + markdown += QStringLiteral( + "- Retained result %1 with explanatory text, **emphasis**, " + "and enough detail to wrap naturally at the nested card " + "width.\n") + .arg(index); + const VisibleCardData answer{ + AuthoritativeItemKey{thread, "turn", "answer"}, + CardKind::AgentMessage, + thread, + "turn", + "answer", + AgentMessageData{"Retained final answer is materializing.", true}}; + TurnSection section{"turn:retained", "turn", {prompt}, prompt.key}; + for (int index = 0; index < 4; ++index) + section.cards.push_back( + agentCard(thread, "turn", index, + QStringLiteral("Retained update %1 preceding the final " + "answer with enough text to wrap.") + .arg(index))); + section.cards.push_back(answer); + ConversationSnapshot snapshot{thread, {std::move(section)}, 0, false}; + + ConversationView view; + view.resize(980, 420); + bool result = expect( + view.reconcile(snapshot), + "retained prompt and partial final answer materialize initially"); + std::get(snapshot.sections.front().cards.back().payload) + .text = utf8(markdown); + result &= expect(view.reconcile(snapshot), + "retained hydration completes before first exposure"); + view.resize(560, 420); + view.show(); + ConversationCard *promptCard = card(view, stableKey(prompt.key)); + 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; + } + 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())); + } + result &= expect( + promptCard && answerCard && answerBody && + promptCard->isAncestorOf(answerCard) && + answerBody->height() >= documentHeight && + answerBody->mapTo(answerCard, QPoint(0, answerBody->height())).y() <= + answerCard->contentsRect().bottom() + 1, + "an initially retained nested final answer fully fits its rendered " + "document and settled card"); + spin(); + qApp->setStyleSheet(originalStyleSheet); + return result; +} + bool testBottomAnchoredCommandOutputGrowth() { const std::string thread = "bottom-anchored-output"; ConversationSnapshot snapshot = conversation(thread, 14); @@ -2658,6 +2750,7 @@ int main(int argc, char **argv) { result &= testPresentationOptionsRetainCardsAndInitialFolding(); result &= testInitialCommandGeometrySettlement(); result &= testRootlessFinalAnswerGeometrySettlement(); + result &= testRetainedNestedFinalAnswerGeometrySettlement(); result &= testBottomAnchoredCommandOutputGrowth(); result &= testCommandOutputStateAcrossNavigation(); result &= testPendingPromptAnimation(); diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp index 23651d3..4b0b513 100644 --- a/tests/codex/ConversationProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -102,7 +102,9 @@ bool testCanonicalGroupingAndProjection() { ThreadPresentation emptyPlan = baseThread("thread-empty-plan"); appendItem(emptyPlan, "turn-1", - item("empty-plan", {{"type", "plan"}, {"text", ""}})); + item("empty-plan", {{"type", "plan"}, + {"text", ""}, + {"status", "completed"}})); const ConversationSnapshot emptyPlanSnapshot = ConversationProjection::project(emptyPlan, {}, 80, 10); const VisibleCardData &emptyPlanCard = @@ -110,8 +112,9 @@ bool testCanonicalGroupingAndProjection() { const auto *generic = std::get_if(&emptyPlanCard.payload); result &= expect(emptyPlanCard.kind == CardKind::GenericActivity && generic && - generic->type == "plan", - "an empty plan retains the generic raw-data fallback"); + generic->type == "plan" && + generic->status == "completed", + "generic fallbacks retain an available lifecycle state"); return result; } @@ -795,8 +798,9 @@ bool testGeneratedImageProjection() { result &= expect(viewCard && viewCard->kind == CardKind::ImageGeneration && viewImage && viewImage->path == "/tmp/review.png" && - viewImage->status.empty() && viewImage->revisedPrompt.empty(), - "image-view items reuse the local image presentation"); + viewImage->status == "completed" && + viewImage->revisedPrompt.empty(), + "materialized image-view items expose their completed state"); return result; } diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index fa1a8f1..51195f1 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -363,6 +363,48 @@ int main() { "retained-a"}, "thread discovery preserves provider order and one retained tail"); + PresentationModel structuralModel; + structuralModel.applyEvent(codexui::codex::presentation::event( + 1, 1, "turn.upsert", + {{"turn", {{"id", "placeholder-turn"}, {"status", "inProgress"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "placeholder"}, {"turnId", "placeholder-turn"}})); + passed &= expect(structuralModel.thread("placeholder") != nullptr && + structuralModel.threadOrder().empty(), + "thread-scoped events retain invisible placeholders"); + structuralModel.applyEvent(codexui::codex::presentation::result( + 2, 1, "thread.resume", "resume-placeholder", true, + {{"thread", {{"id", "placeholder"}, {"parentThreadId", nullptr}}}}, + codexui::codex::presentation::Authority::Merge)); + passed &= expect( + structuralModel.threadOrder() == std::vector{"placeholder"}, + "an explicit root resume admits an existing placeholder"); + structuralModel.applyEvent(codexui::codex::presentation::result( + 3, 1, "threads.list", "structural-threads", true, + {{"threads", nlohmann::json::array( + {{{"id", "child"}, {"parentThreadId", "parent"}}, + {{"id", "parent"}, {"parentThreadId", nullptr}}})}}, + codexui::codex::presentation::Authority::Merge)); + const auto *structuralOwnership = structuralModel.childOwnership("child"); + passed &= expect( + structuralOwnership && structuralOwnership->parentThreadId == "parent" && + structuralOwnership->agentId.empty() && + structuralModel.threadOrder() == + std::vector{"parent", "placeholder"}, + "parentThreadId hides structural children before agent correlation"); + structuralModel.applyEvent(codexui::codex::presentation::result( + 4, 1, "thread.read", "read-structural-parent", true, + {{"thread", {{"id", "parent"}, + {"parentThreadId", nullptr}, + {"turns", nlohmann::json::array()}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "parent"}})); + passed &= expect( + structuralModel.childOwnership("child") && + structuralModel.threadOrder() == + std::vector{"parent", "placeholder"}, + "parent hydration preserves protocol-declared structural ownership"); + PresentationModel ownershipModel; ownershipModel.applyEvent(codexui::codex::presentation::result( 1, 1, "threads.list", "ownership-roots", true, diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index c931b96..aa61b3c 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -74,16 +74,19 @@ from 4.55:1 to 5.09:1. Blue denotes primary action or active work, green denotes success or connection, orange denotes warning or attention, and red denotes failure, stop, removal, or another destructive action. Activity dots use the same primary colors at 10 pixels so their state remains legible without -creating a separate indicator palette. The existing gray palette is unchanged; -only inactive thread dots use the lighter, less saturated `#cacccf` so active -blue threads retain clear visual priority. +creating a separate indicator palette. Thread dots project the app-server's +current runtime status directly: `notLoaded` uses the lighter, less saturated +`#cacccf`, `idle` uses canonical green, `active` uses canonical blue, and +`systemError` uses canonical red. Semantic color is reserved for state-bearing UI: running status text uses blue, successful completion and connection use green, pending requests and warnings use orange, and failures, denials, stop, removal, and validation errors use red. -Thread dots continue to describe activity rather than outcome, so completed or -otherwise inactive threads retain the canonical light-gray dot. Reasoning prose -and metadata without an authoritative status remain neutral because their +Thread dots describe current app-server runtime state rather than reconstructing +historical turn outcomes. Consequently, a provider-reported unloaded thread is +gray even when its last persisted turn completed or failed; neither UI performs +a background turn-history lookup merely to color the thread list. Reasoning +prose and metadata without an authoritative status remain neutral because their content does not provide a reliable success, warning, or failure classification. An optimistic new-thread row is the deliberate pending-state exception: its soft-orange sweep distinguishes local intent from an authoritative active blue @@ -111,7 +114,7 @@ collapsed card, while a contentless card omits it. Authored Markdown copies from the retained source with `text/markdown` and identical plain text; non-Markdown cards copy their deterministic primary-content text rather than rendered widget text. Copy feedback remains local to the action: the glyph -quickly morphs into a canonical green check, holds for 1.5 seconds, and morphs +quickly morphs into a canonical green check, holds for 0.5 seconds, and morphs back, while a rounded `Copied` overlay appears without changing card geometry. Process lifecycle states use the same right-side slot before Copy, rendered as normal-weight lowercase values with canonical semantic colors. The same @@ -169,16 +172,22 @@ follow mode and anchor are retained independently for each thread. The upcoming-turn settings and composer remain anchored to the bottom. The prompt editor starts at one line, grows upward to its maximum, and then scrolls -internally. The message view reserves the canonical composer height. Additional -growth overlays, but does not resize, the viewport. The trailing allowance is -represented as a logical extent equal to the overlap so the user can scroll -the final card to the composer boundary. The scroll content has no permanent -bottom padding. Matching the Changes-tab separator, the moving composer uses -8 px space, a standard divider extending 10 px beyond the adjacent content on -each side, and another 8 px space. This provides the same boundary at the bottom -and while reading higher in history. Extent growth does not move the existing -reading position. Shrinking the composer removes the extent and restores the -canonical geometry. +internally. A draft that fits has no hidden trailing scroll offset. Send and +Steer require non-whitespace input, prompt focus uses a geometry-neutral blue +border, and submission preserves the exact authored text. The message view +reserves the canonical composer height. Additional growth overlays, but does +not resize, the viewport. The trailing allowance is represented as a logical +extent equal to the overlap so the user can scroll the final card to the +composer boundary. The visible scrollbar track ends at that same uncovered +boundary without changing its range or retained anchor. The scroll content has +no permanent bottom padding. Matching the Changes-tab separator, the moving +composer uses 8 px space, a standard divider extending 10 px beyond the +adjacent content on each side, and another 8 px space. This provides the same +boundary at the bottom and while reading higher in history. Extent growth does +not move the existing reading position. Shrinking the composer removes the +extent and restores the canonical geometry. Wheel gestures originating in the +prompt or settings remain owned by the composer and never scroll the message +view behind it. ## Pending prompt presentation @@ -205,6 +214,7 @@ while earlier cards are pending. They are dispatched sequentially per thread. Output boxes grow from zero to 220 pixels. Longer output receives a styled vertical scrollbar. Each box independently follows output at its bottom and pauses when the user scrolls upward. +Long executed-command text opens at its beginning and does not auto-follow. ## Inspector diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index ec6426b..f5810fe 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -436,7 +436,7 @@ export function Card({card, active, collapsed, onToggle, onCopy, nested, turnCon if (copyFeedbackTimer.current) clearTimeout(copyFeedbackTimer.current); const failed = outcome !== "copied"; setCopyFeedback({text: failed ? "Copy failed" : "Copied", failed}); - copyFeedbackTimer.current = setTimeout(() => setCopyFeedback(undefined), 1500); + copyFeedbackTimer.current = setTimeout(() => setCopyFeedback(undefined), 500); }; let title = humanize(card.kind); let body: ReactNode; @@ -478,6 +478,8 @@ export function Card({card, active, collapsed, onToggle, onCopy, nested, turnCon const data = card.payload as PlanData; title = "Plan"; body = ; } else { const data = card.payload as GenericActivityData; title = data.type ? humanize(data.type) : "Activity"; + phaseLabel = data.status ? displayStatus(data.status) : ""; + phaseClass = data.status ? `status ${classifyStatus(data.status).tone}` : ""; body =
{boundedGenericActivity(data.raw)}
; } const copyContent = cardCopyContent(card); @@ -704,7 +706,8 @@ function SettingsPanel({session, draft, onChange}: {session: BrowserFrontendSess const profiles = Array.isArray(profilesDomain) ? profilesDomain : (profilesDomain && typeof profilesDomain === "object" && Array.isArray((profilesDomain as {data?: unknown}).data) ? (profilesDomain as {data: unknown[]}).data : []); const select = (label: string, field: SettingField, choices: readonly [string, string][]) => ; const defaults: [string, string] = ["Thread default", DefaultSetting]; - return
+ return
event.preventDefault()}> {open &&
{select("Model", "model", [defaults, ...modelDefinitions.filter(value => typeof value === "object" && value !== null && !((value as {hidden?: boolean}).hidden)).map(value => [String((value as {displayName?: string}).displayName ?? (value as {model?: string; id?: string}).model ?? (value as {id?: string}).id), String((value as {model?: string; id?: string}).model ?? (value as {id?: string}).id)] as [string, string])])} @@ -733,7 +736,9 @@ function Composer({session, active, draftKey, drafts, options}: {session: Browse element.style.height = "auto"; const maximum = 180; element.style.height = `${Math.min(element.scrollHeight, maximum)}px`; - element.style.overflowY = element.scrollHeight > maximum ? "auto" : "hidden"; + const scrollable = element.scrollHeight > maximum; + element.style.overflowY = scrollable ? "auto" : "hidden"; + if (!scrollable) element.scrollTop = 0; }, [prompt]); const submit = (event: FormEvent) => { event.preventDefault(); @@ -744,11 +749,13 @@ function Composer({session, active, draftKey, drafts, options}: {session: Browse return