From 851ae9b32ae0ae4d93effbe07187a311bd85b3cc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 28 Jul 2026 09:37:56 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=96=B1=EF=B8=8F=20fix:=20Keep=20the=20Las?= =?UTF-8?q?t-Part=20Cursor=20in=20Parallel=20Lanes=20Too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-thirteen review (single P2): `ParallelContentRenderer` computed `lastContentIdx` from the unfiltered array, so a trailing blank label reservation — filtered out of every lane — left NO rendered part carrying the last-part cursor and running-subagent affordances until the label filled. The sequential renderer's walk-back is extracted into a shared `lastVisibleContentIdx` helper (utils/activityLabels) used by both `ContentParts` and `ParallelContentRenderer`, so the two index spaces cannot drift again. Behavior pinned in activityLabels.spec: trailing blank skipped, consecutive blanks skipped, filled label counts, label-free content unchanged. --- .../Chat/Messages/Content/ContentParts.tsx | 22 +++---------- .../Chat/Messages/Content/ParallelContent.tsx | 11 +++++-- .../utils/__tests__/activityLabels.spec.ts | 31 +++++++++++++++++-- client/src/utils/activityLabels.ts | 27 ++++++++++++++++ 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index d5eb560bde..4244158d31 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -8,8 +8,8 @@ import type { } from 'librechat-data-provider'; import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils'; -import { getActivityLabelPart, getActivityLabelText } from '~/utils/activityLabels'; import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; +import { lastVisibleContentIdx } from '~/utils/activityLabels'; import { MessageContext, SearchContext } from '~/Providers'; import PendingSkillCall from './Parts/PendingSkillCall'; import { EditTextPart, EmptyText } from './Parts'; @@ -394,22 +394,10 @@ const ContentParts = memo(function ContentParts({ const safeContent = content ?? []; const showEmptyCursor = safeContent.length === 0 && effectiveIsSubmitting; - /** A trailing BLANK label reservation renders nothing, so counting it as - * the last part would strip the streaming cursor and last-item - * affordances from the last VISIBLE part for the whole interval until - * the next delta. Walk back past invisible label slots. */ - let lastContentIdx = safeContent.length - 1; - while (lastContentIdx > 0) { - const tail = safeContent[lastContentIdx]; - if ( - tail?.type === ContentTypes.ACTIVITY_LABEL && - getActivityLabelText(getActivityLabelPart(tail)).length === 0 - ) { - lastContentIdx -= 1; - } else { - break; - } - } + /** Skips trailing BLANK label reservations — they render nothing, and + * counting one as last would strip the streaming cursor from the last + * VISIBLE part until the next delta. */ + const lastContentIdx = lastVisibleContentIdx(safeContent); // Parallel content: use dedicated renderer with columns (TMessageContentParts includes ContentMetadata) const hasParallelContent = safeContent.some((part) => part?.groupId != null); diff --git a/client/src/components/Chat/Messages/Content/ParallelContent.tsx b/client/src/components/Chat/Messages/Content/ParallelContent.tsx index d0bad2301b..5d8e51b0e3 100644 --- a/client/src/components/Chat/Messages/Content/ParallelContent.tsx +++ b/client/src/components/Chat/Messages/Content/ParallelContent.tsx @@ -1,7 +1,11 @@ import { memo, useMemo } from 'react'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts, SearchResultData, TAttachment } from 'librechat-data-provider'; -import { getActivityLabelPart, getActivityLabelText } from '~/utils/activityLabels'; +import { + getActivityLabelPart, + getActivityLabelText, + lastVisibleContentIdx, +} from '~/utils/activityLabels'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; import { SearchContext } from '~/Providers'; @@ -235,7 +239,10 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({ [content], ); - const lastContentIdx = (content?.length ?? 0) - 1; + /** Same walk-back as `ContentParts`: a trailing BLANK label reservation is + * filtered out of every lane, so counting it as last would leave NO + * rendered part with the last-part cursor until the label fills. */ + const lastContentIdx = lastVisibleContentIdx(content); // Split sequential parts into before/after parallel sections const { before, after } = useMemo(() => { diff --git a/client/src/utils/__tests__/activityLabels.spec.ts b/client/src/utils/__tests__/activityLabels.spec.ts index ec75e0010e..5adda8aa93 100644 --- a/client/src/utils/__tests__/activityLabels.spec.ts +++ b/client/src/utils/__tests__/activityLabels.spec.ts @@ -1,6 +1,6 @@ import { ContentTypes } from 'librechat-data-provider'; -import type { TActivityLabelEvent, TMessage } from 'librechat-data-provider'; -import { applyActivityLabelPart } from '../activityLabels'; +import type { TActivityLabelEvent, TMessage, TMessageContentParts } from 'librechat-data-provider'; +import { applyActivityLabelPart, lastVisibleContentIdx } from '../activityLabels'; const buildMessage = (content: TMessage['content']): TMessage => ({ messageId: 'm1', isCreatedByUser: false, content }) as TMessage; @@ -49,3 +49,30 @@ describe('applyActivityLabelPart', () => { }); }); }); + +describe('lastVisibleContentIdx', () => { + const text = { type: ContentTypes.TEXT, text: 'hello' } as unknown as TMessageContentParts; + const tool = { + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { id: 't1', name: 'web_search', args: '{}', output: 'ok' }, + } as unknown as TMessageContentParts; + + it('skips a trailing blank label reservation', () => { + expect(lastVisibleContentIdx([text, tool, labelPart() as never])).toBe(1); + }); + + it('skips consecutive trailing blank labels', () => { + expect(lastVisibleContentIdx([tool, labelPart() as never, tool, labelPart() as never])).toBe(2); + }); + + it('counts a filled label as visible', () => { + const filled = labelPart({ activity_label: 'Fetched the docs', pending: false }); + expect(lastVisibleContentIdx([tool, filled as never])).toBe(1); + }); + + it('falls back to the raw last index without labels', () => { + expect(lastVisibleContentIdx([text, tool])).toBe(1); + expect(lastVisibleContentIdx([])).toBe(-1); + expect(lastVisibleContentIdx(undefined)).toBe(-1); + }); +}); diff --git a/client/src/utils/activityLabels.ts b/client/src/utils/activityLabels.ts index 9b906ec683..5cf1eca87d 100644 --- a/client/src/utils/activityLabels.ts +++ b/client/src/utils/activityLabels.ts @@ -26,6 +26,33 @@ export function getActivityLabelText(part: ActivityLabelPart | undefined): strin return typeof label === 'string' ? label.trim() : ''; } +/** + * Last content index that actually renders something. Trailing BLANK label + * reservations are invisible (every batch publishes one at batch end), so + * counting one as the last part would suppress the streaming cursor and + * other last-item affordances on the last VISIBLE part for the whole + * interval until the label fills or the next delta arrives. Used by both + * the sequential and parallel content renderers so they stay in lockstep. + */ +export function lastVisibleContentIdx( + content: ReadonlyArray | undefined, +): number { + const parts = content ?? []; + let last = parts.length - 1; + while (last > 0) { + const tail = parts[last]; + if ( + tail?.type === ContentTypes.ACTIVITY_LABEL && + getActivityLabelText(getActivityLabelPart(tail)).length === 0 + ) { + last -= 1; + } else { + break; + } + } + return last; +} + /** * Resolves the assistant response message an activity-label event targets. * Exact-id assistant match when `responseMessageId` is present (a miss