feat(mcp): add preset setup and capability mentions

This commit is contained in:
Xubin Ren
2026-05-24 19:43:20 +08:00
parent 8be258212e
commit 704ac558f6
54 changed files with 8425 additions and 708 deletions
File diff suppressed because it is too large Load Diff
+254 -96
View File
@@ -11,14 +11,15 @@ import {
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
CliAppMentionToken,
McpPresetMentionToken,
cliAppInitials,
splitCliAppMentionSegments,
type CliAppMentionSegment,
mcpPresetInitials,
splitCapabilityMentionSegments,
type CapabilityMentionSegment,
} from "@/components/CliAppMentionText";
import {
Activity,
ArrowUp,
AtSign,
BookOpen,
Check,
ChevronDown,
@@ -48,7 +49,19 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
import type {
CliAppInfo,
GoalStateWsPayload,
McpPresetInfo,
OutboundCliAppMention,
OutboundMcpPresetMention,
SlashCommand,
} from "@/lib/types";
import {
inferProviderFromModelName,
logoFallbackUrls,
providerBrand,
} from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@@ -67,9 +80,12 @@ interface ThreadComposerProps {
placeholder?: string;
isStreaming?: boolean;
modelLabel?: string | null;
modelProvider?: string | null;
modelProviderLabel?: string | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@@ -97,7 +113,7 @@ const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "
const SLASH_PALETTE_GAP_PX = 8;
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
const SLASH_PALETTE_CHROME_PX = 64;
const SLASH_PALETTE_CHROME_PX = 40;
type SlashPalettePlacement = "above" | "below";
@@ -112,6 +128,10 @@ interface CliAppMentionQuery {
end: number;
}
type MentionCandidate =
| { kind: "cli"; name: string; app: CliAppInfo }
| { kind: "mcp"; name: string; preset: McpPresetInfo };
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
@@ -192,6 +212,19 @@ function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
};
}
function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMention {
return {
name: preset.name,
display_name: preset.display_name,
category: preset.category,
transport: preset.transport,
status: preset.status,
configured: preset.configured,
logo_url: preset.logo_url ?? null,
brand_color: preset.brand_color ?? null,
};
}
function RunElapsedStrip({
startedAt,
goalState,
@@ -394,9 +427,12 @@ export function ThreadComposer({
placeholder,
isStreaming = false,
modelLabel = null,
modelProvider = null,
modelProviderLabel = null,
variant = "thread",
slashCommands = [],
cliApps = [],
mcpPresets = [],
imageMode: controlledImageMode,
onImageModeChange,
onStop,
@@ -534,9 +570,9 @@ export function ThreadComposer({
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
const filteredCliApps = useMemo(() => {
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
if (!cliAppMention) return [];
return cliApps
const cliCandidates: MentionCandidate[] = cliApps
.filter((app) => app.installed)
.filter((app) => {
const haystack = [
@@ -548,16 +584,32 @@ export function ThreadComposer({
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.slice(0, 8);
}, [cliAppMention, cliApps]);
.map((app) => ({ kind: "cli", name: app.name, app }));
const mcpCandidates: MentionCandidate[] = mcpPresets
.filter((preset) => preset.installed && preset.configured)
.filter((preset) => {
const haystack = [
preset.name,
preset.display_name,
preset.category,
preset.description,
preset.transport,
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.map((preset) => ({ kind: "mcp", name: preset.name, preset }));
return [...cliCandidates, ...mcpCandidates].slice(0, 8);
}, [cliAppMention, cliApps, mcpPresets]);
const showCliAppMenu = filteredCliApps.length > 0;
const showCliAppMenu = filteredMentionCandidates.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const mentionSegments = useMemo(
() => splitCliAppMentionSegments(value, cliApps),
[cliApps, value],
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets),
[cliApps, mcpPresets, value],
);
const hasMentionDecorations = mentionSegments.some(
(segment) => segment.kind === "cli" || segment.kind === "mcp",
);
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
const activeCliMentionApps = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
@@ -566,6 +618,14 @@ export function ThreadComposer({
return [segment.app];
});
}, [mentionSegments]);
const activeMcpPresetMentions = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "mcp" || seen.has(segment.preset.name)) return [];
seen.add(segment.preset.name);
return [segment.preset];
});
}, [mentionSegments]);
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
placement: "above",
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
@@ -586,10 +646,10 @@ export function ThreadComposer({
}, [filteredSlashCommands.length, selectedCommandIndex]);
useEffect(() => {
if (selectedCliAppIndex >= filteredCliApps.length) {
if (selectedCliAppIndex >= filteredMentionCandidates.length) {
setSelectedCliAppIndex(0);
}
}, [filteredCliApps.length, selectedCliAppIndex]);
}, [filteredMentionCandidates.length, selectedCliAppIndex]);
useEffect(() => {
if (!showAnyPalette) return;
@@ -640,7 +700,7 @@ export function ThreadComposer({
window.removeEventListener("resize", updateLayout);
document.removeEventListener("scroll", updateLayout, true);
};
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
useEffect(() => {
if (!aspectMenuOpen) return;
@@ -695,11 +755,11 @@ export function ThreadComposer({
[resizeTextarea],
);
const chooseCliApp = useCallback(
(app: CliAppInfo) => {
const chooseMentionCandidate = useCallback(
(candidate: MentionCandidate) => {
if (!cliAppMention) return;
const suffix = value.slice(cliAppMention.end);
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
const nextCursor = cliAppMention.start + mention.length;
setValue(next);
@@ -736,8 +796,9 @@ export function ThreadComposer({
}))
: undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload);
const options: SendOptions | undefined =
imageMode || attachedCliApps.length > 0
imageMode || attachedCliApps.length > 0 || attachedMcpPresets.length > 0
? {
...(imageMode
? {
@@ -748,6 +809,7 @@ export function ThreadComposer({
}
: {}),
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
}
: undefined;
onSend(trimmed, payload, options);
@@ -760,25 +822,36 @@ export function ThreadComposer({
setCliAppMenuDismissed(false);
setCursorPosition(0);
resizeTextarea();
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
}, [
activeCliMentionApps,
activeMcpPresetMentions,
canSend,
clear,
imageAspectRatio,
imageMode,
onSend,
readyImages,
resizeTextarea,
value,
]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showCliAppMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
setSelectedCliAppIndex((idx) => (idx + 1) % filteredMentionCandidates.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCliAppIndex(
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
(idx) => (idx - 1 + filteredMentionCandidates.length) % filteredMentionCandidates.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
chooseMentionCandidate(filteredMentionCandidates[selectedCliAppIndex]);
return;
}
if (e.key === "Escape") {
@@ -894,12 +967,12 @@ export function ThreadComposer({
) : null}
{showCliAppMenu ? (
<CliAppMentionPalette
apps={filteredCliApps}
candidates={filteredMentionCandidates}
selectedIndex={selectedCliAppIndex}
layout={slashPaletteLayout}
isHero={isHero}
onHover={setSelectedCliAppIndex}
onChoose={chooseCliApp}
onChoose={chooseMentionCandidate}
/>
) : null}
<div
@@ -947,7 +1020,7 @@ export function ThreadComposer({
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
) : null}
<div className="relative">
{hasCliMentionDecorations ? (
{hasMentionDecorations ? (
<ComposerCliMentionOverlay
segments={mentionSegments}
isHero={isHero}
@@ -978,7 +1051,7 @@ export function ThreadComposer({
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
hasMentionDecorations && "text-transparent selection:bg-primary/20",
)}
/>
</div>
@@ -1019,7 +1092,7 @@ export function ThreadComposer({
"rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
@@ -1038,7 +1111,7 @@ export function ThreadComposer({
}}
className={cn(
"rounded-full border border-border/55 px-2.5 font-medium shadow-[0_2px_8px_rgba(15,23,42,0.04)]",
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
"h-9 text-[12px]",
imageMode
? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/12"
: "bg-card text-muted-foreground hover:bg-card hover:text-foreground",
@@ -1058,7 +1131,7 @@ export function ThreadComposer({
onClick={() => setAspectMenuOpen((open) => !open)}
className={cn(
"rounded-full border border-border/55 bg-card px-2.5 font-medium text-foreground/80 shadow-[0_2px_8px_rgba(15,23,42,0.04)] hover:bg-card",
isHero ? "h-9 text-[12px]" : "h-7.5 text-[10.5px]",
"h-9 text-[12px]",
)}
>
<span>{t(`thread.composer.imageMode.aspect.${imageAspectRatio.replace(":", "_")}`)}</span>
@@ -1078,22 +1151,12 @@ export function ThreadComposer({
) : null}
</div>
{modelLabel ? (
<span
title={modelLabel}
className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero
? "max-w-[13rem] text-[12px] shadow-[0_2px_8px_rgba(15,23,42,0.04)]"
: "max-w-[10rem] text-[10.5px] shadow-[0_2px_8px_rgba(15,23,42,0.035)]",
)}
>
<span
aria-hidden
className="h-1.5 w-1.5 flex-none rounded-full bg-emerald-500/80"
/>
<span className="truncate">{modelLabel}</span>
</span>
<ComposerModelBadge
label={modelLabel}
provider={modelProvider}
providerLabel={modelProviderLabel}
isHero={isHero}
/>
) : null}
{!isHero ? (
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
@@ -1115,7 +1178,7 @@ export function ThreadComposer({
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "" : "h-7.5 w-7.5",
"h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
@@ -1133,12 +1196,79 @@ export function ThreadComposer({
);
}
function ComposerModelBadge({
label,
provider,
providerLabel,
isHero,
}: {
label: string;
provider?: string | null;
providerLabel?: string | null;
isHero: boolean;
}) {
const inferredProvider = provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex];
const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label;
useEffect(() => setLogoIndex(0), [inferredProvider]);
return (
<span
title={title}
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
isHero ? "h-9 max-w-[13.5rem] gap-2 px-2.5 text-[12px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
}}
aria-hidden
>
{showLogo ? (
<img
src={logoUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
) : brand ? (
<span
className={cn(
"grid h-full w-full place-items-center rounded-full text-white",
"text-[8px]",
)}
style={{ backgroundColor: brand.color }}
>
{brand.initials.slice(0, 2)}
</span>
) : (
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3.5 w-3.5" : "h-3 w-3")} />
)}
</span>
<span className="truncate">{label}</span>
</span>
);
}
function ComposerCliMentionOverlay({
segments,
isHero,
className,
}: {
segments: CliAppMentionSegment[];
segments: CapabilityMentionSegment[];
isHero: boolean;
className: string;
}) {
@@ -1154,7 +1284,7 @@ function ComposerCliMentionOverlay({
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
if (segment.kind === "cli") return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
@@ -1163,6 +1293,15 @@ function ComposerCliMentionOverlay({
isHero={isHero}
/>
);
return (
<McpPresetMentionToken
key={`mcp-${segment.preset.name}-${index}`}
preset={segment.preset}
label={segment.text}
variant="composer"
isHero={isHero}
/>
);
})}
</div>
);
@@ -1177,12 +1316,12 @@ interface SlashCommandPaletteProps {
}
interface CliAppMentionPaletteProps {
apps: CliAppInfo[];
candidates: MentionCandidate[];
selectedIndex: number;
layout: SlashPaletteLayout;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (app: CliAppInfo) => void;
onChoose: (candidate: MentionCandidate) => void;
}
function ImageAspectMenu({
@@ -1239,7 +1378,7 @@ function ImageAspectMenu({
}
function CliAppMentionPalette({
apps,
candidates,
selectedIndex,
layout,
isHero,
@@ -1257,98 +1396,117 @@ function CliAppMentionPalette({
aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }}
className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] border",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
"border-border/70 bg-popover p-2 text-popover-foreground shadow-[0_20px_60px_rgba(15,23,42,0.12)]",
"dark:border-white/10 dark:shadow-[0_24px_60px_rgba(0,0,0,0.42)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
<AtSign className="h-3 w-3" aria-hidden />
<span>{t("thread.composer.mentions.label")}</span>
<div className="px-2 pb-1.5 pt-0.5 text-[13px] font-semibold text-muted-foreground/78">
{t("thread.composer.mentions.label")}
</div>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{apps.map((app, index) => {
<div className="overflow-y-auto" style={{ maxHeight: listMaxHeight }}>
{candidates.map((candidate, index) => {
const selected = index === selectedIndex;
const name = candidate.name;
const displayName = candidate.kind === "cli"
? candidate.app.display_name
: candidate.preset.display_name;
const typeLabel = candidate.kind === "cli"
? t("thread.composer.mentions.cliBadge")
: t("thread.composer.mentions.mcpBadge");
const ariaDescription = candidate.kind === "cli"
? t("thread.composer.mentions.cliDescription", { name })
: t("thread.composer.mentions.mcpDescription", { name });
return (
<button
key={app.name}
key={`${candidate.kind}-${name}`}
type="button"
role="option"
aria-selected={selected}
aria-label={`${displayName} @${name} ${ariaDescription} ${typeLabel}`}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(app);
onChoose(candidate);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
"flex h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]",
)}
>
<CliAppMentionLogo app={app} selected={selected} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
@{app.name}
</span>
<span className="truncate text-[13px] font-medium">
{app.display_name}
</span>
<MentionCandidateLogo candidate={candidate} selected={selected} />
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shrink-0 text-[15px] font-medium tracking-normal text-foreground">
{displayName}
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{app.category}
{app.entry_point ? ` · ${app.entry_point}` : ""}
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
@{name}
</span>
</span>
<span
className={cn(
"ml-2 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-normal",
candidate.kind === "cli"
? "bg-orange-500/10 text-orange-600 dark:text-orange-300"
: "bg-sky-500/10 text-sky-600 dark:text-sky-300",
)}
>
{typeLabel}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
function CliAppMentionLogo({
app,
function MentionCandidateLogo({
candidate,
selected,
}: {
app: CliAppInfo;
candidate: MentionCandidate;
selected: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
if (app.logo_url && !failed) {
const [logoIndex, setLogoIndex] = useState(0);
const color = (candidate.kind === "cli"
? candidate.app.brand_color
: candidate.preset.brand_color) || "hsl(var(--primary))";
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const logoUrl = logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [rawLogoUrl]);
if (logoUrl) {
return (
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
selected ? "border-primary/25" : "border-border/65",
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-[5px]",
selected ? "bg-background/55" : "bg-transparent",
)}
>
<img
src={app.logo_url}
src={logoUrl}
alt=""
className="h-4.5 w-4.5 object-contain"
onError={() => setFailed(true)}
className="h-5 w-5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
/>
</span>
);
}
return (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[7.5px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{cliAppInitials(app)}
{candidate.kind === "cli"
? cliAppInitials(candidate.app)
: mcpPresetInitials(candidate.preset)}
</span>
);
}
@@ -6,7 +6,7 @@ import {
AgentActivityCluster,
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import type { CliAppInfo, UIMessage } from "@/lib/types";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@@ -15,6 +15,7 @@ interface ThreadMessagesProps {
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}
export type DisplayUnit =
@@ -166,6 +167,7 @@ export function ThreadMessages({
hiddenMessageCount = 0,
onLoadEarlier,
cliApps = [],
mcpPresets = [],
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
@@ -211,6 +213,7 @@ export function ThreadMessages({
isTurnStreaming={index === liveActivityClusterIndex}
hasBodyBelow={hasBodyBelow}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
) : (
<MessageBubble
@@ -221,6 +224,7 @@ export function ThreadMessages({
: true
}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
)}
</div>
+120 -4
View File
@@ -19,13 +19,19 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, listSlashCommands } from "@/lib/api";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
import {
CLI_APPS_CHANGED_EVENT,
installedCliAppsFromPayload,
isCliAppsPayload,
} from "@/lib/cli-app-events";
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
import {
MCP_PRESETS_CHANGED_EVENT,
installedMcpPresetsFromPayload,
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type { ChatSummary, CliAppInfo, McpPresetInfo, SettingsPayload, SlashCommand, UIMessage } from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@@ -55,6 +61,41 @@ function toModelBadgeLabel(modelName: string | null): string | null {
return leaf || trimmed;
}
interface ModelBadgeInfo {
label: string | null;
provider: string | null;
providerLabel: string | null;
}
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
if (!settings) return null;
const configured = settings.agent.model_preset || "default";
return (
settings.model_presets.find((preset) => preset.name === configured)
?? settings.model_presets.find((preset) => preset.active)
?? null
);
}
function resolvedModelProvider(settings: SettingsPayload | null, modelName: string | null): string | null {
const preset = activeModelPreset(settings);
const rawProvider = preset?.provider || settings?.agent.provider || null;
if (rawProvider === "auto") {
return settings?.agent.resolved_provider || inferProviderFromModelName(modelName) || null;
}
return rawProvider || inferProviderFromModelName(modelName);
}
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
const label = toModelBadgeLabel(modelName || settings?.agent.model || null);
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null);
return {
label,
provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
};
}
const QUICK_ACTION_KEYS = [
{ key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" },
{ key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" },
@@ -103,6 +144,8 @@ export function ThreadShell({
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [heroImageMode, setHeroImageMode] = useState(false);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@@ -141,6 +184,28 @@ export function ThreadShell({
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const refreshModelSettings = useCallback(async () => {
try {
setSettings(await fetchSettings(token));
} catch {
setSettings(null);
}
}, [token]);
useEffect(() => {
void refreshModelSettings();
}, [refreshModelSettings]);
useEffect(() => {
return client.onRuntimeModelUpdate(() => {
void refreshModelSettings();
});
}, [client, refreshModelSettings]);
useEffect(() => {
if (!chatId || loading) return;
@@ -262,6 +327,15 @@ export function ThreadShell({
}
}, [token]);
const refreshMcpPresets = useCallback(async () => {
try {
const payload = await fetchMcpPresets(token);
setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
setMcpPresets([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
@@ -297,6 +371,41 @@ export function ThreadShell({
};
}, [refreshCliApps, token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchMcpPresets(token);
if (!cancelled) setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
if (!cancelled) setMcpPresets([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshMcpPresets();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnMcpPresetsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isMcpPresetsPayload(payload)) {
setMcpPresets(installedMcpPresetsFromPayload(payload));
return;
}
void refreshMcpPresets();
};
window.addEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
};
}, [refreshMcpPresets, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@@ -379,10 +488,13 @@ export function ThreadShell({
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
modelLabel={modelBadge.label}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
@@ -399,10 +511,13 @@ export function ThreadShell({
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
modelLabel={modelBadge.label}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
@@ -444,6 +559,7 @@ export function ThreadShell({
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</section>
);
@@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { CliAppInfo, UIMessage } from "@/lib/types";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadViewportProps {
messages: UIMessage[];
@@ -25,6 +25,7 @@ interface ThreadViewportProps {
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
}
const NEAR_BOTTOM_PX = 48;
@@ -55,6 +56,7 @@ export function ThreadViewport({
conversationKey = null,
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -252,6 +254,7 @@ export function ThreadViewport({
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</div>
</div>