diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 8c56e0371e..c738ef8451 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -266,6 +266,83 @@ const ChatForm = memo(function ChatForm({ stopGenerating, }); + /** Read at call time, not captured: a reclaim resolves into the callback from + * the render it was clicked in, so the closure's `conversationId` is the OLD + * chat — comparing it against itself would pass while `methods` (one form, + * reused across conversations) writes into the chat now on screen. */ + const liveConversationIdRef = useRef(conversationId); + liveConversationIdRef.current = conversationId; + /** Same reason: attachments staged after the click must be seen. */ + const liveFilesRef = useRef(files); + liveFilesRef.current = files; + /** Same reason: the run can pause on `ask_user_question` mid-reclaim. */ + const liveAnswerModeRef = useRef(answerMode.active); + liveAnswerModeRef.current = answerMode.active; + /** A reclaim can resolve after this form unmounts (left the route, closed the + * pane). Its refs still hold the origin chat, so the restore would pass its + * checks and write into a dead form — reporting success and making the caller + * drop the steer, losing the text. Track mount so the restore refuses and the + * caller queues it instead. */ + const composerMountedRef = useRef(true); + useEffect( + () => () => { + composerMountedRef.current = false; + }, + [], + ); + + /** A draft is anything the user has staged, not just typed: `editToComposer` + * MERGES the steer's attachments into the composer's file map and its quotes + * and skill picks into their atoms, so restoring over staged context would + * glue the two submissions together. */ + const hasStagedComposerContext = useRecoilCallback( + ({ snapshot }) => + (convoId: string) => + snapshot.getLoadable(store.pendingQuotesByConvoId(convoId)).getValue().length > 0 || + snapshot.getLoadable(store.pendingManualSkillsByConvoId(convoId)).getValue().length > 0, + [], + ); + + /** + * `editToComposer` for a steer whose reclaim was a round-trip: by the time it + * resolves the composer may have moved on. Refuses (returning false, so the + * caller re-homes the words instead of dropping them) rather than overwrite a + * draft the user has since staged, or drop a steer into whatever chat they + * navigated to. + */ + const restoreReclaimedSteer = useCallback( + ( + text: string, + steerFiles: TMessage['files'], + context: QueuedMessageContext, + originConversationId: string, + ): boolean => { + if (!composerMountedRef.current) { + return false; + } + const liveConversationId = liveConversationIdRef.current; + if (originConversationId !== liveConversationId) { + return false; + } + /** Answer mode owns the composer: `onSubmit` hands its text to + * `answerMode.submitText` before any send/steer routing, so restoring + * here would turn the steer into the tool's answer on the next Enter. */ + if (liveAnswerModeRef.current) { + return false; + } + if ( + (methods.getValues('text') ?? '').trim().length > 0 || + (liveFilesRef.current?.size ?? 0) > 0 || + hasStagedComposerContext(liveConversationId) + ) { + return false; + } + editToComposer(text, steerFiles, context); + return true; + }, + [methods, editToComposer, hasStagedComposerContext], + ); + /** ⌘/Ctrl+Enter = the non-default during-run action, ⌥/Alt+Enter = * interrupt & send — the counterpart of Enter's `submitDuringRun`. */ const handleDuringRunModifier = useCallback( @@ -415,7 +492,13 @@ const ChatForm = memo(function ChatForm({
{/* Run-scoped: `enabled` alone is any primary composer on a steerable endpoint, so a chip that outlives the run would strand a bubble. */} - {steering.enabled && isSubmitting && } + {steering.enabled && isSubmitting && ( + + )}
)} {/* WIP */} diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx index 90f3374a5d..289e458431 100644 --- a/client/src/components/Chat/Input/InFlightSteers.tsx +++ b/client/src/components/Chat/Input/InFlightSteers.tsx @@ -1,16 +1,30 @@ import { memo, useRef, useMemo, useState, useEffect, useCallback } from 'react'; -import { X, Zap } from 'lucide-react'; -import { useRecoilValue } from 'recoil'; +import { useToastContext } from '@librechat/client'; +import { X, Zap, Clock, Pencil } from 'lucide-react'; +import { useRecoilValue, useRecoilCallback } from 'recoil'; import type { TFile, TMessage } from 'librechat-data-provider'; +import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering'; import type { PendingSteer } from '~/store/families'; +import type { MenuEntry } from './SteerMenu'; import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; +import { useSteerCancel, useSteerReclaim, useLocalize } from '~/hooks'; import ImagePreview from '~/components/Chat/Input/Files/ImagePreview'; -import { useSteerCancel, useLocalize } from '~/hooks'; -import { cn } from '~/utils'; +import { RowMenu, useDefaultToggleEntry } from './SteerMenu'; +import { carriedSteerContext, cn } from '~/utils'; import store from '~/store'; +/** Restores a message's text into the composer, or refuses (false) when the + * composer is occupied / on another chat — see `restoreReclaimedSteer` in + * `ChatForm`. Shared by the in-flight cancel and the queued trash safety net. */ +export type RestoreToComposer = ( + text: string, + files: TMessage['files'], + context: QueuedMessageContext, + originConversationId: string, +) => boolean; + const splitFiles = (files?: TMessage['files']) => { const images: NonNullable = []; const others: NonNullable = []; @@ -33,17 +47,26 @@ const splitFiles = (files?: TMessage['files']) => { * * `sending` is still awaiting its 202 ACK (no server id yet, so nothing to * cancel); `pending` is acknowledged and waiting on the next tool-batch - * boundary. + * boundary. Every control here reclaims the steer from the server queue first, + * so they are offered only once `pending` — while `sending` there is no id to + * reclaim with, and the words cannot be held back. */ const InFlightSteer = memo(function InFlightSteer({ steer, + steering, conversationId, + onRestoreToComposer, }: { steer: PendingSteer; + steering: SteeringControls; conversationId: string; + onRestoreToComposer: RestoreToComposer; }) { const localize = useLocalize(); + const { showToast } = useToastContext(); const cancelSteer = useSteerCancel(conversationId); + const reclaimSteer = useSteerReclaim(conversationId); + const toggleEntry = useDefaultToggleEntry(steering); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); const [selectedFile, setSelectedFile] = useState | null>(null); const handlePreviewClose = useCallback((open: boolean) => { @@ -55,6 +78,121 @@ const InFlightSteer = memo(function InFlightSteer({ const { images, others } = useMemo(() => splitFiles(steer.files), [steer.files]); const sending = steer.status === 'sending'; + /** Whether the words have already been re-homed by a terminal conversion (a + * run that ended/errored mid-reclaim queues the still-present chip). The + * queue action is safe either way — the conversion dedupes by id — but a + * composer restore would leave one copy queued and another in the draft. */ + const hasSettled = useRecoilCallback( + ({ snapshot }) => + (steerId: string) => + snapshot + .getLoadable(store.appliedSteerIdsByConvoId(conversationId)) + .getValue() + .includes(steerId), + [conversationId], + ); + + /** + * Takes the steer back off the server queue so its words can be re-homed. + * The chip is left alone until the answer is known: only `reclaimed` proves + * the words never entered the run, and the re-homing callers below own the + * removal from there. + */ + const reclaim = useCallback(async (): Promise => { + const outcome = await reclaimSteer(steer); + if (outcome === 'reclaimed') { + return true; + } + showToast({ + message: localize( + outcome === 'applied' ? 'com_ui_steer_already_applied' : 'com_ui_steer_cancel_failed', + ), + status: outcome === 'applied' ? 'info' : 'error', + }); + return false; + }, [reclaimSteer, steer, showToast, localize]); + + const entries: MenuEntry[] = [ + { + key: 'edit', + label: localize('com_ui_edit_message'), + icon:
{others.length > 0 && ( @@ -148,9 +282,13 @@ const InFlightSteer = memo(function InFlightSteer({ * committed, while the user still sees their words land somewhere stable. */ const InFlightSteers = memo(function InFlightSteers({ + steering, conversationId, + onRestoreToComposer, }: { + steering: SteeringControls; conversationId: string; + onRestoreToComposer: RestoreToComposer; }) { const localize = useLocalize(); const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId)); @@ -184,7 +322,13 @@ const InFlightSteers = memo(function InFlightSteers({ className="flex max-h-[35vh] flex-col items-start gap-2 overflow-y-auto px-2 pb-2" > {inFlight.map((steer) => ( - + ))}
); diff --git a/client/src/components/Chat/Input/PendingSteerChips.tsx b/client/src/components/Chat/Input/PendingSteerChips.tsx index 7f88cae590..7e4503ac2a 100644 --- a/client/src/components/Chat/Input/PendingSteerChips.tsx +++ b/client/src/components/Chat/Input/PendingSteerChips.tsx @@ -1,68 +1,18 @@ import { memo, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; -import * as Ariakit from '@ariakit/react'; -import { - X, - Zap, - Send, - Clock, - Pencil, - Trash2, - Paperclip, - RotateCcw, - MoreHorizontal, -} from 'lucide-react'; +import { X, Zap, Send, Clock, Pencil, Trash2, Paperclip, RotateCcw } from 'lucide-react'; import type { TMessage } from 'librechat-data-provider'; import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering'; import type { PendingSteer, QueuedMessage } from '~/store/families'; +import type { RestoreToComposer } from './InFlightSteers'; +import type { MenuEntry } from './SteerMenu'; +import { RowMenu, useDefaultToggleEntry, ICON_BTN_CLASS, PRIMARY_BTN_CLASS } from './SteerMenu'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; const ROW_CLASS = 'flex w-full items-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary'; -const PRIMARY_BTN_CLASS = - 'flex shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-sm text-text-secondary hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy'; -const ICON_BTN_CLASS = - 'shrink-0 rounded-full p-1 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy'; -const MENU_CLASS = - 'z-50 min-w-[13rem] rounded-xl border border-border-light bg-surface-secondary p-1.5 text-text-primary shadow-lg outline-none'; -const MENU_ITEM_CLASS = - 'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-text-primary data-[active-item]:bg-surface-tertiary aria-disabled:cursor-not-allowed aria-disabled:opacity-50'; - -type MenuEntry = { - key: string; - label: string; - icon: React.ReactNode; - onClick: () => void; -}; - -/** Per-row "…" overflow menu (edit / mode toggle / conversions). */ -function RowMenu({ label, entries }: { label: string; entries: MenuEntry[] }) { - const menu = Ariakit.useMenuStore({ placement: 'top-end' }); - return ( - <> - - - - {entries.map((entry) => ( - { - entry.onClick(); - menu.hide(); - }} - > - {entry.icon} - {entry.label} - - ))} - - - ); -} function AttachmentCount({ count, label }: { count: number; label: string }) { if (count === 0) { @@ -77,44 +27,22 @@ function AttachmentCount({ count, label }: { count: number; label: string }) { ); } -/** - * The overflow item that flips the Enter-during-run default. Shown as the - * OPPOSITE of the current default (the action you would switch to), matching - * the reference UX ("Turn on queueing" while steer is the default). - */ -function useDefaultToggleEntry(steering: SteeringControls): MenuEntry { - const localize = useLocalize(); - return useMemo(() => { - const next = steering.defaultAction === 'steer' ? 'queue' : 'steer'; - return { - key: 'toggle-default', - label: - next === 'queue' - ? localize('com_ui_turn_on_queueing') - : localize('com_ui_turn_on_steering'), - icon: - next === 'queue' ? ( -