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
12 changes: 8 additions & 4 deletions docs/ui-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ overflow appears only when required, while its vertical size remains bounded by
the tallest thumbnail. A standard 1 px neutral border, 6 px radius, 4 px inner
padding, and soft-neutral surface enclose both thumbnails and scrollbar. The
ribbon remains subordinate content of its existing card, never a nested card.
Available thumbnails are named keyboard targets and open with Enter or Space;
Available thumbnails are named keyboard targets and open on mouse release
inside the thumbnail or with Enter or Space;
unavailable placeholders remain announced but are not focusable. Markdown
links in Conversation and Inspector content are reachable by keyboard.

Expand Down Expand Up @@ -223,9 +224,12 @@ accessible text provide the action label. Copy remains available while that
card is collapsed; contentless cards omit it. Markdown cards copy their exact
retained source as both plain clipboard text and `text/markdown`, never
reconstructed rendered text. Structured cards copy a deterministic plain-text
representation of their primary content.
The web copy action reports success, unsupported clipboard access, and write
failure through the canonical notice surface instead of failing silently.
representation of their primary content. After a successful write, only the
copy glyph performs one short breath from its darker hover color to a clearly
lighter peak and back, and a rounded, non-layout-shifting `Copied` overlay
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.

Pending-request dialogs validate required answers and structured MCP content
before accepting the modal. Invalid input keeps the dialog and all entered
Expand Down
69 changes: 68 additions & 1 deletion src/codex/middle/ConversationCards.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@
#include <QTextDocument>
#include <QTimer>
#include <QToolButton>
#include <QToolTip>
#include <QVBoxLayout>
#include <QVariant>
#include <QVariantAnimation>
#include <QWheelEvent>

#include <algorithm>
Expand Down Expand Up @@ -137,6 +139,43 @@ class CardCopyButton final : public QToolButton {
setFocusPolicy(Qt::StrongFocus);
setAccessibleName(QStringLiteral("Copy card content"));
setToolTip(accessibleName());

pulse_ = new QVariantAnimation(this);
pulse_->setDuration(440);
pulse_->setStartValue(QColor(QStringLiteral("#1d2633")));
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<QColor>();
const qreal phase = static_cast<qreal>(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"));
setProperty("copyFeedbackActive", false);
update();
});
}

void showCopiedFeedback() {
pulse_->stop();
pulseScale_ = 1.0;
pulseColor_ = QColor(QStringLiteral("#1d2633"));
setProperty("copyFeedbackActive", true);
if (style()->styleHint(QStyle::SH_Widget_Animation_Duration, nullptr,
this) > 0)
pulse_->start();
else
setProperty("copyFeedbackActive", false);
QToolTip::showText(mapToGlobal(QPoint(width() / 2, height())),
QStringLiteral("Copied"), this, rect(), 1000);
update();
}

