mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🖱️ 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:
parent
d5e86a23db
commit
851ae9b32a
4 changed files with 70 additions and 21 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue