diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 44bcbaf..2769823 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -557,18 +557,14 @@ void ShellWidget::Impl::render() { text(view.conversation.emptyMessage)); middleRegion->conversation().reconcile(view.conversation.snapshot); if (view.conversation.mode == UiConversationMode::Thread) { - QStringList metadata; - if (!view.conversation.workspace.empty()) - metadata << text(view.conversation.workspace); - if (!view.conversation.status.empty()) - metadata << text(view.conversation.status); const QString activity = view.conversation.lastActivityAt ? lastActivityText( *view.conversation.lastActivityAt) : QString{}; middleRegion->setThreadHeading( text(view.conversation.title), - metadata.join(QStringLiteral(" | ")), activity); + text(view.conversation.workspace), activity, + text(view.conversation.status), text(view.conversation.statusTone)); } else if (view.conversation.mode == UiConversationMode::NewThread) { middleRegion->setThreadHeading(text(view.conversation.title), text(view.conversation.workspace)); diff --git a/src/codex/UiSession.cpp b/src/codex/UiSession.cpp index 4312586..1ba34d8 100644 --- a/src/codex/UiSession.cpp +++ b/src/codex/UiSession.cpp @@ -1077,7 +1077,9 @@ class UiSession::Impl final { conversation.mode = UiConversationMode::Thread; conversation.title = thread->title; conversation.workspace = thread->cwd; - conversation.status = std::string(classifyStatus(thread->status).text); + const PresentationStatus status = classifyStatus(thread->status); + conversation.status = std::string(status.text); + conversation.statusTone = std::string(status.tone); conversation.lastActivityAt = thread->lastActivityAt; conversation.emptyMessage = "No materialized activity."; } else if (newThreadIntent) { diff --git a/src/codex/UiSession.h b/src/codex/UiSession.h index 2ab948f..a88b76b 100644 --- a/src/codex/UiSession.h +++ b/src/codex/UiSession.h @@ -96,6 +96,7 @@ struct UiConversationView { std::string title; std::string workspace; std::string status; + std::string statusTone; std::optional lastActivityAt; std::string emptyMessage; middle::ConversationSnapshot snapshot; diff --git a/src/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp index c6f124e..b52524b 100644 --- a/src/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -242,16 +242,31 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { conversationTrailingMetadata = makeLabel({}, "meta"); conversationTrailingMetadata->setObjectName( QStringLiteral("conversationTrailingMetadata")); + conversationTrailingMetadata->setProperty("tone", "strong"); conversationTrailingMetadata->setWordWrap(false); conversationTrailingMetadata->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); + conversationStateSeparator = makeLabel(QStringLiteral("|"), "meta"); + conversationStateSeparator->setProperty("tone", "strong"); + conversationStateSeparator->setSizePolicy(QSizePolicy::Minimum, + QSizePolicy::Preferred); + conversationState = makeLabel({}, "meta"); + conversationState->setObjectName(QStringLiteral("conversationState")); + conversationState->setSizePolicy(QSizePolicy::Minimum, + QSizePolicy::Preferred); conversationTitle->setAlignment(Qt::AlignLeft | Qt::AlignTop); conversationMetadata->setAlignment(Qt::AlignLeft | Qt::AlignTop); conversationTrailingMetadata->setAlignment(Qt::AlignRight | Qt::AlignTop); + conversationState->setAlignment(Qt::AlignLeft | Qt::AlignTop); alignThreadHeadingBaselines(); + auto *trailingHeading = new QHBoxLayout; + trailingHeading->setSpacing(4); + trailingHeading->addWidget(conversationTrailingMetadata, 0, Qt::AlignTop); + trailingHeading->addWidget(conversationStateSeparator, 0, Qt::AlignTop); + trailingHeading->addWidget(conversationState, 0, Qt::AlignTop); threadHeading->addWidget(conversationTitle, 0, Qt::AlignTop); threadHeading->addWidget(conversationMetadata, 1, Qt::AlignTop); - threadHeading->addWidget(conversationTrailingMetadata, 0, Qt::AlignTop); + threadHeading->addLayout(trailingHeading); contentLayout->addLayout(threadHeading); contentLayout->addSpacing(7); contentLayout->addWidget(divider()); @@ -347,13 +362,22 @@ QSplitter *MiddleRegionWidget::splitterWidget() const noexcept { } void MiddleRegionWidget::setThreadHeading(QString title, QString metadata, - QString trailingMetadata) { + QString trailingMetadata, + QString state, QString stateTone) { if (conversationTitle->text() != title) conversationTitle->setText(std::move(title)); if (conversationMetadata->text() != metadata) conversationMetadata->setText(std::move(metadata)); if (conversationTrailingMetadata->text() != trailingMetadata) conversationTrailingMetadata->setText(std::move(trailingMetadata)); + if (conversationState->text() != state) + conversationState->setText(std::move(state)); + conversationStateSeparator->setVisible(!conversationState->text().isEmpty()); + if (conversationState->property("tone").toString() != stateTone) { + conversationState->setProperty("tone", std::move(stateTone)); + conversationState->style()->unpolish(conversationState); + conversationState->style()->polish(conversationState); + } alignThreadHeadingBaselines(); } @@ -361,6 +385,8 @@ void MiddleRegionWidget::alignThreadHeadingBaselines() { conversationTitle->ensurePolished(); conversationMetadata->ensurePolished(); conversationTrailingMetadata->ensurePolished(); + conversationStateSeparator->ensurePolished(); + conversationState->ensurePolished(); const int offset = std::max( 0, conversationTitle->fontMetrics().ascent() - conversationMetadata->fontMetrics().ascent()); @@ -369,6 +395,8 @@ void MiddleRegionWidget::alignThreadHeadingBaselines() { 0, conversationTitle->fontMetrics().ascent() - conversationTrailingMetadata->fontMetrics().ascent()); conversationTrailingMetadata->setContentsMargins(0, trailingOffset, 0, 0); + conversationStateSeparator->setContentsMargins(0, trailingOffset, 0, 0); + conversationState->setContentsMargins(0, trailingOffset, 0, 0); } void MiddleRegionWidget::showNotice(QString message, bool error) { diff --git a/src/codex/middle/MiddleRegionWidget.h b/src/codex/middle/MiddleRegionWidget.h index a8911b7..7185716 100644 --- a/src/codex/middle/MiddleRegionWidget.h +++ b/src/codex/middle/MiddleRegionWidget.h @@ -35,7 +35,8 @@ class MiddleRegionWidget final : public QWidget { [[nodiscard]] QSplitter *splitterWidget() const noexcept; void setThreadHeading(QString title, QString metadata, - QString trailingMetadata = {}); + QString trailingMetadata = {}, + QString state = {}, QString stateTone = {}); void showNotice(QString message, bool error = true); void showSidebar(bool visible); void showInspector(bool visible); @@ -58,6 +59,8 @@ class MiddleRegionWidget final : public QWidget { QLabel *conversationTitle = nullptr; QLabel *conversationMetadata = nullptr; QLabel *conversationTrailingMetadata = nullptr; + QLabel *conversationStateSeparator = nullptr; + QLabel *conversationState = nullptr; QToolButton *reasoningVisibility = nullptr; QToolButton *updateVisibility = nullptr; QToolButton *commandInitialFolding = nullptr; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 6357701..c423ea8 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -119,6 +119,7 @@ QString applicationStyleSheet() { 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; } + QLabel[kind="meta"][tone="strong"] { color: #1d2633; } QLabel[kind="small"] { color: #667085; font-size: %1pt; } QLabel[kind="settingLabel"] { color: #667085; font-weight: 600; } QLabel[tone="active"] { color: #285fca; } diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 214de5b..9598a7a 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -298,8 +298,10 @@ bool testOverlayGeometryAndRegionRouting() { region.resize(1500, 820); region.show(); region.setThreadHeading(QStringLiteral("Thread title"), - QStringLiteral("/workspace | Completed"), - QStringLiteral("Last activity: 14:15:51")); + QStringLiteral("/workspace"), + QStringLiteral("Last activity: 14:15:51"), + QStringLiteral("Completed"), + QStringLiteral("success")); spin(20); QSplitter *splitter = region.splitterWidget(); @@ -323,6 +325,8 @@ bool testOverlayGeometryAndRegionRouting() { auto *conversationTrailingMetadata = splitter->widget(1)->findChild( QStringLiteral("conversationTrailingMetadata")); + auto *conversationState = splitter->widget(1)->findChild( + QStringLiteral("conversationState")); auto *reasoningToggle = splitter->widget(1)->findChild( QStringLiteral("conversationReasoningToggle")); auto *updatesToggle = splitter->widget(1)->findChild( @@ -350,14 +354,23 @@ bool testOverlayGeometryAndRegionRouting() { "Threads and Conversation header dividers share the 10 px inset"); result &= expect(conversationTitle && conversationMetadata && - conversationTrailingMetadata && + conversationTrailingMetadata && conversationState && conversationMetadata->geometry().left() > conversationTitle->geometry().right() && - conversationTrailingMetadata->geometry().right() >= - conversationTrailingMetadata->parentWidget()->width() - - 16 && + 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( diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index fc144e8..738965a 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -642,9 +642,9 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte return
Conversation

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

-

{thread ? [thread.cwd, classifyStatus(thread.status).text].filter(Boolean).join(" | ") +

{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)}

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

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

}
{paneControls &&
{paneControls}
}
diff --git a/web/src/styles.css b/web/src/styles.css index 71e5425..9e93d1a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -68,7 +68,8 @@ h1, h2, h3, p { margin: 0; } .conversation-lockup { display: flex; min-width: 0; align-items: baseline; gap: 10px; } .conversation-heading h1 { flex: 0 1 auto; min-width: 0; margin: 2px 0 3px; font-size: 20px; letter-spacing: -.02em; } .conversation-heading p { flex: 1 1 auto; min-width: 0; margin: 0; color: #667085; font-size: 11px; overflow-wrap: anywhere; } -.conversation-heading .conversation-activity { flex: 0 0 auto; margin-left: auto; white-space: nowrap; text-align: right; } +.conversation-heading .conversation-activity { flex: 0 0 auto; margin-left: auto; color: #1d2633; white-space: nowrap; text-align: right; } +.conversation-activity strong { font-weight: 600; }.conversation-activity strong.active { color: #315ccf; }.conversation-activity strong.success { color: #178857; }.conversation-activity strong.warning { color: #a86815; }.conversation-activity strong.danger { color: #bd3445; } .conversation-heading-actions { display: contents; } .conversation-heading-actions.responsive { display: flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: 8px; } .responsive-pane-controls { display: flex; align-items: center; gap: 5px; } diff --git a/web/tests/responsive-layout.test.mjs b/web/tests/responsive-layout.test.mjs index 84fff0b..2f4331b 100644 --- a/web/tests/responsive-layout.test.mjs +++ b/web/tests/responsive-layout.test.mjs @@ -72,6 +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); session.dispose(); }); @@ -83,6 +84,7 @@ test("responsive CSS keeps the desktop grid and removes the old document-width f assert.match(css, /@media \(max-width:\s*1160px\)[\s\S]*grid-template-columns:\s*220px minmax\(0, 1fr\)/u); assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/u); 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, /\.responsive-drawer\s*\{[^}]*position:\s*fixed/u); assert.match(css, /\.drawer-backdrop\s*\{[^}]*position:\s*fixed/u); assert.match(css, /button:focus-visible[\s\S]*outline:\s*2px solid #6f98e8/u);