protected:
Expand All @@ -147,15 +186,26 @@ class CardCopyButton final : public QToolButton {
color = QColor(QStringLiteral("#98a2b3"));
else if (underMouse() || hasFocus())
color = QColor(QStringLiteral("#1d2633"));
if (property("copyFeedbackActive").toBool())
color = pulseColor_;

QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
const QPointF center(10.0, 11.5);
painter.translate(center);
painter.scale(pulseScale_, pulseScale_);
painter.translate(-center);
painter.setBrush(Qt::NoBrush);
painter.setPen(
QPen(color, 1.3, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.drawRoundedRect(QRectF(4.5, 5.5, 8.0, 9.0), 1.2, 1.2);
painter.drawRoundedRect(QRectF(7.5, 8.5, 8.0, 9.0), 1.2, 1.2);
}

private:
QVariantAnimation *pulse_ = nullptr;
qreal pulseScale_ = 1.0;
QColor pulseColor_ = QColor(QStringLiteral("#1d2633"));
};

void openImageViewer(const QString &path);
Expand Down Expand Up @@ -206,13 +256,28 @@ class ImageThumbnail final : public QLabel {

protected:
void mousePressEvent(QMouseEvent *event) override {
if (event->button() == Qt::LeftButton && activate()) {
if (event->button() == Qt::LeftButton &&
property("imageAvailable").toBool()) {
leftPressArmed_ = true;
setFocus(Qt::MouseFocusReason);
event->accept();
return;
}
leftPressArmed_ = false;
QLabel::mousePressEvent(event);
}

void mouseReleaseEvent(QMouseEvent *event) override {
if (event->button() == Qt::LeftButton && leftPressArmed_) {
leftPressArmed_ = false;
if (rect().contains(event->position().toPoint()))
activate();
event->accept();
return;
}
QLabel::mouseReleaseEvent(event);
}

void keyPressEvent(QKeyEvent *event) override {
if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter ||
event->key() == Qt::Key_Space) &&
Expand All @@ -232,6 +297,7 @@ class ImageThumbnail final : public QLabel {
}

QString path_;
bool leftPressArmed_ = false;
};

class ImageRibbon final : public QScrollArea {
Expand Down Expand Up @@ -917,6 +983,7 @@ class ConversationCard::Impl final {
if (content.markdown)
mime->setData("text/markdown", content.text.toUtf8());
QApplication::clipboard()->setMimeData(mime);
copy->showCopiedFeedback();
});
owner->setProperty("kind", "raised");
std::visit([this](const auto &payload) { createComposition(payload); },
Expand Down
51 changes: 45 additions & 6 deletions tests/codex/ConversationCardsTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
#include <QThread>
#include <QTimer>
#include <QToolButton>
#include <QToolTip>
#include <QVariantAnimation>
#include <QWheelEvent>

#include <algorithm>
Expand Down Expand Up @@ -960,6 +962,24 @@ bool testCardCopyControls() {
mime->data("text/markdown") == cases[index].expected.toUtf8()),
"each content card copies its canonical source while collapsed or "
"expanded");
if (index == 0) {
auto *pulse = button->findChild<QVariantAnimation *>();
result &= expect(
pulse && button->property("copyFeedbackActive").toBool() &&
pulse->startValue().value<QColor>() == QColor("#1d2633") &&
pulse->keyValueAt(0.5).value<QColor>() == QColor("#b9c4d2") &&
pulse->endValue().value<QColor>() == QColor("#1d2633") &&
QToolTip::isVisible() &&
QToolTip::text() == QStringLiteral("Copied"),
"Copy breathes from the hover color to a noticeably lighter peak "
"and back while showing the canonical transient Copied overlay");
const QSize cardSize = card.size();
spin(500);
result &= expect(!button->property("copyFeedbackActive").toBool() &&
card.size() == cardSize,
"Copy feedback completes once without changing card "
"geometry");
}
if (index == 0)
result &= expect(
button->parentWidget()->layout()->indexOf(button) <
Expand Down Expand Up @@ -2262,20 +2282,35 @@ bool testMessageImagePresentation() {
card->findChild<QLabel *>(QStringLiteral("messageImageThumbnail"));
if (thumbnail) {
const QPointF local(thumbnail->rect().center());
QMouseEvent click(QEvent::MouseButtonPress, local, local,
QMouseEvent press(QEvent::MouseButtonPress, local, local,
thumbnail->mapToGlobal(local.toPoint()), Qt::LeftButton,
Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(thumbnail, &click);
QApplication::sendEvent(thumbnail, &press);
spin();
}
viewer = nullptr;
for (QWidget *candidate : QApplication::topLevelWidgets())
if (candidate->objectName() == QStringLiteral("messageImageViewer"))
if (candidate->objectName() == QStringLiteral("messageImageViewer") &&
candidate->isVisible())
viewer = candidate;
result &= expect(!viewer, "mouse-down does not open the image viewer");
if (thumbnail) {
const QPointF local(thumbnail->rect().center());
QMouseEvent release(QEvent::MouseButtonRelease, local, local,
thumbnail->mapToGlobal(local.toPoint()), Qt::LeftButton,
Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(thumbnail, &release);
spin();
}
for (QWidget *candidate : QApplication::topLevelWidgets())
if (candidate->objectName() == QStringLiteral("messageImageViewer") &&
candidate->isVisible())
viewer = candidate;
delete card;
spin();
result &= expect(viewer && viewer->isVisible(),
"an open viewer is independent of its originating card");
"mouse-up opens a viewer that remains independent of its "
"originating card");
if (viewer)
viewer->close();
spin();
Expand Down Expand Up @@ -2306,10 +2341,14 @@ bool testGeneratedImagePresentationAndGenericBound() {
"generated-image card reuses the bounded thumbnail");
if (thumbnail) {
const QPointF local(thumbnail->rect().center());
QMouseEvent click(QEvent::MouseButtonPress, local, local,
QMouseEvent press(QEvent::MouseButtonPress, local, local,
thumbnail->mapToGlobal(local.toPoint()), Qt::LeftButton,
Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(thumbnail, &click);
QApplication::sendEvent(thumbnail, &press);
QMouseEvent release(QEvent::MouseButtonRelease, local, local,
thumbnail->mapToGlobal(local.toPoint()), Qt::LeftButton,
Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(thumbnail, &release);
spin();
}
QWidget *viewer = nullptr;
Expand Down
4 changes: 3 additions & 1 deletion ui-review/UX-DESIGN-DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ 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
rendered widget text.
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.
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
Expand Down
24 changes: 17 additions & 7 deletions web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,21 @@ function ScrollableCode({text, className, label}: {text: string; className: stri
}}><code>{text}</code></pre>;
}

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}) {
type ClipboardOutcome = "copied" | "unsupported" | "failed";

export function Card({card, active, collapsed, onToggle, onCopy, nested, turnContainer = false, nestedCard = false}: {card: VisibleCardData; active: boolean; collapsed: boolean; onToggle: () => void; onCopy?: (content: CardCopyContent) => ClipboardOutcome | Promise<ClipboardOutcome> | void; nested?: ReactNode; turnContainer?: boolean; nestedCard?: boolean}) {
const [copyFeedback, setCopyFeedback] = useState<{text: string; failed: boolean; sequence: number}>();
const copyFeedbackSequence = useRef(0);
const copyFeedbackTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => () => { if (copyFeedbackTimer.current) clearTimeout(copyFeedbackTimer.current); }, []);
const copy = async (content: CardCopyContent) => {
const outcome = await (onCopy ? onCopy(content) : writeCardClipboard(content));
if (!outcome) return;
if (copyFeedbackTimer.current) clearTimeout(copyFeedbackTimer.current);
const failed = outcome !== "copied";
setCopyFeedback({text: failed ? "Copy failed" : "Copied", failed, sequence: ++copyFeedbackSequence.current});
copyFeedbackTimer.current = setTimeout(() => setCopyFeedback(undefined), 1000);
};
let title = humanize(card.kind);
let body: ReactNode;
let phaseClass = "";
Expand Down Expand Up @@ -470,7 +484,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 <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>}</>}
<header><span>{title}</span><span className="card-meta"><small>{card.itemId}</small>{copyContent.text && <span className="card-copy-control"><button className={`card-copy-button${copyFeedback ? " feedback-active" : ""}`} onClick={() => void copy(copyContent)} aria-label="Copy card content"><CopyIcon key={copyFeedback?.sequence ?? 0} /></button>{copyFeedback && <span className={`card-copy-overlay${copyFeedback.failed ? " failed" : ""}`} role="status" aria-live="polite">{copyFeedback.text}</span>}</span>}{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 @@ -627,11 +641,7 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte
}
folding.current.set(key, !collapsed); forceCardState(value => value + 1);
};
const copyCard = (content: CardCopyContent) => void writeCardClipboard(content).then(outcome => {
if (outcome === "copied") session.notify("Card content copied.");
else if (outcome === "unsupported") session.notify("Clipboard access is not available in this browser.", true);
else session.notify("Card content could not be copied.", true);
});
const copyCard = (content: CardCopyContent) => writeCardClipboard(content);
const visibleSections = conversation.sections
.map(section => ({...section, cards: section.cards.filter(cardVisible)}))
.filter(section => section.cards.length > 0);
Expand Down
3 changes: 2 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