diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index 45557687..1db7082b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -654,8 +654,8 @@ public CompletableFuture requestToolExecuti this.confirmDialog.addDisposeListener(e -> { Composite ancestor = this.getParent(); while (ancestor != null && !ancestor.isDisposed()) { - if (ancestor instanceof ChatContentViewer) { - ((ChatContentViewer) ancestor).requestRefreshScrollerLayout(); + if (ancestor instanceof ChatContentViewer viewer) { + SwtUtils.invokeOnDisplayThreadAsync(viewer::refreshScrollerLayout, viewer); break; } ancestor = ancestor.getParent(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index 86a0bbf7..03431e00 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -7,7 +7,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; @@ -23,6 +26,7 @@ import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.ui.PlatformUI; @@ -70,9 +74,18 @@ public class ChatContentViewer extends ScrolledComposite { private BaseTurnWidget latestUserTurn; private CopilotTurnWidget latestCopilotTurn; private BaseTurnWidget latestTurnWidget; - // Auto-scroll state management private boolean autoScrollEnabled; + /** Streaming events queued by LSP threads and drained on the UI thread in batches. */ + private final Queue pendingEvents = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean drainScheduled = new AtomicBoolean(false); + + /** Client-area width of the last layout pass; a change forces a full re-measure (text re-wraps). */ + private int lastLayoutWidth = -1; + + /** Guards against scheduling more than one pending async refresh from a burst of resize events. */ + private final AtomicBoolean refreshScheduled = new AtomicBoolean(false); + /** * Create the composite. * @@ -98,7 +111,7 @@ public ChatContentViewer(Composite parent, int style, ChatServiceManager service this.addControlListener(new ControlAdapter() { @Override public void controlResized(ControlEvent e) { - refreshScrollerLayout(); + scheduleCoalescedRefresh(); } }); @@ -186,123 +199,163 @@ public void setConversationId(String conversationId) { } /** - * Process turn event. + * Process turn event. Events are queued and drained in batches on the UI thread so the LSP thread + * is never blocked and multiple in-flight events coalesce into a single layout pass. */ public void processTurnEvent(ChatProgressValue value) { - SwtUtils.invokeOnDisplayThread(() -> { - if (!turns.containsKey(value.getTurnId())) { - CopilotCore.LOGGER.error(new IllegalStateException("turnId not found: " + value.getTurnId())); - return; - } - BaseTurnWidget turnWidget = turns.get(value.getTurnId()); - if (turnWidget == null) { - appendMessageToTheLatestTurn(value.getReply()); - } + pendingEvents.offer(value); + if (drainScheduled.compareAndSet(false, true)) { + SwtUtils.invokeOnDisplayThreadAsync(this::drainPendingEvents, this); + } + } - ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); - - if (value.getKind() == WorkDoneProgressKind.report) { - if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { - thinkingTurn.setConversationContext(conversationId, value.getTurnId()); - thinkingTurn.appendThinking(value.getThinking()); - updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); - if (hasRenderableOutput(value)) { - // Seal before appending the reply so the spinner stops and the title is fetched. - thinkingTurn.sealThinking(); - } + private void drainPendingEvents() { + drainScheduled.set(false); + if (isDisposed()) { + pendingEvents.clear(); + return; + } + ChatProgressValue event; + boolean sawTurnEnd = false; + while ((event = pendingEvents.poll()) != null) { + doProcessTurnEvent(event); + if (event.getKind() == WorkDoneProgressKind.end) { + sawTurnEnd = true; + } + } + refreshScrollerLayout(false); + if (shouldAutoScrollToBottom()) { + scrollToBottom(); + } + if (sawTurnEnd) { + // A turn's height settles one frame after its content changes. Mid-stream the next chunk's drain + // re-measures and re-scrolls into that settled height; the final chunk has no follow-up, so run + // one on the next frame. + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (isDisposed()) { + return; } + refreshScrollerLayout(false); + if (shouldAutoScrollToBottom()) { + scrollToBottom(); + } + }, this); + } + // Events may have arrived while draining; schedule a follow-up drain if so. + if (!pendingEvents.isEmpty() && drainScheduled.compareAndSet(false, true)) { + SwtUtils.invokeOnDisplayThreadAsync(this::drainPendingEvents, this); + } + } - if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { - // Handle agent mode responses - AgentRound agentRound = value.getAgentRounds().get(0); - - if (agentRound.getReply() != null) { - turnWidget.appendMessage(agentRound.getReply()); - } + private void doProcessTurnEvent(ChatProgressValue value) { + if (!turns.containsKey(value.getTurnId())) { + CopilotCore.LOGGER.error(new IllegalStateException("turnId not found: " + value.getTurnId())); + return; + } + BaseTurnWidget turnWidget = turns.get(value.getTurnId()); + if (turnWidget == null) { + CopilotCore.LOGGER.error(new IllegalStateException("turnWidget not found: " + value.getTurnId())); + appendMessageToTheLatestTurn(value.getReply()); + return; + } - if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) { - AgentToolCall toolCall = agentRound.getToolCalls().get(0); - turnWidget.appendToolCallStatus(toolCall); + ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); - // Extract and process todo list from tool result details - processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall); - } - } else { - // Handle chat mode responses - turnWidget.appendMessage(value.getReply()); - } - } else if (value.getKind() == WorkDoneProgressKind.end) { - // Seal any in-progress thinking block before the turn ends. - if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { + if (value.getKind() == WorkDoneProgressKind.report) { + if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { + thinkingTurn.setConversationContext(conversationId, value.getTurnId()); + thinkingTurn.appendThinking(value.getThinking()); + updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); + if (hasRenderableOutput(value)) { + // Seal before appending the reply so the spinner stops and the title is fetched. thinkingTurn.sealThinking(); - updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); } - turnWidget.flushMessageBuffer(); } - refreshScrollerLayout(); - // Auto-scroll to bottom if enabled - if (shouldAutoScrollToBottom()) { - scrollToBottom(); - } + if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { + // Handle agent mode responses + AgentRound agentRound = value.getAgentRounds().get(0); + + if (agentRound.getReply() != null) { + turnWidget.appendMessage(agentRound.getReply()); + } + + if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) { + AgentToolCall toolCall = agentRound.getToolCalls().get(0); + turnWidget.appendToolCallStatus(toolCall); - String errMsg = value.getErrorMessage(); - if (StringUtils.isNotEmpty(errMsg)) { - errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim(); + // Extract and process todo list from tool result details + processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall); + } + } else { + // Handle chat mode responses + turnWidget.appendMessage(value.getReply()); } - String reason = value.getErrorReason(); - if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) { - // TODO: add enable button for better UX. - errMsg = Messages.chat_model_unsupported_message; + } else if (value.getKind() == WorkDoneProgressKind.end) { + // Seal any in-progress thinking block before the turn ends. + if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { + thinkingTurn.sealThinking(); + updateActiveThinkingBlockId(value.getTurnId(), thinkingTurn); } - if (StringUtils.isNotEmpty(errMsg)) { - // TODO: Remove this legacy fallback after TBB is officially released. - // When the language server has not enabled token-based billing yet, fall back to the - // original main-branch 402 behavior: replace the message with a plan-driven fallback - // notice, switch to the fallback model, refresh quota, and replay the previous input. - CheckQuotaResult quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); - CopilotModel fallbackModel = null; - if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402) { - CopilotPlan userPlan = quotaStatus.copilotPlan(); - fallbackModel = this.serviceManager.getModelService().getFallbackModel(); - String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() - : Messages.chat_noQuotaView_fallbackModel; - - if (MenuUtils.isCfiPlan(userPlan)) { - // Pro, Pro+ and Max message - errMsg = String.format(Messages.chat_noQuotaView_proProplusWarnMsg, fallbackModelName); - } else if (userPlan == CopilotPlan.business || userPlan == CopilotPlan.enterprise) { - // CE and CB message - errMsg = String.format(Messages.chat_noQuotaView_cbCeWarnMsg, fallbackModelName); - } + turnWidget.flushMessageBuffer(); + } + + String errMsg = value.getErrorMessage(); + if (StringUtils.isNotEmpty(errMsg)) { + errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim(); + } + String reason = value.getErrorReason(); + if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) { + // TODO: add enable button for better UX. + errMsg = Messages.chat_model_unsupported_message; + } + if (StringUtils.isNotEmpty(errMsg)) { + // TODO: Remove this legacy fallback after TBB is officially released. + // When the language server has not enabled token-based billing yet, fall back to the + // original main-branch 402 behavior: replace the message with a plan-driven fallback + // notice, switch to the fallback model, refresh quota, and replay the previous input. + CheckQuotaResult quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); + CopilotModel fallbackModel = null; + if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402) { + CopilotPlan userPlan = quotaStatus.copilotPlan(); + fallbackModel = this.serviceManager.getModelService().getFallbackModel(); + String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() + : Messages.chat_noQuotaView_fallbackModel; + + if (MenuUtils.isCfiPlan(userPlan)) { + // Pro, Pro+ and Max message + errMsg = String.format(Messages.chat_noQuotaView_proProplusWarnMsg, fallbackModelName); + } else if (userPlan == CopilotPlan.business || userPlan == CopilotPlan.enterprise) { + // CE and CB message + errMsg = String.format(Messages.chat_noQuotaView_cbCeWarnMsg, fallbackModelName); } + } - renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode(), value.getErrorModelProviderName()); - - // TODO: Remove this legacy fallback after TBB is officially released. - // Only replay the previous input when a fallback model is actually available; otherwise - // setFallBackModelAsActiveModel() is a no-op and re-posting the input with the same - // active model would just trigger the same 402 again. - if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402 - && quotaStatus.copilotPlan() != CopilotPlan.free - && fallbackModel != null) { - // Detach the failed turn so the replayed response creates a new Copilot turn below the - // warning, instead of streaming into the same turn that just rendered the warn widget. - this.latestTurnWidget = null; - this.latestCopilotTurn = null; - - this.serviceManager.getModelService().setFallBackModelAsActiveModel(); - this.serviceManager.getAuthStatusManager().checkQuota(); - - String previousInput = this.serviceManager.getUserPreferenceService().getPreviousInput(StringUtils.EMPTY); - if (StringUtils.isNotEmpty(previousInput)) { - IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); - Map properties = Map.of("previousInput", previousInput, "needCreateUserTurn", false); - eventBroker.post(CopilotEventConstants.TOPIC_CHAT_ON_SEND, properties); - } + renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode(), value.getErrorModelProviderName()); + + // TODO: Remove this legacy fallback after TBB is officially released. + // Only replay the previous input when a fallback model is actually available; otherwise + // setFallBackModelAsActiveModel() is a no-op and re-posting the input with the same + // active model would just trigger the same 402 again. + if (!quotaStatus.tokenBasedBillingEnabled() && value.getCode() == 402 + && quotaStatus.copilotPlan() != CopilotPlan.free + && fallbackModel != null) { + // Detach the failed turn so the replayed response creates a new Copilot turn below the + // warning, instead of streaming into the same turn that just rendered the warn widget. + this.latestTurnWidget = null; + this.latestCopilotTurn = null; + + this.serviceManager.getModelService().setFallBackModelAsActiveModel(); + this.serviceManager.getAuthStatusManager().checkQuota(); + + String previousInput = this.serviceManager.getUserPreferenceService().getPreviousInput(StringUtils.EMPTY); + if (StringUtils.isNotEmpty(previousInput)) { + IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); + Map properties = Map.of("previousInput", previousInput, "needCreateUserTurn", false); + eventBroker.post(CopilotEventConstants.TOPIC_CHAT_ON_SEND, properties); } } - }, this); + } } /** Returns the active thinking block ID last observed while processing this turn's progress. */ @@ -437,23 +490,51 @@ public void renderErrorMessage(String errorMessage) { } /** - * Schedules a single async {@link #refreshScrollerLayout()} call so that multiple dispose/layout - * events that arrive in the same event-loop tick are coalesced into one pass. + * Coalesces resize-triggered refreshes into a single async incremental pass. Writing the scroller + * min size can re-fire {@code controlResized}; deferring instead of recursing avoids a synchronous + * re-entrancy guard while the idempotent min-size writes still converge. */ - public void requestRefreshScrollerLayout() { - SwtUtils.invokeOnDisplayThreadAsync(() -> refreshScrollerLayout(), this); + private void scheduleCoalescedRefresh() { + if (refreshScheduled.compareAndSet(false, true)) { + SwtUtils.invokeOnDisplayThreadAsync(() -> { + refreshScheduled.set(false); + refreshScrollerLayout(false); + }, this); + } } /** - * Update the size of scrolled composite when there are content updates. + * Full re-measure entry point for structural changes (turn start, error/warn widgets, + * expand/collapse of historical thinking blocks) that can resize a non-trailing turn. The streaming + * path instead calls {@link #refreshScrollerLayout(boolean)} with an incremental measure, which + * stays O(1) in the number of turns. */ public void refreshScrollerLayout() { + refreshScrollerLayout(true); + } + + /** + * Re-measure the scroller and update its min size. + * + * @param forceFullMeasure when {@code true}, recursively re-measures every turn; when {@code false} + * only the trailing (mutating) turns are flushed while sealed turns keep cached sizes, keeping + * the pass O(1). A width change always upgrades to a full measure because text re-wraps. + */ + private void refreshScrollerLayout(boolean forceFullMeasure) { if (this.isDisposed()) { return; } - Rectangle clientArea = this.getClientArea(); - Point containerSize = cmpContent.computeSize(clientArea.width, SWT.DEFAULT); + int width = clientArea.width; + boolean fullMeasure = forceFullMeasure || width != lastLayoutWidth; + lastLayoutWidth = width; + + if (!fullMeasure) { + // Only the trailing turns can grow/change during streaming. + flushTrailingTurnCaches(); + } + + Point containerSize = cmpContent.computeSize(width, SWT.DEFAULT, fullMeasure); // Use the default size as a fallback if (latestUserTurn == null) { @@ -461,22 +542,57 @@ public void refreshScrollerLayout() { return; } - Point userTurnSize = latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - Point copilotTurnSize = latestCopilotTurn == null ? new Point(0, 0) - : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); + // Measure at the actual column width, not SWT.DEFAULT: unconstrained width collapses + // soft-wrapped text to a single line and under-estimates the height. roundedHeight must match + // the laid-out height shouldAutoScrollToBottom() reads via getBounds(), or the padding branch + // below reserves phantom whitespace while auto-scroll fires into it (issue #259 flicker). + int userTurnHeight = latestUserTurn.computeSize(width, SWT.DEFAULT).y; + int copilotTurnHeight = latestCopilotTurn == null || latestCopilotTurn.isDisposed() ? 0 + : latestCopilotTurn.computeSize(width, SWT.DEFAULT).y; // Calculate the content height, so that the latest user turn is able to be put at the top of the client area. int contentHeight = 0; - int roundedHeight = userTurnSize.y + copilotTurnSize.y; + int roundedHeight = userTurnHeight + copilotTurnHeight; if (roundedHeight < clientArea.height) { contentHeight = clientArea.height + containerSize.y - roundedHeight; } else { contentHeight = containerSize.y; } - this.setMinHeight(contentHeight); - this.setMinWidth(containerSize.x); - this.layout(true, true); + // Only write min size when it changes: setMin* re-fires controlResized, so skipping no-op writes + // lets the coalesced async refresh converge to a fixed point. + if (this.getMinHeight() != contentHeight) { + this.setMinHeight(contentHeight); + } + if (this.getMinWidth() != containerSize.x) { + this.setMinWidth(containerSize.x); + } + // Incremental layout: only re-position the latest (growing) copilot turn instead of recursing + // into all past turns as conversations grow longer. + if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { + cmpContent.layout(new Control[] {latestCopilotTurn}); + } else { + cmpContent.layout(true, false); + } + this.layout(true, false); + } + + /** + * Flushes the cached layout sizes of the trailing (mutating) turns so the next + * {@code computeSize(width, DEFAULT, false)} re-measures them while sealed historical turns stay + * cached, keeping the layout pass O(1) in the number of historical turns. + */ + private void flushTrailingTurnCaches() { + List dirty = new ArrayList<>(2); + if (latestUserTurn != null && !latestUserTurn.isDisposed()) { + dirty.add(latestUserTurn); + } + if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { + dirty.add(latestCopilotTurn); + } + if (!dirty.isEmpty()) { + cmpContent.layout(dirty.toArray(new Control[0])); + } } /** @@ -493,16 +609,34 @@ private boolean shouldAutoScrollToBottom() { } Rectangle clientArea = this.getClientArea(); - Point userTurnSize = latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - Point copilotTurnSize = latestCopilotTurn == null ? new Point(0, 0) - : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT); - - int roundedHeight = userTurnSize.y + copilotTurnSize.y; + // Use the freshly laid-out bounds rather than computeSize(): the incremental streaming pass + // repositions the trailing turns but does not flush their computeSize cache, so computeSize + // would return a stale height and the auto-scroll trigger would fire seconds too late. + // getBounds() reflects the just-applied layout. + int roundedHeight = currentTurnLaidOutHeight(); // Only auto-scroll when content height exceeds the visible area return roundedHeight >= clientArea.height; } + /** + * Returns the height of the latest (streaming) turn from its applied layout bounds. Falls back to + * {@code computeSize} only when bounds are not yet available (before the first layout pass). + */ + private int currentTurnLaidOutHeight() { + int height = 0; + if (latestUserTurn != null && !latestUserTurn.isDisposed()) { + int userHeight = latestUserTurn.getBounds().height; + height += userHeight > 0 ? userHeight : latestUserTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT).y; + } + if (latestCopilotTurn != null && !latestCopilotTurn.isDisposed()) { + int copilotHeight = latestCopilotTurn.getBounds().height; + height += copilotHeight > 0 ? copilotHeight + : latestCopilotTurn.computeSize(SWT.DEFAULT, SWT.DEFAULT).y; + } + return height; + } + /** * Scroll to the bottom. */ @@ -523,9 +657,7 @@ private void scrollToLatestUserTurn() { return; } - // Use async execution to ensure layout is computed before reading positions. - // Using sync execution would read positions before the layout is complete, - // resulting in incorrect scroll position (always scrolling to 0). + // Async so layout is computed before reading positions; reading synchronously scrolls to 0. SwtUtils.invokeOnDisplayThreadAsync(() -> { if (this.isDisposed() || latestUserTurn.isDisposed()) { return; @@ -537,6 +669,7 @@ private void scrollToLatestUserTurn() { @Override public void dispose() { + pendingEvents.clear(); super.dispose(); for (BaseTurnWidget turn : turns.values()) { turn.dispose(); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java index 8e974b9e..bebcf018 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java @@ -315,7 +315,6 @@ private void refreshBody() { body.requestLayout(); updateScrollerDuringStreaming(); - refreshEnclosingScroller(); } /** Resize the scroller to fit content (up to max height) and auto-scroll to bottom if enabled. */