From 6fe52441fe8c4ae108a6254d91e5fedc7149a212 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sun, 30 Aug 2026 19:42:09 +0200 Subject: [PATCH 1/2] Align message phases with card actions --- docs/ui-behavior.md | 9 ++++- src/codex/middle/ConversationCards.cpp | 56 +++++++++++++++++--------- src/codex/ui/UiStyle.cpp | 1 + tests/codex/ConversationCardsTest.cpp | 55 +++++++++++++------------ ui-review/UX-DESIGN-DECISIONS.md | 7 ++-- web/src/app/App.tsx | 9 +++-- web/src/styles.css | 4 +- web/tests/card-copy.test.mjs | 21 +++++++++- 8 files changed, 104 insertions(+), 58 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 98701ea..e7a839e 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -134,8 +134,9 @@ explicit error state. 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 an animated -teal `You · steering` card. After acknowledgment, the same widget becomes a -soft-teal inset steering card with the canonical teal border and title treatment. +teal `You` card with a right-aligned `steering` specialization. After +acknowledgment, the same widget 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. @@ -231,6 +232,10 @@ appears at the action. Web clipboard failure uses the same local overlay with canonical error styling; reduced-motion mode suppresses the breath without suppressing the result. +Message specializations (`steering`, `update`, and `final answer`) use normal +font weight and sit at the right of the header immediately before Copy. The +title remains at the left; no separator glyph is rendered. + Pending-request dialogs validate required answers and structured MCP content before accepting the modal. Invalid input keeps the dialog and all entered content open for correction. diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 1009dfc..0d6e5c3 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -1062,9 +1062,16 @@ class ConversationCard::Impl final { void setNestedConversationCard(bool nested) { owner->setProperty("nestedConversationCard", nested); if (current.kind == CardKind::UserMessage || - current.kind == CardKind::LocalPrompt) - title->setText(nested ? QStringLiteral("You · steering") - : QStringLiteral("You")); + current.kind == CardKind::LocalPrompt) { + title->setText(QStringLiteral("You")); + if (nested) { + showPhase(QStringLiteral("steering"), + QStringLiteral("steeringMessagePhase")); + setPhaseTone(QStringLiteral("steering")); + } else if (phase) { + phase->hide(); + } + } if (current.kind == CardKind::LocalPrompt) refreshPendingPresentation(); owner->style()->unpolish(owner); @@ -1125,6 +1132,28 @@ class ConversationCard::Impl final { copy->setVisible(!cardCopyContent(current).text.isEmpty()); } + void showPhase(const QString &value, const QString &objectName) { + if (!phase) { + phase = makeLabel({}, "messagePhase", header); + phase->setWordWrap(false); + phase->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + headerLayout->insertWidget(headerLayout->indexOf(copy), phase, 0, + Qt::AlignVCenter); + } + phase->setObjectName(objectName); + phase->setText(value); + phase->show(); + } + + void setPhaseTone(const QString &tone) { + if (!phase || phase->property("tone").toString() == tone) + return; + phase->setProperty("tone", tone); + phase->style()->unpolish(phase); + phase->style()->polish(phase); + phase->update(); + } + void setActiveWork(bool active) { if (owner->property("activeWork").toBool() == active) return; @@ -1158,19 +1187,7 @@ class ConversationCard::Impl final { void createComposition(const AgentMessageData &message) { owner->setProperty("messageRole", "agent"); title->setText(QStringLiteral("Codex")); - title->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - phaseSeparator = makeLabel(QStringLiteral("•"), "messagePhase", header); - phaseSeparator->setObjectName(QStringLiteral("agentMessagePhaseSeparator")); - phaseSeparator->setWordWrap(false); - phaseSeparator->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - phase = makeLabel({}, "messagePhase", header); - phase->setObjectName(QStringLiteral("agentMessagePhase")); - phase->setWordWrap(false); - phase->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - headerLayout->setStretch(0, 0); - headerLayout->insertWidget(1, phaseSeparator, 0, Qt::AlignVCenter); - headerLayout->insertWidget(2, phase, 0, Qt::AlignVCenter); - headerLayout->insertStretch(3, 1); + showPhase({}, QStringLiteral("agentMessagePhase")); body = makeMarkdownLabel({}, content); contentLayout->addWidget(body); updateComposition(message); @@ -1184,12 +1201,12 @@ class ConversationCard::Impl final { owner->style()->unpolish(owner); owner->style()->polish(owner); } - phase->setText(message.finalAnswer ? QStringLiteral("final answer") - : QStringLiteral("update")); + showPhase(message.finalAnswer ? QStringLiteral("final answer") + : QStringLiteral("update"), + QStringLiteral("agentMessagePhase")); const QString phaseStatus = message.finalAnswer ? QStringLiteral("completed") : QStringLiteral("inProgress"); - setStatusTone(phaseSeparator, phaseStatus); setStatusTone(phase, phaseStatus); layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, message.finalAnswer ? 10 : 8); @@ -1421,7 +1438,6 @@ class ConversationCard::Impl final { QWidget *header = nullptr; QHBoxLayout *headerLayout = nullptr; QLabel *title = nullptr; - QLabel *phaseSeparator = nullptr; QLabel *phase = nullptr; CardCopyButton *copy = nullptr; CardDisclosureButton *disclosure = nullptr; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 5a95f98..d55a460 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -116,6 +116,7 @@ QString applicationStyleSheet() { QLabel[kind="brand"] { font-size: %3pt; font-weight: 600; } QLabel[kind="title"] { font-size: %2pt; font-weight: 600; } QLabel[kind="messagePhase"] { font-size: %2pt; font-weight: 400; } + QLabel[kind="messagePhase"][tone="steering"] { color: #146f73; } QLabel[kind="body"] { font-size: %2pt; } QLabel[kind="code"] { font-family: monospace; font-size: %2pt; font-weight: 400; } QLabel[kind="meta"] { color: #667085; font-size: %1pt; } diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 80e0093..564ff6c 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -1119,8 +1119,6 @@ bool testMutableCardsAndCommandOutput() { CardKey{AuthoritativeItemKey{thread, "turn", "user"}})]; auto *agentCardWidget = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "agent"}})]; - auto *agentPhaseSeparator = agentCardWidget->findChild( - QStringLiteral("agentMessagePhaseSeparator")); auto *agentPhase = agentCardWidget->findChild(QStringLiteral("agentMessagePhase")); const auto userLabels = userCard->findChildren(); @@ -1140,15 +1138,15 @@ bool testMutableCardsAndCommandOutput() { }), "authoritative user messages render GitHub Markdown tables"); result &= expect( - titleText(agentCardWidget) == QStringLiteral("Codex") && - agentPhaseSeparator && - agentPhaseSeparator->text() == QStringLiteral("•") && agentPhase && + titleText(agentCardWidget) == QStringLiteral("Codex") && agentPhase && agentPhase->text() == QStringLiteral("update") && agentPhase->property("tone").toString() == QStringLiteral("active") && - agentPhaseSeparator->property("tone").toString() == - QStringLiteral("active") && - agentPhase->font().weight() == QFont::Normal, - "interim agent messages show a normal-weight active update phase"); + agentPhase->font().weight() == QFont::Normal && + agentPhase->parentWidget()->layout()->indexOf(agentPhase) < + agentPhase->parentWidget()->layout()->indexOf( + copyButton(agentCardWidget)), + "interim agent messages show a right-aligned normal-weight update " + "phase before Copy"); auto *filesCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "files"}})]; auto *planCard = identities[stableKey( @@ -1216,14 +1214,11 @@ bool testMutableCardsAndCommandOutput() { result &= expect(card(view, stableKey(value.key)) == identities[stableKey(value.key)], "same-key same-kind card updates in place"); - result &= - expect(titleText(agentCardWidget) == QStringLiteral("Codex") && - agentPhaseSeparator && agentPhase && + result &= expect( + titleText(agentCardWidget) == QStringLiteral("Codex") && agentPhase && agentPhase->text() == QStringLiteral("final answer") && agentPhase->property("tone").toString() == QStringLiteral("success") && - agentPhaseSeparator->property("tone").toString() == - QStringLiteral("success") && agentPhase->font().weight() == QFont::Normal, "final agent messages show a normal-weight success answer phase"); result &= @@ -1461,16 +1456,24 @@ bool testCardFoldingGeometryAndRetention() { "a steering prompt joins the active turn"); spin(40); ConversationCard *steeringCard = card(view, stableKey(steeringKey)); + auto *steeringPhase = steeringCard + ? steeringCard->findChild( + QStringLiteral("steeringMessagePhase")) + : nullptr; auto *steeringAnimation = steeringCard ? steeringCard->findChild( QString{}, Qt::FindDirectChildrenOnly) : nullptr; - result &= - expect(steeringCard && userCard->isAncestorOf(steeringCard) && + result &= expect( + steeringCard && userCard->isAncestorOf(steeringCard) && steeringCard->property("nestedConversationCard").toBool() && - cardTitle(steeringCard) == QStringLiteral("You · steering") && - cardTitleColor(steeringCard) == - QColor(QStringLiteral("#146f73")) && + cardTitle(steeringCard) == QStringLiteral("You") && steeringPhase && + steeringPhase->text() == QStringLiteral("steering") && + steeringPhase->font().weight() == QFont::Normal && + steeringPhase->parentWidget()->layout()->indexOf(steeringPhase) < + steeringPhase->parentWidget()->layout()->indexOf( + copyButton(steeringCard)) && + cardTitleColor(steeringCard) == QColor(QStringLiteral("#146f73")) && steeringAnimation && steeringAnimation->isActive(), "a pending steering You card is nested and keeps its animation"); @@ -1484,14 +1487,14 @@ bool testCardFoldingGeometryAndRetention() { ConversationCard *authoritativeSteering = card(view, stableKey(steeringKey)); result &= expect(authoritativeSteering == steeringCard && - userCard->isAncestorOf(authoritativeSteering) && + userCard->isAncestorOf(authoritativeSteering) && authoritativeSteering->cardKind() == CardKind::UserMessage && - cardTitle(authoritativeSteering) == - QStringLiteral("You · steering") && - authoritativeSteering->palette().color( - QPalette::Window) == QColor(QStringLiteral("#eefafa")) && - steeringAnimation && !steeringAnimation->isActive(), - "steering acknowledgement morphs the same nested card"); + cardTitle(authoritativeSteering) == QStringLiteral("You") && + steeringPhase->text() == QStringLiteral("steering") && + authoritativeSteering->palette().color(QPalette::Window) == + QColor(QStringLiteral("#eefafa")) && + steeringAnimation && !steeringAnimation->isActive(), + "steering acknowledgement morphs the same nested card"); auto retainedEmptyReasoning = std::ranges::find_if( snapshot.sections.front().cards, [&emptyReasoning](const auto &value) { diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index f4176f5..34137a7 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -103,9 +103,10 @@ interim Codex updates, and the initial Command execution and Image folds. Filter removes retained content, final answers remain visible, and changing the Command default does not override an existing card's user-owned state. -Cards with content use one header action order: title/phase, flexible space, -backgroundless copy icon, then disclosure. The two icons share a vertical -center and the canonical compact 4 px action gap. Copy remains reachable on a +Cards with content use one header action order: title, flexible space, +normal-weight specialization, backgroundless copy icon, then disclosure. The +specialization has no separator glyph. The two icons share a vertical center +and the canonical compact 4 px action gap. Copy remains reachable on a 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 diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 35d50dd..1d77489 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -441,14 +441,15 @@ export function Card({card, active, collapsed, onToggle, onCopy, nested, turnCon let title = humanize(card.kind); let body: ReactNode; let phaseClass = ""; + let phaseLabel = ""; if (card.kind === "userMessage") { - const data = card.payload as UserMessageData; title = nestedCard ? "You · steering" : "You"; + const data = card.payload as UserMessageData; title = "You"; phaseLabel = nestedCard ? "steering" : ""; body = <>; } else if (card.kind === "localPrompt") { - const data = card.payload as LocalPromptData; title = data.state === "failed" ? "Not sent" : nestedCard ? "You · steering" : "You"; + 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}
}; } else if (card.kind === "agentMessage") { - const data = card.payload as AgentMessageData; title = "Codex"; phaseClass = data.finalAnswer ? "final" : "update"; + const data = card.payload as AgentMessageData; title = "Codex"; phaseClass = data.finalAnswer ? "final" : "update"; phaseLabel = data.finalAnswer ? "final answer" : "update"; body = ; } else if (card.kind === "reasoning") { const data = card.payload as ReasoningData; title = "Reasoning"; @@ -484,7 +485,7 @@ export function Card({card, active, collapsed, onToggle, onCopy, nested, turnCon const activeWork = (card.kind === "commandExecution" || card.kind === "imageGeneration") && ["active", "inProgress", "running", "started"].includes((card.payload as CommandExecutionData | ImageGenerationData).status); return
-
{title}{card.itemId}{copyContent.text && {copyFeedback && {copyFeedback.text}}}{foldable && }
{!collapsed && <>{body}{nested &&
{nested}
}} +
{title}{card.itemId}{phaseLabel && {phaseLabel}}{copyContent.text && {copyFeedback && {copyFeedback.text}}}{foldable && }
{!collapsed && <>{body}{nested &&
{nested}
}}
; } diff --git a/web/src/styles.css b/web/src/styles.css index e5c84c3..8147e63 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -91,7 +91,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; }.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-meta .card-copy-button.feedback-active svg { animation: copy-breathe 440ms ease-in-out; }.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: 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-meta .card-copy-button.feedback-active svg { animation: copy-breathe 440ms ease-in-out; }.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.collapsed > header { margin-bottom: 0; } .conversation-card.userMessage, .conversation-card.localPrompt { background: #eaf2ff; border-color: #bfd3f9; } .conversation-card.userMessage > header span, .conversation-card.localPrompt > header span { color: #285fca; } @@ -104,7 +104,7 @@ h1, h2, h3, p { margin: 0; } .conversation-card.reasoning { border-left: 3px solid #7896df; } .conversation-card.agentMessage.update { background: #fff; border-color: #dce2eb; } .conversation-card.agentMessage.final { background: #f4f0ff; border-color: #d4c5f2; } -.conversation-card.agentMessage.final > header span { color: #53389e; } +.conversation-card.agentMessage.final > header span { color: #53389e; }.conversation-card.agentMessage.final > header .card-phase.final { color: #176b45; } .conversation-card.fileChanges { border-left: 3px solid #28a56c; } @keyframes awaiting { to { background-position: -200% 0; } } @keyframes copy-breathe { 0%, 100% { color: #1d2633; transform: translateX(4px) scale(1); } 50% { color: #b9c4d2; transform: translateX(4px) scale(1.16); } } diff --git a/web/tests/card-copy.test.mjs b/web/tests/card-copy.test.mjs index 88c24d1..f4bf019 100644 --- a/web/tests/card-copy.test.mjs +++ b/web/tests/card-copy.test.mjs @@ -86,7 +86,26 @@ test("nested user messages expose the steering identity", () => { onToggle() {}, })); assert.match(markup, /conversation-card userMessage[^"]*steering/u); - assert.match(markup, /You · steering/u); + assert.match(markup, />You<\/span>[\s\S]*card-phase steering">steering { + const update = itemCard("agentMessage", "update", {text: "Working", finalAnswer: false}); + const updateMarkup = renderToStaticMarkup(createElement(Card, { + card: update, active: true, collapsed: false, onToggle() {}, + })); + assert.match(updateMarkup, /card-phase update">updatefinal answer { From cd3ce522b4856774f49de20d1e624c94b0d8c170 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sun, 30 Aug 2026 23:26:13 +0200 Subject: [PATCH 2/2] Stabilize card status and prompt lifecycle --- docs/codex-architecture.md | 27 +- docs/ui-behavior.md | 47 ++- src/codex/PresentationStatus.h | 51 ++- src/codex/UiSession.cpp | 279 ++++++------- src/codex/middle/ConversationCards.cpp | 154 ++++--- src/codex/middle/ConversationProjection.cpp | 11 +- src/codex/middle/InspectorPane.cpp | 2 +- src/codex/middle/MiddleTypes.h | 12 +- src/codex/middle/PromptCoordinator.cpp | 35 +- src/codex/middle/PromptCoordinator.h | 20 +- src/codex/middle/ThreadPane.cpp | 8 +- src/codex/ui/UiStyle.cpp | 2 + tests/codex/ApplicationLayoutTest.cpp | 99 ++--- tests/codex/ConversationCardsTest.cpp | 366 ++++++++++------ tests/codex/ConversationProjectionTest.cpp | 111 ++--- tests/codex/PresentationPipelineTest.cpp | 7 + tests/codex/ShellIntegrationTest.cpp | 390 ++++++++++-------- ui-review/UX-DESIGN-DECISIONS.md | 20 +- web/src/app/App.tsx | 53 ++- web/src/app/BrowserFrontendSession.ts | 62 ++- web/src/app/Humanize.ts | 1 + .../conversation/ConversationProjection.ts | 13 +- web/src/conversation/MiddleTypes.ts | 5 +- web/src/conversation/PromptCoordinator.ts | 24 +- web/src/presentation/PresentationStatus.ts | 33 +- web/src/styles.css | 11 +- web/tests/browser-session-parity.test.mjs | 44 +- web/tests/card-copy.test.mjs | 48 ++- .../conversation-projection-parity.test.mjs | 42 +- web/tests/qualification.test.mjs | 11 +- web/tests/responsive-layout.test.mjs | 9 +- web/tests/supporting-surfaces-parity.test.mjs | 8 +- 32 files changed, 1173 insertions(+), 832 deletions(-) diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 1cc5c08..1582b8c 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -112,8 +112,8 @@ deliberately small and protocol-complete: or focusing the composer and preserving its local-admission scroll behavior; - an optional read-only frame observer feeds the bounded Protocol diagnostic without giving that renderer state or replay authority; -- absolute wakeups let the existing Qt timer drive deferred dispatch and visual - acknowledgment transitions without introducing another scheduler. +- absolute wakeups let the existing Qt timer drive deferred dispatch and the + pending-feedback threshold without introducing another scheduler. Downward communication uses the value-type `PresentationClient`: generic correlated `execute(action, data, completion)`, fire-and-forget @@ -661,19 +661,20 @@ are updated in place, absent keys are removed, new keys are inserted at their projected positions, and an identical typed projection is a true visual no-op. Prompt admission and app-server acknowledgment are separate states. On Send or -Steer, CodexUI immediately appends a client-local pending user card to the -destination thread. The card uses a muted blue user-prompt treatment and a -Qt-painted highlight sweeping left and right until the correlated app-server -result callback arrives. Only the matching `turn.start` or `turn.steer` +Steer, CodexUI immediately appends a calm client-local user card with an +emphasized blue or teal border to the destination thread. If the correlated +app-server result has not arrived after one second, a Qt-painted highlight +begins sweeping left and right. Only the matching `turn.start` or `turn.steer` completion callback acknowledges the prompt; conversation events cannot infer acknowledgment. Each request carries a unique `clientUserMessageId`, allowing the resulting user item to bind exactly even when prompts have identical text. -A fast successful result retains a 500-millisecond accepted transition so the -state change remains visible. Pending cards survive thread switching and +The matching success or definitive failure stops the sweep immediately; the +one-second wakeup changes presentation only and cannot acknowledge a request. +Pending cards survive thread switching and become normal authoritative user messages when the corresponding app-server -item materializes. The pending and authoritative forms share one visual key -and anchor during the accepted transition. Once materialization and that -transition are complete, the local submission is removed and the retained item +item materializes. The pending and authoritative forms share one visual key, +anchor, and active-turn border during that transition. Once materialization and +acknowledgment are complete, the local submission is removed and the retained item uses its authoritative identity. Failure produces a retained error card. The composer remains enabled while acknowledgments are outstanding. Multiple @@ -1595,8 +1596,8 @@ The remaining presentation-level choices are implemented as follows: - the Info/Protocol view retains at most 2,000 text blocks and the presentation model retains at most 256 authority-free telemetry records; the protocol statistics summary is below the expanding log; -- pending prompt acknowledgment uses a per-thread animated card rather than an - application-wide busy state or composer lock; +- overdue prompt acknowledgment uses delayed per-thread card feedback rather + than an application-wide busy state or composer lock; - reaching the conversation bottom re-enables automatic following, including after scrolling through composer-added trailing space or a contraction clamp; - paused conversation updates preserve the first visible stable card and its diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index e7a839e..a661902 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -69,7 +69,7 @@ bottom or is owned by the user. unaffected. - 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, + thread becomes terminal, the display reconciles that step to `completed`, Failed, or Interrupted. Pending steps remain Pending, and retained protocol data is not rewritten. - Each visible thread is presented as a compact card. Its status indicator is @@ -110,9 +110,10 @@ events and Enter used to confirm an active input-method composition never submit a prompt. Submitting a prompt creates a client-local pending prompt card at the bottom of -the destination thread immediately. The card uses a muted version of the normal -blue user-card treatment, with a brighter blue highlight sweeping left and -right across it until the app-server acknowledges the operation. +the destination thread immediately. The card begins with the calm blue +user-card treatment and an emphasized border. If the correlated app-server +result has not arrived after one second, a brighter blue highlight begins +sweeping left and right. Ordinary attached files appear as local Markdown links at the bottom of that card from its first frame. The same composed Markdown is sent to app-server and retained by the authoritative user message, so acknowledgment does not reflow @@ -121,11 +122,12 @@ encoded as path content rather than being misread as a fragment or query. 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. On -successful acknowledgment, the card shows a short accepted sweep before it -becomes a normal user message. If the authoritative app-server item arrives -during that transition, it inherits the pending card's stable visual anchor and -replaces it after the 500-millisecond transition completes. Only the correlated +user switches threads and returns. Successful acknowledgment or definitive +failure stops delayed feedback immediately. The one-second 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 anchor and replaces it as +soon as both correlation and acknowledgment are complete. Only the correlated `turn.start` or `turn.steer` completion callback acknowledges a prompt; conversation events never infer acknowledgment. Each operation carries a unique `clientUserMessageId`, which binds the authoritative user item without @@ -133,16 +135,21 @@ confusing identical prompt text. A failed submission remains visible with an explicit error state. 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 an animated -teal `You` card with a right-aligned `steering` specialization. After +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 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. -After acknowledgment, the authoritative outer You card uses a stronger static -blue border while its turn remains active. It has no animation, glow, shading, -or geometry change. Completion restores the canonical border in place. +At acknowledgment, the retained outer You card immediately uses the stronger +static blue running border. That border belongs to the card across its local- +prompt-to-authoritative-message morph while pending feedback stops; it +has no animation, glow, shading, or geometry change. A successful `turn.start` +result retains active ownership until the separate authoritative lifecycle +catches up, so the optimistic-to-running handoff has no neutral-border frame. +Completion restores the canonical border in place. The composer is cleared immediately after local admission and remains enabled. Users may enter additional prompts while earlier prompts await acknowledgment. @@ -201,7 +208,13 @@ User messages use the canonical soft-blue identity surface. Final Codex messages use the canonical soft-violet identity surface, while interim Codex updates remain neutral and identify their phase in the header. Process cards also remain neutral so they support rather than dominate the primary exchange. -Status text alone uses canonical semantic state colors. +Their lifecycle status is a normal-weight lowercase value at the right of the +header, immediately before Copy, and uses canonical semantic state colors. +Thread rows, conversation metadata, Inspector entries, and process cards share +the same vocabulary: `running`, `completed`, `failed`, `interrupted`, `pending`, +and `not loaded`. Command exit +code, cwd, and duration; file totals; and agent execution context remain below +the primary content. Generated-image cards have no duplicate body-status row. Every conversation card with visible detail uses the same keyboard-focusable disclosure chevron: down when expanded and left when collapsed. Title-only @@ -416,8 +429,8 @@ correct launcher and taskbar icon. Long-running operations need scoped progress presentation rather than a global busy state. Candidate scopes include prompt acknowledgment, thread creation, -and loading a long thread. Pending prompt acknowledgment already has its own -animated highlight sweep. Any additional progress indicator must preserve input +and loading a long thread. Overdue prompt acknowledgment already has its own +delayed highlight sweep. Any additional progress indicator must preserve input and navigation that can safely remain interactive, identify the operation it represents, and avoid suggesting that unrelated threads are blocked. No general spinner contract is defined yet. diff --git a/src/codex/PresentationStatus.h b/src/codex/PresentationStatus.h index 340e356..dc65baa 100644 --- a/src/codex/PresentationStatus.h +++ b/src/codex/PresentationStatus.h @@ -3,6 +3,8 @@ #ifndef CODEXUI_CODEX_PRESENTATIONSTATUS_H #define CODEXUI_CODEX_PRESENTATIONSTATUS_H +#include +#include #include namespace codexui::codex { @@ -13,6 +15,8 @@ enum class StatusKind { Completed, Failed, Interrupted, + Pending, + NotLoaded, }; struct PresentationStatus { @@ -24,17 +28,54 @@ struct PresentationStatus { constexpr PresentationStatus classifyStatus(std::string_view status) noexcept { if (status == "active" || status == "inProgress" || status == "running" || status == "started") - return {StatusKind::Active, "Running", "active"}; + return {StatusKind::Active, "running", "active"}; if (status == "completed" || status == "idle") - return {StatusKind::Completed, "Completed", "success"}; + return {StatusKind::Completed, "completed", "success"}; if (status == "failed" || status == "systemError") - return {StatusKind::Failed, "Failed", "danger"}; + return {StatusKind::Failed, "failed", "danger"}; if (status == "interrupted") - return {StatusKind::Interrupted, "Interrupted", "warning"}; - return {StatusKind::Unknown, status.empty() ? "Unknown" : status, + return {StatusKind::Interrupted, "interrupted", "warning"}; + if (status == "pending") + return {StatusKind::Pending, "pending", {}}; + if (status == "notLoaded") + return {StatusKind::NotLoaded, "not loaded", {}}; + return {StatusKind::Unknown, status.empty() ? "unknown" : status, std::string_view{}}; } +inline std::string displayStatus(std::string_view status) { + const PresentationStatus classified = classifyStatus(status); + if (classified.kind != StatusKind::Unknown || status.empty()) + return std::string(classified.text); + + std::string result; + result.reserve(status.size() + 4); + bool pendingSpace = false; + for (std::size_t index = 0; index < status.size(); ++index) { + const unsigned char character = static_cast(status[index]); + if (std::isspace(character) || character == '-' || character == '_' || + character == '.' || character == '/') { + pendingSpace = !result.empty(); + continue; + } + const unsigned char previous = + index == 0 ? 0 : static_cast(status[index - 1]); + const unsigned char next = + index + 1 == status.size() + ? 0 + : static_cast(status[index + 1]); + const bool upper = std::isupper(character); + const bool boundary = + upper && (std::islower(previous) || std::isdigit(previous) || + (std::isupper(previous) && std::islower(next))); + if ((pendingSpace || boundary) && !result.empty() && result.back() != ' ') + result.push_back(' '); + result.push_back(static_cast(std::tolower(character))); + pendingSpace = false; + } + return result.empty() ? std::string("unknown") : result; +} + constexpr bool isActiveStatus(std::string_view status) noexcept { return classifyStatus(status).kind == StatusKind::Active; } diff --git a/src/codex/UiSession.cpp b/src/codex/UiSession.cpp index 1ba34d8..4bcf7d1 100644 --- a/src/codex/UiSession.cpp +++ b/src/codex/UiSession.cpp @@ -35,9 +35,8 @@ std::string stringValue(const nlohmann::json &object, const char *key) { if (!object.is_object()) return {}; const auto found = object.find(key); - return found != object.end() && found->is_string() - ? found->get() - : std::string{}; + return found != object.end() && found->is_string() ? found->get() + : std::string{}; } std::string safeMessage(const nlohmann::json &value) { @@ -81,8 +80,7 @@ std::optional resultTurnId(const nlohmann::json &result) { return id; const nlohmann::json turn = data.value("turn", nlohmann::json::object()); id = stringValue(turn, "id"); - return id.empty() ? std::nullopt - : std::optional{std::move(id)}; + return id.empty() ? std::nullopt : std::optional{std::move(id)}; } std::string trimAscii(std::string value) { @@ -116,6 +114,7 @@ class UiSession::Impl final { std::uint64_t readRevision = 0; bool operationReady = false; bool resumeInFlight = false; + std::optional provisionalActiveTurnId; std::unordered_set recoveryAttemptedSubmissions; void resetForConnection() noexcept { @@ -124,6 +123,7 @@ class UiSession::Impl final { readRevision = 0; operationReady = false; resumeInFlight = false; + provisionalActiveTurnId.reset(); } }; @@ -139,8 +139,8 @@ class UiSession::Impl final { UiSession::Clock clock) : client(std::move(client)), defaultWorkspace(std::move(defaultWorkspace)), - clock(clock ? std::move(clock) : UiSession::Clock{ - systemClockMilliseconds}), + clock(clock ? std::move(clock) + : UiSession::Clock{systemClockMilliseconds}), alive(std::make_shared(true)) {} ~Impl() { *alive = false; } @@ -177,6 +177,25 @@ class UiSession::Impl final { return providerReady() && model.connection().role == "controller"; } + [[nodiscard]] std::optional + activeTurnId(const std::string &threadId) const { + if (const auto authoritative = model.activeTurnId(threadId)) + return authoritative; + const auto runtime = runtimeByThread.find(threadId); + if (runtime == runtimeByThread.end() || + !runtime->second.provisionalActiveTurnId) + return std::nullopt; + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return runtime->second.provisionalActiveTurnId; + const auto turn = + thread->turns.find(*runtime->second.provisionalActiveTurnId); + if (turn != thread->turns.end() && + isTerminalTurnStatus(turn->second.status)) + return std::nullopt; + return runtime->second.provisionalActiveTurnId; + } + void resetRuntimeForConnection() { resolvingRequests.clear(); deferredPromptDispatch.clear(); @@ -211,8 +230,7 @@ class UiSession::Impl final { const std::string action = stringValue(event, "action"); const std::string correlationId = stringValue(event, "correlationId"); const bool staleReadResult = - kind == "result" && action == "thread.read" && - !correlationId.empty() && + kind == "result" && action == "thread.read" && !correlationId.empty() && staleReadResultCorrelations.erase(correlationId) > 0; if (!staleReadResult) model.applyEvent(event); @@ -228,10 +246,8 @@ class UiSession::Impl final { } const std::string type = stringValue(event, "type"); - const nlohmann::json data = - event.value("data", nlohmann::json::object()); - const nlohmann::json scope = - event.value("scope", nlohmann::json::object()); + const nlohmann::json data = event.value("data", nlohmann::json::object()); + const nlohmann::json scope = event.value("scope", nlohmann::json::object()); const std::string eventThreadId = stringValue(scope, "threadId"); const bool hydrationResult = kind == "result" && presentation::isThreadHydrationAction(action); @@ -276,8 +292,8 @@ class UiSession::Impl final { (type == "connection.bridge" && stringValue(data, "state") == "opened" && providerReady()))) hydrateProvider(); - if (kind == "event" && type == "connection.controller" && - providerReady() && model.connection().role == "controller") { + if (kind == "event" && type == "connection.controller" && providerReady() && + model.connection().role == "controller") { ensureThreadSettingsHydrated(selectedThreadId); for (const std::string &threadId : prompts.queuedThreadIds()) schedulePromptDispatch(threadId); @@ -293,7 +309,7 @@ class UiSession::Impl final { } } else if (!eventThreadId.empty()) { if (const ThreadPresentation *thread = model.thread(eventThreadId)) - prompts.reconcile(eventThreadId, *thread, now()); + prompts.reconcile(eventThreadId, *thread); } if (!staleReadResult && kind == "result" && action == "thread.read" && @@ -361,22 +377,22 @@ class UiSession::Impl final { selectedThreadId.clear(); newThreadIntent = true; newThreadName = std::move(draft.name); - newThreadWorkspace = draft.workspace.empty() - ? defaultWorkspace - : std::move(draft.workspace); + newThreadWorkspace = + draft.workspace.empty() ? defaultWorkspace : std::move(draft.workspace); newThreadOptions = nlohmann::json::object(); if (!draft.baseInstructions.empty()) - newThreadOptions["baseInstructions"] = - std::move(draft.baseInstructions); + newThreadOptions["baseInstructions"] = std::move(draft.baseInstructions); if (!draft.developerInstructions.empty()) newThreadOptions["developerInstructions"] = std::move(draft.developerInstructions); if (draft.ephemeral) newThreadOptions["ephemeral"] = true; optimisticThread = UiOptimisticThreadView{ - std::string(DraftThreadId), {}, + std::string(DraftThreadId), + {}, newThreadName.empty() ? "New thread" : newThreadName, - newThreadWorkspace, UiOptimisticThreadPhase::Awaiting}; + newThreadWorkspace, + UiOptimisticThreadPhase::Awaiting}; effects.push_back(UiEffect::ClearComposerDraft); effects.push_back(UiEffect::FocusComposer); changed(); @@ -398,8 +414,7 @@ class UiSession::Impl final { runtime.readRevision = revision; client.execute( "thread.read", {{"threadId", threadId}, {"includeTurns", true}}, - [this, token, threadId, - revision](const nlohmann::json &result) { + [this, token, threadId, revision](const nlohmann::json &result) { if (!*token) return; const auto current = runtimeByThread.find(threadId); @@ -414,16 +429,14 @@ class UiSession::Impl final { ThreadRuntimeState &runtime = current->second; if (result.value("ok", false)) { runtime.hydration = Hydration::Hydrated; - if (runtime.settingsHydration == - SettingsHydration::WaitingForRead) + if (runtime.settingsHydration == SettingsHydration::WaitingForRead) resumeThreadForSettings(threadId); schedulePromptDispatch(threadId); return; } if (isTransientCancellation(result)) { runtime.hydration = Hydration::NotHydrated; - if (runtime.settingsHydration == - SettingsHydration::WaitingForRead) + if (runtime.settingsHydration == SettingsHydration::WaitingForRead) runtime.settingsHydration = SettingsHydration::Unknown; return; } @@ -477,8 +490,8 @@ class UiSession::Impl final { } runtime.settingsHydration = SettingsHydration::Failed; if (selectedThreadId == threadId) { - const std::string message = safeMessage( - result.value("error", nlohmann::json::object())); + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); showNotice(message.empty() ? "Thread settings refresh failed" : message); } @@ -522,8 +535,8 @@ class UiSession::Impl final { "not sent."); return false; } - draft.text = middle::promptWithFileLinks(std::move(draft.text), - draft.attachments); + draft.text = + middle::promptWithFileLinks(std::move(draft.text), draft.attachments); const bool selectedNewThreadDraft = draft.visiblySelectedThreadId == DraftThreadId && newThreadIntent; if (!draft.visiblySelectedThreadId.empty() && @@ -565,13 +578,17 @@ class UiSession::Impl final { } } - const auto activeTurn = - destination == DraftThreadId - ? std::optional{} - : model.activeTurnId(destination); - static_cast(prompts.admit( + const auto activeTurn = destination == DraftThreadId + ? std::optional{} + : activeTurnId(destination); + const std::int64_t admittedAt = now(); + const std::uint64_t submissionId = prompts.admit( destination, std::move(draft.text), std::move(draft.attachments), - std::move(draft.turnStartOptions), thread, activeTurn, now())); + std::move(draft.turnStartOptions), thread, activeTurn, admittedAt); + const std::int64_t animationAt = + admittedAt + middle::PendingAnimationDelayMilliseconds; + pendingAnimationDeadlines[submissionId] = animationAt; + scheduleWakeup(animationAt); effects.push_back(UiEffect::PrepareLocalPromptAdmission); if (destination == DraftThreadId) { pendingThreadStartOptions = std::move(draft.threadStartOptions); @@ -627,10 +644,10 @@ class UiSession::Impl final { showNotice(error); return; } - const std::string threadId = stringValue( - result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); + const std::string threadId = + stringValue(result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); if (threadId.empty()) { const std::string error = "Thread creation returned no thread identifier"; @@ -672,8 +689,7 @@ class UiSession::Impl final { pendingThreadWorkspace.clear(); if (!requestedName.empty()) client.execute("thread.rename", - {{"threadId", threadId}, - {"name", requestedName}}); + {{"threadId", threadId}, {"name", requestedName}}); changed(); schedulePromptDispatch(threadId); }); @@ -714,24 +730,21 @@ class UiSession::Impl final { resumePromptQueue(threadId); return; } - const auto dispatch = - prompts.beginNext(threadId, model.activeTurnId(threadId)); + const auto dispatch = prompts.beginNext(threadId, activeTurnId(threadId)); if (dispatch) dispatchPrompt(*dispatch); } void dispatchPrompt(middle::PromptDispatch dispatch) { - nlohmann::json input = nlohmann::json::array( - {{{"type", "text"}, - {"text", dispatch.prompt}, - {"text_elements", nlohmann::json::array()}}}); + nlohmann::json input = + nlohmann::json::array({{{"type", "text"}, + {"text", dispatch.prompt}, + {"text_elements", nlohmann::json::array()}}}); for (const AttachmentDraft &attachment : dispatch.attachments) { if (attachment.mimeType.starts_with("image/")) - input.push_back({{"type", "localImage"}, - {"path", attachment.path}}); + input.push_back({{"type", "localImage"}, {"path", attachment.path}}); else if (attachment.mimeType.starts_with("audio/")) - input.push_back({{"type", "localAudio"}, - {"path", attachment.path}}); + input.push_back({{"type", "localAudio"}, {"path", attachment.path}}); } const std::string threadId = dispatch.threadId; const std::uint64_t submissionId = dispatch.id; @@ -797,8 +810,7 @@ class UiSession::Impl final { }); } - void completePrompt(const std::string &threadId, - std::uint64_t submissionId, + void completePrompt(const std::string &threadId, std::uint64_t submissionId, const nlohmann::json &result) { if (isTransientCancellation(result)) { if (prompts.requeue(threadId, submissionId)) { @@ -819,9 +831,14 @@ class UiSession::Impl final { if (result.value("ok", false)) { if (runtime != runtimeByThread.end()) runtime->second.operationReady = true; - static_cast(prompts.acknowledge( - threadId, submissionId, resultTurnId(result), now())); - scheduleAcceptedTransition(threadId, submissionId); + const middle::PromptSubmission *submission = + prompts.submission(threadId, submissionId); + const bool startsTurn = submission && submission->startsTurn; + const std::optional turnId = resultTurnId(result); + static_cast( + prompts.acknowledge(threadId, submissionId, turnId)); + if (startsTurn && turnId && runtime != runtimeByThread.end()) + runtime->second.provisionalActiveTurnId = *turnId; if (optimisticThread && optimisticThread->threadId == threadId) optimisticThread->phase = UiOptimisticThreadPhase::Confirmed; } else { @@ -834,6 +851,7 @@ class UiSession::Impl final { optimisticThread->phase = UiOptimisticThreadPhase::Failed; showNotice(message.empty() ? "Turn submission failed" : message); } + pendingAnimationDeadlines.erase(submissionId); changed(); schedulePromptDispatch(threadId); } @@ -890,57 +908,30 @@ class UiSession::Impl final { return true; } - void scheduleAcceptedTransition(const std::string &threadId, - std::uint64_t submissionId) { - const middle::PromptSubmission *submission = - prompts.submission(threadId, submissionId); - if (!submission || submission->state != middle::PromptState::Accepted) - return; - const std::int64_t deadline = - submission->acceptedAtMilliseconds + - middle::AcknowledgementTransitionMilliseconds; - acceptedTransitionDeadlines[{threadId, submissionId}] = deadline; - scheduleWakeup(deadline); - } - void tick() { nextWakeupAt.reset(); - const auto deferred = std::exchange(deferredPromptDispatch, - std::set{}); + const auto deferred = + std::exchange(deferredPromptDispatch, std::set{}); for (const std::string &threadId : deferred) dispatchNextPrompt(threadId); const std::int64_t current = now(); bool projectionChanged = false; - for (auto iterator = acceptedTransitionDeadlines.begin(); - iterator != acceptedTransitionDeadlines.end();) { + for (auto iterator = pendingAnimationDeadlines.begin(); + iterator != pendingAnimationDeadlines.end();) { if (iterator->second > current) { scheduleWakeup(iterator->second); ++iterator; continue; } - const auto &[threadId, submissionId] = iterator->first; - const middle::PromptSubmission *submission = - prompts.submission(threadId, submissionId); - if (submission && submission->state == middle::PromptState::Accepted && - submission->acceptedTransitionActive(current)) { - iterator->second = submission->acceptedAtMilliseconds + - middle::AcknowledgementTransitionMilliseconds; - scheduleWakeup(iterator->second); - ++iterator; - continue; - } - if (const ThreadPresentation *thread = model.thread(threadId)) - prompts.reconcile(threadId, *thread, current); - iterator = acceptedTransitionDeadlines.erase(iterator); + iterator = pendingAnimationDeadlines.erase(iterator); projectionChanged = true; } if (projectionChanged) changed(); } - [[nodiscard]] bool isPendingActionable( - const std::string &requestKey) const { + [[nodiscard]] bool isPendingActionable(const std::string &requestKey) const { const auto request = model.pendingRequestPresentations().find(requestKey); return canControlProvider() && request != model.pendingRequestPresentations().end() && @@ -948,19 +939,19 @@ class UiSession::Impl final { !resolvingRequests.contains(requestKey); } - [[nodiscard]] UiPendingRequestView pendingView( - const PendingRequestPresentation &request) const { - return {request.id, - request.kind, - request.threadId, - request.generation, - request.raw, - PendingRequestPolicy::title(request.kind), - PendingRequestPolicy::detail(request.id, request.threadId, - request.raw), - PendingRequestPolicy::directAcceptLabel(request.kind), - PendingRequestPolicy::supportsDirectAccept(request.kind), - isPendingActionable(request.id)}; + [[nodiscard]] UiPendingRequestView + pendingView(const PendingRequestPresentation &request) const { + return { + request.id, + request.kind, + request.threadId, + request.generation, + request.raw, + PendingRequestPolicy::title(request.kind), + PendingRequestPolicy::detail(request.id, request.threadId, request.raw), + PendingRequestPolicy::directAcceptLabel(request.kind), + PendingRequestPolicy::supportsDirectAccept(request.kind), + isPendingActionable(request.id)}; } bool resolvePending(UiPendingRequestView request, @@ -1018,9 +1009,8 @@ class UiSession::Impl final { } } else if (newThreadIntent) { result.identity = DraftThreadId; - result.canonical["cwd"] = newThreadWorkspace.empty() - ? defaultWorkspace - : newThreadWorkspace; + result.canonical["cwd"] = + newThreadWorkspace.empty() ? defaultWorkspace : newThreadWorkspace; } else { result.canonical["cwd"] = defaultWorkspace; } @@ -1036,17 +1026,14 @@ class UiSession::Impl final { std::string draftWorkspace) { const std::int64_t current = now(); const std::string visibleThreadId = - selectedThreadId.empty() && newThreadIntent - ? std::string(DraftThreadId) - : selectedThreadId; + selectedThreadId.empty() && newThreadIntent ? std::string(DraftThreadId) + : selectedThreadId; viewState = UiSessionView{}; viewState.selectedThreadId = selectedThreadId; viewState.newThreadIntent = newThreadIntent; - viewState.threads = - ui::projectThreadListSnapshot(model, visibleThreadId); + viewState.threads = ui::projectThreadListSnapshot(model, visibleThreadId); viewState.inspector = ui::projectInspectorSnapshot( - model, selectedThreadId, - [this](std::string_view requestId) { + model, selectedThreadId, [this](std::string_view requestId) { return isPendingActionable(std::string(requestId)); }); viewState.settings = projectSettings(); @@ -1056,13 +1043,12 @@ class UiSession::Impl final { middle::AuthoritativeItemIndex authoritativeItems = middle::indexAuthoritativeItems(visibleThreadId, thread); if (thread) - prompts.reconcile(selectedThreadId, authoritativeItems, current); + prompts.reconcile(selectedThreadId, authoritativeItems); const std::size_t authoritativeCount = authoritativeItems.ordered.size(); HistoryWindow &history = historyWindows[visibleThreadId]; if (!conversationFollowing && authoritativeCount > history.lastAuthoritativeCount) - history.effective += - authoritativeCount - history.lastAuthoritativeCount; + history.effective += authoritativeCount - history.lastAuthoritativeCount; else if (conversationFollowing) history.effective = history.requested; history.lastAuthoritativeCount = authoritativeCount; @@ -1071,14 +1057,13 @@ class UiSession::Impl final { conversation.snapshot = middle::ConversationProjection::project( authoritativeItems, thread, prompts.submissions(visibleThreadId), history.effective, current); - conversation.snapshot.activeTurnId = - model.activeTurnId(selectedThreadId); + conversation.snapshot.activeTurnId = activeTurnId(selectedThreadId); if (thread) { conversation.mode = UiConversationMode::Thread; conversation.title = thread->title; conversation.workspace = thread->cwd; const PresentationStatus status = classifyStatus(thread->status); - conversation.status = std::string(status.text); + conversation.status = displayStatus(thread->status); conversation.statusTone = std::string(status.tone); conversation.lastActivityAt = thread->lastActivityAt; conversation.emptyMessage = "No materialized activity."; @@ -1106,10 +1091,10 @@ class UiSession::Impl final { status.providerState = connection.providerState; status.connectionSettings = connection.settings; status.workspace = conversation.workspace; - status.activeTurn = - model.activeTurnId(selectedThreadId).has_value(); + status.activeTurn = activeTurnId(selectedThreadId).has_value(); status.totalPending = model.pendingRequestCount(); - const std::string selectedKey = stringValue(connection.settings, "selected"); + const std::string selectedKey = + stringValue(connection.settings, "selected"); const nlohmann::json available = connection.settings.value("available", nlohmann::json::array()); if (available.is_array()) { @@ -1163,8 +1148,7 @@ class UiSession::Impl final { std::uint64_t observedConnectionGeneration = 0; std::uint64_t observedProviderGeneration = 0; std::set deferredPromptDispatch; - std::map, std::int64_t> - acceptedTransitionDeadlines; + std::map pendingAnimationDeadlines; std::vector notices; std::vector effects; @@ -1174,9 +1158,8 @@ class UiSession::Impl final { UiSession::UiSession(PresentationClient client, std::string defaultWorkspace, Clock clock) - : impl(std::make_unique(std::move(client), - std::move(defaultWorkspace), - std::move(clock))) {} + : impl(std::make_unique( + std::move(client), std::move(defaultWorkspace), std::move(clock))) {} UiSession::~UiSession() = default; @@ -1210,9 +1193,8 @@ std::string UiSession::conversationKey() const { : impl->selectedThreadId; } -const UiSessionView & -UiSession::refreshView(bool conversationFollowing, - std::string draftWorkspace) { +const UiSessionView &UiSession::refreshView(bool conversationFollowing, + std::string draftWorkspace) { return impl->refreshView(conversationFollowing, std::move(draftWorkspace)); } @@ -1229,9 +1211,7 @@ void UiSession::refreshThreads() { impl->client.execute("threads.list", nlohmann::json::object()); } -void UiSession::connectTransport() { - impl->client.send("connection.connect"); -} +void UiSession::connectTransport() { impl->client.send("connection.connect"); } void UiSession::disconnectTransport() { impl->client.send("connection.disconnect"); @@ -1294,10 +1274,10 @@ void UiSession::forkThread(const std::string &threadId) { [implementation = impl.get(), token](const nlohmann::json &result) { if (!*token || !result.value("ok", false)) return; - const std::string id = stringValue( - result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); + const std::string id = + stringValue(result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); if (!id.empty()) implementation->selectThread(id); }); @@ -1309,8 +1289,7 @@ void UiSession::toggleThreadArchive(const std::string &threadId) { const ThreadPresentation *thread = impl->model.thread(threadId); if (!thread) return; - impl->client.execute(thread->archived ? "thread.unarchive" - : "thread.archive", + impl->client.execute(thread->archived ? "thread.unarchive" : "thread.archive", {{"threadId", threadId}}); } @@ -1324,18 +1303,18 @@ bool UiSession::submitPrompt(UiPromptDraft draft) { } void UiSession::interruptTurn() { - const auto turn = impl->model.activeTurnId(impl->selectedThreadId); + const auto turn = impl->activeTurnId(impl->selectedThreadId); if (turn) - impl->client.execute("turn.interrupt", - {{"threadId", impl->selectedThreadId}, - {"turnId", *turn}}); + impl->client.execute( + "turn.interrupt", + {{"threadId", impl->selectedThreadId}, {"turnId", *turn}}); } void UiSession::loadEarlierConversation() { - const std::string key = impl->selectedThreadId.empty() && - impl->newThreadIntent - ? std::string(DraftThreadId) - : impl->selectedThreadId; + const std::string key = + impl->selectedThreadId.empty() && impl->newThreadIntent + ? std::string(DraftThreadId) + : impl->selectedThreadId; Impl::HistoryWindow &history = impl->historyWindows[key]; history.requested += middle::ConversationProjection::DefaultAuthoritativeItemLimit; diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 0d6e5c3..97fefff 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -146,15 +146,15 @@ class CardCopyButton final : public QToolButton { pulse_->setKeyValueAt(0.5, QColor(QStringLiteral("#b9c4d2"))); pulse_->setEndValue(QColor(QStringLiteral("#1d2633"))); pulse_->setEasingCurve(QEasingCurve::InOutSine); - QObject::connect(pulse_, &QVariantAnimation::valueChanged, this, - [this](const QVariant &value) { - pulseColor_ = value.value(); - const qreal phase = static_cast(pulse_->currentTime()) / - pulse_->duration(); - pulseScale_ = - 1.0 + 0.12 * (1.0 - std::abs(2.0 * phase - 1.0)); - update(); - }); + QObject::connect( + pulse_, &QVariantAnimation::valueChanged, this, + [this](const QVariant &value) { + pulseColor_ = value.value(); + const qreal phase = + static_cast(pulse_->currentTime()) / pulse_->duration(); + pulseScale_ = 1.0 + 0.12 * (1.0 - std::abs(2.0 * phase - 1.0)); + update(); + }); QObject::connect(pulse_, &QVariantAnimation::finished, this, [this] { pulseScale_ = 1.0; pulseColor_ = QColor(QStringLiteral("#1d2633")); @@ -312,8 +312,8 @@ class ImageRibbon final : public QScrollArea { setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); setStyleSheet( QStringLiteral("QScrollArea#messageImages{background:#fbfcfe;" - "border:1px solid #d7dee8;border-radius:6px;}" - "QWidget#messageImageStrip{background:transparent;}")); + "border:1px solid #d7dee8;border-radius:6px;}" + "QWidget#messageImageStrip{background:transparent;}")); strip_ = new QWidget; strip_->setObjectName(QStringLiteral("messageImageStrip")); @@ -503,13 +503,8 @@ bool setVisibleMarkdown(QLabel *label, const QString &markdown) { QString displayStatus(const QString &status) { const QByteArray encoded = status.toUtf8(); - const PresentationStatus classified = classifyStatus(std::string_view( - encoded.constData(), static_cast(encoded.size()))); - const QString display = QString::fromUtf8( - classified.text.data(), static_cast(classified.text.size())); - return classified.kind == StatusKind::Unknown - ? UiStyle::humanizeLabel(display) - : display; + return text(codexui::codex::displayStatus(std::string_view( + encoded.constData(), static_cast(encoded.size())))); } QString statusTone(const QString &status) { @@ -532,7 +527,7 @@ void setStatusTone(QLabel *label, const QString &status) { } QString commandMetadata(const CommandExecutionData &command) { - QStringList metadata{displayStatus(text(command.status))}; + QStringList metadata; if (command.exitCode) metadata << QStringLiteral("exit %1").arg(*command.exitCode); if (!command.cwd.empty()) @@ -549,8 +544,8 @@ QString agentMetadata(const AgentActivityData &activity) { QStringList metadata; if (!activity.tool.empty()) metadata << text(activity.tool); - metadata << displayStatus( - text(activity.status.empty() ? activity.kind : activity.status)); + 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()) @@ -627,7 +622,7 @@ QString planMarkdown(const PlanData &plan) { for (const PlanStepData &step : plan.steps) { const QString marker = step.status == "completed" ? QStringLiteral("✓") : step.status == "inProgress" ? QStringLiteral("◉") - : QStringLiteral("○"); + : QStringLiteral("○"); rows << QStringLiteral("%1 %2 ").arg(marker, text(step.text)); } return rows.join(QLatin1Char('\n')); @@ -657,7 +652,7 @@ CardCopyContent cardCopyContent(const VisibleCardData &card) { return { joinedCopyText({text(trimTrailingEmptyLines(payload.command)), text(trimTrailingEmptyLines(payload.output))}), - false}; + false}; } else if constexpr (std::is_same_v) { return { joinedCopyText({text(payload.prompt), text(payload.resultText)}), @@ -685,10 +680,6 @@ CardCopyContent cardCopyContent(const VisibleCardData &card) { card.payload); } -bool acceptedTransitionActive(const LocalPromptData &prompt, qint64 now) { - return prompt.acceptedTransitionActive(now); -} - bool presentationEquals(const VisibleCardData &left, const VisibleCardData &right) { if (left.kind != right.kind) @@ -699,7 +690,7 @@ bool presentationEquals(const VisibleCardData &left, return first->prompt == second->prompt && first->imagePaths == second->imagePaths && first->state == second->state && - first->acceptedAtMilliseconds == second->acceptedAtMilliseconds && + first->showPendingAnimation == second->showPendingAnimation && first->error == second->error; } return left.payload == right.payload; @@ -1050,7 +1041,8 @@ class ConversationCard::Impl final { } bool setAuthoritativeTurnActive(bool active) { - const bool next = active && current.kind == CardKind::UserMessage; + const bool next = active && (current.kind == CardKind::LocalPrompt || + current.kind == CardKind::UserMessage); if (authoritativeTurnActive == next) return false; authoritativeTurnActive = next; @@ -1081,7 +1073,7 @@ class ConversationCard::Impl final { void setNestedCards(const std::vector &cards) { const std::unordered_set retained(cards.begin(), - cards.end()); + cards.end()); for (int index = nestedLayout->count() - 1; index >= 0; --index) { auto *card = dynamic_cast( nestedLayout->itemAt(index)->widget()); @@ -1145,6 +1137,16 @@ class ConversationCard::Impl final { phase->show(); } + void showStatus(const QString &status, const QString &objectName) { + if (status.isEmpty()) { + if (phase) + phase->hide(); + return; + } + showPhase(displayStatus(status), objectName); + setStatusTone(phase, status); + } + void setPhaseTone(const QString &tone) { if (!phase || phase->property("tone").toString() == tone) return; @@ -1233,6 +1235,7 @@ class ConversationCard::Impl final { void updateComposition(const CommandExecutionData &execution) { setActiveWork(isActiveStatus(execution.status)); + showStatus(text(execution.status), QStringLiteral("commandStatus")); const std::string trimmedCommand = trimTrailingEmptyLines(execution.command); const QString displayCommand = text(trimmedCommand); @@ -1252,9 +1255,7 @@ class ConversationCard::Impl final { // the documented follow-latest state. output->restoreScrollState({true, 0}); } - metadata->setText(commandMetadata(execution)); - setStatusTone(metadata, text(execution.status)); - metadata->show(); + setVisibleText(metadata, commandMetadata(execution)); } void createComposition(const AgentActivityData &activity) { @@ -1269,10 +1270,8 @@ class ConversationCard::Impl final { } void updateComposition(const AgentActivityData &activity) { - metadata->setText(agentMetadata(activity)); - setStatusTone(metadata, text(activity.status.empty() ? activity.kind - : activity.status)); - metadata->show(); + showStatus(text(activity.status), QStringLiteral("agentActivityStatus")); + setVisibleText(metadata, agentMetadata(activity)); setVisibleText(body, text(activity.prompt)); setVisibleMarkdown(detail, text(activity.resultText)); } @@ -1299,14 +1298,13 @@ class ConversationCard::Impl final { void updateComposition(const FileChangesData &changes) { setVisibleText(body, fileChangesText(changes)); - QStringList values{displayStatus(text(changes.status))}; - values << QStringLiteral("%1 paths").arg(changes.changes.size()); + 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(" | "))); - setStatusTone(metadata, text(changes.status)); metadata->show(); } @@ -1323,9 +1321,7 @@ class ConversationCard::Impl final { void createComposition(const ImageGenerationData &image) { title->setText(QStringLiteral("Generated image")); - metadata = makeLabel({}, "meta", content); body = makeLabel({}, "body", content); - contentLayout->addWidget(metadata); contentLayout->addWidget(body); createImageContainer(); updateComposition(image); @@ -1333,12 +1329,11 @@ class ConversationCard::Impl final { void updateComposition(const ImageGenerationData &image) { setActiveWork(isActiveStatus(image.status)); + showStatus(text(image.status), QStringLiteral("imageGenerationStatus")); const bool generated = !image.status.empty() || !image.revisedPrompt.empty(); title->setText(generated ? QStringLiteral("Generated image") : QStringLiteral("Image")); - setVisibleText(metadata, displayStatus(text(image.status))); - setStatusTone(metadata, text(image.status)); 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. @@ -1393,17 +1388,15 @@ class ConversationCard::Impl final { const auto *prompt = std::get_if(¤t.payload); if (!prompt) return false; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const bool transitioning = acceptedTransitionActive(*prompt, now); const bool waiting = prompt->state == PromptState::Queued || prompt->state == PromptState::InFlight; const bool failed = prompt->state == PromptState::Failed; const bool steering = owner->property("nestedConversationCard").toBool(); - const QString foreground = waiting || transitioning - ? steering ? QStringLiteral("#146f73") - : QStringLiteral("#536b8f") - : failed ? QStringLiteral("#982f3d") - : QStringLiteral("#1d2633"); + const QString foreground = + waiting + ? steering ? QStringLiteral("#146f73") : QStringLiteral("#536b8f") + : failed ? QStringLiteral("#982f3d") + : QStringLiteral("#1d2633"); const QString style = QStringLiteral("background:transparent;color:%1;").arg(foreground); bool changed = false; @@ -1422,7 +1415,7 @@ class ConversationCard::Impl final { changed = setVisibleText(metadata, status) || changed; - if (waiting || transitioning) { + if (waiting && prompt->showPendingAnimation) { if (!animationTimer->isActive()) animationTimer->start(); } else { @@ -1520,14 +1513,17 @@ void ConversationCard::paintEvent(QPaintEvent *event) { 9.0); return; } - if (impl_->current.kind == CardKind::UserMessage && - impl_->authoritativeTurnActive) { + const auto paintActiveTurnBorder = [this] { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.setBrush(Qt::NoBrush); painter.setPen(QPen(QColor(QStringLiteral("#6f98e8")), 1.5)); painter.drawRoundedRect(QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0), 8.0, 8.0); + }; + if (impl_->current.kind == CardKind::UserMessage) { + if (impl_->authoritativeTurnActive) + paintActiveTurnBorder(); return; } if (impl_->current.kind != CardKind::LocalPrompt) @@ -1539,42 +1535,38 @@ void ConversationCard::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); const QRectF bounds = QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5); - const qint64 now = QDateTime::currentMSecsSinceEpoch(); const bool waiting = prompt->state == PromptState::Queued || prompt->state == PromptState::InFlight; - const bool transitioning = acceptedTransitionActive(*prompt, now); const bool failed = prompt->state == PromptState::Failed; const bool steering = property("nestedConversationCard").toBool(); - const QColor background = waiting || transitioning - ? QColor(steering ? QStringLiteral("#d9efef") - : QStringLiteral("#dbe7f8")) - : failed ? QColor(QStringLiteral("#fff0f2")) - : QColor(steering - ? QStringLiteral("#eefafa") + const bool animated = waiting && prompt->showPendingAnimation; + const QColor background = failed + ? QColor(QStringLiteral("#fff0f2")) + : QColor(steering ? QStringLiteral("#eefafa") : QStringLiteral("#eaf2ff")); - const QColor border = waiting || transitioning - ? QColor(steering ? QStringLiteral("#78bdc0") - : QStringLiteral("#9eb9df")) - : failed ? QColor(QStringLiteral("#efb8c0")) - : QColor(steering - ? QStringLiteral("#9fd7d8") + const QColor border = failed + ? QColor(QStringLiteral("#efb8c0")) + : waiting + ? QColor(steering ? QStringLiteral("#5caeb1") + : QStringLiteral("#79a0d7")) + : QColor(steering ? QStringLiteral("#9fd7d8") : QStringLiteral("#bfd3f9")); painter.setBrush(background); - painter.setPen(QPen(border, 1.0)); + painter.setPen(QPen(border, waiting ? 1.5 : 1.0)); painter.drawRoundedRect(bounds, 8.0, 8.0); - if (!waiting && !transitioning) + if (!animated) { + if (impl_->authoritativeTurnActive) + paintActiveTurnBorder(); return; + } + const qint64 now = QDateTime::currentMSecsSinceEpoch(); const qint64 phase = now % (2 * PendingHalfCycleMilliseconds); - const qreal position = - waiting ? phase <= PendingHalfCycleMilliseconds - ? qreal(phase) / PendingHalfCycleMilliseconds - : qreal(2 * PendingHalfCycleMilliseconds - phase) / - PendingHalfCycleMilliseconds - : std::clamp(qreal(now - prompt->acceptedAtMilliseconds) / - AcknowledgementTransitionMilliseconds, - 0.0, 1.0); + const qreal position = phase <= PendingHalfCycleMilliseconds + ? qreal(phase) / PendingHalfCycleMilliseconds + : qreal(2 * PendingHalfCycleMilliseconds - phase) / + PendingHalfCycleMilliseconds; const qreal center = bounds.left() + position * bounds.width(); const qreal radius = std::max(28.0, bounds.width() * 0.24); QLinearGradient sweep(center - radius, 0.0, center + radius, 0.0); @@ -1592,10 +1584,10 @@ void ConversationCard::paintEvent(QPaintEvent *event) { painter.restore(); painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(QColor(steering ? QStringLiteral("#5caeb1") - : QStringLiteral("#79a0d7")), - 1.5)); + painter.setPen(QPen(border, 1.5)); painter.drawRoundedRect(bounds, 8.0, 8.0); + if (impl_->authoritativeTurnActive) + paintActiveTurnBorder(); } ConversationCard *createConversationCard(const VisibleCardData &data, diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp index c8daec2..4653ae3 100644 --- a/src/codex/middle/ConversationProjection.cpp +++ b/src/codex/middle/ConversationProjection.cpp @@ -394,8 +394,7 @@ ConversationSnapshot ConversationProjection::project( continue; const AuthoritativeItem &item = authoritativeItems.ordered[index]; const auto binding = bindings.find(item.key); - if (binding != bindings.end() && - binding->second->localCardVisible(nowMilliseconds)) + if (binding != bindings.end() && binding->second->localCardVisible()) continue; CardKey visualKey = item.promptAlias ? CardKey{item.promptAlias->key} : CardKey{item.key}; @@ -482,7 +481,7 @@ ConversationSnapshot ConversationProjection::project( } for (const PromptSubmission &submission : localSubmissions) { - if (!submission.localCardVisible(nowMilliseconds)) + if (!submission.localCardVisible()) continue; std::optional materializedIndex; if (submission.materializedItem) @@ -512,7 +511,11 @@ ConversationSnapshot ConversationProjection::project( submission.state == PromptState::Queued ? PromptState::InFlight : submission.state, - submission.acceptedAtMilliseconds, + (submission.state == PromptState::Queued || + submission.state == PromptState::InFlight) && + nowMilliseconds - + submission.admittedAtMilliseconds >= + PendingAnimationDelayMilliseconds, submission.error, localImagePaths(submission)}}; const bool authoritativeRootExists = diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index f490076..b02664a 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -77,7 +77,7 @@ QLabel *makeLabel(QString value, const char *kind = "body") { QLabel *statusLabel(const std::string &status) { const PresentationStatus classified = classifyStatus(status); - auto *label = makeLabel(text(classified.text), "meta"); + auto *label = makeLabel(text(displayStatus(status)), "meta"); if (!classified.tone.empty()) label->setProperty("tone", classified.tone.data()); return label; diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 0f62c39..6334dcc 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -15,7 +15,7 @@ namespace codexui::codex::middle { -inline constexpr std::int64_t AcknowledgementTransitionMilliseconds = 500; +inline constexpr std::int64_t PendingAnimationDelayMilliseconds = 1000; inline constexpr std::size_t AuthoritativeHistoryPageSize = 80; struct AuthoritativeItemKey { @@ -161,18 +161,10 @@ struct LocalPromptData { std::uint64_t submissionId = 0; std::string prompt; PromptState state = PromptState::Queued; - std::int64_t acceptedAtMilliseconds = 0; + bool showPendingAnimation = false; std::string error; std::vector imagePaths; - [[nodiscard]] bool - acceptedTransitionActive(std::int64_t nowMilliseconds) const noexcept { - return state == PromptState::Accepted && acceptedAtMilliseconds > 0 && - nowMilliseconds >= acceptedAtMilliseconds && - nowMilliseconds - acceptedAtMilliseconds < - AcknowledgementTransitionMilliseconds; - } - bool operator==(const LocalPromptData &) const = default; }; diff --git a/src/codex/middle/PromptCoordinator.cpp b/src/codex/middle/PromptCoordinator.cpp index 8fb1911..f982a1e 100644 --- a/src/codex/middle/PromptCoordinator.cpp +++ b/src/codex/middle/PromptCoordinator.cpp @@ -141,19 +141,9 @@ std::string promptWithFileLinks(std::string prompt, return prompt; } -bool PromptSubmission::acceptedTransitionActive( - std::int64_t nowMilliseconds) const noexcept { - return state == PromptState::Accepted && acceptedAtMilliseconds > 0 && - nowMilliseconds >= acceptedAtMilliseconds && - nowMilliseconds - acceptedAtMilliseconds < - AcknowledgementTransitionMilliseconds; -} - -bool PromptSubmission::localCardVisible( - std::int64_t nowMilliseconds) const noexcept { +bool PromptSubmission::localCardVisible() const noexcept { return state == PromptState::Queued || state == PromptState::InFlight || - state == PromptState::Failed || !materializedItem || - acceptedTransitionActive(nowMilliseconds); + state == PromptState::Failed || !materializedItem; } std::uint64_t PromptCoordinator::admit( @@ -171,6 +161,7 @@ std::uint64_t PromptCoordinator::admit( submission.prompt = std::move(prompt); submission.attachments = std::move(attachments); submission.turnOptions = std::move(turnOptions); + submission.admittedAtMilliseconds = nowMilliseconds; submission.expectedTurnId = std::move(activeTurnId); if (authoritativeThread) { @@ -219,13 +210,11 @@ PromptCoordinator::beginNext(const std::string &threadId, bool PromptCoordinator::acknowledge( const std::string &threadId, std::uint64_t submissionId, - std::optional authoritativeTurnId, - std::int64_t nowMilliseconds) { + std::optional authoritativeTurnId) { PromptSubmission *pending = find(threadId, submissionId); if (!pending || pending->state != PromptState::InFlight) return false; pending->state = PromptState::Accepted; - pending->acceptedAtMilliseconds = nowMilliseconds; pending->error.clear(); if (authoritativeTurnId) pending->expectedTurnId = std::move(authoritativeTurnId); @@ -326,16 +315,14 @@ bool PromptCoordinator::reassignThread(const std::string &fromThreadId, } void PromptCoordinator::reconcile(const std::string &threadId, - const ThreadPresentation &authoritativeThread, - std::int64_t nowMilliseconds) { + const ThreadPresentation &authoritativeThread) { auto authoritativeItems = indexAuthoritativeItems(threadId, &authoritativeThread); - reconcile(threadId, authoritativeItems, nowMilliseconds); + reconcile(threadId, authoritativeItems); } void PromptCoordinator::reconcile(const std::string &threadId, - AuthoritativeItemIndex &authoritativeItems, - std::int64_t nowMilliseconds) { + AuthoritativeItemIndex &authoritativeItems) { applyVisualAliases(threadId, authoritativeItems); auto found = byThread.find(threadId); if (found == byThread.end()) @@ -415,8 +402,7 @@ void PromptCoordinator::reconcile(const std::string &threadId, } for (const PromptSubmission &submission : found->second) { if (submission.state != PromptState::Accepted || - !submission.materializedItem || - submission.acceptedTransitionActive(nowMilliseconds)) + !submission.materializedItem) continue; visualAliasesByThread[threadId].insert_or_assign( *submission.materializedItem, @@ -424,10 +410,9 @@ void PromptCoordinator::reconcile(const std::string &threadId, submission.admissionAnchor, submission.admissionOrdinal}); } - std::erase_if(found->second, [nowMilliseconds](const auto &submission) { + std::erase_if(found->second, [](const auto &submission) { return submission.state == PromptState::Accepted && - submission.materializedItem && - !submission.acceptedTransitionActive(nowMilliseconds); + submission.materializedItem; }); applyVisualAliases(threadId, authoritativeItems); } diff --git a/src/codex/middle/PromptCoordinator.h b/src/codex/middle/PromptCoordinator.h index df85370..5fc00b8 100644 --- a/src/codex/middle/PromptCoordinator.h +++ b/src/codex/middle/PromptCoordinator.h @@ -34,7 +34,7 @@ struct PromptSubmission { std::vector attachments; nlohmann::json turnOptions = nlohmann::json::object(); PromptState state = PromptState::Queued; - std::int64_t acceptedAtMilliseconds = 0; + std::int64_t admittedAtMilliseconds = 0; std::string error; std::optional admissionAnchor; bool admissionAtStart = false; @@ -42,10 +42,7 @@ struct PromptSubmission { std::optional expectedTurnId; std::optional materializedItem; - [[nodiscard]] bool - acceptedTransitionActive(std::int64_t nowMilliseconds) const noexcept; - [[nodiscard]] bool - localCardVisible(std::int64_t nowMilliseconds) const noexcept; + [[nodiscard]] bool localCardVisible() const noexcept; }; struct PromptDispatch { @@ -107,8 +104,7 @@ class PromptCoordinator final { [[nodiscard]] bool acknowledge(const std::string &threadId, std::uint64_t submissionId, - std::optional authoritativeTurnId, - std::int64_t nowMilliseconds); + std::optional authoritativeTurnId); [[nodiscard]] bool fail(const std::string &threadId, std::uint64_t submissionId, std::string error); [[nodiscard]] bool requeue(const std::string &threadId, @@ -123,14 +119,12 @@ class PromptCoordinator final { // Correlates prompts with authoritative userMessage items. Exact client ids // may bind before acknowledgement so the awaiting card is never duplicated; // the content fallback is used only after the real operation callback. Fully - // resolved submissions are removed after their accepted transition while a - // compact visual alias retains the admitted card identity and boundary. + // resolved submissions are removed immediately while a compact visual alias + // retains the admitted card identity and boundary. void reconcile(const std::string &threadId, - const ThreadPresentation &authoritativeThread, - std::int64_t nowMilliseconds); + const ThreadPresentation &authoritativeThread); void reconcile(const std::string &threadId, - AuthoritativeItemIndex &authoritativeItems, - std::int64_t nowMilliseconds); + AuthoritativeItemIndex &authoritativeItems); [[nodiscard]] std::span submissions(const std::string &threadId) const noexcept; diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index b9916c7..26ebd1d 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -224,9 +224,9 @@ void updateRow(QWidget *row, const std::string &threadId, titleText.prepend(QStringLiteral("! ")); title->setText(titleText); const PresentationStatus classified = classifyStatus(threadStatus); - status->setText(optimistic ? optimisticFailed ? QStringLiteral("Not created") - : QStringLiteral("Creating") - : text(classified.text)); + status->setText(optimistic ? optimisticFailed ? QStringLiteral("not created") + : QStringLiteral("creating") + : text(displayStatus(threadStatus))); const QString tone = optimistic ? optimisticFailed ? QStringLiteral("danger") : QStringLiteral("warning") : requestCount != 0 ? QStringLiteral("warning") @@ -821,7 +821,7 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { } QListWidgetItem *item = found->second; const QString title = text(row.title); - const QString status = text(classifyStatus(row.status).text); + const QString status = text(displayStatus(row.status)); QStringList accessibleParts{title, status, QStringLiteral("level %1").arg(row.depth + 1)}; if (row.hasChildren) diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index d55a460..dbf9c96 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -507,6 +507,8 @@ QString applicationStyleSheet() { QString humanizeLabel(QString value) { value = value.trimmed(); + if (value.compare(QStringLiteral("xhigh"), Qt::CaseInsensitive) == 0) + return QStringLiteral("Extra high"); QString result; result.reserve(value.size() + 4); bool space = false; diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 9598a7a..6277cde 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -297,11 +297,10 @@ bool testOverlayGeometryAndRegionRouting() { "composer construction reports no pre-canonical trailing space"); region.resize(1500, 820); region.show(); - region.setThreadHeading(QStringLiteral("Thread title"), - QStringLiteral("/workspace"), - QStringLiteral("Last activity: 14:15:51"), - QStringLiteral("Completed"), - QStringLiteral("success")); + region.setThreadHeading( + QStringLiteral("Thread title"), QStringLiteral("/workspace"), + QStringLiteral("Last activity: 14:15:51"), QStringLiteral("completed"), + QStringLiteral("success")); spin(20); QSplitter *splitter = region.splitterWidget(); @@ -352,38 +351,37 @@ bool testOverlayGeometryAndRegionRouting() { conversationDividerRect.left() == 10 && conversationDividerRect.right() == splitter->widget(1)->width() - 11, "Threads and Conversation header dividers share the 10 px inset"); - result &= - expect(conversationTitle && conversationMetadata && - conversationTrailingMetadata && conversationState && - conversationMetadata->geometry().left() > - conversationTitle->geometry().right() && - conversationTrailingMetadata->geometry().right() < - conversationState->geometry().left() && - conversationState->geometry().right() >= - conversationState->parentWidget()->width() - 16 && - conversationTrailingMetadata->text() == - QStringLiteral("Last activity: 14:15:51") && - conversationTrailingMetadata->property("tone").toString() == - QStringLiteral("strong") && - conversationState->text() == QStringLiteral("Completed") && - conversationState->property("tone").toString() == - QStringLiteral("success") && - conversationState->width() >= - conversationState->fontMetrics().horizontalAdvance( - conversationState->text()) && - conversationTrailingMetadata->width() >= - conversationTrailingMetadata->fontMetrics() - .horizontalAdvance( - conversationTrailingMetadata->text()) && - std::abs((conversationMetadata->geometry().top() + - conversationMetadata->contentsMargins().top() + - conversationMetadata->fontMetrics().ascent()) - - (conversationTitle->geometry().top() + - conversationTitle->contentsMargins().top() + - conversationTitle->fontMetrics().ascent())) <= 1, + result &= expect( + conversationTitle && conversationMetadata && + conversationTrailingMetadata && conversationState && + conversationMetadata->geometry().left() > + conversationTitle->geometry().right() && + conversationTrailingMetadata->geometry().right() < + conversationState->geometry().left() && + conversationState->geometry().right() >= + conversationState->parentWidget()->width() - 16 && + conversationTrailingMetadata->text() == + QStringLiteral("Last activity: 14:15:51") && + conversationTrailingMetadata->property("tone").toString() == + QStringLiteral("strong") && + conversationState->text() == QStringLiteral("completed") && + conversationState->property("tone").toString() == + QStringLiteral("success") && + conversationState->width() >= + conversationState->fontMetrics().horizontalAdvance( + conversationState->text()) && + conversationTrailingMetadata->width() >= + conversationTrailingMetadata->fontMetrics().horizontalAdvance( + conversationTrailingMetadata->text()) && + std::abs((conversationMetadata->geometry().top() + + conversationMetadata->contentsMargins().top() + + conversationMetadata->fontMetrics().ascent()) - + (conversationTitle->geometry().top() + + conversationTitle->contentsMargins().top() + + conversationTitle->fontMetrics().ascent())) <= 1, "thread title metadata align by baseline and activity aligns right"); result &= expect( - reasoningToggle && updatesToggle && commandFoldingToggle && + reasoningToggle && updatesToggle && commandFoldingToggle && imageFoldingToggle && !reasoningToggle->isChecked() && updatesToggle->isChecked() && commandFoldingToggle->isChecked() && imageFoldingToggle->isChecked() && @@ -714,7 +712,7 @@ bool testThreadSelectionProjection() { sortButton->property("codexChevron").toBool() && title->property("kind").toString() == QStringLiteral("title") && selected->data(Qt::DisplayRole).toString().isEmpty() && - selectedAccessible.contains(QStringLiteral("B, Running")) && + selectedAccessible.contains(QStringLiteral("B, running")) && selectedAccessible.contains(QStringLiteral("level 2")) && parentAccessible.contains(QStringLiteral("A")) && parentAccessible.contains(QStringLiteral("expanded")) && @@ -788,6 +786,9 @@ bool testIncrementalThreadSettings() { models, nlohmann::json::array(), 1, {{"approvalPolicy", "on-request"}}); bool result = expect(canonicalSettingsStyle, "thread settings use canonical application styling"); + result &= expect(UiStyle::humanizeLabel(QStringLiteral("xhigh")) == + QStringLiteral("Extra high"), + "the fallback reasoning effort uses a human-readable label"); result &= expect( model->currentData().toString() == QStringLiteral("gpt-b") && approval->currentData().toString() == QStringLiteral("on-request"), @@ -796,7 +797,7 @@ bool testIncrementalThreadSettings() { settings.setContext("thread-a", {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}, models, nlohmann::json::array(), 2, - {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}); + {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}); result &= expect(!settings.turnStartOptions().contains("model") && !settings.turnStartOptions().contains("approvalPolicy"), "authoritative settings clear their pending overrides"); @@ -1755,7 +1756,7 @@ bool testInspectorDetailParity() { "Agents show child, sender, and receiver thread identities"); QLabel *agentStatus = nullptr; for (QLabel *label : inspector.findChildren()) { - if (label->text() == QStringLiteral("Running")) { + if (label->text() == QStringLiteral("running")) { agentStatus = label; break; } @@ -1960,9 +1961,9 @@ bool testTerminalPlanStatusReconciliation() { inspector.findChildren(), [&value](const QLabel *label) { return label->text() == value; }); }; - bool result = expect(hasExactLabel(QStringLiteral("Running")) && + bool result = expect(hasExactLabel(QStringLiteral("running")) && hasExactLabel(QStringLiteral("pending")), - "active plans preserve Running and Pending statuses"); + "active plans preserve running and pending statuses"); const auto markdownLabels = inspector.findChildren(); result &= expect(std::ranges::any_of( @@ -1983,20 +1984,20 @@ bool testTerminalPlanStatusReconciliation() { refresh(inspector, model, "plan-thread"); }; setThreadStatus(4, "completed"); - result &= expect(!hasExactLabel(QStringLiteral("Running")) && - hasExactLabel(QStringLiteral("Completed")) && + result &= expect(!hasExactLabel(QStringLiteral("running")) && + hasExactLabel(QStringLiteral("completed")) && hasExactLabel(QStringLiteral("pending")), - "a terminal thread reconciles stale Running to Completed " - "without changing Pending"); + "a terminal thread reconciles stale running to completed " + "without changing pending"); setThreadStatus(5, "failed"); - result &= expect(hasExactLabel(QStringLiteral("Failed")) && + result &= expect(hasExactLabel(QStringLiteral("failed")) && hasExactLabel(QStringLiteral("pending")), - "a failed thread reconciles stale Running to Failed"); + "a failed thread reconciles stale running to failed"); setThreadStatus(6, "interrupted"); result &= - expect(hasExactLabel(QStringLiteral("Interrupted")) && - hasExactLabel(QStringLiteral("pending")), - "an interrupted thread reconciles stale Running to Interrupted"); + expect(hasExactLabel(QStringLiteral("interrupted")) && + hasExactLabel(QStringLiteral("pending")), + "an interrupted thread reconciles stale running to interrupted"); return result; } diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 564ff6c..11cc770 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -236,26 +236,42 @@ bool testActiveWorkBordersFollowStatus() { "command", CommandExecutionData{"sleep 1", {}, "inProgress", {}, {}, {}}}; ConversationCard commandCard(command); - bool result = expect(commandCard.property("activeWork").toBool(), - "a running command uses the emphasized card border"); + auto *commandStatus = + commandCard.findChild(QStringLiteral("commandStatus")); + bool result = expect( + commandCard.property("activeWork").toBool() && commandStatus && + commandStatus->property("tone").toString() == + QStringLiteral("active"), + "a running command uses the emphasized card border and active header " + "status"); std::get(command.payload).status = "completed"; result &= expect(commandCard.apply(command) && !commandCard.property("activeWork").toBool(), "a completed command returns to the normal card border"); VisibleCardData image{AuthoritativeItemKey{"active-border", "turn", "image"}, - CardKind::ImageGeneration, - "active-border", - "turn", - "image", + CardKind::ImageGeneration, + "active-border", + "turn", + "image", ImageGenerationData{{}, "inProgress", {}}}; ConversationCard imageCard(image); - result &= expect(imageCard.property("activeWork").toBool(), - "a loading figure uses the emphasized card border"); + auto *imageStatus = + imageCard.findChild(QStringLiteral("imageGenerationStatus")); + result &= expect( + imageCard.property("activeWork").toBool() && imageStatus && + imageStatus->font().capitalization() == QFont::MixedCase && + imageStatus->text() == QStringLiteral("running") && + imageStatus->property("tone").toString() == QStringLiteral("active"), + "a loading figure uses the emphasized card border and active header " + "status"); std::get(image.payload).status = "completed"; - result &= expect(imageCard.apply(image) && - !imageCard.property("activeWork").toBool(), - "a loaded figure returns to the normal card border"); + result &= expect( + imageCard.apply(image) && !imageCard.property("activeWork").toBool() && + imageStatus->text() == QStringLiteral("completed") && + imageStatus->property("tone").toString() == QStringLiteral("success"), + "a loaded figure returns to the normal card border and success header " + "status"); return result; } @@ -325,7 +341,7 @@ QToolButton *copyButton(ConversationCard *card) { return header ? header->findChild( QStringLiteral("cardCopyButton"), Qt::FindDirectChildrenOnly) - : nullptr; + : nullptr; } QRect paintedDisclosureBounds(QToolButton *button) { @@ -492,7 +508,7 @@ bool testStructuralOrderAndIdentity() { "earlier-user", UserMessageData{"Earlier prompt", {}}}; paged.sections.front().cards.insert(paged.sections.front().cards.begin(), - earlierPrompt); + earlierPrompt); paged.sections.front().rootCardKey = earlierPrompt.key; result &= expect(pagedView.reconcile(paged), "older history can introduce the real turn prompt"); @@ -500,12 +516,12 @@ bool testStructuralOrderAndIdentity() { ConversationCard *earlierRoot = card(pagedView, stableKey(earlierPrompt.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"); + 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"); paged.sections.front().cards.erase(paged.sections.front().cards.begin()); result &= expect(pagedView.reconcile(paged), @@ -513,13 +529,13 @@ bool testStructuralOrderAndIdentity() { spin(); result &= expect(card(pagedView, stableKey(laterPrompt.key)) == laterRoot && - card(pagedView, stableKey(activity.key)) == activityCard && - !laterRoot->property("turnContainer").toBool() && - !laterRoot->isAncestorOf(activityCard), - "a retained steering message never becomes an inferred turn root"); + card(pagedView, stableKey(activity.key)) == activityCard && + !laterRoot->property("turnContainer").toBool() && + !laterRoot->isAncestorOf(activityCard), + "a retained steering message never becomes an inferred turn root"); paged.sections.front().cards.insert(paged.sections.front().cards.begin(), - earlierPrompt); + earlierPrompt); result &= expect(pagedView.reconcile(paged), "the declared turn root can return"); spin(); @@ -527,10 +543,10 @@ bool testStructuralOrderAndIdentity() { card(pagedView, 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, + 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"); return result; @@ -817,15 +833,15 @@ bool testPromptAdmissionFollowOwnership() { "composer growth preserves the painted viewport"); view.prepareForLocalPromptAdmission(); VisibleCardData pending{LocalPromptKey{1001}, - CardKind::LocalPrompt, - "prompt-follow", - {}, - {}, - LocalPromptData{1001, + CardKind::LocalPrompt, + "prompt-follow", + {}, + {}, + LocalPromptData{1001, "a newly admitted pending prompt", - PromptState::InFlight, - 0, - {}}}; + PromptState::InFlight, + 0, + {}}}; snapshot.sections.back().cards.push_back(pending); view.reconcile(snapshot); view.setTrailingSpaceHeight(0); @@ -1033,7 +1049,7 @@ bool testCardCopyControls() { result &= expect( QApplication::clipboard()->text() == QStringLiteral("Late **summary**") && QApplication::clipboard()->mimeData()->hasFormat("text/markdown"), - "late Markdown content copies from the updated source"); + "late Markdown content copies from the updated source"); return result; } @@ -1046,7 +1062,7 @@ bool testMutableCardsAndCommandOutput() { {AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, thread, "turn", "user", UserMessageData{"hello **Markdown**\n\n| Value | Rating " - "|\n|---|---|\n| State | 10 |\n\n" + "|\n|---|---|\n| State | 10 |\n\n" "[Docs](https://example.com)"}}, {AuthoritativeItemKey{thread, "turn", "agent"}, CardKind::AgentMessage, thread, "turn", "agent", AgentMessageData{"answer", false}}, @@ -1066,7 +1082,7 @@ bool testMutableCardsAndCommandOutput() { "turn", "plan", PlanData{"Keep the card compact", {{"Inspect data", "completed"}, {"Render cards", "inProgress"}}, - {}}}, + {}}}, {AuthoritativeItemKey{thread, "turn", "generic"}, CardKind::GenericActivity, thread, "turn", "generic", GenericActivityData{"custom activity", {{"detail", "initial"}}}}, @@ -1113,14 +1129,32 @@ bool testMutableCardsAndCommandOutput() { commandCard->findChild(QStringLiteral("commandOutputView"))); auto *commandText = dynamic_cast( commandCard->findChild(QStringLiteral("commandTextView"))); - bool result = expect(output && output->isHidden(), - "empty-line command output has no black surface"); + auto *commandStatus = + commandCard->findChild(QStringLiteral("commandStatus")); + auto *commandMeta = + commandCard->findChild(QStringLiteral("commandMetadata")); + bool result = expect( + output && output->isHidden() && commandStatus && commandMeta && + commandMeta->isHidden() && + commandStatus->property("tone").toString() == + QStringLiteral("active") && + commandStatus->font().capitalization() == QFont::MixedCase && + commandStatus->text() == QStringLiteral("running") && + commandStatus->parentWidget()->layout()->indexOf(commandStatus) < + commandStatus->parentWidget()->layout()->indexOf( + copyButton(commandCard)), + "empty-line command output has no black surface and exposes its " + "lowercase status before Copy"); auto *userCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "user"}})]; auto *agentCardWidget = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "agent"}})]; auto *agentPhase = agentCardWidget->findChild(QStringLiteral("agentMessagePhase")); + auto *activityCard = identities[stableKey( + CardKey{AuthoritativeItemKey{thread, "turn", "activity"}})]; + auto *activityStatus = + activityCard->findChild(QStringLiteral("agentActivityStatus")); const auto userLabels = userCard->findChildren(); result &= expect(std::ranges::any_of( @@ -1147,15 +1181,29 @@ bool testMutableCardsAndCommandOutput() { copyButton(agentCardWidget)), "interim agent messages show a right-aligned normal-weight update " "phase before Copy"); + result &= expect( + activityStatus && + activityStatus->font().capitalization() == QFont::MixedCase && + activityStatus->text() == QStringLiteral("running") && + activityStatus->property("tone").toString() == + QStringLiteral("active"), + "agent activity exposes its canonical lowercase status in the header"); auto *filesCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "files"}})]; + auto *filesStatus = + filesCard->findChild(QStringLiteral("fileChangesStatus")); auto *planCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "plan"}})]; - result &= - expect(containsLabelText( - filesCard, QStringLiteral("src/card.cpp · Update +2 −1")) && - containsLabelText(filesCard, QStringLiteral("+2 −1")), - "file-change cards show paths, kinds, and truthful diff counts"); + result &= expect( + containsLabelText(filesCard, + QStringLiteral("src/card.cpp · Update +2 −1")) && + containsLabelText(filesCard, QStringLiteral("+2 −1")) && + 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"); result &= expect( containsLabelText(planCard, QStringLiteral("Keep the card compact")) && containsLabelText(planCard, QStringLiteral("✓ Inspect data")) && @@ -1189,6 +1237,9 @@ bool testMutableCardsAndCommandOutput() { command.output = utf8(QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible\n\n \t")); command.status = "completed"; + command.exitCode = 0; + command.cwd = "/workspace"; + command.durationMilliseconds = 1500; std::get(cards[3].payload).resultText = "result"; std::get(cards[4].payload).summary += " more"; std::get(cards[5].payload) @@ -1221,6 +1272,15 @@ bool testMutableCardsAndCommandOutput() { QStringLiteral("success") && agentPhase->font().weight() == QFont::Normal, "final agent messages show a normal-weight success answer phase"); + result &= expect( + commandStatus->text() == QStringLiteral("completed") && + commandStatus->property("tone").toString() == + QStringLiteral("success") && + commandMeta->text() == + QStringLiteral("exit 0 | /workspace | 1.5 s") && + !commandMeta->text().contains(QStringLiteral("completed")), + "command completion moves only lifecycle status while retaining exit, " + "cwd, and duration below output"); result &= expect(!output->isHidden() && output->minimumHeight() == 0 && output->maximumHeight() == 220 && @@ -1306,8 +1366,8 @@ bool testCardFoldingGeometryAndRetention() { "turn", "reasoning", ReasoningData{"A retained public summary with enough " - "detail to create real height.\n\n" - "The second paragraph proves expansion uses " + "detail to create real height.\n\n" + "The second paragraph proves expansion uses " "the final wrapped size."}}; const VisibleCardData command{ AuthoritativeItemKey{thread, "turn", "command"}, @@ -1337,19 +1397,19 @@ bool testCardFoldingGeometryAndRetention() { "Inspection complete", {}}}; const VisibleCardData image{AuthoritativeItemKey{thread, "turn", "image"}, - CardKind::ImageGeneration, - thread, - "turn", - "image", + CardKind::ImageGeneration, + thread, + "turn", + "image", ImageGenerationData{"/tmp/folding-preview.png", "completed", "A folding preview"}}; const VisibleCardData plan{ AuthoritativeItemKey{thread, "turn", "plan"}, - CardKind::Plan, - thread, - "turn", - "plan", + CardKind::Plan, + thread, + "turn", + "plan", PlanData{"Verify folding", {{"Inspect geometry", "completed"}}, {}}}; const VisibleCardData generic{ AuthoritativeItemKey{thread, "turn", "generic"}, @@ -1367,13 +1427,13 @@ bool testCardFoldingGeometryAndRetention() { ReasoningData{}}; ConversationSnapshot snapshot{ thread, - {{"turn:folding", - "turn", + {{"turn:folding", + "turn", {user, agent, reasoning, command, files, activity, image, plan, generic, - emptyReasoning}, - user.key}}, - 0, - false}; + emptyReasoning}, + user.key}}, + 0, + false}; snapshot.activeTurnId = "turn"; ConversationView view; @@ -1437,10 +1497,10 @@ bool testCardFoldingGeometryAndRetention() { result &= expect(userCard->property("turnContainer").toBool() && - userCard->isAncestorOf(agentCardWidget) && - userCard->isAncestorOf(reasoningCard) && + userCard->isAncestorOf(agentCardWidget) && + userCard->isAncestorOf(reasoningCard) && agentCardWidget->property("nestedConversationCard").toBool(), - "the first You card structurally owns its turn activity"); + "the first You card structurally owns its turn activity"); const LocalPromptKey steeringKey{4343}; VisibleCardData steering{ @@ -1450,7 +1510,7 @@ bool testCardFoldingGeometryAndRetention() { "turn", {}, LocalPromptData{ - 4343, "A steering prompt", PromptState::InFlight, 0, {}, {}}}; + 4343, "A steering prompt", PromptState::InFlight, true, {}, {}}}; snapshot.sections.front().cards.push_back(steering); result &= expect(view.reconcile(snapshot), "a steering prompt joins the active turn"); @@ -1508,7 +1568,7 @@ bool testCardFoldingGeometryAndRetention() { expect(!disclosure(emptyReasoningCard)->isHidden() && disclosure(emptyReasoningCard)->property("chevronDirection") == "left", - "reasoning disclosure appears collapsed when detail arrives"); + "reasoning disclosure appears collapsed when detail arrives"); wheel(view, 10000); @@ -1594,9 +1654,9 @@ bool testCardFoldingGeometryAndRetention() { card(view, stableKey(promptActivity.key)); result &= expect(promptCard && !promptCard->isCollapsed() && promptActivityCard && - promptCard->isAncestorOf(promptActivityCard) && - setFolded(promptCard, true), - "temporary You prompts start expanded and can be folded"); + promptCard->isAncestorOf(promptActivityCard) && + setFolded(promptCard, true), + "temporary You prompts start expanded and can be folded"); ConversationCard *const admittedPromptCard = promptCard; QWidget *const admittedPromptHeader = admittedPromptCard ? admittedPromptCard->findChild( @@ -1679,7 +1739,7 @@ bool testCardFoldingGeometryAndRetention() { result &= expect( edgeCard && edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() > - followedTitleTop && + followedTitleTop && edgeView.verticalScrollBar()->maximum() < expandedScrollMaximum && edgeView.isAtBottom() && edgeView.mode() == ConversationView::Mode::Paused, @@ -1706,7 +1766,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { const std::string nestedThread = "nested-presentation-options"; const AuthoritativeItemKey nestedUserKey{nestedThread, "turn", "user"}; const AuthoritativeItemKey nestedReasoningKey{nestedThread, "turn", - "reasoning"}; + "reasoning"}; ConversationSnapshot nestedSnapshot; nestedSnapshot.threadId = nestedThread; nestedSnapshot.sections.push_back( @@ -1848,7 +1908,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { update && final && reasoning && firstCommand && firstImage && !update->isHidden() && !final->isHidden() && reasoning->isHidden() && !firstCommand->isCollapsed() && !firstImage->isCollapsed(), - "default presentation hides reasoning and opens commands and images"); + "default presentation hides reasoning and opens commands and images"); if (!update || !final || !reasoning || !firstCommand || !firstImage) return false; @@ -2008,18 +2068,22 @@ bool testBottomAnchoredCommandOutputGrowth() { commandCard ? commandCard->findChild(QStringLiteral("commandMetadata")) : nullptr; + auto *status = + commandCard + ? commandCard->findChild(QStringLiteral("commandStatus")) + : nullptr; auto *output = commandCard ? dynamic_cast( commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; - result &= - expect(commandCard && metadata && output && output->isHidden() && - view.isAtBottom() && metadata->property("tone") == "active", - "live command starts with a hidden zero-line output"); - if (!commandCard || !metadata || !output) + result &= expect(commandCard && metadata && metadata->isHidden() && status && + output && output->isHidden() && view.isAtBottom() && + status->property("tone") == "active", + "live command starts with a hidden zero-line output"); + if (!commandCard || !metadata || !status || !output) return false; - const int metadataBottomBefore = - metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y(); + const int cardBottomBefore = + commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())).y(); auto &live = std::get( snapshot.sections.back().cards.back().payload); @@ -2027,12 +2091,11 @@ bool testBottomAnchoredCommandOutputGrowth() { "first wrapped output line with enough words to use real width\n" "second output line\nthird output line\n\n"; result &= expect(view.reconcile(snapshot), "live output becomes visible"); - const int metadataBottomAfter = - metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y(); + const int cardBottomAfter = + commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())).y(); result &= expect(!output->isHidden() && output->height() > 2 * 20 && output->height() == output->sizeHint().height() && - metadataBottomAfter == metadataBottomBefore && - view.isAtBottom(), + cardBottomAfter == cardBottomBefore && view.isAtBottom(), "multiline output takes its needed height and grows upward"); QString cappedOutput; @@ -2042,8 +2105,8 @@ bool testBottomAnchoredCommandOutputGrowth() { result &= expect(view.reconcile(snapshot), "live output reaches its cap"); result &= expect( output->height() == 220 && output->verticalScrollBar()->maximum() > 0 && - metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y() == - metadataBottomBefore, + commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())) + .y() == cardBottomBefore, "capped output keeps its scrollbar and fixed card bottom"); return result; } @@ -2111,7 +2174,8 @@ bool testPendingPromptAnimation() { "prompt-thread", {}, {}, - LocalPromptData{901, "pending prompt", PromptState::InFlight, 0, {}}}; + LocalPromptData{901, "pending prompt", PromptState::InFlight, false, {}, + {}}}; ConversationCard card(pending); card.resize(560, 92); card.show(); @@ -2119,25 +2183,91 @@ bool testPendingPromptAnimation() { const QImage first = card.grab().toImage(); spin(110); const QImage second = card.grab().toImage(); - bool result = - expect(first != second, - "an unacknowledged prompt visibly animates its blue sweep"); + bool result = expect(first == second, + "a newly admitted prompt begins as a calm static card"); result &= expect(first.pixelColor(10, first.height() - 10).blue() > - first.pixelColor(10, first.height() - 10).red() && - first.pixelColor(10, first.height() - 10).blue() > - first.pixelColor(10, first.height() - 10).green(), - "the temporary You card stays in the blue identity family"); - - auto &accepted = std::get(pending.payload); - accepted.state = PromptState::Accepted; - accepted.acceptedAtMilliseconds = QDateTime::currentMSecsSinceEpoch(); + first.pixelColor(10, first.height() - 10).red() && + first.pixelColor(10, first.height() - 10).blue() > + first.pixelColor(10, first.height() - 10).green(), + "the temporary You card stays in the blue identity family"); + + auto &prompt = std::get(pending.payload); + prompt.showPendingAnimation = true; + result &= expect(card.apply(pending), + "the delayed pending state starts the feedback sweep"); + const QImage animatedFirst = card.grab().toImage(); + spin(110); + result &= expect(animatedFirst != card.grab().toImage(), + "an overdue unacknowledged prompt visibly animates"); + + result &= expect(card.setAuthoritativeTurnActive(true), + "the retained prompt immediately owns the active border"); + const auto activeBorderVisible = [&card] { + const QImage frame = card.grab().toImage(); + return frame.pixelColor(1, frame.height() / 2).red() < 175; + }; + for (int frame = 0; frame < 10; ++frame) { + spin(50); + result &= expect(activeBorderVisible(), + "pending feedback never weakens the active border"); + } + + prompt.state = PromptState::Accepted; + prompt.showPendingAnimation = false; result &= expect(card.apply(pending), - "the real acknowledged state updates the pending card"); - spin(560); + "the correlated acknowledgement stops pending feedback"); const QImage settled = card.grab().toImage(); spin(100); result &= expect(settled == card.grab().toImage(), - "the acknowledgment transition stops after 500ms"); + "acknowledgement leaves a stable retained card"); + + VisibleCardData steering{ + LocalPromptKey{902}, + CardKind::LocalPrompt, + "prompt-thread", + "turn", + {}, + LocalPromptData{902, "steering prompt", PromptState::InFlight, false, + {}, {}}}; + ConversationCard steeringCard(steering); + steeringCard.setProperty("nestedConversationCard", true); + steeringCard.resize(520, 92); + steeringCard.show(); + spin(40); + const QImage steeringStatic = steeringCard.grab().toImage(); + spin(100); + result &= expect(steeringStatic == steeringCard.grab().toImage(), + "steering uses the same calm initial timing"); + auto &steeringPrompt = std::get(steering.payload); + steeringPrompt.showPendingAnimation = true; + result &= expect(steeringCard.apply(steering), + "overdue steering starts its teal feedback sweep"); + const QImage steeringAnimated = steeringCard.grab().toImage(); + spin(110); + result &= expect(steeringAnimated != steeringCard.grab().toImage(), + "the delayed steering sweep is visibly animated"); + result &= expect( + steeringAnimated.pixelColor(10, steeringAnimated.height() - 10).green() > + steeringAnimated.pixelColor(10, steeringAnimated.height() - 10) + .red(), + "the steering feedback stays in the teal identity family"); + steeringPrompt.state = PromptState::Accepted; + steeringPrompt.showPendingAnimation = false; + result &= expect(steeringCard.apply(steering), + "steering acknowledgement stops its feedback sweep"); + const QImage steeringSettled = steeringCard.grab().toImage(); + spin(100); + result &= expect(steeringSettled == steeringCard.grab().toImage(), + "acknowledged steering remains visually stable"); + + pending = {LocalPromptKey{901}, + CardKind::UserMessage, + "prompt-thread", + "turn", + "user", + UserMessageData{"pending prompt", {}}}; + result &= expect(card.apply(pending) && activeBorderVisible(), + "authoritative promotion retains the same active border"); return result; } @@ -2182,24 +2312,24 @@ bool testMessageImagePresentation() { const QPixmap thumbnailPixmap = thumbnail ? thumbnail->pixmap() : QPixmap{}; result &= expect( ribbon && thumbnails.size() == 3 && thumbnail && - thumbnail->property("imageAvailable").toBool() && - !thumbnailPixmap.isNull() && thumbnailPixmap.width() <= 280 && - thumbnailPixmap.height() <= 180 && - thumbnails[0]->mapTo(ribbon, QPoint{}).y() == - thumbnails[1]->mapTo(ribbon, QPoint{}).y() && - thumbnails[1]->mapTo(ribbon, QPoint{}).y() == - thumbnails[2]->mapTo(ribbon, QPoint{}).y() && - thumbnails[0]->mapTo(ribbon, QPoint{}).x() < - thumbnails[1]->mapTo(ribbon, QPoint{}).x() && - thumbnails[1]->mapTo(ribbon, QPoint{}).x() < - thumbnails[2]->mapTo(ribbon, QPoint{}).x() && - ribbon->horizontalScrollBar()->maximum() > 0 && - ribbon->verticalScrollBar()->maximum() == 0 && - ribbon->frameWidth() == 1 && ribbon->widget() && - ribbon->widget()->layout() && + thumbnail->property("imageAvailable").toBool() && + !thumbnailPixmap.isNull() && thumbnailPixmap.width() <= 280 && + thumbnailPixmap.height() <= 180 && + thumbnails[0]->mapTo(ribbon, QPoint{}).y() == + thumbnails[1]->mapTo(ribbon, QPoint{}).y() && + thumbnails[1]->mapTo(ribbon, QPoint{}).y() == + thumbnails[2]->mapTo(ribbon, QPoint{}).y() && + thumbnails[0]->mapTo(ribbon, QPoint{}).x() < + thumbnails[1]->mapTo(ribbon, QPoint{}).x() && + thumbnails[1]->mapTo(ribbon, QPoint{}).x() < + thumbnails[2]->mapTo(ribbon, QPoint{}).x() && + ribbon->horizontalScrollBar()->maximum() > 0 && + ribbon->verticalScrollBar()->maximum() == 0 && + ribbon->frameWidth() == 1 && ribbon->widget() && + ribbon->widget()->layout() && ribbon->widget()->layout()->contentsMargins() == QMargins(4, 4, 4, 4), - "multiple bounded thumbnails form one horizontally scrollable " - "and canonically bounded ribbon"); + "multiple bounded thumbnails form one horizontally scrollable " + "and canonically bounded ribbon"); const int narrowRibbonHeight = ribbon ? ribbon->height() : 0; card->resize(1000, card->height()); spin(); diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp index f369f3f..23651d3 100644 --- a/tests/codex/ConversationProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -228,7 +228,7 @@ bool testTurnRootSurvivesHistoryPaging() { nlohmann::json::object(), nullptr, std::nullopt, 200); result &= expect(prompts.beginNext(conflicting.id).has_value() && prompts.acknowledge(conflicting.id, localId, - std::string("turn"), 201), + std::string("turn")), "a locally admitted turn start reaches acknowledgment"); addTurn(conflicting, "turn"); appendItem(conflicting, "turn", @@ -237,7 +237,7 @@ bool testTurnRootSurvivesHistoryPaging() { {"content", {{{"type", "text"}, {"text", "Different authoritative prompt"}}}}})); - prompts.reconcile(conflicting.id, conflicting, 202); + prompts.reconcile(conflicting.id, conflicting); const ConversationSnapshot uniqueRoot = ConversationProjection::project( conflicting, prompts.submissions(conflicting.id), 80, 202); const AuthoritativeItemKey authoritativeRoot{conflicting.id, "turn", @@ -271,43 +271,55 @@ bool testQueueIsolationAndRealAcknowledgement() { result &= expect(secondDispatch && secondDispatch->id == secondId, "different threads have independent in-flight queues"); + const auto pendingAnimationAt = [&first, &prompts, firstId]( + std::int64_t now) { + const ConversationSnapshot snapshot = ConversationProjection::project( + first, prompts.submissions(first.id), 80, now); + const VisibleCardData *card = snapshot.find(LocalPromptKey{firstId}); + const auto *prompt = + card ? std::get_if(&card->payload) : nullptr; + return prompt && prompt->showPendingAnimation; + }; + result &= expect(!pendingAnimationAt(1099), + "pending feedback stays calm during the first second"); + result &= expect(pendingAnimationAt(1100), + "pending feedback starts at the one-second boundary"); + addTurn(first, "turn-2"); appendItem( first, "turn-2", item("user-new", {{"type", "userMessage"}, {"content", {{{"type", "text"}, {"text", "same"}}}}})); - prompts.reconcile(first.id, first, 199); + prompts.reconcile(first.id, first); result &= expect(prompts.submission(first.id, firstId)->state == PromptState::InFlight && !prompts.submission(first.id, firstId)->materializedItem, "events and elapsed time cannot manufacture an ack"); - result &= expect(prompts.acknowledge(first.id, firstId, "turn-2", 200), + result &= expect(prompts.acknowledge(first.id, firstId, "turn-2"), "the matching completion acknowledges the in-flight prompt"); - prompts.reconcile(first.id, first, 200); + prompts.reconcile(first.id, first); const PromptSubmission *accepted = prompts.submission(first.id, firstId); - result &= expect(accepted && accepted->state == PromptState::Accepted && - accepted->materializedItem && - accepted->materializedItem->itemId == "user-new", - "an acknowledged prompt binds to its authoritative item"); - - const ConversationSnapshot transitioning = ConversationProjection::project( - first, prompts.submissions(first.id), 80, 699); - const VisibleCardData *local = cardForSubmission(transitioning, firstId); - result &= expect(local && local->kind == CardKind::LocalPrompt, - "the accepted presentation transition lasts 500ms"); + result &= expect(!accepted, + "a correlated acknowledgement promotes a materialized " + "prompt immediately"); + + auto materializedItems = indexAuthoritativeItems(first.id, &first); + prompts.reconcile(first.id, materializedItems); const ConversationSnapshot materialized = ConversationProjection::project( - first, prompts.submissions(first.id), 80, 700); + materializedItems, &first, prompts.submissions(first.id), 80, 200); const VisibleCardData *authoritative = cardForSubmission(materialized, firstId); result &= expect(authoritative && authoritative->kind == CardKind::UserMessage && authoritative->itemId == "user-new", "the authoritative user item assumes the local stable key"); - result &= expect(stableKey(local->key) == stableKey(authoritative->key), + result &= expect(authoritative && + stableKey(authoritative->key) == + stableKey(LocalPromptKey{firstId}), "materialization does not change the visual identity"); auto compactedItems = indexAuthoritativeItems(first.id, &first); - prompts.reconcile(first.id, compactedItems, 700); + prompts.reconcile(first.id, compactedItems); accepted = prompts.submission(first.id, firstId); const ConversationSnapshot compacted = ConversationProjection::project( compactedItems, &first, prompts.submissions(first.id), 80, 701); @@ -319,7 +331,7 @@ bool testQueueIsolationAndRealAcknowledgement() { !compacted.find(AuthoritativeItemKey{first.id, "turn-2", "user-new"}), "submission cleanup retains the compact local visual identity alias"); auto retainedAliasItems = indexAuthoritativeItems(first.id, &first); - prompts.reconcile(first.id, retainedAliasItems, 701); + prompts.reconcile(first.id, retainedAliasItems); const ConversationSnapshot retainedAlias = ConversationProjection::project( retainedAliasItems, &first, prompts.submissions(first.id), 80, 702); result &= @@ -346,7 +358,7 @@ bool testDispatchChoiceAndPreHydrationTail() { "thread-tail", "after retained history", {}, nlohmann::json::object(), nullptr, std::nullopt, 400); ThreadPresentation retained = baseThread("thread-tail"); - beforeHydration.reconcile(retained.id, retained, 401); + beforeHydration.reconcile(retained.id, retained); const ConversationSnapshot atTail = ConversationProjection::project( retained, beforeHydration.submissions(retained.id), 80, 401); const auto keys = atTail.cardKeys(); @@ -392,7 +404,7 @@ bool testClientIdentityBindsBeforeAcknowledgement() { {{"type", "userMessage"}, {"clientId", dispatch->clientUserMessageId}, {"content", {{{"type", "text"}, {"text", "identity matched"}}}}})); - prompts.reconcile(thread.id, thread, 501); + prompts.reconcile(thread.id, thread); const PromptSubmission *pending = prompts.submission(thread.id, id); result &= expect(pending && pending->state == PromptState::InFlight && pending->materializedItem && @@ -435,7 +447,7 @@ bool testFirstResponseOrderIsAdmissionStable() { appendItem(reasoningFirst, "turn-new", item("reasoning", {{"type", "reasoning"}, {"summary", nlohmann::json::array()}})); - prompts.reconcile(reasoningFirst.id, reasoningFirst, 601); + prompts.reconcile(reasoningFirst.id, reasoningFirst); const AuthoritativeItemKey reasoningKey{reasoningFirst.id, "turn-new", "reasoning"}; const auto beforeUser = ConversationProjection::project( @@ -450,7 +462,7 @@ bool testFirstResponseOrderIsAdmissionStable() { {{"type", "userMessage"}, {"clientId", dispatch->clientUserMessageId}, {"content", {{{"type", "text"}, {"text", "new prompt"}}}}})); - prompts.reconcile(reasoningFirst.id, reasoningFirst, 602); + prompts.reconcile(reasoningFirst.id, reasoningFirst); const auto materialized = ConversationProjection::project( reasoningFirst, prompts.submissions(reasoningFirst.id), 80, 602); result &= @@ -459,19 +471,23 @@ bool testFirstResponseOrderIsAdmissionStable() { "early user-message materialization cannot invert the cards"); result &= expect(prompts.acknowledge(reasoningFirst.id, promptId, - std::string("turn-new"), 700), + std::string("turn-new")), "the reasoning-first prompt is acknowledged"); - prompts.reconcile(reasoningFirst.id, reasoningFirst, 700); + prompts.reconcile(reasoningFirst.id, reasoningFirst); + auto promotedItems = + indexAuthoritativeItems(reasoningFirst.id, &reasoningFirst); + prompts.reconcile(reasoningFirst.id, promotedItems); const auto transitioning = ConversationProjection::project( - reasoningFirst, prompts.submissions(reasoningFirst.id), 80, 700); + promotedItems, &reasoningFirst, prompts.submissions(reasoningFirst.id), + 80, 700); result &= expect(transitioning.cardKeys() == std::vector{LocalPromptKey{promptId}, reasoningKey}, - "the animated-to-blue transition retains prompt order"); + "immediate promotion retains prompt order"); auto compactedItems = indexAuthoritativeItems(reasoningFirst.id, &reasoningFirst); - prompts.reconcile(reasoningFirst.id, compactedItems, 1200); + prompts.reconcile(reasoningFirst.id, compactedItems); const auto compacted = ConversationProjection::project( compactedItems, &reasoningFirst, prompts.submissions(reasoningFirst.id), 80, 1200); @@ -503,13 +519,13 @@ bool testFirstResponseOrderIsAdmissionStable() { {{"type", "userMessage"}, {"clientId", continuedDispatch->clientUserMessageId}, {"content", {{{"type", "text"}, {"text", "continued prompt"}}}}})); - continuedPrompts.reconcile(continued.id, continued, 751); + continuedPrompts.reconcile(continued.id, continued); result &= expect(continuedPrompts.acknowledge(continued.id, continuedId, - std::string("turn-continued"), 800), + std::string("turn-continued")), "the continued-thread prompt is acknowledged"); auto continuedItems = indexAuthoritativeItems(continued.id, &continued); - continuedPrompts.reconcile(continued.id, continuedItems, 1300); + continuedPrompts.reconcile(continued.id, continuedItems); const auto continuedCompacted = ConversationProjection::project( continuedItems, &continued, continuedPrompts.submissions(continued.id), 80, 1300); @@ -548,7 +564,7 @@ bool testFirstResponseOrderIsAdmissionStable() { userFirst, "turn-ordinary", item("reasoning-ordinary", {{"type", "reasoning"}, {"summary", nlohmann::json::array()}})); - ordinaryPrompts.reconcile(userFirst.id, userFirst, 801); + ordinaryPrompts.reconcile(userFirst.id, userFirst); const auto userBeforeReasoning = ConversationProjection::project( userFirst, ordinaryPrompts.submissions(userFirst.id), 80, 801); result &= expect(userBeforeReasoning.cardKeys() == @@ -572,11 +588,11 @@ bool testAnchoredDuplicatePrompts() { bool result = expect(prompts.beginNext(thread.id).has_value(), "first duplicate dispatches"); - result &= expect(prompts.acknowledge(thread.id, firstId, "turn-2", 1010), + result &= expect(prompts.acknowledge(thread.id, firstId, "turn-2"), "first duplicate is acknowledged by id"); result &= expect(prompts.beginNext(thread.id, "turn-2").has_value(), "second duplicate dispatches only after first ack"); - result &= expect(prompts.acknowledge(thread.id, secondId, "turn-2", 1020), + result &= expect(prompts.acknowledge(thread.id, secondId, "turn-2"), "second duplicate is acknowledged by id"); addTurn(thread, "turn-2"); @@ -584,10 +600,12 @@ bool testAnchoredDuplicatePrompts() { item("repeat-1", {{"type", "userMessage"}, {"content", {{{"type", "text"}, {"text", "repeat"}}}}})); - prompts.reconcile(thread.id, thread, 1020); + prompts.reconcile(thread.id, thread); + auto partialItems = indexAuthoritativeItems(thread.id, &thread); + prompts.reconcile(thread.id, partialItems); const ConversationSnapshot partiallyMaterialized = - ConversationProjection::project(thread, prompts.submissions(thread.id), - 80, 1600); + ConversationProjection::project(partialItems, &thread, + prompts.submissions(thread.id), 80, 1600); const auto partialKeys = partiallyMaterialized.cardKeys(); const auto materializedFirst = std::ranges::find(partialKeys, CardKey{LocalPromptKey{firstId}}); @@ -602,17 +620,17 @@ bool testAnchoredDuplicatePrompts() { item("repeat-2", {{"type", "userMessage"}, {"content", {{{"type", "text"}, {"text", "repeat"}}}}})); - prompts.reconcile(thread.id, thread, 1021); + prompts.reconcile(thread.id, thread); const PromptSubmission *first = prompts.submission(thread.id, firstId); const PromptSubmission *second = prompts.submission(thread.id, secondId); - result &= expect( - first && second && first->materializedItem && second->materializedItem && - first->materializedItem->itemId == "repeat-1" && - second->materializedItem->itemId == "repeat-2", - "identical prompts bind in admission order without collision"); + result &= expect(!first && !second, + "identical acknowledged prompts bind and promote without " + "collision"); + auto waitingItems = indexAuthoritativeItems(thread.id, &thread); + prompts.reconcile(thread.id, waitingItems); const ConversationSnapshot waiting = ConversationProjection::project( - thread, prompts.submissions(thread.id), 80, 1021); + waitingItems, &thread, prompts.submissions(thread.id), 80, 1021); const auto keys = waiting.cardKeys(); const auto firstPosition = std::ranges::find(keys, CardKey{LocalPromptKey{firstId}}); @@ -718,7 +736,7 @@ bool testUserMessageImages() { const auto dispatch = prompts.beginNext(replacement.id); result &= expect(dispatch && prompts.acknowledge(replacement.id, submissionId, - std::string("turn-image"), 200), + std::string("turn-image")), "image prompt receives a real acknowledgement"); appendItem( replacement, "turn-image", @@ -728,8 +746,7 @@ bool testUserMessageImages() { {"content", {{{"type", "text"}, {"text", "replacement image"}}, {{"type", "localImage"}, {"path", "/tmp/replacement.png"}}}}})); - prompts.reconcile(replacement.id, replacement, 200); - prompts.reconcile(replacement.id, replacement, 800); + prompts.reconcile(replacement.id, replacement); const ConversationSnapshot replaced = ConversationProjection::project( replacement, prompts.submissions(replacement.id), 80, 800); const VisibleCardData *replacedCard = replaced.find(AuthoritativeItemKey{ diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index 67e4a54..e78a17c 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -2,6 +2,7 @@ #include "codex/PresentationModel.h" #include "codex/PresentationProtocol.h" +#include "codex/PresentationStatus.h" #include "codex/ProtocolNormalizer.h" #include @@ -206,6 +207,12 @@ int main() { }(); bool passed = true; + passed &= expect( + codexui::codex::displayStatus("inProgress") == "running" && + codexui::codex::displayStatus("notLoaded") == "not loaded" && + codexui::codex::displayStatus("futureProviderState") == + "future provider state", + "status presentation is lowercase, semantic, and never leaks camelCase"); passed &= expect(validFrames, "normalizer emits ordered versioned generation frames"); passed &= expect( diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 60afc06..89874cb 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -80,38 +80,38 @@ bool verifyFrontendBoundaryOrdering(Configuration &configuration) { session.setActivityHandler([&activity](const std::string &threadId) { activity.push_back(threadId); }); - const std::string correlation = session.request( - "thread.read", {{"threadId", "ordering"}}, - [&order, &completed](const nlohmann::json &result) { - order.emplace_back("completion"); - completed = result; - }); + const std::string correlation = + session.request("thread.read", {{"threadId", "ordering"}}, + [&order, &completed](const nlohmann::json &result) { + order.emplace_back("completion"); + completed = result; + }); FrontendSessionTestPeer::receive( - session, presentation::result(1, 1, "thread.read", correlation, true, - {{"thread", {{"id", "ordering"}}}}, - Authority::Replace, - {{"threadId", "ordering"}})); + session, + presentation::result(1, 1, "thread.read", correlation, true, + {{"thread", {{"id", "ordering"}}}}, + Authority::Replace, {{"threadId", "ordering"}})); bool result = expect( order == std::vector{"completion", "event"}, "a correlated completion remains ordered before global frame delivery"); - result &= expect(presentation::isPresentationFrame(completed) && - completed.value("action", std::string{}) == - "thread.read", - "a successful completion receives a complete presentation result"); - result &= expect( - activity.empty(), - "selection hydration requests and results do not report activity"); + result &= + expect(presentation::isPresentationFrame(completed) && + completed.value("action", std::string{}) == "thread.read", + "a successful completion receives a complete presentation result"); + result &= + expect(activity.empty(), + "selection hydration requests and results do not report activity"); const std::string renameCorrelation = session.request( "thread.rename", {{"threadId", "ordering"}, {"name", "Renamed"}}); FrontendSessionTestPeer::receive( - session, presentation::result(2, 1, "thread.rename", renameCorrelation, - true, nlohmann::json::object(), - Authority::Merge, - {{"threadId", "ordering"}})); - result &= expect( - activity == std::vector{"ordering", "ordering"}, - "meaningful thread requests and results both report activity"); + session, + presentation::result(2, 1, "thread.rename", renameCorrelation, true, + nlohmann::json::object(), Authority::Merge, + {{"threadId", "ordering"}})); + result &= + expect(activity == std::vector{"ordering", "ordering"}, + "meaningful thread requests and results both report activity"); nlohmann::json failed; const std::string failedCorrelation = session.request( @@ -327,6 +327,20 @@ const middle::LocalPromptData *localPrompt(ShellWidget &shell, return nullptr; } +middle::ConversationCard *userMessage(ShellWidget &shell, + const std::string &message) { + for (QWidget *widget : shell.findChildren()) { + auto *card = dynamic_cast(widget); + if (!card) + continue; + const auto *user = + std::get_if(&card->data().payload); + if (user && user->text == message) + return card; + } + return nullptr; +} + bool hasAgentMessage(ShellWidget &shell, const QString &message) { for (QWidget *widget : shell.findChildren()) { auto *card = dynamic_cast(widget); @@ -374,11 +388,11 @@ struct ShellFlow { threadId + " receives its metadata-only settings refresh"; if (!expect(request.has_value(), message.c_str()) || !request) return false; - return peer.send(presentation::result( - sequence++, generation, "thread.resume", - request->value("correlationId", std::string{}), true, - {{"thread", {{"id", threadId}}}}, Authority::Merge, - {{"threadId", threadId}})); + return peer.send( + presentation::result(sequence++, generation, "thread.resume", + request->value("correlationId", std::string{}), + true, {{"thread", {{"id", threadId}}}}, + Authority::Merge, {{"threadId", threadId}})); } bool verifyHydrationAndNavigation(); @@ -403,9 +417,9 @@ struct ShellFlow { bool ShellFlow::verifyHydrationAndNavigation() { bool result = true; - result &= peer.send( - presentation::event(sequence++, 1, "connection.lifecycle", - {{"state", "connected"}}, Authority::Merge)); + result &= peer.send(presentation::event(sequence++, 1, "connection.lifecycle", + {{"state", "connected"}}, + Authority::Merge)); result &= peer.send(presentation::event(sequence++, 1, "connection.bridge", {{"state", "opened"}, {"connectionId", "test-controller"}, @@ -436,10 +450,10 @@ bool ShellFlow::verifyHydrationAndNavigation() { result &= expect(readB.has_value(), "selecting B requests its own hydration"); if (!readB) return false; - result &= peer.send(presentation::event( - sequence++, 1, "thread.upsert", - {{"thread", thread("thread-a", "A", "notLoaded")}}, Authority::Merge, - {{"threadId", "thread-a"}})); + result &= peer.send( + presentation::event(sequence++, 1, "thread.upsert", + {{"thread", thread("thread-a", "A", "notLoaded")}}, + Authority::Merge, {{"threadId", "thread-a"}})); result &= peer.send(presentation::result( sequence++, 1, "thread.read", readA->value("correlationId", std::string{}), true, @@ -519,31 +533,29 @@ bool ShellFlow::verifyPromptLifecycle() { editor->setPlainText(QStringLiteral("unsent shared draft")); result &= expect(selectThread(list, "thread-b"), "B can be selected while A remains active"); - result &= expect(editor->toPlainText() == - QStringLiteral("unsent shared draft"), - "thread navigation retains the shared composer draft"); + result &= + expect(editor->toPlainText() == QStringLiteral("unsent shared draft"), + "thread navigation retains the shared composer draft"); result &= expect(!peer.waitFor("thread.read", "thread-b", 100).has_value(), "returning to hydrated B does not reread its history"); result &= expect(submit(editor, QStringLiteral("prompt B1")), "B1 is admitted while A1 is in flight"); - result &= expect( - list && list->item(0) && - list->item(0)->data(Qt::UserRole).toString().toStdString() == - "thread-b" && - list->currentItem() == list->item(0), - "real prompt admission immediately promotes B under Recent"); + result &= + expect(list && list->item(0) && + list->item(0)->data(Qt::UserRole).toString().toStdString() == + "thread-b" && + list->currentItem() == list->item(0), + "real prompt admission immediately promotes B under Recent"); const auto startB = peer.waitFor("turn.start", "thread-b"); result &= expect(startB.has_value(), "different threads dispatch independently"); if (startB) - startBCorrelation = - startB->value("correlationId", std::string{}); + startBCorrelation = startB->value("correlationId", std::string{}); result &= peer.send(presentation::result( sequence++, 1, "turn.start", startA->value("correlationId", std::string{}), true, - {{"turn", {{"id", "turn-a-live"}, {"status", "inProgress"}}}}, - Authority::Merge, + {{"turn", {{"id", "turn-a-live"}}}}, Authority::Merge, {{"threadId", "thread-a"}, {"turnId", "turn-a-live"}})); const auto steerA = peer.waitFor("turn.steer", "thread-a"); result &= @@ -561,10 +573,21 @@ bool ShellFlow::verifyPromptLifecycle() { result &= expect( !peer.waitFor("thread.read", "thread-a", 100).has_value(), "switching back to hydrated A does not issue a destructive reread"); - const middle::LocalPromptData *accepted = - localPrompt(shell, QStringLiteral("prompt A1")); - result &= expect(accepted && accepted->state == middle::PromptState::Accepted, - "only the correlated turn result acknowledges A1"); + middle::ConversationCard *promoted = userMessage(shell, "prompt A1"); + result &= expect( + promoted && promoted->property("authoritativeTurnActive").toBool(), + "the correlated result promotes A1 immediately and keeps its emphasized " + "border while the separate " + "active-turn event is delayed"); + result &= peer.send(presentation::event( + sequence++, 1, "turn.upsert", + {{"turn", {{"id", "turn-a-live"}, {"status", "inProgress"}}}}, + Authority::Merge, {{"threadId", "thread-a"}, {"turnId", "turn-a-live"}})); + spin(10); + result &= expect( + promoted && promoted->property("authoritativeTurnActive").toBool(), + "authoritative active-turn ownership replaces the provisional handoff " + "without a neutral border state"); return result; } @@ -581,8 +604,9 @@ bool ShellFlow::verifyReconnectHydration() { result &= expect(readC1.has_value(), "C issues its first hydration read"); if (!readC1) return false; - result &= expect(submit(editor, QStringLiteral("prompt C queued across restart")), - "a prompt can queue behind C's in-flight hydration"); + result &= + expect(submit(editor, QStringLiteral("prompt C queued across restart")), + "a prompt can queue behind C's in-flight hydration"); result &= expect(!peer.waitFor("turn.start", "thread-c", 100).has_value(), "the queued prompt waits for authoritative hydration"); @@ -593,15 +617,19 @@ bool ShellFlow::verifyReconnectHydration() { spin(10); const auto *status = shell.findChild(QStringLiteral("globalStatusLabel")); - result &= expect(status && status->text() == QStringLiteral("Provider unavailable"), - "provider loss cannot leave the shell visibly Ready"); + result &= + expect(status && status->text() == QStringLiteral("Provider unavailable"), + "provider loss cannot leave the shell visibly Ready"); peer.discard(); - result &= expect(submit(editor, QStringLiteral("provider unavailable")), - "the editable composer reaches the guarded admission boundary"); + result &= + expect(submit(editor, QStringLiteral("provider unavailable")), + "the editable composer reaches the guarded admission boundary"); spin(5); - result &= expect(editor->toPlainText() == QStringLiteral("provider unavailable") && - !peer.has("turn.start") && !peer.has("turn.steer"), - "provider loss rejects a stale hidden-thread destination without clearing the draft"); + result &= + expect(editor->toPlainText() == QStringLiteral("provider unavailable") && + !peer.has("turn.start") && !peer.has("turn.steer"), + "provider loss rejects a stale hidden-thread destination without " + "clearing the draft"); generation = 2; result &= peer.send(presentation::event(sequence++, 2, "connection.lifecycle", @@ -629,8 +657,7 @@ bool ShellFlow::verifyReconnectHydration() { sequence++, 2, "threads.list", reconnectedList->value("correlationId", std::string{}), true, {{"threads", - nlohmann::json::array({thread("thread-a", "A"), - thread("thread-b", "B"), + nlohmann::json::array({thread("thread-a", "A"), thread("thread-b", "B"), thread("thread-c", "C")})}}, Authority::Merge)); const auto readC2 = peer.waitFor("thread.read", "thread-c"); @@ -643,18 +670,18 @@ bool ShellFlow::verifyReconnectHydration() { "the restart also rehydrates B's interrupted prompt queue"); if (!readB2) return false; - result &= peer.send(presentation::result( - sequence++, 2, "thread.read", - readB2->value("correlationId", std::string{}), true, - {{"thread", thread("thread-b", "B")}}, Authority::Replace, - {{"threadId", "thread-b"}})); + result &= peer.send( + presentation::result(sequence++, 2, "thread.read", + readB2->value("correlationId", std::string{}), true, + {{"thread", thread("thread-b", "B")}}, + Authority::Replace, {{"threadId", "thread-b"}})); const auto resumedStartB = peer.waitFor("turn.start", "thread-b"); - result &= expect(resumedStartB.has_value(), - "B's interrupted in-flight prompt is reissued after hydration"); + result &= + expect(resumedStartB.has_value(), + "B's interrupted in-flight prompt is reissued after hydration"); if (!resumedStartB) return false; - startBCorrelation = - resumedStartB->value("correlationId", std::string{}); + startBCorrelation = resumedStartB->value("correlationId", std::string{}); result &= peer.send(presentation::result( sequence++, 2, "thread.read", readC2->value("correlationId", std::string{}), true, @@ -667,9 +694,9 @@ bool ShellFlow::verifyReconnectHydration() { Authority::Replace, {{"threadId", "thread-c"}})); result &= completeSettingsRefresh("thread-c"); const auto resumedStartC = peer.waitFor("turn.start", "thread-c"); - result &= expect( - resumedStartC.has_value(), - "a transient provider restart preserves and dispatches C's queued prompt"); + result &= expect(resumedStartC.has_value(), + "a transient provider restart preserves and dispatches C's " + "queued prompt"); if (!resumedStartC) return false; result &= peer.send(presentation::result( @@ -681,8 +708,9 @@ bool ShellFlow::verifyReconnectHydration() { spin(10); const middle::LocalPromptData *preserved = localPrompt(shell, QStringLiteral("prompt C queued across restart")); - result &= expect(preserved && preserved->state == middle::PromptState::Accepted, - "the reconnected turn result acknowledges the preserved prompt"); + result &= + expect(preserved && preserved->state == middle::PromptState::Accepted, + "the reconnected turn result acknowledges the preserved prompt"); result &= expect(hasAgentMessage(shell, QStringLiteral("current C marker")), "a late successful stale read cannot replace newer cards"); peer.discard(); @@ -697,10 +725,9 @@ bool ShellFlow::verifyReconnectHydration() { bool ShellFlow::verifyTerminalCallback() { bool result = true; result &= peer.send(presentation::result( - sequence++, 2, "turn.start", - startBCorrelation, - false, {{"code", -32001}, {"message", "transport cancelled"}}, - Authority::None, {{"threadId", "thread-b"}})); + sequence++, 2, "turn.start", startBCorrelation, false, + {{"code", -32001}, {"message", "transport cancelled"}}, Authority::None, + {{"threadId", "thread-b"}})); spin(10); result &= expect(selectThread(list, "thread-b"), "B remains selectable after reconnection"); @@ -741,11 +768,10 @@ bool ShellFlow::verifyBoundedChildHydration() { expect(!peer.waitFor("thread.read", "child-failure", 100).has_value(), "a failed child hydration does not enter an automatic retry loop"); - result &= expect(selectThread(list, "thread-a") && - selectThread(list, "thread-b"), - "explicit navigation returns to the failed child's parent"); - const auto retriedChildRead = - peer.waitFor("thread.read", "child-failure"); + result &= + expect(selectThread(list, "thread-a") && selectThread(list, "thread-b"), + "explicit navigation returns to the failed child's parent"); + const auto retriedChildRead = peer.waitFor("thread.read", "child-failure"); result &= expect(retriedChildRead.has_value(), "explicit parent navigation retries one failed child read"); if (!retriedChildRead) @@ -754,7 +780,7 @@ bool ShellFlow::verifyBoundedChildHydration() { sequence++, 2, "thread.read", retriedChildRead->value("correlationId", std::string{}), true, {{"thread", threadWithAgentMessage("child-failure", "Child", - "completed child result")}}, + "completed child result")}}, Authority::Replace, {{"threadId", "child-failure"}})); spin(10); @@ -766,12 +792,11 @@ bool ShellFlow::verifyBoundedChildHydration() { tabs->setCurrentIndex(1); spin(); } - result &= expect(inspector && tabs && - hasPresentedText(*inspector, - QStringLiteral("Completed")) && - !hasPresentedText(*inspector, - QStringLiteral("Running")), - "retried child completion replaces the stale Running badge"); + result &= + expect(inspector && tabs && + hasPresentedText(*inspector, QStringLiteral("completed")) && + !hasPresentedText(*inspector, QStringLiteral("running")), + "retried child completion replaces the stale running badge"); return result; } @@ -860,13 +885,11 @@ bool ShellFlow::verifyNotFoundRecovery() { spin(10); result &= expect(!peer.waitFor("turn.steer", "thread-d", 100).has_value(), "an in-flight resume gates dispatch"); - result &= peer.send( - presentation::result(sequence++, 2, "thread.resume", - secondResumeD->value("correlationId", std::string{}), - true, - {{"thread", - thread("thread-d", "D", "active", "turn-d")}}, - Authority::Merge, {{"threadId", "thread-d"}})); + result &= peer.send(presentation::result( + sequence++, 2, "thread.resume", + secondResumeD->value("correlationId", std::string{}), true, + {{"thread", thread("thread-d", "D", "active", "turn-d")}}, + Authority::Merge, {{"threadId", "thread-d"}})); const auto retriedSteerD = peer.waitFor("turn.steer", "thread-d"); result &= expect(retriedSteerD.has_value() && retriedSteerD->value("data", nlohmann::json::object()) @@ -941,8 +964,8 @@ bool ShellFlow::verifyOptimisticNewThread() { shell.findChild(QStringLiteral("threadNewButton")); bool dialogOpened = false; QTimer::singleShot(0, &shell, [&dialogOpened] { - if (auto *dialog = qobject_cast( - QApplication::activeModalWidget())) { + if (auto *dialog = + qobject_cast(QApplication::activeModalWidget())) { dialogOpened = true; dialog->accept(); } @@ -964,10 +987,10 @@ bool ShellFlow::verifyOptimisticNewThread() { return nullptr; }; QListWidgetItem *draft = findThreadItem("draft:new-thread"); - result &= expect( - dialogOpened && draft && list->currentItem() == draft && - draft->data(Qt::UserRole + 6).toBool(), - "accepting the dialog immediately selects one animated draft row"); + result &= + expect(dialogOpened && draft && list->currentItem() == draft && + draft->data(Qt::UserRole + 6).toBool(), + "accepting the dialog immediately selects one animated draft row"); if (!draft) return false; @@ -978,14 +1001,15 @@ bool ShellFlow::verifyOptimisticNewThread() { "the optimistic draft dispatches thread.create"); if (!create) return false; - result &= peer.send(presentation::result( - sequence++, generation, "thread.create", - create->value("correlationId", std::string{}), true, - {{"thread", {{"id", "thread-new"}, - {"name", "New thread"}, - {"cwd", "/workspace/new"}, - {"status", "idle"}}}}, - Authority::Merge, {{"threadId", "thread-new"}})); + result &= peer.send( + presentation::result(sequence++, generation, "thread.create", + create->value("correlationId", std::string{}), true, + {{"thread", + {{"id", "thread-new"}, + {"name", "New thread"}, + {"cwd", "/workspace/new"}, + {"status", "idle"}}}}, + Authority::Merge, {{"threadId", "thread-new"}})); const auto start = peer.waitFor("turn.start", "thread-new"); result &= expect(start.has_value(), @@ -993,16 +1017,16 @@ bool ShellFlow::verifyOptimisticNewThread() { if (!start) return false; QListWidgetItem *promoted = findThreadItem("thread-new"); - result &= expect( - promoted == draft && promoted->data(Qt::UserRole + 6).toBool(), - "thread.create rekeys the same visible item while acknowledgment is pending"); + result &= + expect(promoted == draft && promoted->data(Qt::UserRole + 6).toBool(), + "thread.create rekeys the same visible item while acknowledgment " + "is pending"); result &= peer.send(presentation::result( sequence++, generation, "turn.start", start->value("correlationId", std::string{}), true, {{"turn", {{"id", "turn-new"}, {"status", "inProgress"}}}}, - Authority::Merge, - {{"threadId", "thread-new"}, {"turnId", "turn-new"}})); + Authority::Merge, {{"threadId", "thread-new"}, {"turnId", "turn-new"}})); spin(10); result &= expect(findThreadItem("thread-new") == draft && !draft->data(Qt::UserRole + 6).toBool(), @@ -1019,8 +1043,7 @@ bool ShellFlow::verifyPendingResolutionBoundary() { {{"requestId", id}, {"category", "command-approval"}, {"request", {{"command", command}, {"cwd", "/tmp"}}}}, - Authority::Merge, - {{"threadId", "thread-new"}, {"requestId", id}})); + Authority::Merge, {{"threadId", "thread-new"}, {"requestId", id}})); }; auto *accept = shell.findChild( QStringLiteral("pendingRequestAcceptButton")); @@ -1039,33 +1062,36 @@ bool ShellFlow::verifyPendingResolutionBoundary() { accept->click(); const auto accepted = peer.waitFor("pending-request.resolve"); result &= expect( - accepted && accepted->value("data", nlohmann::json::object()) - .value("requestId", 0) == 91 && + accepted && + accepted->value("data", nlohmann::json::object()) + .value("requestId", 0) == 91 && accepted->value("data", nlohmann::json::object()) .value("result", nlohmann::json::object()) .value("decision", std::string{}) == "accept", "the first response preserves the native request identity and decision"); - result &= expect(!peer.waitFor("pending-request.resolve", {}, 100).has_value(), - "a repeated click cannot resolve the same request twice"); + result &= + expect(!peer.waitFor("pending-request.resolve", {}, 100).has_value(), + "a repeated click cannot resolve the same request twice"); spin(30); result &= expect(!accept->isEnabled() && !reject->isEnabled(), "a resolving request disables all response actions"); - result &= peer.send(presentation::event( - sequence++, generation, "pending-request.removed", - nlohmann::json::object(), Authority::Remove, - {{"threadId", "thread-new"}, {"requestId", 91}})); + result &= peer.send( + presentation::event(sequence++, generation, "pending-request.removed", + nlohmann::json::object(), Authority::Remove, + {{"threadId", "thread-new"}, {"requestId", 91}})); result &= pending(92, "observer approval"); - result &= peer.send(presentation::event( - sequence++, generation, "connection.controller", - {{"controllerConnectionId", "different-controller"}}, - Authority::Replace)); + result &= peer.send( + presentation::event(sequence++, generation, "connection.controller", + {{"controllerConnectionId", "different-controller"}}, + Authority::Replace)); spin(30); result &= expect(!accept->isEnabled() && !reject->isEnabled(), "an observer can inspect but cannot answer a request"); accept->click(); - result &= expect(!peer.waitFor("pending-request.resolve", {}, 100).has_value(), - "disabled observer actions emit no response"); + result &= + expect(!peer.waitFor("pending-request.resolve", {}, 100).has_value(), + "disabled observer actions emit no response"); result &= peer.send(presentation::event( sequence++, generation, "connection.controller", @@ -1075,10 +1101,9 @@ bool ShellFlow::verifyPendingResolutionBoundary() { "current controller ownership restores request actions"); reject->click(); const auto rejected = peer.waitFor("pending-request.resolve"); - result &= expect( - rejected && rejected->value("data", nlohmann::json::object()) - .value("requestId", 0) == 92, - "the restored controller can resolve the retained request"); + result &= expect(rejected && rejected->value("data", nlohmann::json::object()) + .value("requestId", 0) == 92, + "the restored controller can resolve the retained request"); return result; } @@ -1096,15 +1121,18 @@ bool verifyPendingRequestTextBoundaries() { inspected = true; const auto labels = dialog->findChildren(); const auto command = std::ranges::find_if(labels, [](QLabel *label) { - return label && label->text().contains( - QStringLiteral("untrusted command")); + return label && + label->text().contains(QStringLiteral("untrusted command")); }); - plainText = command != labels.end() && - (*command)->textFormat() == Qt::PlainText; + plainText = + command != labels.end() && (*command)->textFormat() == Qt::PlainText; dialog->reject(); }); const PendingRequestDescriptor request{ - "unsafe-command", "command-approval", "thread-a", 1, + "unsafe-command", + "command-approval", + "thread-a", + 1, {{"command", "untrusted command"}}}; static_cast(PendingRequestDialog::present(request, nullptr)); @@ -1122,7 +1150,10 @@ bool verifyPendingRequestTextBoundaries() { dialog->reject(); }); const PendingRequestDescriptor elicitation{ - "unsafe-link", "mcp-elicitation", "thread-a", 1, + "unsafe-link", + "mcp-elicitation", + "thread-a", + 1, {{"url", "https://example.invalid/\">"}}}; static_cast(PendingRequestDialog::present(elicitation, nullptr)); @@ -1147,9 +1178,8 @@ bool verifyPendingRequestValidationRetainsInput() { QTimer::singleShot(0, [&] { auto *warning = qobject_cast(QApplication::activeModalWidget()); - incompleteWarning = warning && - warning->windowTitle() == - QStringLiteral("Incomplete response"); + incompleteWarning = warning && warning->windowTitle() == + QStringLiteral("Incomplete response"); if (warning) warning->done(QMessageBox::Ok); }); @@ -1161,15 +1191,17 @@ bool verifyPendingRequestValidationRetainsInput() { submit->click(); }); const PendingRequestDescriptor questions{ - "questions", "user-input", "thread-a", 1, + "questions", + "user-input", + "thread-a", + 1, {{"questions", - nlohmann::json::array( - {{{"id", "first"}, - {"question", "First?"}, - {"options", nlohmann::json::array()}}, - {{"id", "second"}, - {"question", "Second?"}, - {"options", nlohmann::json::array()}}})}}}; + nlohmann::json::array({{{"id", "first"}, + {"question", "First?"}, + {"options", nlohmann::json::array()}}, + {{"id", "second"}, + {"question", "Second?"}, + {"options", nlohmann::json::array()}}})}}}; const auto questionResponse = PendingRequestDialog::present(questions, nullptr); const bool answersPreserved = @@ -1194,26 +1226,28 @@ bool verifyPendingRequestValidationRetainsInput() { QTimer::singleShot(0, [&] { auto *warning = qobject_cast(QApplication::activeModalWidget()); - invalidJsonWarning = warning && - warning->windowTitle() == - QStringLiteral("Invalid response"); + invalidJsonWarning = warning && warning->windowTitle() == + QStringLiteral("Invalid response"); if (warning) warning->done(QMessageBox::Ok); }); submit->click(); - mcpDialogRetained = dialog->isVisible() && - editor->toPlainText() == QStringLiteral("["); + mcpDialogRetained = + dialog->isVisible() && editor->toPlainText() == QStringLiteral("["); editor->setPlainText(QStringLiteral("{\"accepted\":true}")); submit->click(); }); const PendingRequestDescriptor elicitation{ - "elicitation", "mcp-elicitation", "thread-a", 1, + "elicitation", + "mcp-elicitation", + "thread-a", + 1, {{"message", "Structured response"}, {"requestedSchema", {{"type", "object"}}}}}; const auto mcpResponse = PendingRequestDialog::present(elicitation, nullptr); const bool validJsonReturned = - mcpResponse && mcpResponse->result.value("action", std::string{}) == - "accept" && + mcpResponse && + mcpResponse->result.value("action", std::string{}) == "accept" && mcpResponse->result["content"] == nlohmann::json({{"accepted", true}}); return expect(incompleteWarning && questionDialogRetained && answersPreserved, @@ -1224,15 +1258,15 @@ bool verifyPendingRequestValidationRetainsInput() { } bool verifyPermissionRequestDisclosure() { - const nlohmann::json permissions = - {{"fileSystem", - {{"write", nlohmann::json::array({"/tmp/"})}, - {"entries", - nlohmann::json::array( - {{{"access", "read"}, - {"path", {{"type", "glob_pattern"}, {"pattern", "*.md"}}}}})}}}, - {"network", {{"enabled", true}}}, - {"futureCapability", {{"mode", "bounded"}}}}; + const nlohmann::json permissions = { + {"fileSystem", + {{"write", nlohmann::json::array({"/tmp/"})}, + {"entries", + nlohmann::json::array( + {{{"access", "read"}, + {"path", {{"type", "glob_pattern"}, {"pattern", "*.md"}}}}})}}}, + {"network", {{"enabled", true}}}, + {"futureCapability", {{"mode", "bounded"}}}}; bool completeDisclosure = false; QTimer::singleShot(0, [&] { auto *dialog = qobject_cast(QApplication::activeModalWidget()); @@ -1248,12 +1282,14 @@ bool verifyPermissionRequestDisclosure() { all.contains(QStringLiteral("File system / write / 1: " "/tmp/")) && all.contains(QStringLiteral("Network / enabled: Yes")) && - all.contains( - QStringLiteral("futureCapability / mode: bounded")); + all.contains(QStringLiteral("futureCapability / mode: bounded")); dialog->accept(); }); const PendingRequestDescriptor request{ - "permissions", "permissions-approval", "thread-a", 1, + "permissions", + "permissions-approval", + "thread-a", + 1, {{"permissions", permissions}, {"reason", "test disclosure"}}}; const auto response = PendingRequestDialog::present(request, nullptr); return expect(completeDisclosure, diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 34137a7..b5c673d 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -113,6 +113,11 @@ non-Markdown cards copy their deterministic primary-content text rather than rendered widget text. Copy feedback remains local to the action: the glyph breathes once from its darker hover color to a noticeably lighter peak and 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 +lowercase vocabulary is used in thread rows, conversation metadata, and the +Inspector; raw camelCase protocol values are humanized. Non-status execution +metadata remains in the card body. Folding is immediate rather than animated and anchors the selected title row, so content only contracts upward or grows downward below the interaction point. Multiple message images form one source-ordered horizontal ribbon. It keeps the @@ -176,13 +181,16 @@ canonical geometry. ## Pending prompt presentation -Local admission creates a muted-blue prompt card immediately. A brighter blue -highlight sweeps left and right until app-server acknowledgment. +Local admission creates a calm blue prompt card with an emphasized border +immediately. A brighter blue highlight starts sweeping left and right only +after one second without app-server acknowledgment. The card belongs to its destination thread and persists through navigation. Only the correlated `turn.start` or `turn.steer` completion callback -acknowledges it. Each request carries a unique `clientUserMessageId`; after a -successful callback the card keeps a 500-millisecond accepted transition before -normal message presentation. Failure produces an explicit error state. +acknowledges it. Each request carries a unique `clientUserMessageId`; the +matching callback stops delayed feedback immediately and permits normal message +presentation as soon as the authoritative item is correlated. Failure produces +an explicit error state. Steering uses the same timing with the canonical teal +hue. The authoritative outer You card uses a stronger static blue border while its turn is active. This state must not animate or alter card geometry; the moving @@ -241,6 +249,6 @@ desktop identity. ## Long-operation feedback Progress feedback is scoped to the operation it represents. Prompt -acknowledgment uses the pending card's animated highlight sweep. Thread creation +acknowledgment uses a delayed highlight sweep only while overdue. Thread creation and long thread loading may receive dedicated scoped indicators, but no global spinner or application-wide input lock is defined. diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 1d77489..58f1103 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -3,7 +3,7 @@ import type {FormEvent, ReactNode, RefObject} from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { - ConversationViewportState, DefaultSetting, anchoredScrollTop, changeSettingDraft, canonicalThreadSettings, classifyStatus, + ConversationViewportState, DefaultSetting, anchoredScrollTop, changeSettingDraft, canonicalThreadSettings, classifyStatus, displayStatus, foldedCardScrollTop, nestedScrollConsumes, pendingDecisionOptions, pendingRequestDetails, pendingResponse, permissionProfileLabel, stableKey, settingDraftFor, settingPromptOptions, trimTrailingEmptyLines, @@ -141,11 +141,6 @@ function effectivePlanStepStatus(stepStatus: string, turnStatus: string, threadS return outcome === "completed" ? "completed" : outcome === "failed" ? "failed" : outcome === "interrupted" ? "interrupted" : stepStatus; } -function displayStatus(status: string): string { - const classified = classifyStatus(status); - return classified.kind === "unknown" ? humanize(status) : classified.text; -} - function ThreadPane({session, revision, onRequestNewThread, drawer = false, paneRef, onClose}: {session: BrowserFrontendSession; revision: number; onRequestNewThread: () => void} & DrawerPaneProps) { void revision; const snapshot = session.getSnapshot(); @@ -204,7 +199,7 @@ function ThreadPane({session, revision, onRequestNewThread, drawer = false, pane const hasChildren = (thread?.childThreadOrder.length ?? 0) > 0; const optimisticClass = optimistic ? ` optimistic-${optimistic.state}` : ""; const title = thread?.title || optimistic?.title || id; - const detail = optimistic ? optimistic.state === "failed" ? "Not created" : optimistic.state === "confirmed" ? "Created" : "Creating" : thread?.cwd || thread?.preview || id; + const detail = optimistic ? optimistic.state === "failed" ? "not created" : optimistic.state === "confirmed" ? "created" : "creating" : thread?.cwd || thread?.preview || id; return
{data.prompt}
{data.error &&
{data.error}
}; } else if (card.kind === "agentMessage") { - const data = card.payload as AgentMessageData; title = "Codex"; phaseClass = data.finalAnswer ? "final" : "update"; phaseLabel = data.finalAnswer ? "final answer" : "update"; + const data = card.payload as AgentMessageData; title = "Codex"; cardVariant = data.finalAnswer ? "final" : "update"; phaseClass = cardVariant; phaseLabel = data.finalAnswer ? "final answer" : "update"; body = ; } else if (card.kind === "reasoning") { const data = card.payload as ReasoningData; title = "Reasoning"; body = data.summary ? : active ?
Working…
: null; } else if (card.kind === "commandExecution") { - const data = card.payload as CommandExecutionData; title = "Command execution"; + const data = card.payload as CommandExecutionData; title = "Command execution"; phaseLabel = displayStatus(data.status); phaseClass = `status ${classifyStatus(data.status).tone}`; + const metadata = commandMetadata(data); body = <> {data.output && } - {commandMetadata(data)}; + {metadata && {metadata}}; } else if (card.kind === "fileChanges") { - const data = card.payload as FileChangesData; title = "File changes"; + const data = card.payload as FileChangesData; title = "File changes"; phaseLabel = displayStatus(data.status); phaseClass = `status ${classifyStatus(data.status).tone}`; body = <>
{data.changes.map(change =>
{change.path}{humanize(change.kind)} {change.additions !== undefined && +{change.additions}} {change.deletions !== undefined && −{change.deletions}} -
)}
{fileChangeMetadata(data)}; +
)}
{fileChangeMetadata(data)}; } else if (card.kind === "agentActivity") { - const data = card.payload as AgentActivityData; title = "Agent activity"; - body = <>{agentMetadata(data)} + const data = card.payload as AgentActivityData; title = "Agent activity"; phaseLabel = data.status ? displayStatus(data.status) : ""; phaseClass = data.status ? `status ${classifyStatus(data.status).tone}` : ""; + const metadata = agentMetadata(data); + body = <>{metadata && {metadata}} {data.prompt &&
{data.prompt}
}{data.resultText && }; } else if (card.kind === "imageGeneration") { - const data = card.payload as ImageGenerationData; title = data.status || data.revisedPrompt ? "Generated image" : "Image"; - body = <>{data.status && {displayStatus(data.status)}} - {data.revisedPrompt &&
{data.revisedPrompt}
}; + const data = card.payload as ImageGenerationData; title = data.status || data.revisedPrompt ? "Generated image" : "Image"; phaseLabel = data.status ? displayStatus(data.status) : ""; phaseClass = data.status ? `status ${classifyStatus(data.status).tone}` : ""; + body = <>{data.revisedPrompt &&
{data.revisedPrompt}
}; } else if (card.kind === "plan") { const data = card.payload as PlanData; title = "Plan"; body = ; } else { @@ -481,10 +477,11 @@ export function Card({card, active, collapsed, onToggle, onCopy, nested, turnCon const copyContent = cardCopyContent(card); const foldable = ["userMessage", "localPrompt", "agentMessage", "commandExecution", "agentActivity", "reasoning", "fileChanges", "imageGeneration", "plan", "genericActivity"].includes(card.kind) && !(card.kind === "reasoning" && !(card.payload as ReasoningData).summary); - const activeTurn = active && turnContainer && card.kind === "userMessage"; + const activeTurn = active && turnContainer && (card.kind === "localPrompt" || card.kind === "userMessage"); const activeWork = (card.kind === "commandExecution" || card.kind === "imageGeneration") && ["active", "inProgress", "running", "started"].includes((card.payload as CommandExecutionData | ImageGenerationData).status); - return
+ const delayedPending = card.kind === "localPrompt" && (card.payload as LocalPromptData).showPendingAnimation; + return
{title}{card.itemId}{phaseLabel && {phaseLabel}}{copyContent.text && {copyFeedback && {copyFeedback.text}}}{foldable && }
{!collapsed && <>{body}{nested &&
{nested}
}}
; } @@ -648,14 +645,14 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte .filter(section => section.cards.length > 0); const renderCard = (card: VisibleCardData, nested?: ReactNode, turnContainer = false, nestedCard = false) => { const key = stableKey(card.key); const collapsed = cardCollapsed(card, key); - return toggleCard(key, collapsed)} onCopy={copyCard} nested={nested} turnContainer={turnContainer} nestedCard={nestedCard} />; + return toggleCard(key, collapsed)} onCopy={copyCard} nested={nested} turnContainer={turnContainer} nestedCard={nestedCard} />; }; return
Conversation

{thread?.title ?? (snapshot.newThreadIntent ? snapshot.newThreadDraft?.name || "New thread" : "Select a thread")}

{thread ? thread.cwd : snapshot.newThreadIntent ? `${snapshot.newThreadDraft?.workspace ?? ""} | Send a message to create this thread.` : "Choose a thread from the left."}

- {thread?.lastActivityAt !== undefined &&

{lastActivityText(thread.lastActivityAt)} {classifyStatus(thread.status).text}

}
+ {thread?.lastActivityAt !== undefined &&

{lastActivityText(thread.lastActivityAt)} {displayStatus(thread.status)}

}
{paneControls &&
{paneControls}
}
@@ -723,7 +720,7 @@ function SettingsPanel({session, draft, onChange}: {session: BrowserFrontendSess function Composer({session, active, draftKey, drafts, options}: {session: BrowserFrontendSession; active: boolean; draftKey: string; drafts: Map; options: SettingPromptOptions}) { const [prompt, setPrompt] = useState(drafts.get(draftKey) ?? ""); const editor = useRef(null); - const running = session.model.activeTurnId(session.getSnapshot().selectedThreadId) !== undefined; + const running = session.activeTurnId(session.getSnapshot().selectedThreadId) !== undefined; useBrowserLayoutEffect(() => { const element = editor.current; if (!element) return; @@ -787,11 +784,11 @@ function Inspector({session, revision, drawer = false, paneRef, onClose}: {sessi })}
:

No structured plan is available for this thread.

)} {tab === "agents" && (!selected || selected.agentOrder.length === 0 ?

No correlated agents are present.

: selected.agentOrder.map(id => { const agent = selected.agents.get(id)!; return
- {agent.raw.agentPath ? String(agent.raw.agentPath) : id}{humanize(agent.status)} + {agent.raw.agentPath ? String(agent.raw.agentPath) : id}{displayStatus(agent.status)} {agent.childThreadId && Thread {agent.childThreadId}}{typeof agent.raw.resultText === "string" &&

{agent.raw.resultText}

}
; }))} {tab === "requests" && (requests.length === 0 ?

No pending approval or input requests.

: requests.map(request => ))} - {tab === "state" && <>{selected &&
}
{JSON.stringify(plainState, null, 2)}
} + {tab === "state" && <>{selected &&
}
{JSON.stringify(plainState, null, 2)}
} {tab === "protocol" &&
{[...session.getSnapshot().protocolFrames].reverse().map((frame, index) =>
{humanize(String((frame as Record).type ?? (frame as Record).action ?? "Frame"))}
{JSON.stringify(frame, null, 2)}
)}
}
; diff --git a/web/src/app/BrowserFrontendSession.ts b/web/src/app/BrowserFrontendSession.ts index e7bd0ed..88ddcc0 100644 --- a/web/src/app/BrowserFrontendSession.ts +++ b/web/src/app/BrowserFrontendSession.ts @@ -10,10 +10,12 @@ import type {JsonObject} from "../presentation/PresentationProtocol.js"; import {isObject, member, stringMember} from "../presentation/PresentationProtocol.js"; import {PresentationModel} from "../presentation/PresentationModel.js"; import type {PendingRequestPresentation} from "../presentation/PresentationModel.js"; +import {isTerminalTurnStatus} from "../presentation/PresentationStatus.js"; import {ProtocolNormalizer} from "../presentation/ProtocolNormalizer.js"; import {PromptCoordinator, indexAuthoritativeItems, promptWithFileLinks} from "../conversation/PromptCoordinator.js"; import type {AttachmentDraft, PromptDispatch} from "../conversation/PromptCoordinator.js"; import {DefaultAuthoritativeItemLimit, projectConversation} from "../conversation/ConversationProjection.js"; +import {PendingAnimationDelayMilliseconds} from "../conversation/MiddleTypes.js"; import type {ConversationSnapshot} from "../conversation/MiddleTypes.js"; import {readBrowserStorage, writeBrowserStorage} from "./BrowserStorage.js"; @@ -92,6 +94,7 @@ interface ThreadRuntimeState { operationReady: boolean; resumeInFlight: boolean; readRevision: number; + provisionalActiveTurnId: string | undefined; readonly recoveryAttemptedSubmissions: Set; } interface OperationResponse {ok: boolean; data?: unknown; error?: unknown; stale?: boolean} @@ -111,7 +114,7 @@ export class BrowserFrontendSession { private readonly runtimeByThread = new Map(); private readonly resolvingRequests = new Set(); private readonly pendingUserOperations = new Set(); - private readonly acceptedTransitionTimers = new Map>(); + private readonly pendingAnimationTimers = new Map>(); private transport: WebSocketTransport | undefined; private selectedThreadId = ""; private newThreadIntent = false; @@ -233,8 +236,8 @@ export class BrowserFrontendSession { dispose(): void { this.disposed = true; this.connection.dispose(); this.transport = undefined; - for (const timer of this.acceptedTransitionTimers.values()) clearTimeout(timer); - this.acceptedTransitionTimers.clear(); + for (const timer of this.pendingAnimationTimers.values()) clearTimeout(timer); + this.pendingAnimationTimers.clear(); if (this.noticeTimer) clearTimeout(this.noticeTimer); this.noticeTimer = undefined; } @@ -324,7 +327,9 @@ export class BrowserFrontendSession { const thread = this.model.thread(this.selectedThreadId); const index = indexAuthoritativeItems(projectionId, thread); if (thread) this.prompts.decorate(this.selectedThreadId, index); - return projectConversation(index, this.prompts.submissions(projectionId), limit, Date.now(), thread); + const conversation = projectConversation(index, this.prompts.submissions(projectionId), limit, Date.now(), thread); + conversation.activeTurnId = this.activeTurnId(this.selectedThreadId); + return conversation; } loadMore(): void { /* Default parity window is sufficient until viewport pausing is introduced. */ } @@ -341,8 +346,9 @@ export class BrowserFrontendSession { if (!this.newThreadIntent) { this.setNotice("Select a thread or choose New thread before sending."); return false; } destination = DraftThreadId; thread = undefined; } - this.prompts.admit(destination, canonicalPrompt, attachments, turnOptions, thread, - destination === DraftThreadId ? undefined : this.model.activeTurnId(destination), Date.now()); + const submissionId = this.prompts.admit(destination, canonicalPrompt, attachments, turnOptions, thread, + destination === DraftThreadId ? undefined : this.activeTurnId(destination), Date.now()); + this.schedulePendingAnimation(submissionId); if (destination !== DraftThreadId) { this.threadRuntime(destination); this.promotePromptedThread(destination); @@ -391,7 +397,7 @@ export class BrowserFrontendSession { } interrupt(): void { - const turnId = this.model.activeTurnId(this.selectedThreadId); + const turnId = this.activeTurnId(this.selectedThreadId); if (turnId) this.request("turn.interrupt", {threadId: this.selectedThreadId, turnId}); } operationPending(action: string, threadId = ""): boolean { @@ -451,11 +457,19 @@ export class BrowserFrontendSession { const connection = this.model.connection(); return connection.connected && connection.providerState === "ready"; } + activeTurnId(threadId: string): string | undefined { + const authoritative = this.model.activeTurnId(threadId); + if (authoritative !== undefined) return authoritative; + const provisional = this.runtimeByThread.get(threadId)?.provisionalActiveTurnId; + const turn = provisional === undefined ? undefined : this.model.thread(threadId)?.turns.get(provisional); + return turn && isTerminalTurnStatus(turn.status) ? undefined : provisional; + } private threadRuntime(threadId: string): ThreadRuntimeState { let runtime = this.runtimeByThread.get(threadId); if (!runtime) { runtime = {hydration: "notHydrated", operationReady: false, resumeInFlight: false, - readRevision: 0, recoveryAttemptedSubmissions: new Set()}; + readRevision: 0, provisionalActiveTurnId: undefined, + recoveryAttemptedSubmissions: new Set()}; this.runtimeByThread.set(threadId, runtime); } return runtime; @@ -469,6 +483,7 @@ export class BrowserFrontendSession { runtime.hydration = "notHydrated"; runtime.operationReady = false; runtime.resumeInFlight = false; + runtime.provisionalActiveTurnId = undefined; runtime.recoveryAttemptedSubmissions.clear(); for (const submission of this.prompts.submissions(threadId)) if (submission.state === "inFlight") this.prompts.requeue(threadId, submission.id); @@ -625,7 +640,7 @@ export class BrowserFrontendSession { this.ensureThreadHydrated(threadId); return; } - const dispatch = this.prompts.beginNext(threadId, this.model.activeTurnId(threadId)); + const dispatch = this.prompts.beginNext(threadId, this.activeTurnId(threadId)); if (!dispatch) return; this.dispatchPrompt(dispatch); } @@ -649,8 +664,12 @@ export class BrowserFrontendSession { if (response.ok) { if (runtime) runtime.operationReady = true; const turn = isObject(response.data) ? member(response.data, "turn", {}) : {}; - this.prompts.acknowledge(dispatch.threadId, dispatch.id, stringMember(turn, "id") || undefined, Date.now()); - this.scheduleAcceptedTransition(dispatch.threadId, dispatch.id); + const turnId = stringMember(turn, "id") || undefined; + const startsTurn = this.prompts.submission(dispatch.threadId, dispatch.id)?.startsTurn === true; + this.prompts.acknowledge(dispatch.threadId, dispatch.id, turnId); + if (startsTurn && turnId !== undefined) { + if (runtime) runtime.provisionalActiveTurnId = turnId; + } this.optimisticThreads = this.optimisticThreads.map(thread => thread.id === dispatch.threadId ? {...thread, state: "confirmed"} : thread); } else { @@ -659,6 +678,7 @@ export class BrowserFrontendSession { this.optimisticThreads = this.optimisticThreads.map(thread => thread.id === dispatch.threadId ? {...thread, state: "failed"} : thread); } + this.cancelPendingAnimation(dispatch.id); this.publish(); queueMicrotask(() => this.dispatchNextPrompt(dispatch.threadId)); }); @@ -692,20 +712,22 @@ export class BrowserFrontendSession { const itemId = stringMember(scope, "itemId"); if (stringMember(thread.turns.get(turnId)?.items.get(itemId)?.raw, "type") !== "userMessage") return; } - this.prompts.reconcile(threadId, thread, Date.now()); + this.prompts.reconcile(threadId, thread); } private errorMessage(response: {error?: unknown}): string { return stringMember(response.error, "message") || "Codex operation failed"; } - private scheduleAcceptedTransition(threadId: string, submissionId: number): void { - const previous = this.acceptedTransitionTimers.get(submissionId); - if (previous) clearTimeout(previous); - this.acceptedTransitionTimers.set(submissionId, setTimeout(() => { - this.acceptedTransitionTimers.delete(submissionId); - const thread = this.model.thread(threadId); - if (thread) this.prompts.reconcile(threadId, thread, Date.now()); + private schedulePendingAnimation(submissionId: number): void { + this.cancelPendingAnimation(submissionId); + this.pendingAnimationTimers.set(submissionId, setTimeout(() => { + this.pendingAnimationTimers.delete(submissionId); this.publish(); - }, 500)); + }, PendingAnimationDelayMilliseconds)); + } + private cancelPendingAnimation(submissionId: number): void { + const timer = this.pendingAnimationTimers.get(submissionId); + if (timer) clearTimeout(timer); + this.pendingAnimationTimers.delete(submissionId); } private handlePresentationFrame(frame: JsonObject): void { if (frame.kind !== "event") return; diff --git a/web/src/app/Humanize.ts b/web/src/app/Humanize.ts index 2848c93..7768591 100644 --- a/web/src/app/Humanize.ts +++ b/web/src/app/Humanize.ts @@ -1,5 +1,6 @@ export function humanizeProtocolLabel(value: string): string { if (value === "contextCompaction") return "Context compaction"; + if (value.toLocaleLowerCase() === "xhigh") return "Extra high"; const result: string[] = []; let pendingSpace = false; const characters = [...value.trim()]; diff --git a/web/src/conversation/ConversationProjection.ts b/web/src/conversation/ConversationProjection.ts index 4add06d..76bf381 100644 --- a/web/src/conversation/ConversationProjection.ts +++ b/web/src/conversation/ConversationProjection.ts @@ -1,6 +1,6 @@ import type {ItemPresentation, ThreadPresentation} from "../presentation/PresentationModel.js"; import {isObject, member, stringMember} from "../presentation/PresentationProtocol.js"; -import {AuthoritativeHistoryPageSize, terminalOutputHasVisibleText} from "./MiddleTypes.js"; +import {AuthoritativeHistoryPageSize, PendingAnimationDelayMilliseconds, terminalOutputHasVisibleText} from "./MiddleTypes.js"; import type { AgentActivityData, AgentMessageData, AuthoritativeItemKey, CardKey, CardKind, CardPayload, CommandExecutionData, ConversationSnapshot, FileChangeData, FileChangesData, GenericActivityData, @@ -151,7 +151,8 @@ export function projectConversation( for (let index = suffixStart; index < authoritativeItems.ordered.length; ++index) retainedPositions.push(index); const hidden = suffixStart - pinnedRoots.size; const result: ConversationSnapshot = { - threadId: authoritativeItems.threadId, sections: [], hiddenAuthoritativeItemCount: hidden, hasMore: hidden > 0, + threadId: authoritativeItems.threadId, sections: [], hiddenAuthoritativeItemCount: hidden, + hasMore: hidden > 0, activeTurnId: undefined, }; const bindings = new Map(); for (const submission of localSubmissions) if (submission.materializedItem) @@ -161,7 +162,7 @@ export function projectConversation( const item = authoritativeItems.ordered[index]!; const identity = `${item.key.threadId}\0${item.key.turnId}\0${item.key.itemId}`; const binding = bindings.get(identity); - if (binding && localCardVisible(binding, nowMilliseconds)) continue; + if (binding && localCardVisible(binding)) continue; let visualKey: CardKey = item.promptAlias?.key ?? item.key; if (binding) visualKey = {kind: "prompt", submissionId: binding.id}; let position = index * 2 + 1; @@ -177,7 +178,7 @@ export function projectConversation( card: authoritativeCard(item.key, item.presentation, visualKey)}); } for (const submission of localSubmissions) { - if (!localCardVisible(submission, nowMilliseconds)) continue; + if (!localCardVisible(submission)) continue; const materialized = submission.materializedItem ? authoritativePosition(authoritativeItems, submission.materializedItem) : undefined; const position = submissionPosition(submission, authoritativeItems, materialized); @@ -188,7 +189,9 @@ export function projectConversation( const payload: LocalPromptData = { submissionId: submission.id, prompt: submission.prompt, state: submission.state === "queued" ? "inFlight" : submission.state, - acceptedAtMilliseconds: submission.acceptedAtMilliseconds, error: submission.error, + showPendingAnimation: (submission.state === "queued" || submission.state === "inFlight") + && nowMilliseconds - submission.admittedAtMilliseconds >= PendingAnimationDelayMilliseconds, + error: submission.error, imagePaths: localImagePaths(submission), }; const turnRootPosition = authoritativeItems.turnRoots.get(turnId); diff --git a/web/src/conversation/MiddleTypes.ts b/web/src/conversation/MiddleTypes.ts index bd5860c..b1df1bc 100644 --- a/web/src/conversation/MiddleTypes.ts +++ b/web/src/conversation/MiddleTypes.ts @@ -1,6 +1,6 @@ import type {JsonObject} from "../presentation/PresentationProtocol.js"; -export const AcknowledgementTransitionMilliseconds = 500; +export const PendingAnimationDelayMilliseconds = 1000; export const AuthoritativeHistoryPageSize = 80; export interface AuthoritativeItemKey {kind: "item"; threadId: string; turnId: string; itemId: string} @@ -28,7 +28,7 @@ export interface PlanStepData {text: string; status: string} export interface PlanData {explanation: string; steps: PlanStepData[]; legacyText: string} export interface GenericActivityData {type: string; raw: JsonObject} export interface LocalPromptData { - submissionId: number; prompt: string; state: PromptState; acceptedAtMilliseconds: number; + submissionId: number; prompt: string; state: PromptState; showPendingAnimation: boolean; error: string; imagePaths: string[]; } export type CardPayload = UserMessageData | AgentMessageData | CommandExecutionData | AgentActivityData @@ -39,6 +39,7 @@ export interface VisibleCardData { export interface TurnSection {key: string; turnId: string; cards: VisibleCardData[]; rootCardKey?: CardKey} export interface ConversationSnapshot { threadId: string; sections: TurnSection[]; hiddenAuthoritativeItemCount: number; hasMore: boolean; + activeTurnId: string | undefined; } function component(value: string): string { return `${value.length}:${value}`; } diff --git a/web/src/conversation/PromptCoordinator.ts b/web/src/conversation/PromptCoordinator.ts index 38c4f33..37f0e90 100644 --- a/web/src/conversation/PromptCoordinator.ts +++ b/web/src/conversation/PromptCoordinator.ts @@ -1,13 +1,12 @@ import type {ThreadPresentation, ItemPresentation} from "../presentation/PresentationModel.js"; import {isObject, stringMember} from "../presentation/PresentationProtocol.js"; -import {AcknowledgementTransitionMilliseconds} from "./MiddleTypes.js"; import type {AuthoritativeItemKey, LocalPromptKey, PromptState} from "./MiddleTypes.js"; export interface AttachmentDraft {path: string; name: string; mimeType: string; size: number} export interface PromptSubmission { id: number; admissionOrdinal: number; threadId: string; clientUserMessageId: string; prompt: string; attachments: AttachmentDraft[]; turnOptions: Record; state: PromptState; - acceptedAtMilliseconds: number; error: string; admissionAnchor?: AuthoritativeItemKey; + admittedAtMilliseconds: number; error: string; admissionAnchor?: AuthoritativeItemKey; admissionAtStart: boolean; startsTurn: boolean; expectedTurnId?: string; materializedItem?: AuthoritativeItemKey; } export interface PromptDispatch { @@ -83,14 +82,9 @@ export function promptWithFileLinks(prompt: string, attachments: readonly Attach return links.length === 0 ? prompt : `${prompt}\n\nAttached files:\n${links.join("\n")}`; } -export function acceptedTransitionActive(submission: PromptSubmission, now: number): boolean { - return submission.state === "accepted" && submission.acceptedAtMilliseconds > 0 - && now >= submission.acceptedAtMilliseconds - && now - submission.acceptedAtMilliseconds < AcknowledgementTransitionMilliseconds; -} -export function localCardVisible(submission: PromptSubmission, now: number): boolean { +export function localCardVisible(submission: PromptSubmission): boolean { return submission.state === "queued" || submission.state === "inFlight" || submission.state === "failed" - || submission.materializedItem === undefined || acceptedTransitionActive(submission, now); + || submission.materializedItem === undefined; } function cloneKey(key: AuthoritativeItemKey): AuthoritativeItemKey { return {...key}; } @@ -105,7 +99,7 @@ export class PromptCoordinator { 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", acceptedAtMilliseconds: 0, error: "", + turnOptions: structuredClone(turnOptions), state: "queued", admittedAtMilliseconds: now, error: "", admissionAtStart: false, startsTurn: activeTurnId === undefined, }; if (activeTurnId !== undefined) submission.expectedTurnId = activeTurnId; @@ -137,10 +131,10 @@ export class PromptCoordinator { return dispatch; } - acknowledge(threadId: string, id: number, turnId: string | undefined, now: number): boolean { + acknowledge(threadId: string, id: number, turnId: string | undefined): boolean { const pending = this.find(threadId, id); if (!pending || pending.state !== "inFlight") return false; - pending.state = "accepted"; pending.acceptedAtMilliseconds = now; pending.error = ""; + pending.state = "accepted"; pending.error = ""; if (turnId !== undefined) pending.expectedTurnId = turnId; return true; } @@ -189,7 +183,7 @@ export class PromptCoordinator { return true; } - reconcile(threadId: string, threadOrIndex: ThreadPresentation | AuthoritativeItemIndex, now: number): AuthoritativeItemIndex { + reconcile(threadId: string, threadOrIndex: ThreadPresentation | AuthoritativeItemIndex): AuthoritativeItemIndex { const index = "ordered" in threadOrIndex ? threadOrIndex : indexAuthoritativeItems(threadId, threadOrIndex); this.applyVisualAliases(threadId, index); const submissions = this.byThread.get(threadId); @@ -223,7 +217,7 @@ export class PromptCoordinator { } const aliases = this.visualAliasesByThread.get(threadId) ?? new Map(); for (const submission of submissions) { - if (submission.state !== "accepted" || !submission.materializedItem || acceptedTransitionActive(submission, now)) continue; + if (submission.state !== "accepted" || !submission.materializedItem) continue; aliases.set(authoritativeKey(submission.materializedItem), { key: {kind: "prompt", submissionId: submission.id}, admissionOrdinal: submission.admissionOrdinal, materializedItem: cloneKey(submission.materializedItem), @@ -232,7 +226,7 @@ export class PromptCoordinator { } this.visualAliasesByThread.set(threadId, aliases); this.byThread.set(threadId, submissions.filter(submission => submission.state !== "accepted" - || !submission.materializedItem || acceptedTransitionActive(submission, now))); + || !submission.materializedItem)); this.applyVisualAliases(threadId, index); return index; } diff --git a/web/src/presentation/PresentationStatus.ts b/web/src/presentation/PresentationStatus.ts index 20f5c84..5321466 100644 --- a/web/src/presentation/PresentationStatus.ts +++ b/web/src/presentation/PresentationStatus.ts @@ -1,4 +1,4 @@ -export type StatusKind = "unknown" | "active" | "completed" | "failed" | "interrupted"; +export type StatusKind = "unknown" | "active" | "completed" | "failed" | "interrupted" | "pending" | "notLoaded"; export interface PresentationStatus { readonly kind: StatusKind; @@ -8,14 +8,35 @@ export interface PresentationStatus { export function classifyStatus(status: string): PresentationStatus { if (["active", "inProgress", "running", "started"].includes(status)) - return {kind: "active", text: "Running", tone: "active"}; + return {kind: "active", text: "running", tone: "active"}; if (["completed", "idle"].includes(status)) - return {kind: "completed", text: "Completed", tone: "success"}; + return {kind: "completed", text: "completed", tone: "success"}; if (["failed", "systemError"].includes(status)) - return {kind: "failed", text: "Failed", tone: "danger"}; + return {kind: "failed", text: "failed", tone: "danger"}; if (status === "interrupted") - return {kind: "interrupted", text: "Interrupted", tone: "warning"}; - return {kind: "unknown", text: status === "" ? "Unknown" : status, tone: ""}; + return {kind: "interrupted", text: "interrupted", tone: "warning"}; + if (status === "pending") return {kind: "pending", text: "pending", tone: ""}; + if (status === "notLoaded") return {kind: "notLoaded", text: "not loaded", tone: ""}; + return {kind: "unknown", text: status === "" ? "unknown" : status, tone: ""}; +} + +export function displayStatus(status: string): string { + const classified = classifyStatus(status); + if (classified.kind !== "unknown" || status === "") return classified.text; + const words = [...status.trim()].reduce((result, original, index, characters) => { + if (/\s|[-_./]/u.test(original)) { + if (result.length > 0 && result.at(-1) !== " ") result.push(" "); + return result; + } + const previous = characters[index - 1] ?? ""; + const next = characters[index + 1] ?? ""; + const boundary = /[A-Z]/u.test(original) && (/[a-z\d]/u.test(previous) + || (/[A-Z]/u.test(previous) && /[a-z]/u.test(next))); + if (boundary && result.length > 0 && result.at(-1) !== " ") result.push(" "); + result.push(original.toLocaleLowerCase()); + return result; + }, []); + return words.join("").trim() || "unknown"; } export function isActiveStatus(status: string): boolean { diff --git a/web/src/styles.css b/web/src/styles.css index 8147e63..6c09714 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -92,14 +92,19 @@ h1, h2, h3, p { margin: 0; } .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-meta .card-copy-button.feedback-active svg { animation: copy-breathe 440ms ease-in-out; }.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; } .conversation-card.userMessage > header span, .conversation-card.localPrompt > header span { color: #285fca; } .turn-nested > .conversation-card.userMessage.steering { background: #eefafa; border-color: #9fd7d8; } .turn-nested > .conversation-card.steering > header span { color: #146f73; } -.turn-nested > .conversation-card.localPrompt.steering { background: linear-gradient(100deg, #eefafa, #d9efef, #eefafa); background-size: 200% 100%; border-color: #78bdc0; } -.conversation-card.localPrompt { background: linear-gradient(100deg, #eaf2ff, #dbe7f8, #eaf2ff); background-size: 200% 100%; animation: awaiting 1.7s linear infinite; } -.conversation-card.userMessage.turn-container.active-turn { border-color: #6f98e8; } +.conversation-card.localPrompt { position: relative; border-color: #79a0d7; } +.conversation-card.localPrompt::before { position: absolute; z-index: 0; inset: 0; content: ""; background: linear-gradient(100deg, transparent, #75a0ef69, transparent); background-size: 200% 100%; pointer-events: none; opacity: 0; } +.conversation-card.localPrompt.delayed-pending::before { opacity: 1; animation: awaiting 1.7s linear infinite; } +.conversation-card.localPrompt > * { position: relative; z-index: 1; } +.turn-nested > .conversation-card.localPrompt.steering { background: #eefafa; border-color: #5caeb1; } +.turn-nested > .conversation-card.localPrompt.steering::before { background: linear-gradient(100deg, transparent, #5cb4b869, transparent); background-size: 200% 100%; } +.conversation-card.turn-container.active-turn { border-color: #6f98e8; } .conversation-card.active-work { border: 1.5px solid #98a2b3; } .conversation-card.reasoning { border-left: 3px solid #7896df; } .conversation-card.agentMessage.update { background: #fff; border-color: #dce2eb; } diff --git a/web/tests/browser-session-parity.test.mjs b/web/tests/browser-session-parity.test.mjs index 8a16f9b..65d0df3 100644 --- a/web/tests/browser-session-parity.test.mjs +++ b/web/tests/browser-session-parity.test.mjs @@ -112,6 +112,11 @@ test("browser session uses the C++ action routing and preserves prompt-response assert.equal(start.payload.params.threadId, "thread-1"); assert.equal(start.payload.params.input[0].text, "new prompt"); 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"); + 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"); socket.receive(appserver({jsonrpc: "2.0", method: "turn/started", params: { threadId: "thread-1", turn: {id: "turn-1", status: "inProgress", items: []}, @@ -134,9 +139,44 @@ test("browser session uses the C++ action routing and preserves prompt-response assert.equal(visible[1], stableKey({kind: "item", threadId: "thread-1", turnId: "turn-1", itemId: "reasoning-1"})); assert.equal(session.model.connection().connected, true); assert.equal(session.model.connection().providerState, "ready"); - await new Promise(resolve => setTimeout(resolve, 510)); assert.equal(session.conversation().sections[0].cards[0].kind, "userMessage", - "the native 500ms acknowledgement timer materializes without another server event"); + "correlated acknowledgement materializes without a post-ack timer"); + session.dispose(); +}); + +test("acknowledged turn roots stay active across a delayed lifecycle event", async () => { + const socket = new FakeSocket(); + const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); + session.connect(); socket.open(); await readyProvider(socket, "border-handoff"); + respond(socket, requests(socket, "thread/list").at(-1), + {data: [{id: "handoff-thread", status: {type: "idle"}}]}); + session.selectThread("handoff-thread"); + respond(socket, requests(socket, "thread/read").at(-1), + {thread: {id: "handoff-thread", status: {type: "idle"}, turns: []}}); + await session.submitPrompt("handoff prompt"); await Promise.resolve(); + const start = requests(socket, "turn/start").at(-1); + socket.receive(appserver({jsonrpc: "2.0", method: "item/started", params: { + threadId: "handoff-thread", turnId: "handoff-turn", item: { + id: "handoff-user", type: "userMessage", clientId: start.payload.params.clientUserMessageId, + content: [{type: "text", text: "handoff prompt"}], + }, + }})); + respond(socket, start, {turn: {id: "handoff-turn"}}); + await waitForPublish(); + const promoted = session.conversation(); + assert.equal(promoted.activeTurnId, "handoff-turn"); + assert.equal(promoted.sections[0]?.cards[0]?.kind, "userMessage"); + + socket.receive(appserver({jsonrpc: "2.0", method: "turn/started", params: { + threadId: "handoff-thread", turn: {id: "handoff-turn", status: "inProgress", items: []}, + }})); + await waitForPublish(); + assert.equal(session.conversation().activeTurnId, "handoff-turn"); + socket.receive(appserver({jsonrpc: "2.0", method: "turn/completed", params: { + threadId: "handoff-thread", turn: {id: "handoff-turn", status: "completed", items: []}, + }})); + await waitForPublish(); + assert.equal(session.conversation().activeTurnId, undefined); session.dispose(); }); diff --git a/web/tests/card-copy.test.mjs b/web/tests/card-copy.test.mjs index f4bf019..d97616e 100644 --- a/web/tests/card-copy.test.mjs +++ b/web/tests/card-copy.test.mjs @@ -61,7 +61,7 @@ test("Copy precedes folding and remains available on collapsed cards", () => { assert.ok(!emptyMarkup.includes("Copy card content")); }); -test("only an authoritative running-turn You container is emphasized", () => { +test("the retained running-turn You container stays emphasized", () => { const user = itemCard("userMessage", "prompt", { text: "Prompt", imagePaths: [], }); @@ -75,6 +75,16 @@ test("only an authoritative running-turn You container is emphasized", () => { onToggle() {}, })); assert.doesNotMatch(finished, /active-turn/u); + + const acknowledged = itemCard("localPrompt", "local", { + submissionId: 7, prompt: "pending", state: "accepted", + showPendingAnimation: false, error: "", imagePaths: [], + }); + const retained = renderToStaticMarkup(createElement(Card, { + card: acknowledged, active: true, collapsed: false, + turnContainer: true, onToggle() {}, + })); + assert.match(retained, /localPrompt.*turn-container.*active-turn/u); }); test("nested user messages expose the steering identity", () => { @@ -91,6 +101,23 @@ test("nested user messages expose the steering identity", () => { assert.doesNotMatch(markup, /[·•]\s*steering/u); }); +test("local prompt sweep is rendered only after delayed feedback activates", () => { + const card = showPendingAnimation => itemCard("localPrompt", "local", { + submissionId: 8, prompt: "pending", state: "inFlight", + showPendingAnimation, error: "", imagePaths: [], + }); + const calm = renderToStaticMarkup(createElement(Card, { + card: card(false), active: true, collapsed: false, turnContainer: true, + onToggle() {}, + })); + assert.doesNotMatch(calm, /delayed-pending/u); + const overdue = renderToStaticMarkup(createElement(Card, { + card: card(true), active: true, collapsed: false, turnContainer: true, + onToggle() {}, + })); + assert.match(overdue, /localPrompt[^"]*delayed-pending/u); +}); + test("Codex phases are plain right-side metadata before Copy", () => { const update = itemCard("agentMessage", "update", {text: "Working", finalAnswer: false}); const updateMarkup = renderToStaticMarkup(createElement(Card, { @@ -148,7 +175,10 @@ test("typed activity cards retain complete metadata and bounded diagnostics", () card: command, active: false, collapsed: false, onToggle() {}, onCopy() {}, })); assert.match(commandMarkup, /Command execution/u); - assert.match(commandMarkup, /Completed \| exit 0 \| \/workspace \| 1\.5 s/u); + assert.match(commandMarkup, /card-phase status success">completed/u); + assert.match(commandMarkup, /exit 0 \| \/workspace \| 1\.5 s/u); + assert.doesNotMatch(commandMarkup, /completed \| exit 0/u); + assert.ok(commandMarkup.indexOf("card-phase status success") < commandMarkup.indexOf("card-copy-button")); assert.doesNotMatch(commandMarkup, /done\n\n/u); const agent = itemCard("agentActivity", "agent", { @@ -161,6 +191,7 @@ test("typed activity cards retain complete metadata and bounded diagnostics", () })); for (const value of ["spawn_agent", "worker", "gpt-test", "medium", "thread child", "root/worker", "sender root", "Inspect this"]) assert.match(agentMarkup, new RegExp(value, "u")); + assert.match(agentMarkup, /card-phase status success">completed/u); assert.match(agentMarkup, /Done<\/strong>/u); const files = itemCard("fileChanges", "files", {status: "completed", changes: [ @@ -170,7 +201,18 @@ test("typed activity cards retain complete metadata and bounded diagnostics", () const filesMarkup = renderToStaticMarkup(createElement(Card, { card: files, active: false, collapsed: false, onToggle() {}, onCopy() {}, })); - assert.match(filesMarkup, /Completed \| 2 paths \| \+5 −1/u); + assert.match(filesMarkup, /card-phase status success">completed/u); + assert.match(filesMarkup, /2 paths \| \+5 −1/u); + assert.doesNotMatch(filesMarkup, /completed \| 2 paths/u); + + const image = itemCard("imageGeneration", "image", { + path: "/tmp/generated.png", status: "completed", revisedPrompt: "A generated image", + }); + const imageMarkup = renderToStaticMarkup(createElement(Card, { + card: image, active: false, collapsed: false, onToggle() {}, onCopy() {}, + })); + assert.match(imageMarkup, /card-phase status success">completed/u); + assert.equal((imageMarkup.match(/>completed { assert.equal(prompts.beginNext(second.id).id, secondId); addTurn(first, "turn-2"); append(first, "turn-2", "user-new", {type: "userMessage", content: [{type: "text", text: "same"}]}); - prompts.reconcile(first.id, first, 199); + prompts.reconcile(first.id, first); assert.equal(prompts.submission(first.id, firstId).state, "inFlight"); assert.equal(prompts.submission(first.id, firstId).materializedItem, undefined); - assert.equal(prompts.acknowledge(first.id, firstId, "turn-2", 200), true); - prompts.reconcile(first.id, first, 200); - assert.equal(prompts.submission(first.id, firstId).materializedItem.itemId, "user-new"); - const transitioning = projectConversation(first, prompts.submissions(first.id), 80, 699); + const beforeDelay = projectConversation(first, prompts.submissions(first.id), 80, 1099); const localKey = {kind: "prompt", submissionId: firstId}; - assert.equal(findCard(transitioning, localKey).kind, "localPrompt"); - const materialized = projectConversation(first, prompts.submissions(first.id), 80, 700); + assert.equal(findCard(beforeDelay, localKey).payload.showPendingAnimation, false); + const afterDelay = projectConversation(first, prompts.submissions(first.id), 80, 1100); + assert.equal(findCard(afterDelay, localKey).payload.showPendingAnimation, true); + assert.equal(prompts.acknowledge(first.id, firstId, "turn-2"), true); + const acknowledgedIndex = prompts.reconcile(first.id, first); + assert.equal(prompts.submission(first.id, firstId), undefined); + const materialized = projectConversation(acknowledgedIndex, prompts.submissions(first.id), 80, 200, first); assert.equal(findCard(materialized, localKey).kind, "userMessage"); const index = indexAuthoritativeItems(first.id, first); - prompts.reconcile(first.id, index, 700); + prompts.reconcile(first.id, index); const compacted = projectConversation(index, prompts.submissions(first.id), 80, 701, first); assert.equal(prompts.submission(first.id, firstId), undefined); assert.equal(findCard(compacted, localKey).kind, "userMessage"); @@ -100,7 +102,7 @@ test("C++ first response order remains at the local prompt admission boundary", const dispatch = prompts.beginNext(thread.id); addTurn(thread, "turn-new"); append(thread, "turn-new", "reasoning", {type: "reasoning", summary: []}); - prompts.reconcile(thread.id, thread, 601); + prompts.reconcile(thread.id, thread); const promptKey = stableKey({kind: "prompt", submissionId: promptId}); const reasoningKey = stableKey({kind: "item", threadId: thread.id, turnId: "turn-new", itemId: "reasoning"}); const reasoningFirst = projectConversation(thread, prompts.submissions(thread.id), 80, 601); @@ -110,13 +112,13 @@ test("C++ first response order remains at the local prompt admission boundary", append(thread, "turn-new", "user-new", { type: "userMessage", clientId: dispatch.clientUserMessageId, content: [{type: "text", text: "new prompt"}], }); - prompts.reconcile(thread.id, thread, 602); + prompts.reconcile(thread.id, thread); assert.deepEqual(keys(projectConversation(thread, prompts.submissions(thread.id), 80, 602)), [promptKey, reasoningKey]); - assert.equal(prompts.acknowledge(thread.id, promptId, "turn-new", 700), true); - prompts.reconcile(thread.id, thread, 700); - assert.deepEqual(keys(projectConversation(thread, prompts.submissions(thread.id), 80, 700)), [promptKey, reasoningKey]); + assert.equal(prompts.acknowledge(thread.id, promptId, "turn-new"), true); + const promotedIndex = prompts.reconcile(thread.id, thread); + assert.deepEqual(keys(projectConversation(promotedIndex, prompts.submissions(thread.id), 80, 700, thread)), [promptKey, reasoningKey]); const index = indexAuthoritativeItems(thread.id, thread); - prompts.reconcile(thread.id, index, 1200); + prompts.reconcile(thread.id, index); const blue = projectConversation(index, prompts.submissions(thread.id), 80, 1200, thread); assert.deepEqual(keys(blue), [promptKey, reasoningKey]); assert.equal(findCard(blue, {kind: "prompt", submissionId: promptId}).kind, "userMessage"); @@ -127,15 +129,15 @@ test("C++ duplicate prompts bind in admission order and share one turn section", const prompts = new PromptCoordinator(); const first = prompts.admit(thread.id, "repeat", [], {}, thread, undefined, 1000); const second = prompts.admit(thread.id, "repeat", [], {}, thread, undefined, 1001); - prompts.beginNext(thread.id); prompts.acknowledge(thread.id, first, "turn-2", 1010); - prompts.beginNext(thread.id, "turn-2"); prompts.acknowledge(thread.id, second, "turn-2", 1020); + prompts.beginNext(thread.id); prompts.acknowledge(thread.id, first, "turn-2"); + prompts.beginNext(thread.id, "turn-2"); prompts.acknowledge(thread.id, second, "turn-2"); addTurn(thread, "turn-2"); append(thread, "turn-2", "repeat-1", {type: "userMessage", content: [{type: "text", text: "repeat"}]}); append(thread, "turn-2", "repeat-2", {type: "userMessage", content: [{type: "text", text: "repeat"}]}); - prompts.reconcile(thread.id, thread, 1021); - assert.equal(prompts.submission(thread.id, first).materializedItem.itemId, "repeat-1"); - assert.equal(prompts.submission(thread.id, second).materializedItem.itemId, "repeat-2"); - const snapshot = projectConversation(thread, prompts.submissions(thread.id), 80, 1021); + const promoted = prompts.reconcile(thread.id, thread); + assert.equal(prompts.submission(thread.id, first), undefined); + assert.equal(prompts.submission(thread.id, second), undefined); + const snapshot = projectConversation(promoted, prompts.submissions(thread.id), 80, 1021, thread); const visible = keys(snapshot); assert.ok(visible.indexOf(`prompt:${first}`) < visible.indexOf(`prompt:${second}`)); assert.equal(snapshot.sections[1].turnId, "turn-2"); diff --git a/web/tests/qualification.test.mjs b/web/tests/qualification.test.mjs index 9d641c7..3143271 100644 --- a/web/tests/qualification.test.mjs +++ b/web/tests/qualification.test.mjs @@ -80,6 +80,7 @@ test("protocol labels are humanized only at the render boundary", () => { assert.equal(humanizeProtocolLabel("contextCompaction"), "Context compaction"); assert.equal(humanizeProtocolLabel("thread.settings.changed"), "Thread settings changed"); assert.equal(humanizeProtocolLabel("commandExecution"), "Command execution"); + assert.equal(humanizeProtocolLabel("xhigh"), "Extra high"); assert.equal("contextCompaction", "contextCompaction", "the protocol value remains unchanged"); }); @@ -176,7 +177,7 @@ test("conversation presentation preferences retain filtered cards and initialize session.dispose(); }); -test("Plan reconciles stale Running against terminal lifecycle without changing Pending", () => { +test("Plan reconciles stale running against terminal lifecycle without changing pending", () => { const session = new BrowserFrontendSession("ws://bridge.test/codex", () => { throw new Error("not connected"); }); session.model.applyEvent(event(1, 1, "thread.upsert", {thread: {id: "plan-thread", status: "active"}}, "merge", {threadId: "plan-thread"})); session.model.applyEvent(event(2, 1, "turn.upsert", {turn: {id: "plan-turn", status: "inProgress"}}, "merge", {threadId: "plan-thread", turnId: "plan-turn"})); @@ -186,13 +187,13 @@ test("Plan reconciles stale Running against terminal lifecycle without changing }, "replace", {threadId: "plan-thread", turnId: "plan-turn"})); session.selectThread("plan-thread"); const render = () => renderToStaticMarkup(createElement(App, {session})); - assert.match(render(), /Running<\/small>[\s\S]*Pending<\/small>/u); + assert.match(render(), /running<\/small>[\s\S]*pending<\/small>/u); - for (const [sequence, source, display] of [[4, "completed", "Completed"], [5, "failed", "Failed"], [6, "interrupted", "Interrupted"]]) { + for (const [sequence, source, display] of [[4, "completed", "completed"], [5, "failed", "failed"], [6, "interrupted", "interrupted"]]) { session.model.applyEvent(event(sequence, 1, "thread.upsert", {thread: {id: "plan-thread", status: source}}, "merge", {threadId: "plan-thread"})); const markup = render(); - assert.doesNotMatch(markup, /Running<\/small>/u); - assert.match(markup, new RegExp(`${display}[\\s\\S]*Pending`, "u")); + assert.doesNotMatch(markup, /running<\/small>/u); + assert.match(markup, new RegExp(`${display}[\\s\\S]*pending`, "u")); } session.dispose(); }); diff --git a/web/tests/responsive-layout.test.mjs b/web/tests/responsive-layout.test.mjs index 5214c66..b96f89e 100644 --- a/web/tests/responsive-layout.test.mjs +++ b/web/tests/responsive-layout.test.mjs @@ -72,7 +72,7 @@ test("thread hierarchy exposes selected tree-item semantics", () => { assert.match(markup, /role="treeitem" aria-level="1" aria-selected="true"/u); assert.match(markup, /aria-current="true" aria-label="Open Accessible thread, \/workspace"/u); assert.match(markup, /class="conversation-lockup"[\s\S]*Last activity:/u); - assert.match(markup, /Last activity:[\s\S]*Completed<\/strong>/u); + assert.match(markup, /Last activity:[\s\S]*completed<\/strong>/u); session.dispose(); }); @@ -86,7 +86,9 @@ test("responsive CSS keeps the desktop grid and removes the old document-width f assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*\.top-bar\s*\{[^}]*flex-wrap:\s*wrap/u); assert.match(css, /\.conversation-heading \.conversation-activity\s*\{[^}]*color:\s*#1d2633/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*\{[^}]*#eefafa[^}]*#d9efef[^}]*border-color:\s*#78bdc0/u); + assert.match(css, /\.conversation-card\.localPrompt\.steering\s*\{[^}]*background:\s*#eefafa;[^}]*border-color:\s*#5caeb1/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); assert.match(css, /\.send-button\.steer:hover\s*\{[^}]*background:\s*#126b70/u); assert.match(css, /\.send-button\.steer:active\s*\{[^}]*background:\s*#0f595d/u); @@ -97,6 +99,9 @@ test("responsive CSS keeps the desktop grid and removes the old document-width f assert.match(css, /\.composer-actions span\s*\{[^}]*color:\s*#667085/u); assert.match(css, /\.conversation-lockup\s*\{[^}]*align-items:\s*baseline/u); assert.match(css, /\.conversation-heading \.conversation-activity\s*\{[^}]*margin-left:\s*auto[^}]*text-align:\s*right/u); + assert.doesNotMatch(css, /font-variant-caps/u); + assert.match(css, /\.card-phase\.status\.active\s*\{[^}]*color:\s*#285fca/u); + assert.match(css, /\.card-phase\.status\.success\s*\{[^}]*color:\s*#176b45/u); assert.match(css, /\.card-copy-button\.feedback-active svg\s*\{[^}]*animation:\s*copy-breathe 440ms/u); assert.match(css, /@keyframes copy-breathe\s*\{[^}]*0%, 100%\s*\{[^}]*color:\s*#1d2633[^}]*\}[^}]*50%\s*\{[^}]*color:\s*#b9c4d2/u); assert.match(css, /\.card-copy-overlay\s*\{[^}]*border-radius:\s*6px[^}]*background:\s*#1d2633/u); diff --git a/web/tests/supporting-surfaces-parity.test.mjs b/web/tests/supporting-surfaces-parity.test.mjs index 44a8a22..750ba89 100644 --- a/web/tests/supporting-surfaces-parity.test.mjs +++ b/web/tests/supporting-surfaces-parity.test.mjs @@ -3,11 +3,17 @@ import test from "node:test"; import { applySettingChange, canonicalSettingValues, canonicalThreadSettings, changeSettingDraft, collaborationMode, negativePendingResponse, - pendingDecisionOptions, pendingRequestDetails, pendingResponse, permissionProfileLabel, positivePendingResponse, + displayStatus, pendingDecisionOptions, pendingRequestDetails, pendingResponse, permissionProfileLabel, positivePendingResponse, sandboxPolicy, threadStartOptions, turnStartOptions, settingDraftFor, settingPromptOptions, } from "../dist/index.js"; +test("status presentation is lowercase and human-readable", () => { + assert.equal(displayStatus("inProgress"), "running"); + assert.equal(displayStatus("notLoaded"), "not loaded"); + assert.equal(displayStatus("futureProviderState"), "future provider state"); +}); + test("native turn-setting option shaping", () => { assert.deepEqual(canonicalThreadSettings( {model: "old", reasoningEffort: "medium", sandbox: "readOnly", personality: "friendly"},