From c3141f24408188d10c1e6b5e1b0f6d78bc9911a2 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sun, 30 Aug 2026 18:55:34 +0200 Subject: [PATCH] Add local copy feedback --- docs/ui-behavior.md | 12 +++-- src/codex/middle/ConversationCards.cpp | 69 +++++++++++++++++++++++++- tests/codex/ConversationCardsTest.cpp | 51 ++++++++++++++++--- ui-review/UX-DESIGN-DECISIONS.md | 4 +- web/src/app/App.tsx | 24 ++++++--- web/src/styles.css | 3 +- web/tests/responsive-layout.test.mjs | 4 ++ 7 files changed, 147 insertions(+), 20 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 03aa300..98701ea 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -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. @@ -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 diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index c111a1b..1009dfc 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -32,8 +32,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -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(); + 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")); + 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: @@ -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); @@ -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) && @@ -232,6 +297,7 @@ class ImageThumbnail final : public QLabel { } QString path_; + bool leftPressArmed_ = false; }; class ImageRibbon final : public QScrollArea { @@ -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); }, diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index ace5b38..80e0093 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include #include @@ -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(); + result &= expect( + pulse && button->property("copyFeedbackActive").toBool() && + pulse->startValue().value() == QColor("#1d2633") && + pulse->keyValueAt(0.5).value() == QColor("#b9c4d2") && + pulse->endValue().value() == 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) < @@ -2262,20 +2282,35 @@ bool testMessageImagePresentation() { card->findChild(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(); @@ -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; diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 97c9f49..f4176f5 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -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 diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index cfb6ac3..35d50dd 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -423,7 +423,21 @@ function ScrollableCode({text, className, label}: {text: string; className: stri }}>{text}; } -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 | void; nested?: ReactNode; turnContainer?: boolean; nestedCard?: boolean}) { + const [copyFeedback, setCopyFeedback] = useState<{text: string; failed: boolean; sequence: number}>(); + const copyFeedbackSequence = useRef(0); + const copyFeedbackTimer = useRef>(); + 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 = ""; @@ -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
-
{title}{card.itemId}{copyContent.text && }{foldable && }
{!collapsed && <>{body}{nested &&
{nested}
}} +
{title}{card.itemId}{copyContent.text && {copyFeedback && {copyFeedback.text}}}{foldable && }
{!collapsed && <>{body}{nested &&
{nested}
}}
; } @@ -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); diff --git a/web/src/styles.css b/web/src/styles.css index 6694e81..e5c84c3 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-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-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; }.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; } @@ -107,6 +107,7 @@ h1, h2, h3, p { margin: 0; } .conversation-card.agentMessage.final > header span { color: #53389e; } .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); } } .card-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.55; } .image-ribbon { display: flex; align-items: flex-start; gap: 8px; box-sizing: border-box; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 4px; border: 1px solid #d7dee8; border-radius: 6px; background: #fbfcfe; }.image-ribbon > code { flex: 0 0 auto; } .markdown-text { font-size: 14px; } diff --git a/web/tests/responsive-layout.test.mjs b/web/tests/responsive-layout.test.mjs index d9a60ba..5214c66 100644 --- a/web/tests/responsive-layout.test.mjs +++ b/web/tests/responsive-layout.test.mjs @@ -97,6 +97,10 @@ 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.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); + assert.match(css, /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*animation-duration:\s*\.001ms/u); assert.match(css, /@media \(max-width:\s*520px\)[\s\S]*\.conversation-lockup \.conversation-activity\s*\{[^}]*flex-basis:\s*100%/u); assert.match(css, /\.composer-dock\s*\{[^}]*bottom:\s*0[^}]*padding:\s*8px 0 16px[^}]*background:\s*#f2f5f9/u); assert.match(css, /\.composer-dock::before\s*\{[^}]*bottom:\s*100%[^}]*height:\s*8px[^}]*background:\s*#f2f5f9/u);