pin reaction badge on assistant blocks, hide reaction buttons after reacting

This commit is contained in:
Levi Neely 2026-07-27 19:27:20 +02:00
parent e2592d821f
commit cf1f0f75d9
4 changed files with 52 additions and 8 deletions

View File

@ -683,14 +683,26 @@ Item {
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// Reaction buttons
Row {
// Reaction badge (pinned when reacted)
Label {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 4
z: 10
text: block.reaction || ""
font.pixelSize: 18
visible: block.reaction !== ""
}
// Reaction buttons (hover only, hidden when already reacted)
Row {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.rightMargin: block.reaction !== "" ? 36 : 4
anchors.margins: 4
z: 10
spacing: 2
opacity: assistantHover.hovered ? 0.9 : 0
opacity: (assistantHover.hovered && block.reaction === "") ? 0.9 : 0
Behavior on opacity { NumberAnimation { duration: 150 } }
Repeater {

View File

@ -66,6 +66,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
return block.content;
case ResponseIdRole:
return block.responseId;
case ReactionRole:
return block.reaction;
case SubBlocksRole:
if (block.cachedContent != block.content) {
block.cachedSubBlocks = parseSubBlocks(block.content);
@ -105,6 +107,7 @@ QHash<int, QByteArray> ChatBlockModel::roleNames() const
{SubBlocksRole, "subBlocks"},
{FormattedContentRole, "formattedContent"},
{ResponseIdRole, "responseId"},
{ReactionRole, "reaction"},
};
}
@ -236,6 +239,8 @@ QList<ChatBlock> ChatBlockModel::parseBlocks(const QString &text) const
static const QRegularExpression headerRe(
R"(^\[(user|assistant|reasoning|call|tool|error|info)(?::([^\]]*))?\](.*)$)",
QRegularExpression::MultilineOption);
static const QRegularExpression reactionRe(
R"(^\[reaction to assistant response ([^ ]+): ([^ ]+) \(([^)]+)\)\])");
auto it = headerRe.globalMatch(text);
QList<QPair<int, QRegularExpressionMatch>> headers;
@ -253,6 +258,15 @@ QList<ChatBlock> ChatBlockModel::parseBlocks(const QString &text) const
return blocks;
}
// First pass: collect reaction-to-response mappings.
QHash<QString, QString> reactionMap;
for (const auto &[pos, match] : headers) {
auto rm = reactionRe.match(match.captured(0));
if (rm.hasMatch()) {
reactionMap[rm.captured(1)] = rm.captured(3);
}
}
for (int i = 0; i < headers.size(); ++i) {
const auto &[pos, match] = headers[i];
QString typeStr = match.captured(1);
@ -284,10 +298,14 @@ QList<ChatBlock> ChatBlockModel::parseBlocks(const QString &text) const
else if (typeStr == "error") b.type = ChatBlock::Error;
else if (typeStr == "info") b.type = ChatBlock::Info;
if (b.type == ChatBlock::Assistant)
if (b.type == ChatBlock::Assistant) {
b.responseId = name;
else
auto it = reactionMap.constFind(name);
if (it != reactionMap.constEnd())
b.reaction = it.value();
} else {
b.name = name;
}
b.content = content;
blocks.append(b);
}

View File

@ -20,6 +20,7 @@ struct ChatBlock {
Type type;
QString name; // tool/call name, empty for others
QString responseId; // stable assistant response ID
QString reaction; // emoji reaction text (empty if not reacted)
QString content; // raw text content
mutable QVariantList cachedSubBlocks; // cached parseSubBlocks result
mutable QString cachedContent; // content when subBlocks were cached
@ -53,6 +54,7 @@ public:
SubBlocksRole, // QVariantList of sub-block maps
FormattedContentRole, // pre-formatted HTML for call/tool expanded view
ResponseIdRole,
ReactionRole,
};
explicit ChatBlockModel(QObject *parent = nullptr);

View File

@ -1508,14 +1508,26 @@ ApplicationWindow {
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// Reaction buttons
Row {
// Reaction badge (pinned when reacted)
Label {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 4
z: 10
text: block.reaction || ""
font.pixelSize: 18
visible: block.reaction !== ""
}
// Reaction buttons (hover only, hidden when already reacted)
Row {
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.rightMargin: block.reaction !== "" ? 36 : 4
anchors.margins: 4
z: 10
spacing: 2
opacity: assistantHover.hovered ? 0.9 : 0
opacity: (assistantHover.hovered && block.reaction === "") ? 0.9 : 0
Behavior on opacity { NumberAnimation { duration: 150 } }
Repeater {