🖱️ fix: Keep the Last-Part Cursor in Parallel Lanes Too

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.
This commit is contained in:
Danny Avila 2026-07-28 09:37:56 -04:00
parent d5e86a23db
commit 851ae9b32a
4 changed files with 70 additions and 21 deletions

View file

@ -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);

View file

@ -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(() => {

View file

@ -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);
});
});

View file

@ -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<TMessageContentParts | undefined> | 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