From 0e53d53b07ee1d4611c0f5fb605a290f596de980 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 23 Sep 2026 17:37:57 +0200 Subject: [PATCH] fix: space chat rows evenly after thinking and answers (#11315) * fix: space chat rows evenly after thinking and answers Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep a flow step's answer actions and space its step label Co-Authored-By: Claude Opus 5.5 (1M context) * fix: pick the answer that shows actions from later answers, not the next row Co-Authored-By: Claude Opus 5.5 (1M context) * fix: group answer actions by the step run's job, not its label Co-Authored-By: Claude Opus 5.5 (1M context) * docs: state the per-run action row rule in AssistantMessage comments Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../copilot/chat/AIChatDisplay.svelte | 22 +++++++++++++++++ .../copilot/chat/AIChatMessage.svelte | 24 +++++++++++++++---- .../copilot/chat/AssistantMessage.svelte | 20 ++++++++++------ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 17d752ae39..8bb774cfde 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -271,6 +271,27 @@ // Shared with the agent run viewer, which needs the same programmatic-scroll // guard for the same reason. const sticker = createBottomSticker() + + // Per message: whether it is the last answer of its turn — per flow step run, since each + // run's last answer carries the only link to that run. Keyed on the answer's own job (a step + // label repeats when a step runs in a loop), and on answers rather than the next row: a + // flow's tool rows have their own job, and a stopped turn can end on a tool row. + const showsAnswerActions = $derived.by(() => { + const shows: boolean[] = new Array(messages.length).fill(false) + const answeredLater = new Set() + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role === 'user' || message.role === 'summary') { + answeredLater.clear() + } else if (message.role === 'assistant' && message.content) { + const run = message.jobId ?? message.stepName + shows[i] = !answeredLater.has(run) + answeredLater.add(run) + } + } + return shows + }) + function scrollDown() { sticker.scrollToEnd(scrollElement) } @@ -831,6 +852,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {availableContext} bind:editingMessageIndex isLast={messageIndex === messages.length - 1} + showAnswerActions={showsAnswerActions[messageIndex]} /> {/each} {#if freeTierExhausted} diff --git a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte index e7cfa270ee..d639255c93 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte @@ -46,6 +46,7 @@ messageIndex: number editingMessageIndex: number | null isLast?: boolean + showAnswerActions?: boolean } let { @@ -53,7 +54,8 @@ messageIndex, availableContext, editingMessageIndex = $bindable(null), - isLast = false + isLast = false, + showAnswerActions = true }: Props = $props() // The edit box edits a copy of THIS message's original context, not the live @@ -82,8 +84,8 @@ {:else}
0 && 'mt-4 mb-6', isLast && '!mb-12', message.role !== 'user' || !chatHost.supportsMessageEditing @@ -138,10 +140,22 @@ />
{:else} -
+ +
{#if message.role === 'assistant'}
{:else if message.role === 'tool'}
{#if stepName}
@@ -151,7 +155,7 @@ expanded={reasoningExpanded} onToggle={() => (reasoningToggled = !reasoningExpanded)} shimmer={reasoningStreaming} - class="mb-2" + class={message.content ? 'mb-1' : ''} labelClass="truncate" contentClass="font-main text-secondary {markdownProse.xs}" > @@ -160,16 +164,18 @@ {/if} {#if s3Object} - +
+ +
{:else if message.content} -
+
{/if} -{#if message.content} +{#if message.content && showActions} + below. A thinking-only row has no answer to copy, and its run's last answer links the run. -->