From c9afc86a8ae79f72fb2895f719602d10b5aa8bd4 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 3 Sep 2026 19:10:36 +0200 Subject: [PATCH 1/2] Separate turn prompt from nested activity --- docs/ui-behavior.md | 3 +++ src/codex/middle/ConversationCards.cpp | 4 +++- tests/codex/ConversationCardsTest.cpp | 10 ++++++++++ web/src/styles.css | 2 +- web/tests/responsive-layout.test.mjs | 1 + 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index eec579a..9fe5455 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -31,6 +31,9 @@ keyed by stable thread, turn, and item IDs; local prompt cards are keyed by their submission IDs. The same keyed reconcile path handles initial display and updates, mutating a card in place when its visible data changes. An identical visible projection does not rebuild widgets or change geometry. +The complete prompt content, including attachments, is separated from the +first nested turn card by the canonical 8 px structural gap. This spacing is +layout geometry and never becomes part of authored Markdown. Local prompt admission resumes bottom following when the only pause was caused by composer overlay growth, so the complete pending prompt becomes visible. diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index ee39da6..6dfeb46 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -1009,7 +1009,9 @@ class ConversationCard::Impl final { nestedCards = new QWidget(owner); nestedCards->setObjectName(QStringLiteral("conversationNestedCards")); nestedLayout = new QVBoxLayout(nestedCards); - nestedLayout->setContentsMargins(0, 0, 0, 0); + // The outer card contributes 6 px here; complete the canonical 8 px + // boundary between prompt content and the first nested turn card. + nestedLayout->setContentsMargins(0, 2, 0, 0); nestedLayout->setSpacing(8); nestedCards->hide(); layout->addWidget(nestedCards); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 015e0f2..686ce42 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -1519,6 +1519,16 @@ bool testCardFoldingGeometryAndRetention() { userCard->isAncestorOf(reasoningCard) && agentCardWidget->property("nestedConversationCard").toBool(), "the first You card structurally owns its 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(); + result &= expect(promptContent && + firstNestedTop - promptContentBottom == 8, + "turn prompt content and its first nested card use the " + "canonical 8 px boundary"); const LocalPromptKey steeringKey{4343}; VisibleCardData steering{ diff --git a/web/src/styles.css b/web/src/styles.css index 5225413..32da6de 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -89,7 +89,7 @@ h1, h2, h3, p { margin: 0; } .conversation-scroll::-webkit-scrollbar-track { margin-block-end: calc(var(--composer-overlay-height) + 8px); } .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: 10px; } +.turn-nested { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } .conversation-card { border: 1px solid #dce2eb; border-radius: 11px; background: #fff; padding: 13px 15px; box-shadow: 0 2px 5px #1720330a; overflow: hidden; } .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; } diff --git a/web/tests/responsive-layout.test.mjs b/web/tests/responsive-layout.test.mjs index 4ae5a8c..342069e 100644 --- a/web/tests/responsive-layout.test.mjs +++ b/web/tests/responsive-layout.test.mjs @@ -93,6 +93,7 @@ test("responsive CSS keeps the desktop grid and removes the old document-width f assert.match(css, /\.thread-row \.status-dot\.danger\s*\{[^}]*background:\s*#c43d4d/u); assert.match(css, /\.conversation-card\.userMessage\.steering\s*\{[^}]*background:\s*#eefafa;[^}]*border-color:\s*#9fd7d8/u); assert.match(css, /\.conversation-card\.localPrompt\.steering\s*\{[^}]*background:\s*#eefafa;[^}]*border-color:\s*#5caeb1/u); + assert.match(css, /\.turn-nested\s*\{[^}]*gap:\s*8px;[^}]*margin-top:\s*8px/u); assert.doesNotMatch(css, /acknowledgment-fade/u); assert.match(css, /conversation-card\.turn-container\.active-turn[^}]*#6f98e8/u); assert.match(css, /\.send-button\.steer\s*\{[^}]*background:\s*#167b80[^}]*color:\s*#fff/u); From c09d22c1975d742b530065f62331fac1a76a78db Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Thu, 3 Sep 2026 20:16:32 +0200 Subject: [PATCH 2/2] Make nested turn separation visible --- docs/ui-behavior.md | 4 +-- src/codex/middle/ConversationCards.cpp | 6 ++--- tests/codex/ConversationCardsTest.cpp | 37 +++++++++++++++++++++----- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 9fe5455..7cad721 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -31,8 +31,8 @@ keyed by stable thread, turn, and item IDs; local prompt cards are keyed by their submission IDs. The same keyed reconcile path handles initial display and updates, mutating a card in place when its visible data changes. An identical visible projection does not rebuild widgets or change geometry. -The complete prompt content, including attachments, is separated from the -first nested turn card by the canonical 8 px structural gap. This spacing is +The complete prompt content, including attachments, adds the canonical 8 px +structural section gap before its first nested turn card. This spacing is layout geometry and never becomes part of authored Markdown. Local prompt admission resumes bottom following when the only pause was caused diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 6dfeb46..c0a64e9 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -1009,9 +1009,9 @@ class ConversationCard::Impl final { nestedCards = new QWidget(owner); nestedCards->setObjectName(QStringLiteral("conversationNestedCards")); nestedLayout = new QVBoxLayout(nestedCards); - // The outer card contributes 6 px here; complete the canonical 8 px - // boundary between prompt content and the first nested turn card. - nestedLayout->setContentsMargins(0, 2, 0, 0); + // 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); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 686ce42..befee16 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -1458,7 +1458,24 @@ bool testCardFoldingGeometryAndRetention() { view.resize(700, 820); view.setTrailingSpaceHeight(500); view.show(); - bool result = expect(view.reconcile(snapshot), "folding fixture renders"); + ConversationSnapshot promptOnly = snapshot; + promptOnly.sections.front().cards = {user}; + bool result = + expect(view.reconcile(promptOnly), "prompt-only folding fixture renders"); + spin(); + + ConversationCard *promptOnlyCard = card(view, stableKey(user.key)); + QWidget *promptOnlyNestedCards = + promptOnlyCard + ? promptOnlyCard->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly) + : nullptr; + result &= expect(promptOnlyCard && promptOnlyNestedCards && + promptOnlyNestedCards->isHidden(), + "an initial turn prompt reserves no nested-card gap"); + result &= expect(view.reconcile(snapshot), + "first nested activity extends the folding fixture"); spin(); ConversationCard *userCard = card(view, stableKey(user.key)); @@ -1483,10 +1500,12 @@ bool testCardFoldingGeometryAndRetention() { disclosure(reasoningCard)->property("chevronDirection") == "left", "all cards share disclosure controls with role-correct initial state"); result &= expect( - userCard && userCard->property("authoritativeTurnActive").toBool() && + userCard && userCard == promptOnlyCard && + userCard->property("authoritativeTurnActive").toBool() && !agentCardWidget->property("authoritativeTurnActive").toBool() && !userCard->findChild(QStringLiteral("activeTurnAnimation")), - "only the running outer You card receives a static emphasized border"); + "the retained running outer You card receives a static emphasized " + "border"); snapshot.activeTurnId.reset(); result &= expect(view.reconcile(snapshot) && userCard == card(view, stableKey(user.key)) && @@ -1525,10 +1544,14 @@ bool testCardFoldingGeometryAndRetention() { promptContent ? promptContent->geometry().y() + promptContent->height() : -1; const int firstNestedTop = agentCardWidget->mapTo(userCard, QPoint{}).y(); - result &= expect(promptContent && - firstNestedTop - promptContentBottom == 8, - "turn prompt content and its first nested card use the " - "canonical 8 px boundary"); + QWidget *nestedCards = userCard->findChild( + QStringLiteral("conversationNestedCards"), Qt::FindDirectChildrenOnly); + result &= expect( + promptContent && nestedCards && nestedCards->layout() && + nestedCards->layout()->contentsMargins().top() == 8 && + firstNestedTop - promptContentBottom == 14, + "turn prompt content adds a visible canonical 8 px section boundary " + "before its first nested card"); const LocalPromptKey steeringKey{4343}; VisibleCardData steering{