Skip to content

Commit 84961c9

Browse files
feat(ai-chat): apply chat font to messages and fix goal status
Push the configured chat (Default Font) typeface down into message bubbles, tool-call cards, and their inner text browsers — styled widgets don't inherit setFont(), so the font is applied per-widget and threaded into the rendered HTML, with code spans re-familied on the document model and the hinting preference honored. Also fix the goal-status row and busy-placeholder clock so a goal reaching a terminal state mid-rebind no longer leaves the input stuck on "Agent is working…". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 106f0b3 commit 84961c9

7 files changed

Lines changed: 730 additions & 23 deletions

File tree

src/docks/AiAgentDock.cpp

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,29 +330,34 @@ bool AiAgentDock::attachGoalAgent(GoalAgent *goal)
330330
emit goalDebugLogAppended(entry);
331331
});
332332
connect(m_goalAgent, &GoalAgent::statusChanged, this, [this](GoalAgent::Status s) {
333-
if (!m_model) return;
333+
// The view's goal-status update drives both the on-screen status row
334+
// AND the input's busy-placeholder clock (m_goalRunning), so it MUST
335+
// run on every transition independent of m_model — otherwise a goal
336+
// that reaches a terminal status while the model is detached (mid
337+
// rebind) would leave the placeholder stuck at "Agent is working…".
338+
// Only the appendSystemMessage transcript notes are gated on m_model.
334339
switch (s) {
335340
case GoalAgent::Active:
336-
m_model->appendSystemMessage(tr("⟡ Goal started"));
337341
if (m_view && m_goalAgent) {
338342
m_view->setGoalActive(
339343
m_goalAgent->currentCriterionIndex() + 1,
340344
m_goalAgent->criteria().size(),
341345
0, m_goalAgent->maxIterations());
342346
}
347+
if (m_model) m_model->appendSystemMessage(tr("⟡ Goal started"));
343348
break;
344349
case GoalAgent::Achieved:
345-
m_model->appendSystemMessage(tr("✓ Goal achieved: %1").arg(
346-
m_goalAgent ? m_goalAgent->lastActionText().left(200) : QString()));
347350
if (m_view) m_view->setGoalTerminal(tr("Goal achieved"));
351+
if (m_model) m_model->appendSystemMessage(tr("✓ Goal achieved: %1").arg(
352+
m_goalAgent ? m_goalAgent->lastActionText() : QString()));
348353
break;
349354
case GoalAgent::Cancelled:
350-
m_model->appendSystemMessage(tr("⊘ Goal cancelled"));
351355
if (m_view) m_view->setGoalTerminal(tr("Goal stopped"));
356+
if (m_model) m_model->appendSystemMessage(tr("⊘ Goal cancelled"));
352357
break;
353358
case GoalAgent::Failed:
354-
m_model->appendSystemMessage(tr("✗ Goal failed"));
355359
if (m_view) m_view->setGoalTerminal(tr("Goal failed"));
360+
if (m_model) m_model->appendSystemMessage(tr("✗ Goal failed"));
356361
break;
357362
default:
358363
break;

src/widgets/AcpMessageWidget.cpp

Lines changed: 436 additions & 1 deletion
Large diffs are not rendered by default.

src/widgets/AcpMessageWidget.h

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#ifndef ACP_MESSAGE_WIDGET_H
2020
#define ACP_MESSAGE_WIDGET_H
2121

22+
#include <QFont>
2223
#include <QFrame>
2324
#include <QPixmap>
2425
#include <QString>
@@ -63,6 +64,14 @@ class AcpMessageWidget : public QFrame
6364
void setFromGoalAgent(bool goal);
6465
bool isFromGoalAgent() const { return m_fromGoalAgent; }
6566

67+
// Apply the chat (Default Font) typeface explicitly. Required because this
68+
// bubble and its inner QTextBrowser both carry a stylesheet, and styled
69+
// widgets do NOT inherit a parent's setFont() — Qt re-resolves their font
70+
// from the application default. So the transcript host's font never reaches
71+
// the bubble body; we must push it down per-widget (QTextDocument default
72+
// font + user QLabels) here.
73+
void setChatFont(const QFont &font);
74+
6675
bool isCollapsed() const { return m_collapsed; }
6776
QString role() const { return m_role; }
6877
QString plainText() const { return m_text; }
@@ -74,6 +83,9 @@ class AcpMessageWidget : public QFrame
7483
// we need to re-fit, because Qt does not auto-relayout content widgets on
7584
// font inheritance alone.
7685
void changeEvent(QEvent *event) override;
86+
// Watches the assistant browser's viewport to reveal/position the per-code-
87+
// block copy button on hover and hide it on leave.
88+
bool eventFilter(QObject *watched, QEvent *event) override;
7789

7890
private:
7991
void rerender();
@@ -82,14 +94,47 @@ class AcpMessageWidget : public QFrame
8294
void scheduleRerender();
8395
void flushRerender();
8496

97+
// Assistant code-block affordances. Code (fenced `<pre>`) blocks get a
98+
// distinct inset surface; a single reusable hover button copies the block
99+
// under the cursor. Built lazily on first assistant render.
100+
void ensureCopyButton();
101+
void rebuildCopyIcon();
102+
void scanCodeRegions(); // map fenced-code doc ranges + raw text
103+
void updateCopyButtonForPos(const QPoint &viewportPos);
104+
QString codeStyleSheet() const; // pre/code CSS with chat font + palette
105+
// Post-process the assistant document's code fragments: rewrite the
106+
// monospace family Qt bakes during setMarkdown to the chat family, loosen
107+
// line spacing inside code blocks, and add top/bottom block margins around
108+
// each fenced region so it separates from adjacent prose. Runs on the
109+
// document model (reliable) where QTextDocument's CSS subset is not.
110+
void styleCodeInDocument();
111+
// One fenced code region: document character range + its raw text.
112+
struct CodeRegion { int start; int end; QString text; };
113+
85114
QString m_role;
86115
QString m_text;
87116
bool m_collapsed = false;
88117
bool m_fromGoalAgent = false;
89118

119+
// Chat (Default Font) typeface, pushed in via setChatFont(). Held so that
120+
// content created/re-rendered after the initial setFont() (streamed chunks,
121+
// lazily-built user text blocks) is stamped with the same font. Default-
122+
// constructed until the first setChatFont() call.
123+
QFont m_chatFont;
124+
bool m_chatFontSet = false;
125+
90126
QTextBrowser *m_browser = nullptr; // assistant + non-thought rendered widgets
91127
QToolButton *m_thoughtHeader = nullptr; // thought role
92128
QVBoxLayout *m_layout = nullptr;
129+
130+
// Hover copy button for fenced code blocks (assistant role only). One
131+
// reusable button parented to the browser viewport, repositioned to the
132+
// top-right of the code region under the cursor. m_codeRegions is rebuilt
133+
// on every assistant render; m_hoverCodeIndex tracks which region the
134+
// button currently serves (-1 = hidden).
135+
QToolButton *m_copyCodeBtn = nullptr;
136+
QVector<CodeRegion> m_codeRegions;
137+
int m_hoverCodeIndex = -1;
93138
// Debounce timer for assistant markdown re-renders. setMarkdown on a long
94139
// table-bearing payload is O(N) per call; without debouncing, every
95140
// streamed chunk re-parses the whole document and the UI thread stalls.

src/widgets/AcpSessionView.cpp

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
#include <QClipboard>
3939
#include <QComboBox>
4040
#include <QDialog>
41+
#include <QElapsedTimer>
4142
#include <QEvent>
4243
#include <QFileDialog>
4344
#include <QFont>
@@ -566,6 +567,15 @@ void AcpSessionView::buildUi()
566567
}
567568
});
568569

