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) <noreply@anthropic.com>

* fix: keep a flow step's answer actions and space its step label

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: pick the answer that shows actions from later answers, not the next row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: group answer actions by the step run's job, not its label

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: state the per-run action row rule in AssistantMessage comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-23 17:37:57 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 62d0088de5
commit 0e53d53b07
3 changed files with 54 additions and 12 deletions
@@ -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<string | undefined>()
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}
@@ -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}
<div
class={twMerge(
'mb-2 min-w-0',
message.role === 'tool' && 'mb-1',
'mb-1 min-w-0',
message.role === 'user' && 'mb-2',
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
isLast && '!mb-12',
message.role !== 'user' || !chatHost.supportsMessageEditing
@@ -138,10 +140,22 @@
/>
</div>
{:else}
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
<!-- Assistant and tool rows carry their own 4px above and below their text, so every
pair of consecutive rows sits the same distance apart. -->
<div
class={twMerge(
'text-sm px-2',
message.role === 'user' && 'py-1',
message.role === 'tool' && 'text-primary'
)}
>
{#if message.role === 'assistant'}
<div class="px-[1px] group/answer"
><AssistantMessage {message} workspace={messageWorkspace} /></div
><AssistantMessage
{message}
workspace={messageWorkspace}
showActions={showAnswerActions}
/></div
>
{:else if message.role === 'tool'}
<div class="px-[1px]"
@@ -23,9 +23,13 @@
// Workspace the message's paths are resolved against: the one the chat
// operates on, which is not always the one being navigated.
workspace: string | undefined
// Only the last answer of a run (a copilot turn, or a flow step's run) gets the copy /
// timestamp / run row: it stays in the layout while invisible, so on an answer between
// tool calls it would double the gap below it.
showActions?: boolean
}
let { message, workspace }: Props = $props()
let { message, workspace, showActions = true }: Props = $props()
// The run this answer came out of. Only a flow chat has one — a copilot turn runs in
// the browser — so the job link is absent rather than empty elsewhere.
@@ -137,7 +141,7 @@
the padding the message column already carries. -->
{#if stepName}
<div
class="flex items-center gap-2 -ml-6 mb-1 text-2xs text-tertiary"
class="flex items-center gap-2 -ml-6 pt-1 text-2xs text-tertiary"
title="Answered by {stepName}"
>
<Bot size={16} class="shrink-0" />
@@ -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}
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
<div class="py-1">
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
</div>
{:else if message.content}
<div class="w-full space-y-2 {markdownProse.sm}">
<div class="w-full space-y-2 py-1 {markdownProse.sm}">
<Markdown md={message.content} {plugins} />
</div>
{/if}
{#if message.content}
{#if message.content && showActions}
<!-- Kept in flow while invisible, so revealing it on hover does not nudge the message
below. A thinking-only row has no answer to copy, and the next row links its run. -->
below. A thinking-only row has no answer to copy, and its run's last answer links the run. -->
<div
class="flex items-center gap-2 text-2xs text-tertiary opacity-0 transition-opacity duration-150 group-hover/answer:opacity-100 focus-within:opacity-100"
>