Skip to content

Commit 70a46de

Browse files
perf(AcpMessageWidget): debounce markdown re-renders during streaming
Parsing markdown on every streamed chunk is O(N) per call, which stalls the UI thread on long or table-heavy replies where the full payload is re-parsed each time. An 80 ms single-shot timer coalesces rapid chunks into a single render pass. setText bypasses the timer and renders immediately since wholesale replacements represent settled state, not an in-progress stream.
1 parent bf02c50 commit 70a46de

2 files changed

Lines changed: 39 additions & 1 deletion

File tree

‎src/widgets/AcpMessageWidget.cpp‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
#include <QTextBrowser>
3030
#include <QTextCursor>
3131
#include <QTextDocument>
32+
#include <QTimer>
3233
#include <QToolButton>
3334
#include <QVBoxLayout>
3435

@@ -132,17 +133,47 @@ AcpMessageWidget::AcpMessageWidget(QString role, QWidget *parent)
132133
configureBubbleBrowser(m_browser);
133134
m_layout->addWidget(m_browser);
134135
}
136+
137+
// Debounce assistant/thought re-renders so streamed chunks don't drown the
138+
// UI thread in markdown parsing — especially severe for table-heavy
139+
// replies where every chunk re-parses the whole payload.
140+
m_rerenderTimer = new QTimer(this);
141+
m_rerenderTimer->setSingleShot(true);
142+
m_rerenderTimer->setInterval(80);
143+
connect(m_rerenderTimer, &QTimer::timeout, this, &AcpMessageWidget::rerender);
144+
}
145+
146+
void AcpMessageWidget::scheduleRerender()
147+
{
148+
if (m_rerenderTimer) {
149+
if (!m_rerenderTimer->isActive()) {
150+
m_rerenderTimer->start();
151+
}
152+
} else {
153+
rerender();
154+
}
155+
}
156+
157+
void AcpMessageWidget::flushRerender()
158+
{
159+
if (m_rerenderTimer && m_rerenderTimer->isActive()) {
160+
m_rerenderTimer->stop();
161+
}
135162
}
136163

137164
void AcpMessageWidget::appendChunk(const QString &chunk)
138165
{
139166
m_text += chunk;
140-
rerender();
167+
scheduleRerender();
141168
}
142169

143170
void AcpMessageWidget::setText(const QString &fullText)
144171
{
145172
m_text = fullText;
173+
// Wholesale replacements are usually terminal states (compaction done,
174+
// model rewrote in place) — render immediately so the user sees the
175+
// settled state rather than waiting for the debounce.
176+
flushRerender();
146177
rerender();
147178
}
148179

‎src/widgets/AcpMessageWidget.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
#include "AcpProtocol.h"
2828

2929
class QTextBrowser;
30+
class QTimer;
3031
class QToolButton;
3132
class QLabel;
3233
class QVBoxLayout;
@@ -74,6 +75,8 @@ class AcpMessageWidget : public QFrame
7475
void rerender();
7576
void refitBrowserHeight();
7677
void applyCollapsed(bool collapsed);
78+
void scheduleRerender();
79+
void flushRerender();
7780

7881
QString m_role;
7982
QString m_text;
@@ -82,6 +85,10 @@ class AcpMessageWidget : public QFrame
8285
QTextBrowser *m_browser = nullptr; // assistant + non-thought rendered widgets
8386
QToolButton *m_thoughtHeader = nullptr; // thought role
8487
QVBoxLayout *m_layout = nullptr;
88+
// Debounce timer for assistant markdown re-renders. setMarkdown on a long
89+
// table-bearing payload is O(N) per call; without debouncing, every
90+
// streamed chunk re-parses the whole document and the UI thread stalls.
91+
QTimer *m_rerenderTimer = nullptr;
8592

8693
// User role: image thumbnails kept alongside their original pixmap so we
8794
// can rescale to bubble width on resize without quality loss.

0 commit comments

Comments
 (0)