Skip to content

Commit 1c668db

Browse files
perf(acp): time-box turn expand, lazy card body
Expanding a folded turn can build hundreds of tool-call cards in one go and freeze the UI. Most of those cards stay collapsed, yet each still paid for a full QTextBrowser stack. - Materialize turn entries in time-boxed slices via a zero-interval expand pump so the event loop stays responsive. - Build each card's body (and render timer) only on first expand. - Resolve chatFont() once per hydrate/expand batch and pass it through.
1 parent f99b776 commit 1c668db

5 files changed

Lines changed: 179 additions & 64 deletions

File tree

‎src/widgets/AcpSessionView.cpp‎

Lines changed: 121 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,7 @@ void AcpSessionView::hydrateFromModel()
873873
&m_foldedMask);
874874
recordTruncation(plan);
875875

876+
const QFont font = chatFont();
876877
int pendingHidden = 0;
877878
for (int i = 0; i < timeline.size(); ++i) {
878879
const auto fold = foldAt.constFind(i);
@@ -893,7 +894,7 @@ void AcpSessionView::hydrateFromModel()
893894
insertTimelineWidget(makeTruncationPlaceholder(pendingHidden), false);
894895
pendingHidden = 0;
895896
}
896-
if (QWidget *w = createEntryWidget(timeline.at(i)))
897+
if (QWidget *w = createEntryWidget(timeline.at(i), font))
897898
insertTimelineWidget(w, false);
898899
}
899900
if (pendingHidden > 0) {
@@ -997,6 +998,8 @@ void AcpSessionView::rebind(AcpSessionModel *model, AcpConnection *connection)
997998
m_truncatedToolCallIds.clear();
998999
m_reportMessages.clear();
9991000
m_foldedTurns.clear();
1001+
m_expandQueue.clear();
1002+
if (m_expandPump) m_expandPump->stop();
10001003
m_foldedMask.clear();
10011004
m_foldedCount = 0;
10021005
m_liveRow.clear(); // already queued for deletion with the rest of the layout
@@ -1118,7 +1121,7 @@ void AcpSessionView::removeTranscriptWidget(QWidget *w)
11181121

11191122
// Builds (and registers) the widget for one timeline entry; the caller places
11201123
// it. Null when the entry renders nothing (empty bubble, unknown tool call).
1121-
QWidget *AcpSessionView::createEntryWidget(const AcpTimelineEntry &entry)
1124+
QWidget *AcpSessionView::createEntryWidget(const AcpTimelineEntry &entry, const QFont &font)
11221125
{
11231126
if (!m_model) return nullptr;
11241127
if (entry.kind == AcpTimelineEntry::Kind::Message) {
@@ -1129,7 +1132,7 @@ QWidget *AcpSessionView::createEntryWidget(const AcpTimelineEntry &entry)
11291132
if (!messageHasVisibleContent(msg))
11301133
return nullptr;
11311134
auto *w = new AcpMessageWidget(msg.role, m_transcriptHost);
1132-
w->setChatFont(chatFont()); // styled widget: must set font explicitly
1135+
w->setChatFont(font); // styled widget: must set font explicitly
11331136
if (msg.fromGoalAgent
11341137
|| msg.marker == QLatin1String(kAcpMarkerGoalAchieved)) {
11351138
w->setFromGoalAgent(true);
@@ -1145,7 +1148,7 @@ QWidget *AcpSessionView::createEntryWidget(const AcpTimelineEntry &entry)
11451148
if (it == m_model->toolCalls().constEnd())
11461149
return nullptr;
11471150
auto *card = new AcpToolCallCard(it.value(), m_transcriptHost);
1148-
card->setChatFont(chatFont()); // styled widget: must set font explicitly
1151+
card->setChatFont(font); // styled widget: must set font explicitly
11491152
if (isGoalJudgeToolId(it.key()))
11501153
card->setGoalTint(true);
11511154
m_toolCallCards.insert(entry.toolCallId, card);
@@ -1206,24 +1209,32 @@ void AcpSessionView::setFoldedMask(const QVector<int> &entries, bool folded)
12061209
const auto &timeline = m_model->timeline();
12071210
if (m_foldedMask.size() < timeline.size())
12081211
m_foldedMask.resize(timeline.size());
1209-
for (int i : entries) {
1210-
if (i < 0 || i >= timeline.size())
1211-
continue;
1212-
const std::uint8_t want = folded ? 1 : 0;
1213-
if (m_foldedMask.at(i) == want)
1214-
continue;
1215-
m_foldedMask[i] = want;
1216-
m_foldedCount += folded ? 1 : -1;
1217-
// Updates for a folded entry must not resurrect its widget: reuse the
1218-
// truncation suppression sets that every update path already consults.
1219-
const AcpTimelineEntry &e = timeline.at(i);
1220-
if (e.kind == AcpTimelineEntry::Kind::Message) {
1221-
if (folded) m_truncatedMessageIndices.insert(e.messageIndex);
1222-
else m_truncatedMessageIndices.remove(e.messageIndex);
1223-
} else {
1224-
if (folded) m_truncatedToolCallIds.insert(e.toolCallId);
1225-
else m_truncatedToolCallIds.remove(e.toolCallId);
1226-
}
1212+
for (int i : entries)
1213+
setEntryFolded(i, folded);
1214+
}
1215+
1216+
void AcpSessionView::setEntryFolded(int i, bool folded)
1217+
{
1218+
if (!m_model) return;
1219+
const auto &timeline = m_model->timeline();
1220+
if (i < 0 || i >= timeline.size())
1221+
return;
1222+
if (m_foldedMask.size() < timeline.size())
1223+
m_foldedMask.resize(timeline.size());
1224+
const std::uint8_t want = folded ? 1 : 0;
1225+
if (m_foldedMask.at(i) == want)
1226+
return;
1227+
m_foldedMask[i] = want;
1228+
m_foldedCount += folded ? 1 : -1;
1229+
// Updates for a folded entry must not resurrect its widget: reuse the
1230+
// truncation suppression sets that every update path already consults.
1231+
const AcpTimelineEntry &e = timeline.at(i);
1232+
if (e.kind == AcpTimelineEntry::Kind::Message) {
1233+
if (folded) m_truncatedMessageIndices.insert(e.messageIndex);
1234+
else m_truncatedMessageIndices.remove(e.messageIndex);
1235+
} else {
1236+
if (folded) m_truncatedToolCallIds.insert(e.toolCallId);
1237+
else m_truncatedToolCallIds.remove(e.toolCallId);
12271238
}
12281239
}
12291240

@@ -1374,22 +1385,29 @@ void AcpSessionView::setTurnExpanded(int turnBegin, bool expanded)
13741385
ft.expanded = expanded;
13751386
if (ft.row) ft.row->setExpanded(expanded);
13761387

1377-
const auto &timeline = m_model->timeline();
13781388
if (expanded) {
1379-
setFoldedMask(ft.entries, false);
1380-
int at = ft.row ? m_transcriptLayout->indexOf(ft.row) + 1 : -1;
1381-
for (int i : ft.entries) {
1382-
if (i < 0 || i >= timeline.size())
1383-
continue;
1384-
QWidget *w = createEntryWidget(timeline.at(i));
1385-
if (!w)
1386-
continue;
1387-
if (at >= 0)
1388-
m_transcriptLayout->insertWidget(at++, w);
1389-
else
1390-
insertTimelineWidget(w, false);
1389+
// A turn can hold hundreds of tool calls and bubbles; building them all
1390+
// in this click handler froze the UI for seconds. The first slice runs
1391+
// now (the rows right under the fold row), the rest one slice per
1392+
// event-loop pass so paint and input keep flowing. Every entry is built.
1393+
ft.nextEntry = 0;
1394+
if (!materializeTurnSlice(turnBegin, chatFont())) {
1395+
if (!m_expandQueue.contains(turnBegin))
1396+
m_expandQueue.append(turnBegin);
1397+
if (!m_expandPump) {
1398+
m_expandPump = new QTimer(this);
1399+
m_expandPump->setSingleShot(true);
1400+
m_expandPump->setInterval(0);
1401+
connect(m_expandPump, &QTimer::timeout, this, &AcpSessionView::pumpExpandQueue);
1402+
}
1403+
m_expandPump->start();
13911404
}
13921405
} else {
1406+
// Collapsing mid-expansion drops the pending slices; entries never
1407+
// built have no widget and are still folded.
1408+
ft.nextEntry = -1;
1409+
m_expandQueue.removeOne(turnBegin);
1410+
const auto &timeline = m_model->timeline();
13931411
for (int i : ft.entries) {
13941412
if (i >= 0 && i < timeline.size())
13951413
removeEntryWidget(timeline.at(i));
@@ -1399,6 +1417,74 @@ void AcpSessionView::setTurnExpanded(int turnBegin, bool expanded)
13991417
syncTranscriptHostWidth();
14001418
}
14011419

1420+
bool AcpSessionView::materializeTurnSlice(int turnBegin, const QFont &font)
1421+
{
1422+
// ~1/3 of a 60 Hz frame: leaves the rest for the layout pass and paint the
1423+
// new widgets trigger. Always builds at least one entry so a slice that
1424+
// overruns (a large diff card) still makes progress.
1425+
constexpr qint64 kSliceBudgetNs = 6'000'000;
1426+
auto it = m_foldedTurns.find(turnBegin);
1427+
if (it == m_foldedTurns.end() || !m_model || !m_transcriptLayout)
1428+
return true;
1429+
FoldedTurn &ft = it.value();
1430+
if (!ft.expanded || ft.nextEntry < 0)
1431+
return true;
1432+
1433+
const auto &timeline = m_model->timeline();
1434+
const int count = ft.entries.size();
1435+
// Insert after the last built entry still on screen (truncation may have
1436+
// dropped some between slices), else right under the fold row.
1437+
QWidget *anchor = nullptr;
1438+
for (int k = qMin(ft.nextEntry, count) - 1; k >= 0 && !anchor; --k) {
1439+
const int i = ft.entries.at(k);
1440+
if (i >= 0 && i < timeline.size())
1441+
anchor = widgetForTimelineEntry(timeline.at(i));
1442+
}
1443+
if (!anchor)
1444+
anchor = ft.row;
1445+
const int anchorIdx = anchor ? m_transcriptLayout->indexOf(anchor) : -1;
1446+
int at = anchorIdx >= 0 ? anchorIdx + 1 : -1; // -1: append at the tail
1447+
1448+
QElapsedTimer clock;
1449+
clock.start();
1450+
while (ft.nextEntry < count) {
1451+
const int i = ft.entries.at(ft.nextEntry++);
1452+
if (i < 0 || i >= timeline.size())
1453+
continue;
1454+
setEntryFolded(i, false);
1455+
const AcpTimelineEntry &entry = timeline.at(i);
1456+
if (QWidget *existing = widgetForTimelineEntry(entry)) {
1457+
// Rare: an update re-created it already. Keep order after it.
1458+
const int idx = m_transcriptLayout->indexOf(existing);
1459+
if (idx >= 0)
1460+
at = idx + 1;
1461+
} else if (QWidget *w = createEntryWidget(entry, font)) {
1462+
if (at >= 0)
1463+
m_transcriptLayout->insertWidget(at++, w);
1464+
else
1465+
insertTimelineWidget(w, false);
1466+
}
1467+
if (clock.nsecsElapsed() >= kSliceBudgetNs)
1468+
break;
1469+
}
1470+
if (ft.nextEntry < count)
1471+
return false;
1472+
ft.nextEntry = -1;
1473+
return true;
1474+
}
1475+
1476+
void AcpSessionView::pumpExpandQueue()
1477+
{
1478+
if (m_expandQueue.isEmpty())
1479+
return;
1480+
const int turnBegin = m_expandQueue.first();
1481+
if (materializeTurnSlice(turnBegin, chatFont()))
1482+
m_expandQueue.removeFirst();
1483+
syncTranscriptHostWidth();
1484+
if (!m_expandQueue.isEmpty())
1485+
m_expandPump->start();
1486+
}
1487+
14021488
// A goal-judge turn is removed from the tail of the timeline; a fold recorded
14031489
// for entries that no longer exist must go with it.
14041490
void AcpSessionView::pruneStaleFoldedTurns()

‎src/widgets/AcpSessionView.h‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,14 +202,21 @@ private slots:
202202
void ensureLiveTurnRow();
203203
void dropLiveTurnRow();
204204
void setTurnExpanded(int turnBegin, bool expanded);
205+
// Materializes one time-boxed slice of an expanding turn; true when nothing
206+
// is left (done, collapsed, or the turn is gone).
207+
bool materializeTurnSlice(int turnBegin, const QFont &font);
208+
void pumpExpandQueue();
205209
void removeEntryWidget(const AcpTimelineEntry &entry);
206210
// Copy buttons under each finished turn's final report.
207211
// includeNewest: the newest turn has ended.
208212
void refreshResponseActions(bool includeNewest);
209213
void attachResponseActions(int messageIndex, AcpMessageWidget *w);
210-
QWidget *createEntryWidget(const AcpTimelineEntry &entry);
214+
// font: the chat typeface, resolved once by batch callers (chatFont() walks
215+
// qApp's children and reads settings).
216+
QWidget *createEntryWidget(const AcpTimelineEntry &entry, const QFont &font);
211217
AcpTurnFoldRow *makeFoldRow(const AcpTurnFolding::Turn &turn, AcpTurnFoldRow *adopt = nullptr);
212218
void setFoldedMask(const QVector<int> &entries, bool folded);
219+
void setEntryFolded(int timelineIndex, bool folded);
213220
void pruneStaleFoldedTurns();
214221
void syncTranscriptHostWidth();
215222
void scrollToBottomDeferred();
@@ -386,8 +393,13 @@ private slots:
386393
QPointer<AcpTurnFoldRow> row;
387394
QVector<int> entries; // timeline indices hidden while collapsed
388395
bool expanded = false;
396+
// Expanding builds widgets in time-boxed slices: next `entries` position
397+
// to materialize, -1 when nothing is pending.
398+
int nextEntry = -1;
389399
};
390400
QHash<int, FoldedTurn> m_foldedTurns; // key: first timeline index of the turn
401+
QVector<int> m_expandQueue; // turn begins with slices still pending, FIFO
402+
QTimer *m_expandPump = nullptr; // zero-interval: one slice per event-loop pass
391403
QVector<std::uint8_t> m_foldedMask; // per timeline index; 1 = currently folded (no widget)
392404
int m_foldedCount = 0;
393405
QPointer<AcpTurnFoldRow> m_liveRow; // heads the running turn; ticks once per second

‎src/widgets/AcpToolCallCard.cpp‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -928,28 +928,15 @@ AcpToolCallCard::AcpToolCallCard(const AcpProtocol::AcpToolCall &initial, QWidge
928928

929929
outer->addLayout(header);
930930

931-
m_body = new QTextBrowser(this);
932-
m_body->setStyleSheet(QStringLiteral("QTextBrowser { background: transparent; border: none; }"));
933-
m_body->setOpenExternalLinks(true);
934-
// Cards size to content. Internal scroll on a card produces a nested
935-
// scrollbar inside the transcript's own scroll area — bad UX. The expand
936-
// button collapses verbose output instead.
937-
m_body->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
938-
m_body->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
939-
m_body->setFrameShape(QFrame::NoFrame);
940-
m_body->document()->setDocumentMargin(0);
941-
outer->addWidget(m_body);
931+
// The body (QTextBrowser: scroll area, viewport, two scrollbars, document)
932+
// and its render timer are built on first expand — ensureBody(). Most cards
933+
// stay collapsed, and an expanded "Worked for" turn instantiates hundreds.
942934

943935
connect(m_expandBtn, &QToolButton::toggled, this, [this](bool checked) {
944936
m_userToggled = true;
945937
setCollapsed(!checked);
946938
});
947939

948-
m_renderTimer = new QTimer(this);
949-
m_renderTimer->setSingleShot(true);
950-
m_renderTimer->setInterval(kBodyRenderDebounceMs);
951-
connect(m_renderTimer, &QTimer::timeout, this, &AcpToolCallCard::flushBodyRender);
952-
953940
refreshHeader();
954941
// Cards land collapsed by default — the title alone is enough to scan a
955942
// turn. Body rendering is intentionally lazy: tool output can be huge, and
@@ -961,6 +948,28 @@ AcpToolCallCard::AcpToolCallCard(const AcpProtocol::AcpToolCall &initial, QWidge
961948
maybeAutoExpandForDiff();
962949
}
963950

951+
void AcpToolCallCard::ensureBody()
952+
{
953+
if (m_body) return;
954+
m_body = new QTextBrowser(this);
955+
m_body->setStyleSheet(QStringLiteral("QTextBrowser { background: transparent; border: none; }"));
956+
m_body->setOpenExternalLinks(true);
957+
// Cards size to content. Internal scroll on a card produces a nested
958+
// scrollbar inside the transcript's own scroll area — bad UX. The expand
959+
// button collapses verbose output instead.
960+
m_body->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
961+
m_body->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
962+
m_body->setFrameShape(QFrame::NoFrame);
963+
m_body->document()->setDocumentMargin(0);
964+
if (m_chatFontSet) m_body->document()->setDefaultFont(m_chatFont);
965+
m_outer->addWidget(m_body);
966+
967+
m_renderTimer = new QTimer(this);
968+
m_renderTimer->setSingleShot(true);
969+
m_renderTimer->setInterval(kBodyRenderDebounceMs);
970+
connect(m_renderTimer, &QTimer::timeout, this, &AcpToolCallCard::flushBodyRender);
971+
}
972+
964973
void AcpToolCallCard::setGoalTint(bool on)
965974
{
966975
if (m_fromGoalAgent == on)
@@ -1628,6 +1637,7 @@ void AcpToolCallCard::showEvent(QShowEvent *event)
16281637
void AcpToolCallCard::setCollapsed(bool collapsed)
16291638
{
16301639
m_collapsed = collapsed;
1640+
if (!collapsed) ensureBody();
16311641
if (m_body) m_body->setVisible(!collapsed);
16321642
if (collapsed && m_renderTimer && m_renderTimer->isActive()) {
16331643
m_renderTimer->stop();

‎src/widgets/AcpToolCallCard.h‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class AcpToolCallCard : public QFrame
7373
void showEvent(QShowEvent *event) override;
7474

7575
private:
76+
void ensureBody();
7677
void refreshHeader();
7778
void scheduleBodyRender();
7879
void flushBodyRender();
@@ -110,13 +111,13 @@ class AcpToolCallCard : public QFrame
110111
bool m_autoExpandedForDiff = false; // we've already auto-expanded once for a completed diff
111112
bool m_bodyDirty = true; // body document is stale vs m_content/rawInput/rawOutput
112113
bool m_refitScheduled = false; // a deferred refit is already queued
113-
QTimer *m_renderTimer = nullptr; // coalesces expensive QTextDocument body renders
114+
QTimer *m_renderTimer = nullptr; // coalesces expensive QTextDocument body renders; built with m_body
114115

115116
QLabel *m_statusIcon = nullptr;
116117
QLabel *m_titleLabel = nullptr;
117118
QLabel *m_durationLabel = nullptr;
118119
QToolButton *m_expandBtn = nullptr;
119-
QTextBrowser *m_body = nullptr;
120+
QTextBrowser *m_body = nullptr; // null until first expand (ensureBody)
120121
QVBoxLayout *m_outer = nullptr;
121122
};
122123

0 commit comments

Comments
 (0)