refactor(webui): shrink fork implementation
This commit is contained in:
@@ -5,13 +5,13 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
} from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
GitFork,
|
||||
ImageIcon,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
@@ -22,12 +22,6 @@ import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { CliAppMentionText } from "@/components/CliAppMentionText";
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { formatTurnLatency } from "@/lib/format";
|
||||
@@ -90,7 +84,7 @@ export function MessageBubble({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onCopyMessage = useCallback(() => {
|
||||
const onCopyAssistantReply = useCallback(() => {
|
||||
void copyTextToClipboard(message.content).then((ok) => {
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
@@ -114,11 +108,6 @@ export function MessageBubble({
|
||||
const hasImages = images.length > 0;
|
||||
const hasMedia = media.length > 0;
|
||||
const hasText = message.content.trim().length > 0;
|
||||
const showUserActions = hasText;
|
||||
const timeLabel = formatMessageClock(message.createdAt);
|
||||
const copyLabel = copied
|
||||
? t("message.copiedMessage", { defaultValue: "Copied" })
|
||||
: t("message.copyMessage", { defaultValue: "Copy" });
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -144,43 +133,6 @@ export function MessageBubble({
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
{showUserActions ? (
|
||||
<TooltipProvider delayDuration={180} skipDelayDuration={80}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-8 items-center justify-end gap-1 self-end",
|
||||
"text-[13px] text-muted-foreground/65 opacity-0 transition-opacity duration-150",
|
||||
"group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{hasText ? (
|
||||
<MessageActionTooltip label={copyLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyMessage}
|
||||
aria-label={copyLabel}
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</MessageActionTooltip>
|
||||
) : null}
|
||||
{timeLabel ? (
|
||||
<span className="ml-1 shrink-0 select-none tabular-nums" title={timeLabel}>
|
||||
{timeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -235,54 +187,50 @@ export function MessageBubble({
|
||||
</MarkdownText>
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
{showAssistantFooterRow ? (
|
||||
<TooltipProvider delayDuration={180} skipDelayDuration={80}>
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||
{showCopyButton ? (
|
||||
<MessageActionTooltip label={copyReplyLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyMessage}
|
||||
aria-label={copyReplyLabel}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</MessageActionTooltip>
|
||||
) : null}
|
||||
{showForkButton ? (
|
||||
<MessageActionTooltip label={forkLabel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForkFromHere}
|
||||
aria-label={forkLabel}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<ForkFromHereIcon className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</MessageActionTooltip>
|
||||
) : null}
|
||||
{showLatencyFooter ? (
|
||||
<span
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
title={t("message.turnLatencyTitle")}
|
||||
>
|
||||
{formatTurnLatency(latencyMs)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||
{showCopyButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyAssistantReply}
|
||||
aria-label={copyReplyLabel}
|
||||
title={copyReplyLabel}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{showForkButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForkFromHere}
|
||||
aria-label={forkLabel}
|
||||
title={forkLabel}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<GitFork className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
{showLatencyFooter ? (
|
||||
<span
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
title={t("message.turnLatencyTitle")}
|
||||
>
|
||||
{formatTurnLatency(latencyMs)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
@@ -290,27 +238,6 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function MessageActionTooltip({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="rounded-full border-border/70 bg-background px-2.5 py-1 text-[12px] font-medium text-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10 dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
@@ -330,39 +257,6 @@ function AutomationSourceBadge({ label, triggerLabel }: { label: string; trigger
|
||||
);
|
||||
}
|
||||
|
||||
function formatMessageClock(createdAt: number): string {
|
||||
if (!Number.isFinite(createdAt) || createdAt <= 0) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(createdAt));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function ForkFromHereIcon({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
// Tabler Icons "arrow-fork" (MIT, Copyright Paweł Kuna).
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M16 3h5v5" />
|
||||
<path d="M8 3h-5v5" />
|
||||
<path d="M21 3l-7.536 7.536a5 5 0 0 0 -1.464 3.534v6.93" />
|
||||
<path d="M3 3l7.536 7.536a5 5 0 0 1 1.464 3.534v.93" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMcpMentionPresets(
|
||||
presets: McpPresetInfo[],
|
||||
attachments: UIMcpPresetAttachment[] | undefined,
|
||||
|
||||
@@ -172,7 +172,6 @@ interface ThreadComposerProps {
|
||||
workspaceError?: string | null;
|
||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||
pendingQueueKey?: string | null;
|
||||
externalError?: string | null;
|
||||
}
|
||||
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -766,7 +765,6 @@ export function ThreadComposer({
|
||||
workspaceError = null,
|
||||
onWorkspaceScopeChange,
|
||||
pendingQueueKey = null,
|
||||
externalError = null,
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
@@ -1149,10 +1147,6 @@ export function ThreadComposer({
|
||||
});
|
||||
}, [clear, pendingQueueKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalError) setInlineError(externalError);
|
||||
}, [externalError]);
|
||||
|
||||
const appendTranscription = useCallback((text: string) => {
|
||||
const transcript = text.trim();
|
||||
if (!transcript) return;
|
||||
|
||||
@@ -8,10 +8,10 @@ import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
allMessages?: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
hiddenUserMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
@@ -65,9 +65,9 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
allMessages,
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
hiddenUserMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
@@ -81,15 +81,12 @@ export function ThreadMessages({
|
||||
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
||||
[forkBoundaryMessageCount, units],
|
||||
);
|
||||
const assistantForkIndexById = useMemo(
|
||||
() => assistantForkIndexByMessageId(allMessages ?? messages),
|
||||
[allMessages, messages],
|
||||
);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
const liveActivityClusterIndices = useMemo(
|
||||
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
||||
[isStreaming, units],
|
||||
);
|
||||
let nextUserIndex = hiddenUserMessageCount;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
@@ -123,6 +120,11 @@ export function ThreadMessages({
|
||||
unit.type === "message" && unit.message.role === "user"
|
||||
? unit.message.id
|
||||
: undefined;
|
||||
const forkIndex =
|
||||
unit.type === "message" && unit.message.role === "assistant" && copyFlags[index]
|
||||
? nextUserIndex
|
||||
: undefined;
|
||||
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
|
||||
|
||||
return (
|
||||
<Fragment key={unitKey(unit, index)}>
|
||||
@@ -149,20 +151,15 @@ export function ThreadMessages({
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromHere={
|
||||
onForkFromMessage
|
||||
? forkHandlerForAssistantMessage(
|
||||
unit.message,
|
||||
copyFlags[index],
|
||||
assistantForkIndexById,
|
||||
onForkFromMessage,
|
||||
)
|
||||
onForkFromMessage && forkIndex !== undefined
|
||||
? () => onForkFromMessage(forkIndex)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{index === forkBoundaryAfterUnitIndex ? (
|
||||
<ForkBoundaryDivider label={t("thread.fork.fromHistory")} />
|
||||
<ForkBoundaryDivider label={t("thread.forkedFromHistory")} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
@@ -195,34 +192,6 @@ function ForkBoundaryDivider({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function assistantForkIndexByMessageId(messages: UIMessage[]): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
let nextUserIndex = 0;
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
nextUserIndex += 1;
|
||||
} else if (message.role === "assistant") {
|
||||
out.set(message.id, nextUserIndex);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function forkHandlerForAssistantMessage(
|
||||
message: UIMessage,
|
||||
canForkAssistant: boolean,
|
||||
assistantForkIndexById: Map<string, number>,
|
||||
onForkFromMessage: NonNullable<ThreadMessagesProps["onForkFromMessage"]>,
|
||||
): (() => void) | undefined {
|
||||
if (message.role === "assistant" && canForkAssistant) {
|
||||
const beforeUserIndex = assistantForkIndexById.get(message.id);
|
||||
return beforeUserIndex === undefined
|
||||
? undefined
|
||||
: () => onForkFromMessage(beforeUserIndex);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
|
||||
const indices = new Set<number>();
|
||||
let markedCurrentActivity = false;
|
||||
|
||||
@@ -278,8 +278,6 @@ export function ThreadShell({
|
||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const [forkError, setForkError] = useState<string | null>(null);
|
||||
const [forkHydratingChatId, setForkHydratingChatId] = useState<string | null>(null);
|
||||
const shellRef = useRef<HTMLElement | null>(null);
|
||||
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
||||
@@ -288,7 +286,6 @@ export function ThreadShell({
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
const prevChatIdForComposerRef = useRef<string | null>(chatId);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
const skipLayoutCacheRef = useRef(false);
|
||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -340,12 +337,6 @@ export function ThreadShell({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevChatIdForComposerRef.current === chatId) return;
|
||||
prevChatIdForComposerRef.current = chatId;
|
||||
setForkError(null);
|
||||
}, [chatId]);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
|
||||
const showHeroComposer = messages.length === 0 && !loading;
|
||||
@@ -455,12 +446,6 @@ export function ThreadShell({
|
||||
setMessages(projectWebuiThreadMessages(historical));
|
||||
}, [chatId, historical, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading || forkHydratingChatId !== chatId) return;
|
||||
setForkHydratingChatId(null);
|
||||
setScrollToBottomSignal((value) => value + 1);
|
||||
}, [chatId, forkHydratingChatId, loading]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (chatId) {
|
||||
const prev = prevChatIdForCacheRef.current;
|
||||
@@ -539,7 +524,6 @@ export function ThreadShell({
|
||||
|
||||
const handleThreadSend = useCallback(
|
||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
setForkError(null);
|
||||
setScrollToBottomSignal((value) => value + 1);
|
||||
send(content, images, withWorkspaceScope(options));
|
||||
},
|
||||
@@ -637,21 +621,13 @@ export function ThreadShell({
|
||||
const handleForkFromMessage = useCallback(
|
||||
async (beforeUserIndex: number) => {
|
||||
if (!chatId || !onForkChat) return;
|
||||
setForkError(null);
|
||||
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
||||
if (!forkedChatId) {
|
||||
setForkError(t("thread.fork.failed", {
|
||||
defaultValue: "Could not fork this chat. Try again.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!forkedChatId) return;
|
||||
messageCacheRef.current.delete(forkedChatId);
|
||||
appliedHistoryVersionRef.current.delete(forkedChatId);
|
||||
pendingCanonicalHydrateRef.current.add(forkedChatId);
|
||||
setForkHydratingChatId(forkedChatId);
|
||||
setForkError(null);
|
||||
},
|
||||
[chatId, onForkChat, t],
|
||||
[chatId, onForkChat],
|
||||
);
|
||||
|
||||
const composer = (
|
||||
@@ -665,7 +641,7 @@ export function ThreadShell({
|
||||
{session ? (
|
||||
<ThreadComposer
|
||||
onSend={handleThreadSend}
|
||||
disabled={!chatId || forkHydratingChatId === chatId}
|
||||
disabled={!chatId}
|
||||
isStreaming={isStreaming}
|
||||
placeholder={
|
||||
showHeroComposer
|
||||
@@ -692,7 +668,6 @@ export function ThreadShell({
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
pendingQueueKey={chatId}
|
||||
externalError={forkError}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
@@ -776,7 +751,6 @@ export function ThreadShell({
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
allMessages={displayMessages}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface ThreadViewportHandle {
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
allMessages?: UIMessage[];
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
@@ -64,7 +63,6 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
|
||||
|
||||
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||
messages,
|
||||
allMessages,
|
||||
isStreaming,
|
||||
composer,
|
||||
emptyState,
|
||||
@@ -100,6 +98,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
[messages, visibleMessageCount],
|
||||
);
|
||||
const hiddenMessageCount = messages.length - visibleMessages.length;
|
||||
const hiddenUserMessageCount =
|
||||
hiddenMessageCount > 0
|
||||
? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length
|
||||
: 0;
|
||||
const visibleForkBoundaryMessageCount =
|
||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||
? forkBoundaryMessageCount - hiddenMessageCount
|
||||
@@ -299,9 +301,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
allMessages={allMessages ?? messages}
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
hiddenUserMessageCount={hiddenUserMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
|
||||
Reference in New Issue
Block a user