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 { LegendList, type LegendListRef } from '@legendapp/list/react';
|
||||||
|
|
||||||
import ChatMessage from './ChatMessage';
|
import ChatMessage from './ChatMessage';
|
||||||
|
import { filterVisibleParts, isEmptyTextPart } from './message/partUtils';
|
||||||
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
||||||
import TurnItem from './components/TurnItem';
|
import TurnItem from './components/TurnItem';
|
||||||
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
||||||
@@ -637,6 +638,9 @@ const TurnBlock = React.memo(({
|
|||||||
activityOwnerMessageId,
|
activityOwnerMessageId,
|
||||||
isFirstAssistantInTurn: isFirstAssistant,
|
isFirstAssistantInTurn: isFirstAssistant,
|
||||||
isLastAssistantInTurn: isLastAssistant,
|
isLastAssistantInTurn: isLastAssistant,
|
||||||
|
hasEarlierAssistantText: chatRenderMode === 'live' && isLastAssistant && visibleAssistantMessages.some((assistant, index) => (
|
||||||
|
index < assistantIndex && filterVisibleParts(assistant.parts).some((part) => part.type === 'text' && !isEmptyTextPart(part))
|
||||||
|
)),
|
||||||
isLatestTurn: isLastTurn,
|
isLatestTurn: isLastTurn,
|
||||||
isWorking: isLastTurn && sessionIsWorking && (
|
isWorking: isLastTurn && sessionIsWorking && (
|
||||||
chatRenderMode === 'sorted'
|
chatRenderMode === 'sorted'
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export interface TurnGroupingContext {
|
|||||||
activityOwnerMessageId?: string;
|
activityOwnerMessageId?: string;
|
||||||
isFirstAssistantInTurn: boolean;
|
isFirstAssistantInTurn: boolean;
|
||||||
isLastAssistantInTurn: boolean;
|
isLastAssistantInTurn: boolean;
|
||||||
|
hasEarlierAssistantText?: boolean;
|
||||||
isLatestTurn: boolean;
|
isLatestTurn: boolean;
|
||||||
summaryBody?: string;
|
summaryBody?: string;
|
||||||
activityParts?: TurnActivityRecord[];
|
activityParts?: TurnActivityRecord[];
|
||||||
|
|||||||
@@ -1730,6 +1730,17 @@ const AssistantMessageBody = React.memo(({
|
|||||||
|
|
||||||
const renderedParts = React.useMemo(() => {
|
const renderedParts = React.useMemo(() => {
|
||||||
const rendered: React.ReactNode[] = [];
|
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 => {
|
const renderSegmentBlock = (segment: TurnActivityGroup): React.ReactNode | null => {
|
||||||
if (!shouldRenderActivityGroup || !toggleActivityGroup) {
|
if (!shouldRenderActivityGroup || !toggleActivityGroup) {
|
||||||
@@ -1820,6 +1831,16 @@ const AssistantMessageBody = React.memo(({
|
|||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
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(
|
rendered.push(
|
||||||
<div key={`assistant-text-${messageId}-${i}`} ref={messageTextContentRef} data-message-text-export-source="true">
|
<div key={`assistant-text-${messageId}-${i}`} ref={messageTextContentRef} data-message-text-export-source="true">
|
||||||
<AssistantTextPart
|
<AssistantTextPart
|
||||||
@@ -1982,6 +2003,8 @@ const AssistantMessageBody = React.memo(({
|
|||||||
isMobile,
|
isMobile,
|
||||||
isActivityOwnerMessage,
|
isActivityOwnerMessage,
|
||||||
isSortedRenderMode,
|
isSortedRenderMode,
|
||||||
|
isLastAssistantInTurn,
|
||||||
|
hasStopFinish,
|
||||||
lastRenderableTextPartIndex,
|
lastRenderableTextPartIndex,
|
||||||
messageId,
|
messageId,
|
||||||
messageActionButtons,
|
messageActionButtons,
|
||||||
|
|||||||
@@ -907,7 +907,7 @@ const TaskSummaryEntryRow = React.memo(({
|
|||||||
</span>
|
</span>
|
||||||
{hasLabel ? (
|
{hasLabel ? (
|
||||||
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
|
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
|
||||||
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
|
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons, 'typography-meta')
|
||||||
) : (
|
) : (
|
||||||
status === 'error' ? (
|
status === 'error' ? (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -958,7 +958,7 @@ const TaskSummaryEntriesList = React.memo(({
|
|||||||
const visibleStartIndex = entries.length - visibleEntries.length;
|
const visibleStartIndex = entries.length - visibleEntries.length;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="w-full min-w-0 space-y-1">
|
||||||
{hiddenCount > 0 ? (
|
{hiddenCount > 0 ? (
|
||||||
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more…</div>
|
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more…</div>
|
||||||
@@ -1050,6 +1050,7 @@ const TaskToolSummary: React.FC<{
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]',
|
'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:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
|
||||||
'before:top-[-0.25rem] before:bottom-0'
|
'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('/');
|
const lastSlash = path.lastIndexOf('/');
|
||||||
|
|
||||||
if (lastSlash === -1) {
|
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}
|
{showFileIcons ? <FileTypeIcon filePath={path} className="h-3.5 w-3.5 flex-shrink-0" /> : null}
|
||||||
<Text
|
<Text
|
||||||
variant={animate ? 'generate-effect' : 'static'}
|
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)' }}
|
style={{ color: 'var(--tools-title)' }}
|
||||||
>
|
>
|
||||||
{path}
|
{path}
|
||||||
@@ -1180,7 +1181,7 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
|
|||||||
return (
|
return (
|
||||||
<span className={cn('min-w-0 inline-flex items-center gap-1 overflow-hidden', grow && 'flex-1')} title={path}>
|
<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}
|
{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}
|
{hasAbsoluteRoot ? <span className="flex-shrink-0" style={{ color: 'var(--tools-description)' }}>/</span> : null}
|
||||||
<span
|
<span
|
||||||
className="min-w-0 shrink truncate whitespace-nowrap"
|
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.turnId !== right.turnId) return false;
|
||||||
if (left.isFirstAssistantInTurn !== right.isFirstAssistantInTurn) 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.isLastAssistantInTurn !== right.isLastAssistantInTurn) return false;
|
||||||
if (left.isLatestTurn !== right.isLatestTurn) return false;
|
if (left.isLatestTurn !== right.isLatestTurn) return false;
|
||||||
if (left.isWorking !== right.isWorking) return false;
|
if (left.isWorking !== right.isWorking) return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user