fix: add divider before final live assistant answer

Shows a separator when the last live assistant answer follows earlier visible content
Keeps render comparisons in sync with the new assistant-text context
Tweaks tool summary spacing and path text styling
This commit is contained in:
Bohdan Triapitsyn
2026-09-08 01:16:36 +03:00
parent 6ba9125635
commit 5df08ade90
6 changed files with 70 additions and 5 deletions
@@ -3,6 +3,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
import { LegendList, type LegendListRef } from '@legendapp/list/react';
import ChatMessage from './ChatMessage';
import { filterVisibleParts, isEmptyTextPart } from './message/partUtils';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
@@ -637,6 +638,9 @@ const TurnBlock = React.memo(({
activityOwnerMessageId,
isFirstAssistantInTurn: isFirstAssistant,
isLastAssistantInTurn: isLastAssistant,
hasEarlierAssistantText: chatRenderMode === 'live' && isLastAssistant && visibleAssistantMessages.some((assistant, index) => (
index < assistantIndex && filterVisibleParts(assistant.parts).some((part) => part.type === 'text' && !isEmptyTextPart(part))
)),
isLatestTurn: isLastTurn,
isWorking: isLastTurn && sessionIsWorking && (
chatRenderMode === 'sorted'
@@ -115,6 +115,7 @@ export interface TurnGroupingContext {
activityOwnerMessageId?: string;
isFirstAssistantInTurn: boolean;
isLastAssistantInTurn: boolean;
hasEarlierAssistantText?: boolean;
isLatestTurn: boolean;
summaryBody?: string;
activityParts?: TurnActivityRecord[];
@@ -1730,6 +1730,17 @@ const AssistantMessageBody = React.memo(({
const renderedParts = React.useMemo(() => {
const rendered: React.ReactNode[] = [];
let hasRenderedAnswerText = false;
const isFinalLiveAnswer = chatRenderMode === 'live' && isLastAssistantInTurn && hasStopFinish;
const hasEarlierVisibleActivity = isFinalLiveAnswer && Boolean(turnGroupingContext?.activityParts?.some((activity) => {
if (activity.messageId === messageId) {
return false;
}
if (activity.part.type === 'tool') {
return shouldShowTool(activity.part);
}
return (activity.kind !== 'reasoning' || showReasoningTraces) && !isEmptyTextPart(activity.part);
}));
const renderSegmentBlock = (segment: TurnActivityGroup): React.ReactNode | null => {
if (!shouldRenderActivityGroup || !toggleActivityGroup) {
@@ -1820,6 +1831,16 @@ const AssistantMessageBody = React.memo(({
i += 1;
continue;
}
if (isFinalLiveAnswer && !hasRenderedAnswerText && (rendered.length > 0 || hasEarlierVisibleActivity || turnGroupingContext?.hasEarlierAssistantText)) {
rendered.push(
<div
key={`final-answer-divider-${messageId}`}
aria-hidden="true"
className="mt-1.5 mb-4 h-px w-full bg-muted-foreground/60"
/>
);
}
hasRenderedAnswerText = true;
rendered.push(
<div key={`assistant-text-${messageId}-${i}`} ref={messageTextContentRef} data-message-text-export-source="true">
<AssistantTextPart
@@ -1982,6 +2003,8 @@ const AssistantMessageBody = React.memo(({
isMobile,
isActivityOwnerMessage,
isSortedRenderMode,
isLastAssistantInTurn,
hasStopFinish,
lastRenderableTextPartIndex,
messageId,
messageActionButtons,
@@ -907,7 +907,7 @@ const TaskSummaryEntryRow = React.memo(({
</span>
{hasLabel ? (
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons, 'typography-meta')
) : (
status === 'error' ? (
<span className={cn(
@@ -958,7 +958,7 @@ const TaskSummaryEntriesList = React.memo(({
const visibleStartIndex = entries.length - visibleEntries.length;
return (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} className="pt-0" disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more</div>
@@ -1050,6 +1050,7 @@ const TaskToolSummary: React.FC<{
<div
className={cn(
'relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]',
entries.length > 0 && 'pt-0',
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
'before:top-[-0.25rem] before:bottom-0'
)}
@@ -1154,7 +1155,7 @@ const renderPathLikeGitChanges = (path: string, grow = true) => {
);
};
const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, showFileIcons = true) => {
const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, showFileIcons = true, textClassName = TOOL_ROW_DESCRIPTION_CLASS) => {
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) {
@@ -1163,7 +1164,7 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
{showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
<Text
variant={animate ? 'generate-effect' : 'static'}
className={cn('min-w-0 truncate whitespace-nowrap', TOOL_ROW_DESCRIPTION_CLASS, grow && 'flex-1')}
className={cn('min-w-0 truncate whitespace-nowrap', textClassName, grow && 'flex-1')}
style={{ color: 'var(--tools-title)' }}
>
{path}
@@ -1180,7 +1181,7 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
return (
<span className={cn('min-w-0 inline-flex items-center gap-1 overflow-hidden', grow && 'flex-1')} title={path}>
{showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
<span className={cn('min-w-0 inline-flex max-w-full items-baseline overflow-hidden', TOOL_ROW_DESCRIPTION_CLASS, grow && 'flex-1')}>
<span className={cn('min-w-0 inline-flex max-w-full items-baseline overflow-hidden', textClassName, grow && 'flex-1')}>
{hasAbsoluteRoot ? <span className="flex-shrink-0" style={{ color: 'var(--tools-description)' }}>/</span> : null}
<span
className="min-w-0 shrink truncate whitespace-nowrap"
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import type { TurnGroupingContext } from '../lib/turns/types';
import { areRelevantTurnGroupingContextsEqual } from './renderCompare';
const finalAnswerContext: TurnGroupingContext = {
turnId: 'turn',
isFirstAssistantInTurn: false,
isLastAssistantInTurn: true,
isLatestTurn: true,
isWorking: false,
hasTools: false,
hasReasoning: false,
hasEarlierAssistantText: false,
};
describe('final answer divider context', () => {
test('updates the final answer when earlier visible text appears or disappears', () => {
const withEarlierText = { ...finalAnswerContext, hasEarlierAssistantText: true };
expect(areRelevantTurnGroupingContextsEqual(finalAnswerContext, withEarlierText, 'answer', false)).toBe(false);
expect(areRelevantTurnGroupingContextsEqual(withEarlierText, finalAnswerContext, 'answer', false)).toBe(false);
});
test('preserves equivalent rebuilt context', () => {
expect(areRelevantTurnGroupingContextsEqual(finalAnswerContext, { ...finalAnswerContext }, 'answer', false)).toBe(true);
});
test('does not invalidate the user message for assistant decoration', () => {
expect(areRelevantTurnGroupingContextsEqual(
finalAnswerContext,
{ ...finalAnswerContext, hasEarlierAssistantText: true },
'user',
true,
)).toBe(true);
});
});
@@ -291,6 +291,7 @@ export const areRelevantTurnGroupingContextsEqual = (
if (left.turnId !== right.turnId) return false;
if (left.isFirstAssistantInTurn !== right.isFirstAssistantInTurn) return false;
if (left.hasEarlierAssistantText !== right.hasEarlierAssistantText) return false;
if (left.isLastAssistantInTurn !== right.isLastAssistantInTurn) return false;
if (left.isLatestTurn !== right.isLatestTurn) return false;
if (left.isWorking !== right.isWorking) return false;