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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user