@@ -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.
14041490void AcpSessionView::pruneStaleFoldedTurns ()
0 commit comments