Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/ui-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,10 @@ 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
blue You card; after acknowledgment the same widget becomes a softer blue
authoritative steering card. No optimistic card is exchanged for a second
widget, and the turn grows around it without changing existing nested card
identity.
teal `You · steering` 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,
Expand Down
49 changes: 38 additions & 11 deletions src/codex/middle/ConversationCards.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,19 @@ class ConversationCard::Impl final {
return true;
}

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"));
if (current.kind == CardKind::LocalPrompt)
refreshPendingPresentation();
owner->style()->unpolish(owner);
owner->style()->polish(owner);
owner->update();
}

void setNestedCards(const std::vector<ConversationCard *> &cards) {
const std::unordered_set<ConversationCard *> retained(cards.begin(),
cards.end());
Expand All @@ -1004,7 +1017,7 @@ class ConversationCard::Impl final {
nestedLayout->removeWidget(card);
card->setParent(owner->parentWidget());
card->setVisible(!explicitlyHidden);
card->setProperty("nestedConversationCard", false);
card->impl_->setNestedConversationCard(false);
}
for (std::size_t index = 0; index < cards.size(); ++index) {
ConversationCard *card = cards[index];
Expand All @@ -1015,7 +1028,7 @@ class ConversationCard::Impl final {
if (nestedLayout->indexOf(card) != position)
nestedLayout->insertWidget(position, card);
card->setVisible(!explicitlyHidden);
card->setProperty("nestedConversationCard", true);
card->impl_->setNestedConversationCard(true);
}
hasVisibleNestedCards =
std::ranges::any_of(cards, [](const ConversationCard *card) {
Expand Down Expand Up @@ -1301,8 +1314,10 @@ class ConversationCard::Impl final {
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
? QStringLiteral("#536b8f")
? steering ? QStringLiteral("#146f73")
: QStringLiteral("#536b8f")
: failed ? QStringLiteral("#982f3d")
: QStringLiteral("#1d2633");
const QString style =
Expand Down Expand Up @@ -1446,14 +1461,21 @@ void ConversationCard::paintEvent(QPaintEvent *event) {
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(QStringLiteral("#dbe7f8"))
? QColor(steering ? QStringLiteral("#d9efef")
: QStringLiteral("#dbe7f8"))
: failed ? QColor(QStringLiteral("#fff0f2"))
: QColor(QStringLiteral("#eaf2ff"));
: QColor(steering
? QStringLiteral("#eefafa")
: QStringLiteral("#eaf2ff"));
const QColor border = waiting || transitioning
? QColor(QStringLiteral("#9eb9df"))
? QColor(steering ? QStringLiteral("#78bdc0")
: QStringLiteral("#9eb9df"))
: failed ? QColor(QStringLiteral("#efb8c0"))
: QColor(QStringLiteral("#bfd3f9"));
: QColor(steering
? QStringLiteral("#9fd7d8")
: QStringLiteral("#bfd3f9"));
painter.setBrush(background);
painter.setPen(QPen(border, 1.0));
painter.drawRoundedRect(bounds, 8.0, 8.0);
Expand All @@ -1473,9 +1495,12 @@ void ConversationCard::paintEvent(QPaintEvent *event) {
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);
sweep.setColorAt(0.0, QColor(47, 111, 235, 0));
sweep.setColorAt(0.5, QColor(117, 160, 239, 105));
sweep.setColorAt(1.0, QColor(47, 111, 235, 0));
sweep.setColorAt(0.0, steering ? QColor(22, 123, 128, 0)
: QColor(47, 111, 235, 0));
sweep.setColorAt(0.5, steering ? QColor(92, 180, 184, 105)
: QColor(117, 160, 239, 105));
sweep.setColorAt(1.0, steering ? QColor(22, 123, 128, 0)
: QColor(47, 111, 235, 0));
QPainterPath clip;
clip.addRoundedRect(bounds, 8.0, 8.0);
painter.save();
Expand All @@ -1484,7 +1509,9 @@ void ConversationCard::paintEvent(QPaintEvent *event) {
painter.restore();

painter.setBrush(Qt::NoBrush);
painter.setPen(QPen(QColor(QStringLiteral("#79a0d7")), 1.5));
painter.setPen(QPen(QColor(steering ? QStringLiteral("#5caeb1")
: QStringLiteral("#79a0d7")),
1.5));
painter.drawRoundedRect(bounds, 8.0, 8.0);
}

Expand Down
8 changes: 5 additions & 3 deletions src/codex/ui/UiStyle.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ QString applicationStyleSheet() {
QPushButton[kind="history"]:hover { background: #d8e7ff; border-color: #9ebcf3; }
QPushButton[kind="request"] { background: #fff6df; border-color: #e5c77d; color: #8a5208; }
QPushButton[kind="request"]:hover { background: #ffefc4; border-color: #d5ad50; }
QPushButton[kind="steer"] { background: #ffffff; border-color: #2f6feb; color: #2f6feb; }
QPushButton[kind="steer"]:hover { background: #e5eeff; border-color: #285fca; color: #285fca; }
QPushButton[kind="steer"] { background: #167b80; border-color: #167b80; color: white; }
QPushButton[kind="steer"]:hover { background: #126b70; border-color: #126b70; color: white; }
QPushButton[kind="steer"]:pressed { background: #0f595d; border-color: #0f595d; color: white; }
QPushButton[kind="cancel"] { background: #eef1f5; border-color: #c8d0dc; color: #475467; }
QPushButton[kind="cancel"]:hover { background: #e3e8ef; border-color: #aeb8c6; }
QPushButton[kind="subtle"], QToolButton[kind="subtle"] {
Expand Down Expand Up @@ -240,7 +241,8 @@ QString applicationStyleSheet() {
QFrame[kind="raised"][tone="warning"] { background: #fff6df; border-color: #e5c77d; }
QFrame[messageRole="user"] { background: #eaf2ff; border: 1px solid #bfd3f9; border-radius: 8px; }
QFrame[messageRole="user"] QLabel[kind="title"] { color: #285fca; }
QFrame[messageRole="user"][nestedConversationCard="true"] { background: #f3f7ff; border-color: #cfddf4; }
QFrame[messageRole="user"][nestedConversationCard="true"] { background: #eefafa; border-color: #9fd7d8; }
QFrame[messageRole="user"][nestedConversationCard="true"] QLabel[kind="title"] { color: #146f73; }
QFrame[messageRole="agent"][messagePhase="final"] { background: #f4f0ff; border: 1px solid #d4c5f2; border-radius: 8px; }
QFrame[messageRole="agent"][messagePhase="final"] QLabel[kind="title"] { color: #53389e; }
QFrame[messageRole="agent"][messagePhase="update"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 8px; }
Expand Down
29 changes: 29 additions & 0 deletions tests/codex/ConversationCardsTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,28 @@ ConversationCard *card(ConversationView &view, const std::string &key) {
return nullptr;
}

QString cardTitle(const ConversationCard *card) {
if (!card)
return {};
const auto labels = card->findChildren<QLabel *>();
const auto title = std::ranges::find_if(labels, [](QLabel *label) {
return label->property("kind").toString() == QStringLiteral("title");
});
return title == labels.end() ? QString{} : (*title)->text();
}

QColor cardTitleColor(const ConversationCard *card) {
if (!card)
return {};
const auto labels = card->findChildren<QLabel *>();
const auto title = std::ranges::find_if(labels, [](QLabel *label) {
return label->property("kind").toString() == QStringLiteral("title");
});
return title == labels.end()
? QColor{}
: (*title)->palette().color(QPalette::WindowText);
}

std::vector<std::string> visualCardKeys(ConversationView &view) {
std::vector<ConversationCard *> cards;
for (QWidget *widget : view.findChildren<QWidget *>())
Expand Down Expand Up @@ -1426,6 +1448,9 @@ bool testCardFoldingGeometryAndRetention() {
result &=
expect(steeringCard && userCard->isAncestorOf(steeringCard) &&
steeringCard->property("nestedConversationCard").toBool() &&
cardTitle(steeringCard) == QStringLiteral("You · steering") &&
cardTitleColor(steeringCard) ==
QColor(QStringLiteral("#146f73")) &&
steeringAnimation && steeringAnimation->isActive(),
"a pending steering You card is nested and keeps its animation");

Expand All @@ -1441,6 +1466,10 @@ bool testCardFoldingGeometryAndRetention() {
expect(authoritativeSteering == steeringCard &&
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");

Expand Down
7 changes: 7 additions & 0 deletions ui-review/UX-DESIGN-DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ are not mapped to the canonical application scale.
| Orange | `#a85d0c` | `#8e4d09` | `#743e07` | `#fff6df` | `#e5c77d` | `#8a5208` |
| Red | `#c43d4d` | `#aa3342` | `#8f2b38` | `#fff0f2` | `#efb8c0` | `#982f3d` |
| Violet | `#6941c6` | `#5b37ad` | `#4b2e90` | `#f4f0ff` | `#d4c5f2` | `#53389e` |
| Teal | `#167b80` | `#126b70` | `#0f595d` | `#eefafa` | `#9fd7d8` | `#146f73` |

Teal is a secondary interaction and identity family, not a status color. It
connects the Steer action with the resulting steering user message and
distinguishes that message from its blue owning turn without implying success,
warning, or failure. Existing state families remain authoritative everywhere
else.

Neutral separators and borders use three canonical intensity steps:

Expand Down
18 changes: 9 additions & 9 deletions web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,15 +423,15 @@ function ScrollableCode({text, className, label}: {text: string; className: stri
}}><code>{text}</code></pre>;
}

export function Card({card, active, collapsed, onToggle, onCopy, nested, turnContainer = false}: {card: VisibleCardData; active: boolean; collapsed: boolean; onToggle: () => void; onCopy: (content: CardCopyContent) => void; nested?: ReactNode; turnContainer?: boolean}) {
export function Card({card, active, collapsed, onToggle, onCopy, nested, turnContainer = false, nestedCard = false}: {card: VisibleCardData; active: boolean; collapsed: boolean; onToggle: () => void; onCopy: (content: CardCopyContent) => void; nested?: ReactNode; turnContainer?: boolean; nestedCard?: boolean}) {
let title = humanize(card.kind);
let body: ReactNode;
let phaseClass = "";
if (card.kind === "userMessage") {
const data = card.payload as UserMessageData; title = "You";
const data = card.payload as UserMessageData; title = nestedCard ? "You · steering" : "You";
body = <><SafeMarkdown text={data.text} /><ImageRibbon paths={data.imagePaths} /></>;
} else if (card.kind === "localPrompt") {
const data = card.payload as LocalPromptData; title = data.state === "failed" ? "Not sent" : "You";
const data = card.payload as LocalPromptData; title = data.state === "failed" ? "Not sent" : nestedCard ? "You · steering" : "You";
body = <><div className="card-text">{data.prompt}</div><ImageRibbon paths={data.imagePaths} />{data.error && <div className="error-text">{data.error}</div>}</>;
} else if (card.kind === "agentMessage") {
const data = card.payload as AgentMessageData; title = "Codex"; phaseClass = data.finalAnswer ? "final" : "update";
Expand Down Expand Up @@ -469,7 +469,7 @@ export function Card({card, active, collapsed, onToggle, onCopy, nested, turnCon
const activeTurn = active && turnContainer && card.kind === "userMessage";
const activeWork = (card.kind === "commandExecution" || card.kind === "imageGeneration")
&& ["active", "inProgress", "running", "started"].includes((card.payload as CommandExecutionData | ImageGenerationData).status);
return <article className={`conversation-card ${card.kind} ${phaseClass} ${collapsed ? "collapsed" : ""} ${turnContainer ? "turn-container" : ""} ${activeTurn ? "active-turn" : ""} ${activeWork ? "active-work" : ""}`} data-card-key={stableKey(card.key)}>
return <article className={`conversation-card ${card.kind} ${phaseClass} ${collapsed ? "collapsed" : ""} ${turnContainer ? "turn-container" : ""} ${nestedCard ? "steering" : ""} ${activeTurn ? "active-turn" : ""} ${activeWork ? "active-work" : ""}`} data-card-key={stableKey(card.key)}>
<header><span>{title}</span><span className="card-meta"><small>{card.itemId}</small>{copyContent.text && <button className="card-copy-button" onClick={() => onCopy(copyContent)} aria-label="Copy card content"><CopyIcon /></button>}{foldable && <button className="card-fold-button" onClick={onToggle} aria-label={collapsed ? "Expand card" : "Collapse card"}><FoldIcon collapsed={collapsed} /></button>}</span></header>{!collapsed && <>{body}{nested && <div className="turn-nested">{nested}</div>}</>}
</article>;
}
Expand Down Expand Up @@ -635,9 +635,9 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte
const visibleSections = conversation.sections
.map(section => ({...section, cards: section.cards.filter(cardVisible)}))
.filter(section => section.cards.length > 0);
const renderCard = (card: VisibleCardData, nested?: ReactNode, turnContainer = false) => {
const renderCard = (card: VisibleCardData, nested?: ReactNode, turnContainer = false, nestedCard = false) => {
const key = stableKey(card.key); const collapsed = cardCollapsed(card, key);
return <Card key={key} card={card} active={session.model.activeTurnId(projectionId) === card.turnId} collapsed={collapsed} onToggle={() => toggleCard(key, collapsed)} onCopy={copyCard} nested={nested} turnContainer={turnContainer} />;
return <Card key={key} card={card} active={session.model.activeTurnId(projectionId) === card.turnId} collapsed={collapsed} onToggle={() => toggleCard(key, collapsed)} onCopy={copyCard} nested={nested} turnContainer={turnContainer} nestedCard={nestedCard} />;
};
return <main ref={pane} className="conversation-pane" tabIndex={-1}>
<div className="conversation-heading"><div className="conversation-title"><span className="eyebrow">Conversation</span>
Expand Down Expand Up @@ -665,7 +665,7 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte
const rootKey = section.rootCardKey ? stableKey(section.rootCardKey) : "";
const prompt = rootKey === "" ? undefined : section.cards.find(card => stableKey(card.key) === rootKey);
const nestedCards = prompt ? section.cards.filter(card => card !== prompt) : [];
const nested = nestedCards.length > 0 ? nestedCards.map(card => renderCard(card)) : undefined;
const nested = nestedCards.length > 0 ? nestedCards.map(card => renderCard(card, undefined, false, true)) : undefined;
return <section key={section.key} className="turn-section">
{prompt ? renderCard(prompt, nested, true) : section.cards.map(card => renderCard(card))}
</section>;
Expand Down Expand Up @@ -737,8 +737,8 @@ function Composer({session, active, draftKey, drafts, options}: {session: Browse
})) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } }}
placeholder={active ? "Message Codex…" : "Select or create a thread"} rows={1} />
<div className="composer-actions"><span id="composer-keyboard-hint">Enter to send · Shift+Enter for a new line</span>
{running ? <button type="button" className="stop-button" onClick={() => session.interrupt()}>■ Stop</button>
: <button type="submit" className="send-button" disabled={!active || prompt.trim() === ""}>Send ↑</button>}</div>
<span className="composer-submit-actions"><button type="submit" className={`send-button${running ? " steer" : ""}`} disabled={!active || prompt.trim() === ""}>{running ? "Steer ↑" : "Send ↑"}</button>
{running && <button type="button" className="stop-button" onClick={() => session.interrupt()}>■ Stop</button>}</span></div>
</form>;
}

Expand Down
6 changes: 5 additions & 1 deletion web/src/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading