mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
feat: show pending steers inside the streaming reply
This commit is contained in:
parent
0616aa4bf2
commit
027dc4e1bd
11 changed files with 232 additions and 2118 deletions
|
|
@ -33,7 +33,6 @@ import useDictation from '~/hooks/Input/useDictation';
|
|||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import useSteering from '~/hooks/Chat/useSteering';
|
||||
import { BadgeRowProvider } from '~/Providers';
|
||||
import InFlightSteers from './InFlightSteers';
|
||||
import TextareaHeader from './TextareaHeader';
|
||||
import PromptsCommand from './PromptsCommand';
|
||||
import SkillsCommand from './SkillsCommand';
|
||||
|
|
@ -516,18 +515,7 @@ const ChatForm = memo(function ChatForm({
|
|||
<div className="relative flex h-full min-w-0 flex-1 items-stretch md:flex-col">
|
||||
{/* Primary composer owns the selection popup so split-view doesn't double it. */}
|
||||
{index === 0 && quotesEnabled && <QuoteButton conversationId={conversationId} />}
|
||||
{/* `relative` anchors the in-flight steer overlay, which floats above
|
||||
the composer (`bottom-full`) over the bottom of the thread. */}
|
||||
<div className="relative flex w-full flex-col">
|
||||
{/* 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 && (
|
||||
<InFlightSteers
|
||||
steering={steering}
|
||||
conversationId={conversationId}
|
||||
onRestoreToComposer={restoreReclaimedSteer}
|
||||
/>
|
||||
)}
|
||||
{steering.enabled && (
|
||||
<Queue
|
||||
steering={steering}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useRecoilValue } from 'recoil';
|
|||
import { X, Clock, Pencil } from 'lucide-react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { SteeringControls, QueuedMessageContext } from '~/hooks/Chat/useSteering';
|
||||
import type { RestoreToComposer } from '../InFlightSteers';
|
||||
import type { QueuedMessage } from '~/store/families';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
|
@ -11,6 +10,16 @@ import store from '~/store';
|
|||
const ICON_BTN =
|
||||
'shrink-0 rounded-full p-1 text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy';
|
||||
|
||||
/** Restores a message's text into the composer, or refuses (false) when the
|
||||
* composer is occupied / on another chat — see `restoreReclaimedSteer` in
|
||||
* `ChatForm`. Used by the queue rail's edit/trash actions. */
|
||||
export type RestoreToComposer = (
|
||||
text: string,
|
||||
files: TMessage['files'],
|
||||
context: QueuedMessageContext,
|
||||
originConversationId: string,
|
||||
) => boolean;
|
||||
|
||||
interface QueueProps {
|
||||
steering: SteeringControls;
|
||||
conversationId: string;
|
||||
|
|
|
|||
|
|
@ -1,645 +0,0 @@
|
|||
import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useSetAtom, useAtomValue } from 'jotai';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useRecoilValue, useRecoilCallback } from 'recoil';
|
||||
import { X, Zap, ZapOff, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
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 {
|
||||
RowMenu,
|
||||
EscalateNowButton,
|
||||
useDefaultToggleEntry,
|
||||
useInterruptToggleEntry,
|
||||
} from './SteerMenu';
|
||||
import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog';
|
||||
import { supportsGenerationProtocolV2, useArmSteerMutation } from '~/data-provider';
|
||||
import { steerOverlayHeightFamily, escalatingSteerFamily } from '~/store/steer';
|
||||
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 { 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<TMessage['files']> = [];
|
||||
const others: NonNullable<TMessage['files']> = [];
|
||||
for (const file of files ?? []) {
|
||||
(file.type?.startsWith('image/') === true ? images : others).push(file);
|
||||
}
|
||||
return { images, others };
|
||||
};
|
||||
|
||||
/** Collapsed preview height (px) for a long steer before "Show more". Matched
|
||||
* to the JS overflow check below so the toggle appears exactly when clipped;
|
||||
* the tolerance absorbs the trailing markdown margin so content that fits but
|
||||
* for its own bottom margin does not trip a pointless toggle. */
|
||||
const STEER_COLLAPSED_MAX_HEIGHT = 128;
|
||||
const STEER_OVERFLOW_TOLERANCE = 8;
|
||||
/** Axios has no default request timeout. Bound the UI lock while preserving an
|
||||
* honest unknown outcome; the idempotent arm may still complete server-side. */
|
||||
const ARM_CONFIRM_TIMEOUT_MS = 10_000;
|
||||
|
||||
type ArmFailure = {
|
||||
name?: string;
|
||||
response?: { data?: { code?: string } };
|
||||
};
|
||||
|
||||
/** Only a failure without an HTTP response leaves the server-side outcome
|
||||
* unknown. An HTTP rejection is a known response and must not replay the arm. */
|
||||
const isAmbiguousArmFailure = (error: unknown): boolean => {
|
||||
const failure = error as ArmFailure | null | undefined;
|
||||
return failure?.name !== 'AbortError' && failure?.response == null;
|
||||
};
|
||||
|
||||
const armFailureCode = (error: unknown): string | undefined =>
|
||||
(error as ArmFailure | null | undefined)?.response?.data?.code;
|
||||
|
||||
/**
|
||||
* One steer on its way into the run, anchored above the composer as a message
|
||||
* bubble rather than a control chip — the words are already part of the
|
||||
* conversation, they just have no in-thread index yet. It leaves on
|
||||
* `on_steer_applied`, when the persisted STEER part lands at its authoritative
|
||||
* position in the response.
|
||||
*
|
||||
* Text and attachments render through the same leaves as the applied
|
||||
* `SteerPart` (markdown toggle, file preview) so the words don't reformat the
|
||||
* moment the server injects them.
|
||||
*
|
||||
* `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. 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,
|
||||
interruptPending,
|
||||
onRestoreToComposer,
|
||||
}: {
|
||||
steer: PendingSteer;
|
||||
steering: SteeringControls;
|
||||
conversationId: string;
|
||||
interruptPending: boolean;
|
||||
onRestoreToComposer: RestoreToComposer;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const cancelSteer = useSteerCancel(conversationId);
|
||||
const reclaimSteer = useSteerReclaim(conversationId);
|
||||
const toggleEntry = useDefaultToggleEntry(steering);
|
||||
const interruptToggle = useInterruptToggleEntry();
|
||||
const enableUserMsgMarkdown = useRecoilValue<boolean>(store.enableUserMsgMarkdown);
|
||||
const activeGenerationCreatedAt = useRecoilValue(
|
||||
store.activeGenerationCreatedAtByConvoId(conversationId),
|
||||
);
|
||||
const activeGenerationProtocolVersion = useRecoilValue(
|
||||
store.activeGenerationProtocolVersionByConvoId(conversationId),
|
||||
);
|
||||
const [selectedFile, setSelectedFile] = useState<Partial<TFile> | null>(null);
|
||||
const optionsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [escalationAnnouncement, setEscalationAnnouncement] = useState('');
|
||||
const handlePreviewClose = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { images, others } = useMemo(() => splitFiles(steer.files), [steer.files]);
|
||||
const sending = steer.status === 'sending';
|
||||
const preempting = steer.preempt === true;
|
||||
|
||||
/** Long steers (several paragraphs) collapse to a preview so the stack stays
|
||||
* scannable; the toggle is offered only once the content actually overflows
|
||||
* the cap. `scrollHeight` reports the full height even while clamped, so the
|
||||
* same check holds whether expanded or not, and the observer re-measures on
|
||||
* the width reflows that change wrapped-line count. */
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const contentId = useId();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (el == null) {
|
||||
return;
|
||||
}
|
||||
const measure = () =>
|
||||
setOverflowing(el.scrollHeight - STEER_COLLAPSED_MAX_HEIGHT > STEER_OVERFLOW_TOLERANCE);
|
||||
measure();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
/** Relabels the chip in place once the server confirms the durable arm —
|
||||
* same steerId, same position, only the `preempt` flag flips. */
|
||||
const markSteerPreempt = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(steerId: string, revision: number) =>
|
||||
set(store.pendingSteersByConvoId(conversationId), (prev) =>
|
||||
prev.map((item) =>
|
||||
item.steerId === steerId && revision >= (item.preemptRevision ?? 0)
|
||||
? { ...item, preempt: true, preemptRevision: revision }
|
||||
: item,
|
||||
),
|
||||
),
|
||||
[conversationId],
|
||||
);
|
||||
const { mutateAsync: armSteer } = useArmSteerMutation();
|
||||
const setEscalating = useSetAtom(escalatingSteerFamily(conversationId));
|
||||
|
||||
/**
|
||||
* Escalate this waiting steer to an interrupt: one idempotent server op
|
||||
* flips `preempt` on the EXISTING queued item, so its FIFO position, id, and
|
||||
* timestamp survive and there is no reclaim window to race. A transport
|
||||
* failure is retried once because the first request may have committed even
|
||||
* though its response was lost. Every "too late" interleaving (drained,
|
||||
* cancelled, run ended or replaced) is the same honest `armed: false`, and
|
||||
* the chip is only relabelled on a confirmed durable arm. The escalating
|
||||
* flag flips synchronously, before the request: the chip-derived gate cannot
|
||||
* see this arm until the response lands, and the other escalation controls
|
||||
* advertise "one interrupt at a time".
|
||||
*/
|
||||
const escalate = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const generationCreatedAt =
|
||||
steer.generationCreatedAt ?? activeGenerationCreatedAt ?? undefined;
|
||||
if (generationCreatedAt == null) {
|
||||
return;
|
||||
}
|
||||
const trigger = event.currentTarget;
|
||||
setEscalationAnnouncement('');
|
||||
setEscalating(true);
|
||||
const params = {
|
||||
conversationId,
|
||||
steerId: steer.steerId,
|
||||
...(generationCreatedAt != null && { generationCreatedAt }),
|
||||
};
|
||||
const requestArm = async () => {
|
||||
let firstResponseWasLost = false;
|
||||
let acceptingRetry = true;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const firstAttempt = armSteer(params);
|
||||
const attempts =
|
||||
activeGenerationProtocolVersion === 2
|
||||
? firstAttempt.catch((error) => {
|
||||
/** If the overall confirmation window already closed, do not let a
|
||||
* very late rejection launch a detached retry behind the user's
|
||||
* back. The first request itself may still have committed. */
|
||||
if (!acceptingRetry) {
|
||||
throw error;
|
||||
}
|
||||
if (!isAmbiguousArmFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
firstResponseWasLost = true;
|
||||
return armSteer(params);
|
||||
})
|
||||
: firstAttempt;
|
||||
const response = await Promise.race([
|
||||
attempts,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error('Steer arm confirmation timed out')),
|
||||
ARM_CONFIRM_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
const responseSupportsNegotiatedProtocol =
|
||||
activeGenerationProtocolVersion === 1 || supportsGenerationProtocolV2(response);
|
||||
if (responseSupportsNegotiatedProtocol && response.armed === true) {
|
||||
/** The successful state removes the arm button. Move focus to the
|
||||
* stable options control only if the user has not moved elsewhere
|
||||
* while the request was pending. */
|
||||
if (document.activeElement === trigger) {
|
||||
optionsButtonRef.current?.focus();
|
||||
}
|
||||
setEscalationAnnouncement(localize('com_ui_steer_in_flight_preempt'));
|
||||
markSteerPreempt(steer.steerId, response.preemptRevision ?? 0);
|
||||
return;
|
||||
}
|
||||
if (!responseSupportsNegotiatedProtocol) {
|
||||
showToast({ message: localize('com_ui_steer_arm_unconfirmed'), status: 'warning' });
|
||||
return;
|
||||
}
|
||||
/** Once a response was lost, a later `armed: false` cannot prove the
|
||||
* first request did not commit: the steer may have drained or the job
|
||||
* may have paused between attempts. Keep the chip event-driven and
|
||||
* report the result as unknown instead of claiming a lost race. */
|
||||
if (firstResponseWasLost) {
|
||||
showToast({ message: localize('com_ui_steer_arm_unconfirmed'), status: 'warning' });
|
||||
return;
|
||||
}
|
||||
/* `armed: false` is deliberately ambiguous — injected, cancelled,
|
||||
* re-homed, or run over — so the message only says the escalation
|
||||
* lost, and the chip defers to the events for what happened. */
|
||||
showToast({
|
||||
message: localize(
|
||||
response.code === 'PREEMPT_UNSUPPORTED'
|
||||
? 'com_ui_steer_preempt_unsupported'
|
||||
: 'com_ui_steer_arm_lost_race',
|
||||
),
|
||||
status: 'info',
|
||||
});
|
||||
} catch (error) {
|
||||
const ambiguous = isAmbiguousArmFailure(error);
|
||||
if (ambiguous) {
|
||||
showToast({
|
||||
message: localize('com_ui_steer_arm_unconfirmed'),
|
||||
status: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
showToast({
|
||||
message: localize(
|
||||
armFailureCode(error) === 'PREEMPT_UNSUPPORTED'
|
||||
? 'com_ui_steer_preempt_unsupported'
|
||||
: 'com_ui_steer_arm_lost_race',
|
||||
),
|
||||
status: 'info',
|
||||
});
|
||||
} finally {
|
||||
acceptingRetry = false;
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
void requestArm().finally(() => setEscalating(false));
|
||||
},
|
||||
[
|
||||
armSteer,
|
||||
conversationId,
|
||||
steer.steerId,
|
||||
steer.generationCreatedAt,
|
||||
activeGenerationCreatedAt,
|
||||
activeGenerationProtocolVersion,
|
||||
setEscalating,
|
||||
markSteerPreempt,
|
||||
showToast,
|
||||
localize,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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<boolean> => {
|
||||
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: <Pencil className="h-4 w-4" aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
void reclaim().then((reclaimed) => {
|
||||
if (!reclaimed) {
|
||||
return;
|
||||
}
|
||||
const restored = onRestoreToComposer(
|
||||
steer.text,
|
||||
steer.files,
|
||||
carriedSteerContext(steer),
|
||||
conversationId,
|
||||
);
|
||||
if (restored) {
|
||||
steering.removeSteer(steer.steerId);
|
||||
return;
|
||||
}
|
||||
/* The composer moved on while the reclaim was in flight. The words
|
||||
* are already off the server, so queue them rather than overwrite a
|
||||
* newer draft — neither text is the one to throw away. */
|
||||
steering.queueReclaimedSteer(steer);
|
||||
showToast({ message: localize('com_ui_steer_edit_queued'), status: 'info' });
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
/* Non-destructive, but only when it is safe: cancel reliably first (the
|
||||
* optimistic hook removes the chip and restores it if the server would
|
||||
* still inject), then hand the words back to the composer ONLY on a
|
||||
* `reclaimed` outcome. On `applied` (cancel lost the race) or `failed`
|
||||
* the steer may still reach the run, so restoring would duplicate the
|
||||
* text — in the response, or beside the restored bubble. The gated
|
||||
* restore also refuses rather than clobber a draft typed meanwhile. */
|
||||
key: 'cancel',
|
||||
label: localize('com_ui_steer_cancel'),
|
||||
icon: <X className="h-4 w-4" aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
void cancelSteer(steer).then((outcome) => {
|
||||
if (outcome !== 'reclaimed') {
|
||||
return;
|
||||
}
|
||||
// useSteerReclaim has tombstoned both ids and removed any terminal
|
||||
// recovery copy, so exactly one client destination is restored here.
|
||||
const restored = onRestoreToComposer(
|
||||
steer.text,
|
||||
steer.files,
|
||||
carriedSteerContext(steer),
|
||||
conversationId,
|
||||
);
|
||||
if (!restored) {
|
||||
/* Reclaimed, but the composer moved on (draft typed, answer mode,
|
||||
* navigated). The chip is already gone, so queue the words as Edit
|
||||
* does rather than drop them — never lost, just re-homed. */
|
||||
steering.queueReclaimedSteer(steer);
|
||||
showToast({ message: localize('com_ui_steer_edit_queued'), status: 'info' });
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'queue',
|
||||
label: localize('com_ui_convert_to_queue'),
|
||||
icon: <Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />,
|
||||
onClick: () => {
|
||||
void reclaim().then((reclaimed) => {
|
||||
if (reclaimed) {
|
||||
steering.queueReclaimedSteer(steer);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
const preferences: MenuEntry[] = [toggleEntry, interruptToggle];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
data-testid="in-flight-steer"
|
||||
data-steer-status={steer.status}
|
||||
data-steer-preempt={preempting ? 'true' : undefined}
|
||||
/* pointer-events-auto: the overlay container disables events so wheeling
|
||||
* over the gaps reaches the messages behind; each bubble re-enables them
|
||||
* for its own controls and internal scroll. */
|
||||
/* `max-w-full`: the overlay stacks its bubbles with `items-start`, so each
|
||||
* one is sized by its content — a single unbroken word made the bubble as
|
||||
* wide as the word and it ran off the composer, with nothing narrow
|
||||
* enough for `break-words` to wrap against. */
|
||||
className="group pointer-events-auto flex min-w-0 max-w-full flex-col items-start gap-1.5"
|
||||
>
|
||||
{(images.length > 0 || others.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{others.map((file) => (
|
||||
<FileContainer
|
||||
key={file.file_id}
|
||||
file={file as TFile}
|
||||
onClick={() => setSelectedFile(file)}
|
||||
/>
|
||||
))}
|
||||
{images.map((file) => (
|
||||
<div
|
||||
key={file.file_id}
|
||||
className="overflow-hidden rounded-xl border border-border-light"
|
||||
>
|
||||
<ImagePreview
|
||||
url={file.preview ?? file.filepath}
|
||||
alt={file.filename ?? localize('com_ui_attached_image')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* items-start so the sticky controls have room to travel — see below. */}
|
||||
<div className="flex max-w-full items-start gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
/* Outlined, not just filled: an in-flight steer is provisional —
|
||||
* the fill alone reads as a settled message. */
|
||||
'flex min-w-0 items-start gap-2 rounded-3xl border border-border-medium',
|
||||
'bg-surface-secondary py-2 pl-3 pr-4 text-sm text-text-primary',
|
||||
sending && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
{preempting ? (
|
||||
<ZapOff className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
|
||||
) : (
|
||||
<Zap className="mt-1 h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden="true" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{localize(preempting ? 'com_ui_steer_in_flight_preempt' : 'com_ui_steer_in_flight')}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<div
|
||||
ref={contentRef}
|
||||
id={contentId}
|
||||
className={cn('relative w-full', !expanded && 'overflow-hidden')}
|
||||
style={!expanded ? { maxHeight: STEER_COLLAPSED_MAX_HEIGHT } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'markdown prose message-content dark:prose-invert light min-w-0 break-words',
|
||||
'dark:text-gray-20',
|
||||
!enableUserMsgMarkdown && 'whitespace-pre-wrap',
|
||||
)}
|
||||
>
|
||||
{/* No code execution: this bubble sits outside MessageContext, so
|
||||
* Run Code would fire with no message/part to target. */}
|
||||
{enableUserMsgMarkdown ? (
|
||||
<MarkdownLite content={steer.text} codeExecution={false} />
|
||||
) : (
|
||||
steer.text
|
||||
)}
|
||||
</div>
|
||||
{!expanded && overflowing && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-surface-secondary to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{overflowing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
className="inline-flex items-center gap-1 rounded text-xs font-medium text-text-secondary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{expanded ? localize('com_ui_show_less') : localize('com_ui_show_more')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!sending && (
|
||||
/* One always-visible affordance: a label-less menu hidden until hover
|
||||
* is undiscoverable, and edit/queue/cancel all live inside it now, so
|
||||
* the menu shows at rest on every pointer (matching the always-on
|
||||
* controls on the queued rows). `sticky` keeps it in view while the
|
||||
* user scrolls through a tall, expanded steer (the stack scrolls once
|
||||
* it passes 35vh). */
|
||||
<div
|
||||
data-testid="steer-controls"
|
||||
className="sticky top-2 flex shrink-0 items-center gap-1"
|
||||
>
|
||||
{!preempting && (
|
||||
<EscalateNowButton
|
||||
surface="bubble"
|
||||
messageText={steer.text}
|
||||
disabled={
|
||||
interruptPending || steering.pausedOnApproval || !steering.duringRunActive
|
||||
}
|
||||
onClick={escalate}
|
||||
/>
|
||||
)}
|
||||
<RowMenu
|
||||
label={localize('com_ui_more_options')}
|
||||
entries={entries}
|
||||
preferences={preferences}
|
||||
buttonRef={optionsButtonRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{escalationAnnouncement}
|
||||
</span>
|
||||
{others.length > 0 && (
|
||||
<FilePreviewDialog
|
||||
open={selectedFile !== null}
|
||||
onOpenChange={handlePreviewClose}
|
||||
fileName={selectedFile?.filename ?? ''}
|
||||
fileId={selectedFile?.file_id}
|
||||
filePath={selectedFile?.filepath}
|
||||
fileType={selectedFile?.type ?? undefined}
|
||||
fileSize={(selectedFile as TFile | null)?.bytes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Steers the server hasn't applied yet, stacked directly above the composer.
|
||||
* Anchoring them here (instead of guessing an in-thread injection point on the
|
||||
* streaming message) keeps the thread showing only what the server actually
|
||||
* 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));
|
||||
const inFlight = useMemo(() => steers.filter((steer) => steer.status !== 'failed'), [steers]);
|
||||
/** Mirrors `PendingSteerChips`: while one interrupt is unresolved, every
|
||||
* other escalation control disables rather than arming a second seal. The
|
||||
* escalating flag covers an arm request's round trip, before its chip
|
||||
* relabels for the chip-derived check to see. */
|
||||
const escalating = useAtomValue(escalatingSteerFamily(conversationId));
|
||||
const interruptPending = useMemo(
|
||||
() => escalating || inFlight.some((steer) => steer.preempt === true),
|
||||
[escalating, inFlight],
|
||||
);
|
||||
const setOverlayHeight = useSetAtom(steerOverlayHeightFamily(conversationId));
|
||||
|
||||
/** Steers append newest-last, so an overflowing stack would sit scrolled to
|
||||
* the oldest — the steer just submitted (and its cancel) would be below the
|
||||
* fold and read as dropped. Keyed on the newest id, not every render. */
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const newestId = inFlight[inFlight.length - 1]?.steerId;
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (list != null) {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
}, [newestId]);
|
||||
|
||||
/** The overlay is pulled out of flow (absolute), so the messages no longer
|
||||
* shrink to fit it. Publish its rendered height so the message scroll area
|
||||
* can reserve an equal band of bottom padding — keeping the newest message
|
||||
* clear of the overlay at rest while older ones scroll behind it. */
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (list == null) {
|
||||
setOverlayHeight(0);
|
||||
return;
|
||||
}
|
||||
const publish = () => setOverlayHeight(list.offsetHeight);
|
||||
publish();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(publish);
|
||||
observer.observe(list);
|
||||
return () => observer.disconnect();
|
||||
}, [setOverlayHeight, inFlight.length]);
|
||||
|
||||
/** Drop the reserved band when the overlay leaves (run ends while steers are
|
||||
* still in flight, or conversation switch) — the measure effect above only
|
||||
* resets when it re-runs, which unmount does not do. */
|
||||
useEffect(() => () => setOverlayHeight(0), [setOverlayHeight]);
|
||||
|
||||
if (inFlight.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="list"
|
||||
aria-label={localize('com_ui_steer_in_flight')}
|
||||
data-testid="in-flight-steers"
|
||||
/* Floats above the composer over the bottom of the thread instead of
|
||||
* displacing it, so scrolling up reveals the messages behind. Capped: a
|
||||
* steer runs to 16k chars and a run takes up to 10 of them; unbounded it
|
||||
* would cover the whole thread. pointer-events-none lets wheeling over
|
||||
* the gaps reach those messages (each bubble opts back in). */
|
||||
className="pointer-events-none absolute inset-x-0 bottom-full flex max-h-[35vh] flex-col items-start gap-2 overflow-y-auto px-2 pb-2"
|
||||
>
|
||||
{inFlight.map((steer) => (
|
||||
<InFlightSteer
|
||||
key={steer.steerId}
|
||||
steer={steer}
|
||||
steering={steering}
|
||||
conversationId={conversationId}
|
||||
interruptPending={interruptPending}
|
||||
onRestoreToComposer={onRestoreToComposer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default InFlightSteers;
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
import { Fragment, useEffect, useId, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { Zap, ZapOff, Clock, ArrowUp, CircleHelp, MoreHorizontal } from 'lucide-react';
|
||||
import type { Ref } from 'react';
|
||||
import type { SteeringControls } from '~/hooks/Chat/useSteering';
|
||||
import { useShortcutAriaKey, useShortcutDisplay } from '~/hooks/useKeyboardShortcuts';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
/** Shared row/bubble affordances for the during-run surfaces: the in-flight
|
||||
* steer bubbles (`InFlightSteers`) and the queued/failed rows
|
||||
* (`PendingSteerChips`) offer the same actions, so they share one menu. */
|
||||
export 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';
|
||||
export 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 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';
|
||||
const ESCALATION_MESSAGE_LABEL_MAX_LENGTH = 80;
|
||||
|
||||
const activeEscalateListeners = new Set<() => void>();
|
||||
let hoveredEscalateTarget: string | null = null;
|
||||
let focusedEscalateTarget: string | null = null;
|
||||
|
||||
function getActiveEscalateTarget() {
|
||||
return focusedEscalateTarget ?? hoveredEscalateTarget;
|
||||
}
|
||||
|
||||
function subscribeToActiveEscalateTarget(listener: () => void) {
|
||||
activeEscalateListeners.add(listener);
|
||||
return () => activeEscalateListeners.delete(listener);
|
||||
}
|
||||
|
||||
function updateActiveEscalateTarget(kind: 'hover' | 'focus', targetId: string, active: boolean) {
|
||||
const previous = getActiveEscalateTarget();
|
||||
if (kind === 'hover') {
|
||||
if (active) {
|
||||
hoveredEscalateTarget = targetId;
|
||||
} else if (hoveredEscalateTarget === targetId) {
|
||||
hoveredEscalateTarget = null;
|
||||
}
|
||||
} else {
|
||||
if (active) {
|
||||
focusedEscalateTarget = targetId;
|
||||
} else if (focusedEscalateTarget === targetId) {
|
||||
focusedEscalateTarget = null;
|
||||
}
|
||||
}
|
||||
if (previous !== getActiveEscalateTarget()) {
|
||||
activeEscalateListeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveEscalateTarget(targetId: string) {
|
||||
const previous = getActiveEscalateTarget();
|
||||
if (hoveredEscalateTarget === targetId) {
|
||||
hoveredEscalateTarget = null;
|
||||
}
|
||||
if (focusedEscalateTarget === targetId) {
|
||||
focusedEscalateTarget = null;
|
||||
}
|
||||
if (previous !== getActiveEscalateTarget()) {
|
||||
activeEscalateListeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
export type MenuEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
/** Localized description announced on the action and exposed by a help disclosure. */
|
||||
info?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-row "…" overflow menu: message actions first, then a visually separated
|
||||
* "Preferences" section for the sticky mode toggles, so one-off actions and
|
||||
* persistent behavior changes never read as the same kind of choice.
|
||||
*/
|
||||
export function RowMenu({
|
||||
label,
|
||||
entries,
|
||||
preferences,
|
||||
buttonRef,
|
||||
}: {
|
||||
label: string;
|
||||
entries: MenuEntry[];
|
||||
preferences?: MenuEntry[];
|
||||
buttonRef?: Ref<HTMLButtonElement>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const menu = Ariakit.useMenuStore({ placement: 'top-end' });
|
||||
const descriptionPrefix = useId();
|
||||
const preferencesLabelId = `${descriptionPrefix}-preferences`;
|
||||
const [expandedInfoKey, setExpandedInfoKey] = useState<string | null>(null);
|
||||
const renderEntry = (entry: MenuEntry) => {
|
||||
const descriptionId = entry.info == null ? undefined : `${descriptionPrefix}-${entry.key}`;
|
||||
const infoExpanded = entry.info != null && expandedInfoKey === entry.key;
|
||||
const action = (
|
||||
<Ariakit.MenuItem
|
||||
className={MENU_ITEM_CLASS}
|
||||
disabled={entry.disabled === true}
|
||||
accessibleWhenDisabled
|
||||
aria-describedby={descriptionId}
|
||||
onClick={() => {
|
||||
entry.onClick();
|
||||
setExpandedInfoKey(null);
|
||||
menu.hide();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
</Ariakit.MenuItem>
|
||||
);
|
||||
|
||||
if (entry.info == null || descriptionId == null) {
|
||||
return <Fragment key={entry.key}>{action}</Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={entry.key} role="none">
|
||||
<div role="none" className="flex items-center gap-0.5">
|
||||
<div role="none" className="min-w-0 flex-1">
|
||||
{action}
|
||||
</div>
|
||||
<Ariakit.MenuItem
|
||||
aria-label={`${localize('com_ui_more_info')}: ${entry.label}`}
|
||||
aria-expanded={infoExpanded}
|
||||
aria-controls={descriptionId}
|
||||
hideOnClick={false}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-text-secondary hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy data-[active-item]:bg-surface-tertiary"
|
||||
onClick={() =>
|
||||
setExpandedInfoKey((current) => (current === entry.key ? null : entry.key))
|
||||
}
|
||||
>
|
||||
<CircleHelp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
<div
|
||||
id={descriptionId}
|
||||
className={cn(
|
||||
infoExpanded
|
||||
? 'mx-2 mb-1 rounded-lg bg-surface-tertiary px-2 py-1.5 text-xs leading-relaxed text-text-secondary'
|
||||
: 'sr-only',
|
||||
)}
|
||||
>
|
||||
{entry.info}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Ariakit.MenuButton
|
||||
ref={buttonRef}
|
||||
store={menu}
|
||||
aria-label={label}
|
||||
className={ICON_BTN_CLASS}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Ariakit.MenuButton>
|
||||
<Ariakit.Menu store={menu} portal gutter={6} className={MENU_CLASS}>
|
||||
{entries.map(renderEntry)}
|
||||
{preferences != null && preferences.length > 0 && (
|
||||
<>
|
||||
<div role="separator" className="mx-2 my-1 border-t border-border-light" />
|
||||
<div role="group" aria-labelledby={preferencesLabelId}>
|
||||
<div
|
||||
id={preferencesLabelId}
|
||||
className="px-2 pb-0.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_preferences')}
|
||||
</div>
|
||||
{preferences.map(renderEntry)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Ariakit.Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-visible escalation control on a waiting message: interrupt &
|
||||
* steer it now, at the next safe token boundary. The tooltip teaches this
|
||||
* action's OWN shortcut (registry-aware, so a rebinding shows correctly).
|
||||
* Hover/focus marks this exact button as the active shortcut target; without
|
||||
* an active row the shortcut retains its newest-waiting-message fallback.
|
||||
*/
|
||||
export function EscalateNowButton({
|
||||
surface,
|
||||
disabled,
|
||||
messageText,
|
||||
onClick,
|
||||
}: {
|
||||
surface: 'bubble' | 'queued';
|
||||
disabled: boolean;
|
||||
messageText: string;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const chord = useShortcutDisplay('escalateSteer');
|
||||
const ariaKey = useShortcutAriaKey('escalateSteer');
|
||||
const targetId = useId();
|
||||
const activeTarget = useSyncExternalStore(
|
||||
subscribeToActiveEscalateTarget,
|
||||
getActiveEscalateTarget,
|
||||
() => null,
|
||||
);
|
||||
const isActive = !disabled && activeTarget === targetId;
|
||||
const label = localize('com_ui_interrupt_steer_now');
|
||||
const normalizedMessageLabel = messageText.trim().replace(/\s+/g, ' ');
|
||||
const messageCharacters = Array.from(normalizedMessageLabel);
|
||||
const messageLabel =
|
||||
messageCharacters.length > ESCALATION_MESSAGE_LABEL_MAX_LENGTH
|
||||
? `${messageCharacters
|
||||
.slice(0, ESCALATION_MESSAGE_LABEL_MAX_LENGTH - 1)
|
||||
.join('')
|
||||
.trimEnd()}…`
|
||||
: normalizedMessageLabel;
|
||||
const accessibleLabel = messageLabel.length > 0 ? `${label}: ${messageLabel}` : label;
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
clearActiveEscalateTarget(targetId);
|
||||
}
|
||||
return () => clearActiveEscalateTarget(targetId);
|
||||
}, [disabled, targetId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Thin divider binds the arrow to the message on its left, so it can
|
||||
* never read as part of the bare-glyph menu control beside it — the
|
||||
* send-now belongs to THIS chip, and the pairing repeats cleanly when
|
||||
* several messages stack. */}
|
||||
<span aria-hidden="true" className="h-[18px] w-px shrink-0 bg-border-medium" />
|
||||
<Ariakit.TooltipProvider placement="top" timeout={300}>
|
||||
<Ariakit.TooltipAnchor
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={accessibleLabel}
|
||||
aria-keyshortcuts={isActive ? ariaKey : undefined}
|
||||
data-escalate-steer={surface}
|
||||
data-escalate-steer-active={isActive ? 'true' : undefined}
|
||||
data-testid={surface === 'queued' ? 'queued-interrupt-now' : 'steer-escalate-now'}
|
||||
disabled={disabled}
|
||||
onPointerEnter={() =>
|
||||
!disabled && updateActiveEscalateTarget('hover', targetId, true)
|
||||
}
|
||||
onPointerLeave={() => updateActiveEscalateTarget('hover', targetId, false)}
|
||||
onFocus={() => !disabled && updateActiveEscalateTarget('focus', targetId, true)}
|
||||
onBlur={() => updateActiveEscalateTarget('focus', targetId, false)}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
'bg-text-primary text-surface-primary transition-opacity hover:opacity-85',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
|
||||
'disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35',
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.Tooltip className="z-50 rounded-lg bg-surface-tertiary px-2 py-1 text-xs text-text-primary shadow-lg">
|
||||
{chord && isActive ? `${label} · ${chord}` : label}
|
||||
</Ariakit.Tooltip>
|
||||
</Ariakit.TooltipProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export 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' ? (
|
||||
<Clock className="h-4 w-4 text-cyan-500" aria-hidden="true" />
|
||||
) : (
|
||||
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
|
||||
),
|
||||
info: localize('com_nav_info_during_run_action'),
|
||||
onClick: () => steering.setDefaultAction(next),
|
||||
};
|
||||
}, [steering, localize]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The overflow item that flips whether a default steer interrupts generation
|
||||
* (`steerInterruptsByDefault`). Worded as the mode you would switch to, like
|
||||
* the queue/steer toggle above.
|
||||
*/
|
||||
export function useInterruptToggleEntry(): MenuEntry {
|
||||
const localize = useLocalize();
|
||||
const [interrupts, setInterrupts] = useRecoilState(store.steerInterruptsByDefault);
|
||||
const [defaultAction, setDefaultAction] = useRecoilState(store.duringRunDefaultAction);
|
||||
const interruptsByDefault = defaultAction === 'steer' && interrupts;
|
||||
return useMemo(
|
||||
() => ({
|
||||
key: 'toggle-interrupt',
|
||||
label: interruptsByDefault
|
||||
? localize('com_ui_wait_for_tool_steps')
|
||||
: localize('com_ui_always_interrupt'),
|
||||
icon: interruptsByDefault ? (
|
||||
<Zap className="h-4 w-4 text-amber-500" aria-hidden="true" />
|
||||
) : (
|
||||
<ZapOff className="h-4 w-4 text-amber-500" aria-hidden="true" />
|
||||
),
|
||||
info: localize(
|
||||
!interruptsByDefault && defaultAction === 'queue'
|
||||
? 'com_ui_steer_interrupts_enable_info'
|
||||
: 'com_ui_steer_interrupts_default_info',
|
||||
),
|
||||
onClick: () => {
|
||||
if (interruptsByDefault) {
|
||||
setInterrupts(false);
|
||||
return;
|
||||
}
|
||||
if (defaultAction === 'queue') {
|
||||
setDefaultAction('steer');
|
||||
}
|
||||
setInterrupts(true);
|
||||
},
|
||||
}),
|
||||
[defaultAction, interruptsByDefault, localize, setDefaultAction, setInterrupts],
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,7 @@ import { EditTextPart, EmptyText, AgentUpdate } from './Parts';
|
|||
import { lastVisibleContentIdx } from '~/utils/activityLabels';
|
||||
import { MessageContext, SearchContext } from '~/Providers';
|
||||
import PendingSkillCall from './Parts/PendingSkillCall';
|
||||
import PendingSteers from './Parts/PendingSteers';
|
||||
import ApprovalProvider from './ApprovalContext';
|
||||
import MemoryArtifacts from './MemoryArtifacts';
|
||||
import ToolCallGroup from './ToolCallGroup';
|
||||
|
|
@ -458,6 +459,9 @@ const ContentParts = memo(function ContentParts({
|
|||
renderPart={renderPart}
|
||||
renderResumeAttribution={renderResumeAttribution}
|
||||
/>
|
||||
{isLast && isSubmitting && conversationId != null && (
|
||||
<PendingSteers conversationId={conversationId} />
|
||||
)}
|
||||
</ApprovalProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -509,6 +513,9 @@ const ContentParts = memo(function ContentParts({
|
|||
);
|
||||
return nodes;
|
||||
})}
|
||||
{isLast && isSubmitting && conversationId != null && (
|
||||
<PendingSteers conversationId={conversationId} />
|
||||
)}
|
||||
</SearchContext.Provider>
|
||||
</ApprovalProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import useSteerRecovery from '~/hooks/Chat/useSteerRecovery';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import SteerPart from './SteerPart';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ACTION_CLASS =
|
||||
'rounded text-xs font-medium text-text-secondary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy';
|
||||
|
||||
/**
|
||||
* Steers that have not been confirmed by the server yet, rendered at the tail
|
||||
* of the streaming reply — the place the words will land — instead of in a
|
||||
* floating overlay over the composer. Confirmation swaps them for the real
|
||||
* `ContentTypes.STEER` part (`useResumableSSE` removes the pending entry), so
|
||||
* the row's whole job is to hold the position and admit it is provisional.
|
||||
*/
|
||||
function PendingSteers({ conversationId }: { conversationId: string }) {
|
||||
const localize = useLocalize();
|
||||
const steers = useRecoilValue(store.pendingSteersByConvoId(conversationId));
|
||||
const { retry, sendAsNew } = useSteerRecovery(conversationId);
|
||||
|
||||
if (steers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="list" aria-label={localize('com_ui_steer_in_flight')} data-testid="pending-steers">
|
||||
{steers.map((steer) => (
|
||||
<div
|
||||
key={steer.steerId}
|
||||
role="listitem"
|
||||
className={cn(steer.status !== 'failed' && 'opacity-60')}
|
||||
>
|
||||
<SteerPart
|
||||
steer={steer.text}
|
||||
files={steer.files}
|
||||
steerId={steer.steerId}
|
||||
createdAt={steer.createdAt}
|
||||
/>
|
||||
{steer.status === 'failed' ? (
|
||||
<div className="-mt-2 mb-2 flex items-center gap-3 pl-9 text-xs">
|
||||
<span className="text-red-500">{localize('com_ui_steer_failed_inline')}</span>
|
||||
<button type="button" onClick={() => retry(steer.steerId)} className={ACTION_CLASS}>
|
||||
{localize('com_ui_retry')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendAsNew(steer.steerId)}
|
||||
className={ACTION_CLASS}
|
||||
>
|
||||
{localize('com_ui_send_as_new')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="-mt-2 mb-2 pl-9 text-xs text-text-secondary">
|
||||
{localize('com_ui_sending')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(PendingSteers);
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { PendingSteer } from '~/store/families';
|
||||
import PendingSteers from '../PendingSteers';
|
||||
import store from '~/store';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Chat/useSteerRecovery', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({ retry: jest.fn(), sendAsNew: jest.fn(), remove: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('../SteerPart', () => ({
|
||||
__esModule: true,
|
||||
default: ({ steer }: { steer: string }) => <div data-testid="steer-part">{steer}</div>,
|
||||
}));
|
||||
|
||||
const CONVO_ID = 'convo-1';
|
||||
|
||||
const pending = (over: Partial<PendingSteer> = {}): PendingSteer => ({
|
||||
steerId: 's1',
|
||||
text: 'change of plan',
|
||||
status: 'sending',
|
||||
createdAt: 1,
|
||||
...over,
|
||||
});
|
||||
|
||||
function renderPending(steers: PendingSteer[]) {
|
||||
return render(
|
||||
<RecoilRoot initializeState={({ set }) => set(store.pendingSteersByConvoId(CONVO_ID), steers)}>
|
||||
<PendingSteers conversationId={CONVO_ID} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('PendingSteers', () => {
|
||||
it('renders nothing with no pending steers', () => {
|
||||
const { container } = renderPending([]);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders a dimmed steer part with sending status', () => {
|
||||
renderPending([pending()]);
|
||||
expect(screen.getByTestId('steer-part')).toHaveTextContent('change of plan');
|
||||
expect(screen.getByText('com_ui_sending')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers retry and send-as-new on failure', () => {
|
||||
renderPending([pending({ status: 'failed' })]);
|
||||
expect(screen.getByText('com_ui_steer_failed_inline')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_retry')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_send_as_new')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -10,3 +10,4 @@ export { default as useQueueDrain } from './useQueueDrain';
|
|||
export { default as useSteering } from './useSteering';
|
||||
export { default as useSteerCancel, useSteerReclaim } from './useSteerCancel';
|
||||
export { default as useSteerConvert } from './useSteerConvert';
|
||||
export { default as useSteerRecovery } from './useSteerRecovery';
|
||||
|
|
|
|||
86
client/src/hooks/Chat/useSteerRecovery.ts
Normal file
86
client/src/hooks/Chat/useSteerRecovery.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useRecoilCallback } from 'recoil';
|
||||
import type { PendingSteer } from '~/store/families';
|
||||
import { useSteerMessageMutation } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
/**
|
||||
* Retry or re-route a pending steer from OUTSIDE the composer. The thread's
|
||||
* pending block needs these two actions without dragging the whole
|
||||
* `SteeringControls` object through the message tree.
|
||||
*/
|
||||
export default function useSteerRecovery(conversationId: string) {
|
||||
const { mutate: steerMessage } = useSteerMessageMutation();
|
||||
|
||||
const markSending = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(steerId: string, status: PendingSteer['status']) => {
|
||||
set(store.pendingSteersByConvoId(conversationId), (prev) =>
|
||||
prev.map((steer) => (steer.steerId === steerId ? { ...steer, status } : steer)),
|
||||
);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const retry = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
(steerId: string) => {
|
||||
const steers = snapshot
|
||||
.getLoadable(store.pendingSteersByConvoId(conversationId))
|
||||
.getValue();
|
||||
const steer = steers.find((item) => item.steerId === steerId);
|
||||
if (!steer) {
|
||||
return;
|
||||
}
|
||||
markSending(steerId, 'sending');
|
||||
steerMessage(
|
||||
{ conversationId, text: steer.text, files: steer.files },
|
||||
{
|
||||
onSuccess: () => markSending(steerId, 'pending'),
|
||||
onError: () => markSending(steerId, 'failed'),
|
||||
},
|
||||
);
|
||||
},
|
||||
[conversationId, steerMessage, markSending],
|
||||
);
|
||||
|
||||
/** Move a failed steer into the queue: it sends when the reply finishes. */
|
||||
const sendAsNew = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
(steerId: string) => {
|
||||
const steers = snapshot
|
||||
.getLoadable(store.pendingSteersByConvoId(conversationId))
|
||||
.getValue();
|
||||
const steer = steers.find((item) => item.steerId === steerId);
|
||||
if (!steer) {
|
||||
return;
|
||||
}
|
||||
set(store.pendingSteersByConvoId(conversationId), (prev) =>
|
||||
prev.filter((item) => item.steerId !== steerId),
|
||||
);
|
||||
set(store.queuedMessagesByConvoId(conversationId), (prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: steer.steerId,
|
||||
text: steer.text,
|
||||
createdAt: steer.createdAt,
|
||||
files: steer.files,
|
||||
quotes: steer.quotes,
|
||||
manualSkills: steer.manualSkills,
|
||||
},
|
||||
]);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
const remove = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(steerId: string) => {
|
||||
set(store.pendingSteersByConvoId(conversationId), (prev) =>
|
||||
prev.filter((item) => item.steerId !== steerId),
|
||||
);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
return { retry, sendAsNew, remove };
|
||||
}
|
||||
|
|
@ -1747,8 +1747,10 @@
|
|||
"com_ui_select_search_model": "Search model by name",
|
||||
"com_ui_select_search_provider": "Search provider by name",
|
||||
"com_ui_select_search_region": "Search region by name",
|
||||
"com_ui_send_as_new": "Send as new message",
|
||||
"com_ui_send_now": "Send now",
|
||||
"com_ui_send_now_paused": "Available after you respond to the pending request",
|
||||
"com_ui_sending": "Sending...",
|
||||
"com_ui_set": "Set",
|
||||
"com_ui_settings_label_2fa": "Two-factor authentication",
|
||||
"com_ui_settings_label_agent_api_keys": "Agent API keys",
|
||||
|
|
@ -1964,6 +1966,7 @@
|
|||
"com_ui_steer_delivery_unconfirmed": "Delivery unconfirmed",
|
||||
"com_ui_steer_edit_queued": "Your composer already has a draft, so that steering message was queued for after the response instead",
|
||||
"com_ui_steer_failed": "Steering failed",
|
||||
"com_ui_steer_failed_inline": "Couldn't add to this reply",
|
||||
"com_ui_steer_in_flight": "Steering",
|
||||
"com_ui_steer_in_flight_preempt": "Interrupting",
|
||||
"com_ui_steer_interrupts_default": "Steering interrupts generation",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue