feat(mcp): add preset setup and capability mentions
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user