refactor: keep only staged context in the tray

This commit is contained in:
Marco Beretta 2026-07-26 14:34:19 +02:00
parent c4963a742d
commit 0616aa4bf2
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 11 additions and 343 deletions

View file

@ -587,16 +587,11 @@ const ChatForm = memo(function ChatForm({
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<Tray
items={composerItems}
conversationId={conversationId}
conversation={conversation}
files={files}
setFiles={setFiles}
setFilesLoading={setFilesLoading}
isRTL={isRTL}
steering={steering}
steeringEnabled={steering.enabled}
onEditToComposer={editToComposer}
onRestoreToComposer={restoreReclaimedSteer}
/>
{endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>

View file

@ -1,19 +1,11 @@
import { memo } from 'react';
import { useRecoilValue } from 'recoil';
import { Zap, Clock, Pencil, Trash2, RotateCcw, TextQuote, WandSparkles } from 'lucide-react';
import type { TMessage, TConversation } from 'librechat-data-provider';
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
import { TextQuote, WandSparkles } from 'lucide-react';
import type { TConversation } from 'librechat-data-provider';
import type { ComposerItem, ComposerItemKind } from '~/hooks/Input/useComposerItems';
import type { PendingSteer, QueuedMessage } from '~/store/families';
import type { RestoreToComposer } from '../InFlightSteers';
import type { ExtendedFile, FileSetter } from '~/common';
import type { MenuEntry } from '../SteerMenu';
import { RowMenu, useDefaultToggleEntry, ICON_BTN_CLASS, PRIMARY_BTN_CLASS } from '../SteerMenu';
import { useFileHandlingNoChatContext } from '~/hooks';
import { useFileHandlingNoChatContext, useLocalize } from '~/hooks';
import Chip, { type ChipTone } from './Chip';
import FileRow from '../Files/FileRow';
import { useLocalize } from '~/hooks';
import store from '~/store';
const KIND_ICON: Record<ComposerItemKind, JSX.Element> = {
quote: <TextQuote className="h-4 w-4" aria-hidden="true" />,
@ -41,215 +33,28 @@ function ItemChip({ item }: { item: ComposerItem }) {
);
}
function QueuedChip({
message,
steering,
conversationId,
onEditToComposer,
onRestoreToComposer,
}: {
message: QueuedMessage;
steering: SteeringControls;
conversationId: string;
onEditToComposer: (
text: string,
files?: TMessage['files'],
context?: QueuedMessageContext,
) => void;
onRestoreToComposer: RestoreToComposer;
}) {
const localize = useLocalize();
const toggleEntry = useDefaultToggleEntry(steering);
const canSteerNow = steering.duringRunActive && steering.canSteer;
const showPrimary = canSteerNow || !steering.duringRunActive;
const entries: MenuEntry[] = [
{
key: 'edit',
label: localize('com_ui_edit_message'),
icon: <Pencil className="h-4 w-4" aria-hidden="true" />,
onClick: () => {
steering.removeQueued(message.id);
onEditToComposer(message.text, message.files, {
quotes: message.quotes,
manualSkills: message.manualSkills,
});
},
},
toggleEntry,
];
return (
<Chip
block
tone="queued"
label={message.text}
icon={<Clock className="h-4 w-4" aria-hidden="true" />}
data-testid="queued-message-row"
trailing={
<>
{showPrimary && (
<button
type="button"
className={PRIMARY_BTN_CLASS}
onClick={() => steering.sendQueuedNow(message)}
>
{canSteerNow ? (
<>
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
{localize('com_ui_steer')}
</>
) : (
localize('com_ui_send_now')
)}
</button>
)}
<button
type="button"
aria-label={localize('com_ui_remove_queued')}
onClick={() => {
/* Same safety net as the in-flight cancel: return the words to the
* composer when it is free (the gated restore refuses rather than
* clobber a draft), then remove either way. */
onRestoreToComposer(
message.text,
message.files,
{ quotes: message.quotes, manualSkills: message.manualSkills },
conversationId,
);
steering.removeQueued(message.id);
}}
className={ICON_BTN_CLASS}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
</>
}
/>
);
}
function FailedSteerChip({
steer,
steering,
onEditToComposer,
}: {
steer: PendingSteer;
steering: SteeringControls;
onEditToComposer: (
text: string,
files?: TMessage['files'],
context?: QueuedMessageContext,
) => void;
}) {
const localize = useLocalize();
const toggleEntry = useDefaultToggleEntry(steering);
const entries: MenuEntry[] = [
{
key: 'edit',
label: localize('com_ui_edit_message'),
icon: <Pencil className="h-4 w-4" aria-hidden="true" />,
onClick: () => {
steering.removeSteer(steer.steerId);
onEditToComposer(steer.text, steer.files, {
quotes: steer.quotes,
manualSkills: steer.manualSkills,
});
},
},
{
key: 'queue',
label: localize('com_ui_convert_to_queue'),
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
onClick: () =>
steering.convertSteerToQueue(steer.steerId, steer.text, steer.files, {
quotes: steer.quotes,
manualSkills: steer.manualSkills,
}),
},
toggleEntry,
];
return (
<Chip
block
tone="failed"
label={steer.text}
icon={<Zap className="h-4 w-4" aria-hidden="true" />}
data-testid="steer-message-row"
onRemove={() => steering.removeSteer(steer.steerId)}
removeLabel={localize('com_ui_remove_queued')}
trailing={
<>
<span className="shrink-0 text-xs text-red-500">{localize('com_ui_steer_failed')}</span>
<button
type="button"
className={PRIMARY_BTN_CLASS}
onClick={() =>
steering.retrySteer(steer.steerId, steer.text, steer.files, {
quotes: steer.quotes,
manualSkills: steer.manualSkills,
})
}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_steer_retry')}
</button>
<RowMenu label={localize('com_ui_more_options')} entries={entries} />
</>
}
/>
);
}
interface TrayProps {
items: ComposerItem[];
conversationId: string;
conversation: TConversation | null;
/** Staged uploads, rendered by `FileRow` with their own delete semantics. */
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
isRTL: boolean;
steering: SteeringControls;
steeringEnabled: boolean;
onEditToComposer: (
text: string,
files?: TMessage['files'],
context?: QueuedMessageContext,
) => void;
onRestoreToComposer: RestoreToComposer;
}
/**
* The composer's single staged-context region. Replaces the four stacked rows
* (uploads, quotes, manual skills, queued/failed steers) with one region: file
* cards first, then the pills, then the queued and failed rows which carry
* their own primary action and overflow menu full width beneath them.
* The composer's staged-context region: file cards first, then the quote and
* manual-skill pills. Queued and failed-steer rows live elsewhere (the Queue
* rail and the thread, respectively).
*
* Files keep `FileRow` rather than being flattened into pills: the card is
* where an image preview opens full size, where upload progress and the storage
* source are drawn, and where aborting a part-done upload differs from deleting
* a finished one.
*/
function Tray({
items,
conversationId,
conversation,
files,
setFiles,
setFilesLoading,
isRTL,
steering,
steeringEnabled,
onEditToComposer,
onRestoreToComposer,
}: TrayProps) {
function Tray({ items, conversation, files, setFiles, setFilesLoading, isRTL }: TrayProps) {
const localize = useLocalize();
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
const queued = useRecoilValue(store.queuedMessagesByConvoId(steering.queueKey));
const { abortUpload } = useFileHandlingNoChatContext(undefined, {
files,
setFiles,
@ -257,15 +62,7 @@ function Tray({
conversation,
});
const failedSteers = steeringEnabled ? steers.filter((steer) => steer.status === 'failed') : [];
const queuedMessages = steeringEnabled ? queued : [];
if (
items.length === 0 &&
files.size === 0 &&
failedSteers.length === 0 &&
queuedMessages.length === 0
) {
if (items.length === 0 && files.size === 0) {
return null;
}
@ -290,24 +87,6 @@ function Tray({
))}
</div>
)}
{failedSteers.map((steer) => (
<FailedSteerChip
key={steer.steerId}
steer={steer}
steering={steering}
onEditToComposer={onEditToComposer}
/>
))}
{queuedMessages.map((message) => (
<QueuedChip
key={message.id}
message={message}
steering={steering}
conversationId={conversationId}
onEditToComposer={onEditToComposer}
onRestoreToComposer={onRestoreToComposer}
/>
))}
</div>
);
}

View file

@ -1,11 +1,8 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen, fireEvent } from '@testing-library/react';
import type { PendingSteer, QueuedMessage } from '~/store/families';
import type { ComposerItem } from '~/hooks/Input/useComposerItems';
import type { SteeringControls } from '~/hooks/Chat/useSteering';
import type { ExtendedFile } from '~/common';
import store from '~/store';
import Tray from '../Tray';
jest.mock('~/hooks', () => ({
@ -21,31 +18,6 @@ jest.mock('../../Files/FileRow', () => ({
files.size > 0 ? <div data-testid="file-row">{files.size}</div> : null,
}));
const mockRemoveSteer = jest.fn();
const mockRemoveQueued = jest.fn();
const mockRetrySteer = jest.fn();
jest.mock('../../SteerMenu', () => ({
RowMenu: () => <div data-testid="row-menu" />,
useDefaultToggleEntry: () => ({ key: 'toggle', label: 'toggle', onClick: jest.fn() }),
ICON_BTN_CLASS: '',
PRIMARY_BTN_CLASS: '',
}));
const CONVO_ID = 'convo-1';
const steering = {
enabled: true,
queueKey: CONVO_ID,
duringRunActive: false,
canSteer: false,
removeSteer: mockRemoveSteer,
removeQueued: mockRemoveQueued,
retrySteer: mockRetrySteer,
sendQueuedNow: jest.fn(),
convertSteerToQueue: jest.fn(),
} as unknown as SteeringControls;
const item = (overrides: Partial<ComposerItem> = {}): ComposerItem => ({
id: 'quote:0',
kind: 'quote',
@ -55,32 +27,21 @@ const item = (overrides: Partial<ComposerItem> = {}): ComposerItem => ({
...overrides,
});
function renderTray(
items: ComposerItem[],
seed?: (snapshot: MutableSnapshotLike) => void,
files: Map<string, ExtendedFile> = new Map(),
) {
function renderTray(items: ComposerItem[], files: Map<string, ExtendedFile> = new Map()) {
return render(
<RecoilRoot initializeState={seed}>
<RecoilRoot>
<Tray
items={items}
conversationId={CONVO_ID}
conversation={null}
files={files}
setFiles={jest.fn()}
setFilesLoading={jest.fn()}
isRTL={false}
steering={steering}
steeringEnabled
onEditToComposer={jest.fn()}
onRestoreToComposer={jest.fn()}
/>
</RecoilRoot>,
);
}
type MutableSnapshotLike = { set: (atom: never, value: never) => void };
describe('Tray', () => {
beforeEach(() => jest.clearAllMocks());
@ -95,7 +56,6 @@ describe('Tray', () => {
item({ id: 'quote:0', kind: 'quote', label: 'the second paragraph' }),
item({ id: 'skill:a', kind: 'skill', label: 'code-review' }),
],
undefined,
new Map([['f1', {} as ExtendedFile]]),
);
@ -123,74 +83,8 @@ describe('Tray', () => {
});
it('opens for staged files even with nothing else in it', () => {
renderTray([], undefined, new Map([['f1', {} as ExtendedFile]]));
renderTray([], new Map([['f1', {} as ExtendedFile]]));
expect(screen.getByTestId('composer-tray')).toBeInTheDocument();
expect(screen.getByTestId('file-row')).toBeInTheDocument();
});
it('keeps queued messages as full-width rows with their own actions', () => {
const queued: QueuedMessage[] = [
{ id: 'q1', text: 'follow up on this', files: [], quotes: [], manualSkills: [] },
] as unknown as QueuedMessage[];
renderTray([], ({ set }: MutableSnapshotLike) =>
set(store.queuedMessagesByConvoId(CONVO_ID) as never, queued as never),
);
expect(screen.getByTestId('queued-message-row')).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('com_ui_remove_queued'));
expect(mockRemoveQueued).toHaveBeenCalledWith('q1');
});
it('surfaces failed steers with a retry affordance', () => {
const steers: PendingSteer[] = [
{
steerId: 's1',
text: 'actually use zod',
status: 'failed',
files: [],
quotes: [],
manualSkills: [],
},
] as unknown as PendingSteer[];
renderTray([], ({ set }: MutableSnapshotLike) =>
set(store.pendingSteersByConvoId(CONVO_ID) as never, steers as never),
);
expect(screen.getByTestId('steer-message-row')).toBeInTheDocument();
expect(screen.getByText('com_ui_steer_failed')).toBeInTheDocument();
fireEvent.click(screen.getByText('com_ui_steer_retry'));
expect(mockRetrySteer).toHaveBeenCalled();
});
it('ignores steer state when steering is disabled for the endpoint', () => {
const queued = [
{ id: 'q1', text: 'follow up', files: [], quotes: [], manualSkills: [] },
] as unknown as QueuedMessage[];
const { container } = render(
<RecoilRoot
initializeState={({ set }: MutableSnapshotLike) =>
set(store.queuedMessagesByConvoId(CONVO_ID) as never, queued as never)
}
>
<Tray
items={[]}
conversationId={CONVO_ID}
conversation={null}
files={new Map()}
setFiles={jest.fn()}
setFilesLoading={jest.fn()}
isRTL={false}
steering={steering}
steeringEnabled={false}
onEditToComposer={jest.fn()}
onRestoreToComposer={jest.fn()}
/>
</RecoilRoot>,
);
expect(container).toBeEmptyDOMElement();
});
});