570+
// Input-placeholder busy clock — repaints the elapsed-time placeholder once
571+
// a second while any agent is working. The underlying span is measured by
572+
// the monotonic m_busyClock; this timer only triggers the text refresh, so
573+
// a missed tick (event-loop stall) self-corrects on the next fire.
574+
m_busyPlaceholderTimer = new QTimer(this);
575+
m_busyPlaceholderTimer->setInterval(1000);
576+
connect(m_busyPlaceholderTimer, &QTimer::timeout,
577+
this, &AcpSessionView::updateBusyPlaceholderText);
578+
569579
btnRow->addWidget(m_cancelBtn);
570580
btnRow->addWidget(m_sendBtn);
571581

@@ -625,6 +635,8 @@ void AcpSessionView::buildUi()
625635
this, &AcpSessionView::applyChatFont);
626636
connect(settings, &ApplicationSettings::fontSizeChanged,
627637
this, &AcpSessionView::applyChatFont);
638+
connect(settings, &ApplicationSettings::fontHintingChanged,
639+
this, &AcpSessionView::applyChatFont);
628640
}
629641
applyChatFont();
630642

@@ -722,6 +734,7 @@ void AcpSessionView::hydrateFromModel()
722734
&& entry.messageIndex < messages.size()) {
723735
const AcpMessage &msg = messages.at(entry.messageIndex);
724736
auto *w = new AcpMessageWidget(msg.role, m_transcriptHost);
737+
w->setChatFont(chatFont()); // styled widget: must set font explicitly
725738
if (msg.fromGoalAgent) {
726739
w->setFromGoalAgent(true);
727740
}
@@ -732,6 +745,7 @@ void AcpSessionView::hydrateFromModel()
732745
auto it = toolCalls.find(entry.toolCallId);
733746
if (it != toolCalls.end()) {
734747
auto *card = new AcpToolCallCard(it.value(), m_transcriptHost);
748+
card->setChatFont(chatFont()); // styled widget: must set font explicitly
735749
insertTimelineWidget(card);
736750
m_toolCallCards.insert(entry.toolCallId, card);
737751
}
@@ -925,6 +939,7 @@ void AcpSessionView::appendMessageWidget(int idx)
925939
const AcpMessage &msg = m_model->messages().at(idx);
926940

927941
auto *w = new AcpMessageWidget(msg.role, m_transcriptHost);
942+
w->setChatFont(chatFont()); // styled widget: must set font explicitly
928943
if (msg.fromGoalAgent) {
929944
w->setFromGoalAgent(true);
930945
}
@@ -1018,6 +1033,7 @@ void AcpSessionView::onToolCallAddedOrUpdated(const QString &toolCallId)
10181033
auto *card = m_toolCallCards.value(toolCallId, nullptr);
10191034
if (!card) {
10201035
card = new AcpToolCallCard(tc, m_transcriptHost);
1036+
card->setChatFont(chatFont()); // styled widget: must set font explicitly
10211037
insertTimelineWidget(card);
10221038
m_toolCallCards.insert(toolCallId, card);
10231039
m_currentGroupCards.append(card);
@@ -1197,6 +1213,10 @@ void AcpSessionView::onIsProcessingChanged(bool processing)
11971213
}
11981214
}
11991215
if (m_planWidget) m_planWidget->setAgentIdle(!processing);
1216+
1217+
// Recompute the input busy-placeholder against the new ACP state (it also
1218+
// depends on m_goalRunning, so the union is resolved inside).
1219+
refreshBusyPlaceholder();
12001220
}
12011221

12021222
void AcpSessionView::onTurnEnded(int groupId)
@@ -1308,6 +1328,8 @@ QStringList AcpSessionView::goalDebugLog() const
13081328
void AcpSessionView::setGoalActive(int criterionIndex, int totalCriteria, int iteration, int maxIterations)
13091329
{
13101330
if (!m_goalStatusRow) return;
1331+
m_goalRunning = true;
1332+
refreshBusyPlaceholder();
13111333
QString text;
13121334
if (totalCriteria > 1) {
13131335
text = tr("Goal %1/%2 · iter %3/%4")
@@ -1342,6 +1364,8 @@ void AcpSessionView::setGoalActive(int criterionIndex, int totalCriteria, int it
13421364
void AcpSessionView::setGoalTerminal(const QString &statusText)
13431365
{
13441366
if (!m_goalStatusRow) return;
1367+
m_goalRunning = false;
1368+
refreshBusyPlaceholder();
13451369
m_goalStatusLabel->setText(statusText);
13461370
m_goalStopBtn->hide();
13471371
m_goalStatusRow->show();
@@ -1360,6 +1384,8 @@ void AcpSessionView::setGoalTerminal(const QString &statusText)
13601384
void AcpSessionView::clearGoalStatus()
13611385
{
13621386
if (!m_goalStatusRow) return;
1387+
m_goalRunning = false;
1388+
refreshBusyPlaceholder();
13631389
m_goalStatusRow->hide();
13641390
if (m_goalElapsedTimer) m_goalElapsedTimer->stop();
13651391
if (m_goalElapsedLabel) m_goalElapsedLabel->hide();
@@ -1553,6 +1579,63 @@ void AcpSessionView::onElapsedTick()
15531579
}
15541580
}
15551581

1582+
void AcpSessionView::refreshBusyPlaceholder()
1583+
{
1584+
if (!m_input) return;
1585+
1586+
// Union of every running-agent surface. Either an in-flight ACP turn or an
1587+
// active goal keeps the input "busy"; the placeholder reflects that union,
1588+
// not whichever finished last.
1589+
const bool acpBusy = m_model && m_model->isProcessing();
1590+
const bool busy = acpBusy || m_goalRunning;
1591+
1592+
if (busy == m_busyPlaceholderActive) {
1593+
// No edge — the other surface was already keeping us busy. Leave the
1594+
// monotonic clock untouched so the elapsed span keeps accumulating.
1595+
return;
1596+
}
1597+
m_busyPlaceholderActive = busy;
1598+
1599+
if (busy) {
1600+
m_busyClock.start(); // idle→busy edge: anchor the clock once
1601+
updateBusyPlaceholderText(); // paint 0m 0s immediately, don't wait 1 s
1602+
m_busyPlaceholderTimer->start();
1603+
} else {
1604+
m_busyPlaceholderTimer->stop();
1605+
// Busy→idle edge. setInputPlaceholder() handles the viewport
1606+
// invalidation so the input reverts to the idle text immediately
1607+
// instead of staying frozen on the last "Agent is working… (Xm Ys)".
1608+
setInputPlaceholder(tr("Send a message"));
1609+
}
1610+
}
1611+
1612+
void AcpSessionView::updateBusyPlaceholderText()
1613+
{
1614+
if (!m_input || !m_busyPlaceholderActive) return;
1615+
const qint64 elapsedSec = m_busyClock.elapsed() / 1000;
1616+
const qint64 minutes = elapsedSec / 60;
1617+
const qint64 seconds = elapsedSec % 60;
1618+
setInputPlaceholder(tr("Agent is working… (%1m %2s)").arg(minutes).arg(seconds));
1619+
}
1620+
1621+
void AcpSessionView::setInputPlaceholder(const QString &text)
1622+
{
1623+
if (!m_input) return;
1624+
m_input->setPlaceholderText(text);
1625+
// QPlainTextEdit::setPlaceholderText() does not reliably invalidate the
1626+
// viewport on a pure text change (its internal repaint is conditional and
1627+
// varies across Qt 6.5↔6.10), so without this poke a placeholder transition
1628+
// stays frozen on whatever was last painted by an unrelated relayout. The
1629+
// placeholder is drawn in the viewport's paintEvent — invalidate it
1630+
// directly, but only when it's actually on screen (empty document); when
1631+
// the user has typed text the placeholder is hidden and Qt repaints the
1632+
// content edit itself. Routed through one helper so no transition can skip
1633+
// the poke. At most one repaint per second while busy — negligible cost.
1634+
if (m_input->document()->isEmpty()) {
1635+
m_input->viewport()->update();
1636+
}
1637+
}
1638+
15561639
void AcpSessionView::onShowDebugLogClicked()
15571640
{
15581641
// Find the owning AiAgentDock so we can scope the dialog title and goal
@@ -1787,14 +1870,41 @@ void AcpSessionView::rebuildAttachIcon()
17871870
palette().color(QPalette::WindowText)));
17881871
}
17891872

1790-
void AcpSessionView::applyChatFont()
1873+
QFont AcpSessionView::chatFont() const
17911874
{
17921875
auto *settings = appSettings();
1793-
if (!settings) return;
1876+
QFont f = settings ? QFont(settings->fontName(), settings->fontSize())
1877+
: QFont();
1878+
// Mirror the editor's glyph-hinting policy: these are plain Qt widgets, so
1879+
// Scintilla's platform-layer flag doesn't reach them — set it on the QFont
1880+
// directly. Without it, thin fonts (e.g. Lilex) look blurry here while the
1881+
// Scintilla editor looks sharp. See EditorManager / PlatQt for the editor side.
1882+
f.setHintingPreference(settings && !settings->fontHinting()
1883+
? QFont::PreferNoHinting
1884+
: QFont::PreferFullHinting);
1885+
return f;
1886+
}
1887+
1888+
void AcpSessionView::applyChatFont()
1889+
{
1890+
if (!appSettings()) return;
17941891

1795-
QFont f(settings->fontName(), settings->fontSize());
1892+
const QFont f = chatFont();
1893+
// The transcript host carries no stylesheet, so its setFont() is a cheap,
1894+
// harmless default for any unstyled descendants. But the message bubbles
1895+
// (AcpMessageWidget) AND their inner QTextBrowsers are stylesheet'd, and Qt
1896+
// does NOT propagate an inherited font into a styled widget — so we must
1897+
// push the font into each bubble explicitly via setChatFont().
17961898
if (m_transcriptHost) m_transcriptHost->setFont(f);
17971899
if (m_input) m_input->setFont(f);
1900+
1901+
for (AcpMessageWidget *w : m_messageWidgets) {
1902+
if (w) w->setChatFont(f);
1903+
}
1904+
if (m_activeThought) m_activeThought->setChatFont(f);
1905+
for (AcpToolCallCard *c : m_toolCallCards) {
1906+
if (c) c->setChatFont(f);
1907+
}
17981908
}
17991909

18001910
bool AcpSessionView::inputKeyEventIsSubmit(QKeyEvent *ke) const

src/widgets/AcpSessionView.h

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
#ifndef ACP_SESSION_VIEW_H
2020
#define ACP_SESSION_VIEW_H
2121

22+
#include <QElapsedTimer>
23+
#include <QFont>
2224
#include <QHash>
2325
#include <QPointer>
2426
#include <QString>
@@ -172,13 +174,36 @@ private slots:
172174
void resetElapsed();
173175
void onElapsedTick();
174176

177+
// Input-placeholder busy clock. Distinct from the transcript-tail
178+
// heartbeats above: those reset on every structural event, whereas this
179+
// measures the *whole* wall span during which any agent is working. The
180+
// union of "the ACP turn is processing" OR "a goal is active" drives it —
181+
// the clock starts on the idle→busy edge and only stops (restoring the
182+
// resting "Send a message" placeholder) once BOTH are idle again, so a
183+
// second agent starting, or one finishing mid-run, never restarts it.
184+
void refreshBusyPlaceholder();
185+
void updateBusyPlaceholderText();
186+
187+
// Single chokepoint for every chat-input placeholder change. Sets the text
188+
// AND invalidates the viewport when the placeholder is on screen (empty
189+
// document) — QPlainTextEdit::setPlaceholderText() does not reliably
190+
// repaint on a pure text change, so routing all transitions through here
191+
// makes it impossible for a future placeholder change to silently freeze.
192+
void setInputPlaceholder(const QString &text);
193+
175194
// Push the user's saved per-agent preferences (model/mode/effort) into
176195
// the running session. Called once per session after metadata first
177196
// populates the available catalogs.
178197
void applySavedPreferences();
179198

180199
void applyChatFont();
181200

201+
// Build the chat (Default Font) QFont from settings, with the hinting
202+
// policy applied. Used to stamp every bubble explicitly (styled widgets
203+
// don't inherit setFont) and the input. Returns a sensible fallback when
204+
// settings are unavailable (test harness).
205+
QFont chatFont() const;
206+
182207
// Slash-command completion popup.
183208
void showCommandPopup();
184209
void hideCommandPopup();
@@ -246,6 +271,16 @@ private slots:
246271
int m_goalElapsedMs = 0;
247272
int m_goalTerminalGeneration = 0;
248273

274+
// Input-placeholder busy clock (see refreshBusyPlaceholder). m_busyClock is
275+
// monotonic and only re-started on the idle→busy edge; the 1 s timer repaints
276+
// the placeholder. m_goalRunning mirrors the attached goal's active state
277+
// (the view has no direct GoalAgent pointer, so the dock-driven
278+
// setGoalActive/setGoalTerminal/clearGoalStatus calls toggle it).
279+
QTimer *m_busyPlaceholderTimer = nullptr;
280+
QElapsedTimer m_busyClock;
281+
bool m_busyPlaceholderActive = false;
282+
bool m_goalRunning = false;
283+
249284
// Tracking
250285
QHash<int, AcpMessageWidget *> m_messageWidgets;
251286
QHash<QString, AcpToolCallCard *> m_toolCallCards;

0 commit comments

Comments
 (0)