Skip to content

Commit fda550b

Browse files
committed
feat: cap thinking body height during streaming with ScrolledComposite and auto-scroll
1 parent b65fe82 commit fda550b

1 file changed

Lines changed: 77 additions & 6 deletions

File tree

  • com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat

com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.apache.commons.lang3.StringUtils;
1313
import org.eclipse.e4.ui.services.IStylingEngine;
1414
import org.eclipse.swt.SWT;
15+
import org.eclipse.swt.custom.ScrolledComposite;
1516
import org.eclipse.swt.events.MouseAdapter;
1617
import org.eclipse.swt.events.MouseEvent;
1718
import org.eclipse.swt.graphics.Cursor;
@@ -37,16 +38,22 @@ public class ThinkingBlock extends Composite {
3738
private static final Pattern TITLE_PATTERN =
3839
Pattern.compile("(?:^|\\n)\\*\\*([^*\\r\\n]+?)\\*\\*(?=\\r?\\n|$)");
3940

41+
private static final int STREAMING_MAX_HEIGHT = 180;
42+
4043
private Composite header;
4144
private Label iconLabel;
4245
private Label titleLabel;
4346
private Label chevronLabel;
4447

45-
/** Body container holding one {@link ThinkingSection} per parsed section. Hidden when collapsed. */
48+
/** Scrollable wrapper around {@link #body}; used only during streaming. Disposed on finalized expand. */
49+
private ScrolledComposite bodyScroller;
50+
/** Body container holding one {@link ThinkingSection} per parsed section. */
4651
private Composite body;
4752
private final List<ThinkingSection> sections = new ArrayList<>();
4853
private final StringBuilder textBuffer = new StringBuilder();
4954
private boolean expanded = true;
55+
/** Auto-scroll to bottom during streaming. Turned off on any user scroll interaction. */
56+
private boolean autoScroll = true;
5057

5158
/**
5259
* Lifecycle of the block. Transitions are always forward: STREAMING → (SEALED →)? → COMPLETED|CANCELLED.
@@ -123,6 +130,7 @@ public void showCancelled() {
123130
}
124131
setTitleText(Messages.thinking_cancelledTitle);
125132
setExpanded(false);
133+
unwrapBodyFromScroller();
126134
state = State.CANCELLED;
127135
}
128136

@@ -139,6 +147,7 @@ public void markSealed() {
139147
stopSpinner();
140148
setTitleText(Messages.thinking_completedTitle);
141149
setExpanded(false);
150+
unwrapBodyFromScroller();
142151
}
143152

144153
/** True only while new thinking stream fragments should still be appended to this block. */
@@ -226,14 +235,26 @@ public void mouseUp(MouseEvent e) {
226235
}
227236

228237
private void createBody() {
229-
body = new Composite(this, SWT.NONE);
238+
bodyScroller = new ScrolledComposite(this, SWT.V_SCROLL);
239+
bodyScroller.setExpandHorizontal(true);
240+
bodyScroller.setExpandVertical(true);
241+
bodyScroller.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false));
242+
bodyScroller.setAlwaysShowScrollBars(false);
243+
244+
body = new Composite(bodyScroller, SWT.NONE);
230245
GridLayout bodyLayout = new GridLayout(1, false);
231246
bodyLayout.marginHeight = 4;
232247
bodyLayout.marginLeft = 4;
233248
bodyLayout.marginWidth = 0;
234249
bodyLayout.verticalSpacing = 6;
235250
body.setLayout(bodyLayout);
236-
body.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false));
251+
252+
bodyScroller.setContent(body);
253+
254+
// Any user scroll interaction disables auto-scroll unconditionally.
255+
Runnable disableAutoScroll = () -> autoScroll = false;
256+
bodyScroller.getVerticalBar().addListener(SWT.Selection, e -> disableAutoScroll.run());
257+
body.addListener(SWT.MouseVerticalWheel, e -> disableAutoScroll.run());
237258
}
238259

239260
/** Parsed (title?, body) tuple. */
@@ -270,9 +291,41 @@ private void refreshBody() {
270291
}
271292

272293
body.requestLayout();
294+
updateScrollerDuringStreaming();
273295
refreshEnclosingScroller();
274296
}
275297

298+
/** Resize the scroller to fit content (up to max height) and auto-scroll to bottom if enabled. */
299+
private void updateScrollerDuringStreaming() {
300+
if (bodyScroller == null || bodyScroller.isDisposed()) {
301+
return;
302+
}
303+
int contentWidth = bodyScroller.getClientArea().width;
304+
if (contentWidth <= 0) {
305+
contentWidth = SWT.DEFAULT;
306+
}
307+
int contentHeight = body.computeSize(contentWidth, SWT.DEFAULT).y;
308+
bodyScroller.setMinSize(body.computeSize(contentWidth, SWT.DEFAULT));
309+
310+
// Grow with content up to max; avoids blank space when content is small.
311+
GridData scrollerData = (GridData) bodyScroller.getLayoutData();
312+
int newHint = Math.min(contentHeight, STREAMING_MAX_HEIGHT);
313+
if (scrollerData.heightHint != newHint) {
314+
scrollerData.heightHint = newHint;
315+
}
316+
317+
if (state == State.STREAMING && autoScroll) {
318+
bodyScroller.setOrigin(0, contentHeight);
319+
} else if (state == State.STREAMING && !autoScroll) {
320+
// Re-enable auto-scroll if user scrolled back to the bottom.
321+
int scrollPos = bodyScroller.getOrigin().y;
322+
int viewportHeight = bodyScroller.getClientArea().height;
323+
if (scrollPos + viewportHeight >= contentHeight - 10) {
324+
autoScroll = true;
325+
}
326+
}
327+
}
328+
276329
/**
277330
* Split {@code raw} on standalone {@code **Title**} lines (terminator: newline or end of buffer). Text before any
278331
* title becomes a leading section with {@code title == null}.
@@ -361,17 +414,35 @@ private void toggleExpanded() {
361414

362415
private void setExpanded(boolean newExpanded) {
363416
this.expanded = newExpanded;
364-
if (body != null && !body.isDisposed()) {
365-
GridData data = (GridData) body.getLayoutData();
417+
418+
Composite bodyContainer = bodyScroller != null ? bodyScroller : body;
419+
if (bodyContainer != null && !bodyContainer.isDisposed()) {
420+
GridData data = (GridData) bodyContainer.getLayoutData();
366421
data.exclude = !expanded;
367-
body.setVisible(expanded);
422+
bodyContainer.setVisible(expanded);
368423
}
369424
updateChevron();
370425
requestLayout();
371426
// Refresh the enclosing scroller so the revealed/hidden body height is reachable.
372427
refreshEnclosingScroller();
373428
}
374429

430+
/** Move body from ScrolledComposite to be a direct child of this block. */
431+
private void unwrapBodyFromScroller() {
432+
if (bodyScroller == null || bodyScroller.isDisposed() || body == null || body.isDisposed()) {
433+
return;
434+
}
435+
bodyScroller.setContent(null);
436+
body.setParent(this);
437+
GridData bodyData = new GridData(SWT.FILL, SWT.FILL, true, false);
438+
bodyData.exclude = !expanded;
439+
body.setLayoutData(bodyData);
440+
body.setVisible(expanded);
441+
body.moveBelow(bodyScroller);
442+
bodyScroller.dispose();
443+
bodyScroller = null;
444+
}
445+
375446
private void updateChevron() {
376447
if (chevronLabel == null || chevronLabel.isDisposed()) {
377448
return;

0 commit comments

Comments
 (0)