Add composer agent model picker

This commit is contained in:
klopez4212
2026-06-11 09:24:25 +01:00
parent e5f0c32648
commit 68bf20fb0d
11 changed files with 1744 additions and 112 deletions
@@ -1,11 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Schema } from "@tiptap/pm/model";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
buildHighlightPatterns,
findMentionBackspaceDeleteRange,
findMentionDeleteRangeBeforeCursor,
findHighlightMatches,
} from "./mentionHighlightExtension.ts";
const schema = new Schema({
nodes: {
doc: { content: "paragraph+" },
paragraph: {
content: "text*",
group: "block",
parseDOM: [{ tag: "p" }],
toDOM: () => ["p", 0],
},
text: { group: "inline" },
},
marks: {},
});
function textDoc(text) {
return schema.node("doc", null, [
schema.node("paragraph", null, text ? [schema.text(text)] : undefined),
]);
}
function mentionDeleteSpec(fromOffset, toOffset) {
return { mentionDelete: { fromOffset, toOffset } };
}
// ── buildHighlightPatterns ────────────────────────────────────────────
test("returns empty array when no names or channels provided", () => {
@@ -152,3 +181,112 @@ test("#general should NOT match inside #generally (trailing word boundary)", ()
const matches = findHighlightMatches("#generally", patterns);
assert.equal(matches.length, 0);
});
// ── findMentionDeleteRangeBeforeCursor ────────────────────────────────
test("finds a mention delete range when cursor is at the end of a mention", () => {
const doc = textDoc("Hey @alice ");
const from = 5;
const to = 11;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
to,
{ class: "mention-highlight" },
mentionDeleteSpec(0, 0),
),
]);
assert.deepEqual(findMentionDeleteRangeBeforeCursor(decorations, to), {
from,
to,
});
});
test("finds a mention delete range when cursor is inside a mention", () => {
const doc = textDoc("Hey @alice ");
const from = 5;
const to = 11;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
to,
{ class: "mention-highlight" },
mentionDeleteSpec(0, 0),
),
]);
assert.deepEqual(findMentionDeleteRangeBeforeCursor(decorations, 8), {
from,
to,
});
});
test("does not delete a mention when cursor is before it or after its trailing space", () => {
const doc = textDoc("Hey @alice ");
const from = 5;
const to = 11;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
to,
{ class: "mention-highlight" },
mentionDeleteSpec(0, 0),
),
]);
assert.equal(findMentionDeleteRangeBeforeCursor(decorations, from), null);
assert.equal(findMentionDeleteRangeBeforeCursor(decorations, to + 1), null);
});
test("backspace range includes the separator space after a mention", () => {
const doc = textDoc("Hey @alice ");
const from = 5;
const to = 11;
const cursorAfterSpace = 12;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
to,
{ class: "mention-highlight" },
mentionDeleteSpec(0, 0),
),
]);
assert.deepEqual(
findMentionBackspaceDeleteRange(doc, decorations, cursorAfterSpace),
{
from,
to: cursorAfterSpace,
},
);
});
test("finds the full agent mention range from either split decoration", () => {
const doc = textDoc("Ask @kit");
const from = 5;
const to = 9;
const decorations = DecorationSet.create(doc, [
Decoration.inline(
from,
from + 1,
{ class: "agent-mention-at-hidden" },
mentionDeleteSpec(0, to - (from + 1)),
),
Decoration.inline(
from + 1,
to,
{ class: "mention-highlight agent-mention-highlight" },
mentionDeleteSpec(-1, 0),
),
]);
assert.deepEqual(findMentionDeleteRangeBeforeCursor(decorations, from + 1), {
from,
to,
});
assert.deepEqual(findMentionDeleteRangeBeforeCursor(decorations, to), {
from,
to,
});
});
@@ -1,9 +1,19 @@
import { Extension } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
export const mentionHighlightKey = new PluginKey("mentionHighlight");
export type MentionDeleteRange = { from: number; to: number };
type MentionDeleteDecorationSpec = {
mentionDelete?: {
fromOffset: number;
toOffset: number;
};
};
/**
* TipTap extension that applies inline `mention-highlight` decorations
* to `@Name` and `#channel-name` patterns in the document.
@@ -149,6 +159,62 @@ export function findHighlightMatches(
return results;
}
/**
* Returns the full @mention range touched by Backspace at `cursor`, if any.
*
* The stored offsets are relative to each decoration's current mapped
* position, so they stay valid when ProseMirror maps decorations after edits
* elsewhere in the document.
*/
export function findMentionDeleteRangeBeforeCursor(
decorations: DecorationSet,
cursor: number,
): MentionDeleteRange | null {
if (cursor <= 0) return null;
const touchedDecorations = decorations.find(
cursor - 1,
cursor,
hasMentionDeleteSpec,
);
for (const decoration of touchedDecorations) {
const range = getMentionDeleteRange(decoration);
if (range && range.from < cursor && cursor <= range.to) {
return range;
}
}
return null;
}
export function findMentionBackspaceDeleteRange(
doc: ProseMirrorNode,
decorations: DecorationSet,
cursor: number,
): MentionDeleteRange | null {
const directRange = findMentionDeleteRangeBeforeCursor(decorations, cursor);
if (directRange) return directRange;
if (cursor <= 1) return null;
// Autocomplete inserts a separator space after @mentions. When the caret is
// in that natural post-insert position, make one Backspace remove the tag
// and its separator instead of requiring a first press just to eat the space.
const previousChar = doc.textBetween(cursor - 1, cursor, "\n", "\0");
if (previousChar !== " ") return null;
const beforeSpaceRange = findMentionDeleteRangeBeforeCursor(
decorations,
cursor - 1,
);
if (!beforeSpaceRange || beforeSpaceRange.to !== cursor - 1) {
return null;
}
return { from: beforeSpaceRange.from, to: cursor };
}
/**
* Returns true if the transaction's changed ranges touch text that contains
* `@` or `#` — meaning a mention/channel-link boundary may have been
@@ -265,6 +331,7 @@ function buildDecorations(
pos,
mentionPatterns,
"mention-highlight",
{ deleteAsMention: true },
);
addMatchesForPatterns(
decorations,
@@ -272,7 +339,7 @@ function buildDecorations(
pos,
agentMentionPatterns,
"mention-highlight agent-mention-highlight",
{ hideMentionPrefix: true },
{ deleteAsMention: true, hideMentionPrefix: true },
);
addMatchesForPatterns(
decorations,
@@ -292,7 +359,7 @@ function addMatchesForPatterns(
position: number,
patterns: RegExp[],
className: string,
options?: { hideMentionPrefix?: boolean },
options?: { deleteAsMention?: boolean; hideMentionPrefix?: boolean },
) {
for (const pattern of patterns) {
pattern.lastIndex = 0;
@@ -300,17 +367,86 @@ function addMatchesForPatterns(
while (match !== null) {
const from = position + match.index;
const to = from + match[0].length;
const deleteRangeLength = to - from;
if (options?.hideMentionPrefix && match[0].startsWith("@")) {
decorations.push(
Decoration.inline(from, from + 1, {
class: "agent-mention-at-hidden",
}),
Decoration.inline(
from,
from + 1,
{
class: "agent-mention-at-hidden",
},
options.deleteAsMention
? mentionDeleteSpec(0, deleteRangeLength - 1)
: undefined,
),
);
decorations.push(
Decoration.inline(
from + 1,
to,
{ class: className },
options.deleteAsMention ? mentionDeleteSpec(-1, 0) : undefined,
),
);
decorations.push(Decoration.inline(from + 1, to, { class: className }));
} else {
decorations.push(Decoration.inline(from, to, { class: className }));
decorations.push(
Decoration.inline(
from,
to,
{ class: className },
options?.deleteAsMention ? mentionDeleteSpec(0, 0) : undefined,
),
);
}
match = pattern.exec(text);
}
}
}
function mentionDeleteSpec(
fromOffset: number,
toOffset: number,
): MentionDeleteDecorationSpec {
return {
mentionDelete: {
fromOffset,
toOffset,
},
};
}
function hasMentionDeleteSpec(spec: unknown): boolean {
return getMentionDeleteOffsets(spec) !== null;
}
function getMentionDeleteRange(
decoration: Decoration,
): MentionDeleteRange | null {
const offsets = getMentionDeleteOffsets(decoration.spec);
if (!offsets) return null;
const from = decoration.from + offsets.fromOffset;
const to = decoration.to + offsets.toOffset;
if (!Number.isInteger(from) || !Number.isInteger(to) || from >= to) {
return null;
}
return { from, to };
}
function getMentionDeleteOffsets(
spec: unknown,
): MentionDeleteDecorationSpec["mentionDelete"] | null {
if (!spec || typeof spec !== "object") return null;
const mentionDelete = (spec as MentionDeleteDecorationSpec).mentionDelete;
if (!mentionDelete || typeof mentionDelete !== "object") return null;
const { fromOffset, toOffset } = mentionDelete;
if (!Number.isInteger(fromOffset) || !Number.isInteger(toOffset)) {
return null;
}
return mentionDelete;
}
@@ -665,6 +665,46 @@ export function useMentions(
[],
);
const registerMentionPersona = React.useCallback(
(displayName: string, personaId: string) => {
const trimmedName = displayName.trim();
const trimmedPersonaId = personaId.trim();
if (!trimmedName || !trimmedPersonaId) {
return;
}
personaMentionMapRef.current.set(trimmedName, trimmedPersonaId);
mentionMapRef.current.delete(trimmedName);
trimMapToSize(personaMentionMapRef.current, 200);
setSelectedMentionNames((current) => {
if (
current.some(
(name) => name.toLowerCase() === trimmedName.toLowerCase(),
)
) {
return current;
}
return [...current, trimmedName];
});
setSelectedAgentMentionNames((current) => {
if (
current.some(
(name) => name.toLowerCase() === trimmedName.toLowerCase(),
)
) {
return current;
}
return [...current, trimmedName];
});
setMentionQuery(null);
setMentionSelectedIndex(0);
},
[],
);
const getMentionDisplayName = React.useCallback(
(pubkey: string): string | null => {
const normalizedPubkey = normalizePubkey(pubkey);
@@ -867,6 +907,7 @@ export function useMentions(
knownNames: highlightNames,
memberPubkeys,
mentionSelectedIndex,
registerMentionPersona,
registerMentionPubkey,
suggestions,
updateMentionQuery,
@@ -12,6 +12,7 @@ import { isMacPlatform } from "@/shared/lib/platform";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import {
findMentionBackspaceDeleteRange,
MentionHighlightExtension,
mentionHighlightKey,
} from "./mentionHighlightExtension";
@@ -340,6 +341,37 @@ export function useRichTextEditor({
// command/caret logic, fires regardless of selection state, and works
// the same across browser engines. Returning `true` consumes the key.
handleKeyDown: (view, event) => {
if (event.key === "Backspace") {
if (
event.metaKey ||
event.ctrlKey ||
event.altKey ||
event.shiftKey ||
isAutocompleteOpen?.current
) {
return false;
}
const { selection } = view.state;
if (!selection.empty) return false;
const decorations = mentionHighlightKey.getState(view.state);
const range = decorations
? findMentionBackspaceDeleteRange(
view.state.doc,
decorations,
selection.from,
)
: null;
if (!range) return false;
event.preventDefault();
view.dispatch(
view.state.tr.delete(range.from, range.to).scrollIntoView(),
);
return true;
}
if (event.key !== "ArrowUp") return false;
// Respect the same guards as before: no modifiers (let ⌥↑/⇧↑/etc.
// through), autocomplete closed, a handler exists, and the composer
@@ -0,0 +1,566 @@
import * as React from "react";
import {
Bot,
Boxes,
Check,
Circle,
Gem,
Loader2,
Network,
Sparkles,
} from "lucide-react";
import type { AgentModelInfo, AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const DEFAULT_MODEL_KEY = "__sprout_runtime_default__";
export type AgentMentionModelTarget = {
key: string;
displayName: string;
personaId: string | null;
avatarUrl: string | null;
currentModel: string | null;
defaultModel: string | null;
selectedModel: string | null;
modelOptions: AgentModelInfo[];
loadError: string | null;
isNewMention: boolean;
showModelInTrigger: boolean;
willCreateNewInstance: boolean;
};
type AgentMentionModelSelectorProps = {
disabled: boolean;
error: string | null;
isLoading: boolean;
isLoadingPersonas: boolean;
onModelChange: (key: string, model: string | null) => void;
onPersonaSelect: (persona: AgentPersona) => void;
onTriggerMouseDown: () => void;
personas: AgentPersona[];
targets: AgentMentionModelTarget[];
};
type ProviderHint = {
className: string;
icon: React.ReactNode;
label: string;
};
function modelName(model: AgentModelInfo) {
return model.name?.trim() || model.id;
}
function getSelectedModel(target: AgentMentionModelTarget | null) {
if (!target) {
return null;
}
if (target.selectedModel === null) {
return null;
}
return (
target.modelOptions.find((model) => model.id === target.selectedModel) ?? {
id: target.selectedModel,
name: null,
description: null,
}
);
}
function triggerLabel(target: AgentMentionModelTarget | null) {
if (!target?.showModelInTrigger) {
return null;
}
const selectedModel = getSelectedModel(target);
if (selectedModel) {
return modelName(selectedModel);
}
return target.defaultModel ? `${target.defaultModel} default` : "Auto";
}
function defaultModelDescription(defaultModel: string | null) {
return defaultModel ? `Uses ${defaultModel}` : "Use the runtime default";
}
function getProviderHint(model: AgentModelInfo | null): ProviderHint {
const text = model ? `${model.id} ${model.name ?? ""}`.toLowerCase() : "";
if (text.includes("claude") || text.includes("anthropic")) {
return {
className: "text-[#d97757]",
icon: <Sparkles aria-hidden className="h-3.5 w-3.5" />,
label: "Anthropic",
};
}
if (text.includes("gpt") || text.includes("openai")) {
return {
className: "text-foreground",
icon: <Bot aria-hidden className="h-3.5 w-3.5" />,
label: "OpenAI",
};
}
if (text.includes("gemini") || text.includes("google")) {
return {
className: "text-[#4285f4]",
icon: <Gem aria-hidden className="h-3.5 w-3.5" />,
label: "Google",
};
}
if (text.includes("databricks")) {
return {
className: "text-[#ee3d2c]",
icon: <Boxes aria-hidden className="h-3.5 w-3.5" />,
label: "Databricks",
};
}
if (
text.includes("hf://") ||
text.includes("gguf") ||
text.includes("llama") ||
text.includes("mistral") ||
text.includes("mesh")
) {
return {
className: "text-emerald-600",
icon: <Network aria-hidden className="h-3.5 w-3.5" />,
label: "Mesh",
};
}
return {
className: "text-muted-foreground",
icon: <Sparkles aria-hidden className="h-3.5 w-3.5" />,
label: "Model",
};
}
function modelDescription(model: AgentModelInfo) {
const provider = getProviderHint(model).label;
const label = modelName(model);
if (model.description?.trim()) {
return model.description;
}
return model.id !== label ? `${provider} · ${model.id}` : provider;
}
export function AgentMentionModelSelector({
disabled,
error,
isLoading,
isLoadingPersonas,
onModelChange,
onPersonaSelect,
onTriggerMouseDown,
personas,
targets,
}: AgentMentionModelSelectorProps) {
const [open, setOpen] = React.useState(false);
const [activeTargetKey, setActiveTargetKey] = React.useState<string | null>(
null,
);
const [pendingPersonaId, setPendingPersonaId] = React.useState<string | null>(
null,
);
const activeTarget =
targets.find((target) => target.key === activeTargetKey) ??
targets[0] ??
null;
const label = triggerLabel(activeTarget);
const selectedModel = getSelectedModel(activeTarget);
const provider = getProviderHint(selectedModel);
React.useEffect(() => {
if (targets.length === 0) {
setActiveTargetKey(null);
return;
}
setActiveTargetKey((current) => {
if (current && targets.some((target) => target.key === current)) {
return current;
}
const pendingTarget = pendingPersonaId
? targets.find((target) => target.personaId === pendingPersonaId)
: null;
return pendingTarget?.key ?? targets[0].key;
});
}, [pendingPersonaId, targets]);
React.useEffect(() => {
if (
pendingPersonaId &&
targets.some((target) => target.personaId === pendingPersonaId)
) {
setPendingPersonaId(null);
}
}, [pendingPersonaId, targets]);
const handlePersonaSelect = React.useCallback(
(persona: AgentPersona) => {
const existingTarget = targets.find(
(target) => target.personaId === persona.id,
);
if (existingTarget) {
setActiveTargetKey(existingTarget.key);
return;
}
setPendingPersonaId(persona.id);
onPersonaSelect(persona);
window.setTimeout(() => setOpen(true), 0);
},
[onPersonaSelect, targets],
);
const trigger = (
<Button
aria-label="Add an agent"
className={cn(
"h-8 border border-transparent bg-transparent text-muted-foreground shadow-none hover:bg-muted hover:text-foreground",
"focus-visible:ring-1 focus-visible:ring-ring",
label
? "max-w-[13rem] justify-start gap-1.5 rounded-full border-border/50 bg-muted/45 px-2.5 text-foreground"
: "w-8 justify-center rounded-md px-0",
)}
data-testid="agent-model-selector-trigger"
disabled={disabled}
onMouseDown={onTriggerMouseDown}
size="sm"
type="button"
variant="ghost"
>
{isLoading ? (
<Loader2 aria-hidden className="h-3.5 w-3.5 animate-spin" />
) : label ? (
<span className={provider.className}>{provider.icon}</span>
) : (
<Bot aria-hidden className="h-3.5 w-3.5" />
)}
{label ? <span className="truncate">{label}</span> : null}
</Button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Add an agent</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
className="h-[min(24rem,50vh)] w-[min(34rem,calc(100vw-2rem))] overflow-hidden p-1"
data-testid="agent-model-selector-popover"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<div className="grid h-full min-h-0 grid-cols-[minmax(0,13rem)_minmax(0,1fr)] gap-1 overflow-hidden">
<AgentColumn
activeTarget={activeTarget}
isLoading={isLoadingPersonas}
onPersonaSelect={handlePersonaSelect}
onTargetSelect={setActiveTargetKey}
pendingPersonaId={pendingPersonaId}
personas={personas}
targets={targets}
/>
<ModelColumn
activeTarget={activeTarget}
error={error}
isLoading={isLoading}
onClose={() => setOpen(false)}
onModelChange={onModelChange}
/>
</div>
</PopoverContent>
</Popover>
);
}
function AgentColumn({
activeTarget,
isLoading,
onPersonaSelect,
onTargetSelect,
pendingPersonaId,
personas,
targets,
}: {
activeTarget: AgentMentionModelTarget | null;
isLoading: boolean;
onPersonaSelect: (persona: AgentPersona) => void;
onTargetSelect: (key: string) => void;
pendingPersonaId: string | null;
personas: AgentPersona[];
targets: AgentMentionModelTarget[];
}) {
const sortedPersonas = React.useMemo(
() =>
[...personas].sort((left, right) =>
left.displayName.localeCompare(right.displayName),
),
[personas],
);
const personaTargetById = React.useMemo(
() =>
new Map(
targets
.filter((target) => target.personaId)
.map((target) => [target.personaId as string, target]),
),
[targets],
);
const extraTargets = React.useMemo(
() => targets.filter((target) => !target.personaId),
[targets],
);
return (
<div
className="flex min-h-0 min-w-0 flex-col border-r border-border/70 p-1"
data-col="agent"
>
<div className="shrink-0 px-2 py-1.5 text-sm font-semibold">Agent</div>
{isLoading && sortedPersonas.length === 0 ? (
<div className="flex min-h-0 flex-1 items-center gap-2 px-2 py-2 text-sm text-muted-foreground">
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
<span>Loading agents</span>
</div>
) : sortedPersonas.length > 0 || extraTargets.length > 0 ? (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="space-y-0.5 p-1">
{extraTargets.map((target) => (
<AgentOptionButton
avatarUrl={target.avatarUrl}
isSelected={activeTarget?.key === target.key}
key={target.key}
label={target.displayName}
onClick={() => onTargetSelect(target.key)}
testId={`agent-model-selector-agent-${target.key}`}
/>
))}
{sortedPersonas.map((persona) => {
const target = personaTargetById.get(persona.id);
const isSelected = target
? activeTarget?.key === target.key
: pendingPersonaId === persona.id;
return (
<AgentOptionButton
avatarUrl={persona.avatarUrl}
description={
persona.model?.trim() || persona.runtime || "Agent"
}
isSelected={isSelected}
key={persona.id}
label={persona.displayName}
onClick={() =>
target
? onTargetSelect(target.key)
: onPersonaSelect(persona)
}
testId={`agent-model-selector-persona-${persona.id}`}
/>
);
})}
</div>
</div>
) : (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
No agents available.
</div>
)}
</div>
);
}
function AgentOptionButton({
avatarUrl,
description,
isSelected,
label,
onClick,
testId,
}: {
avatarUrl: string | null;
description?: string;
isSelected: boolean;
label: string;
onClick: () => void;
testId: string;
}) {
return (
<button
className={cn(
"flex min-h-10 w-full min-w-0 items-center gap-2 overflow-hidden rounded-sm px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none",
isSelected && "bg-accent",
)}
data-testid={testId}
onClick={onClick}
onMouseDown={(event) => event.preventDefault()}
type="button"
>
<UserAvatar avatarUrl={avatarUrl} displayName={label} size="xs" />
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block truncate">{label}</span>
{description ? (
<span className="block truncate text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
{isSelected ? (
<Check aria-hidden className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
</button>
);
}
function ModelColumn({
activeTarget,
error,
isLoading,
onClose,
onModelChange,
}: {
activeTarget: AgentMentionModelTarget | null;
error: string | null;
isLoading: boolean;
onClose: () => void;
onModelChange: (key: string, model: string | null) => void;
}) {
const handleModelChange = React.useCallback(
(model: string | null) => {
if (!activeTarget) {
return;
}
onModelChange(activeTarget.key, model);
onClose();
},
[activeTarget, onClose, onModelChange],
);
return (
<div className="flex min-h-0 min-w-0 flex-col p-1" data-col="model">
<div className="flex shrink-0 items-center justify-between gap-2 px-2 py-1.5">
<div className="text-sm font-semibold">Model</div>
{isLoading ? (
<Loader2
aria-hidden
className="h-3.5 w-3.5 animate-spin text-muted-foreground"
/>
) : null}
</div>
{error ? (
<div className="mx-1 mb-1 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
{activeTarget?.loadError ? (
<div className="mx-1 mb-1 rounded-md bg-warning/10 px-3 py-2 text-sm text-warning">
{activeTarget.loadError}
</div>
) : null}
{activeTarget ? (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="space-y-0.5 p-1">
<ModelOptionButton
description={defaultModelDescription(activeTarget.defaultModel)}
icon={
<Circle
aria-hidden
className="h-3.5 w-3.5 text-muted-foreground"
/>
}
isSelected={activeTarget.selectedModel === null}
label="Runtime default"
onClick={() => handleModelChange(null)}
testId={`agent-model-selector-model-${activeTarget.key}-${DEFAULT_MODEL_KEY}`}
/>
{activeTarget.modelOptions.map((model) => {
const provider = getProviderHint(model);
return (
<ModelOptionButton
description={modelDescription(model)}
icon={
<span className={provider.className}>{provider.icon}</span>
}
isSelected={activeTarget.selectedModel === model.id}
key={model.id}
label={modelName(model)}
onClick={() => handleModelChange(model.id)}
testId={`agent-model-selector-model-${activeTarget.key}-${model.id}`}
/>
);
})}
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center px-3 py-8 text-center text-sm text-muted-foreground">
Choose an agent to pick a model.
</div>
)}
</div>
);
}
function ModelOptionButton({
description,
icon,
isSelected,
label,
onClick,
testId,
}: {
description: string;
icon: React.ReactNode;
isSelected: boolean;
label: string;
onClick: () => void;
testId: string;
}) {
return (
<button
className={cn(
"flex min-h-10 w-full min-w-0 items-center gap-2 overflow-hidden rounded-sm px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none",
isSelected && "bg-accent",
)}
data-testid={testId}
onClick={onClick}
type="button"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
{icon}
</span>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block truncate">{label}</span>
<span className="block truncate text-xs text-muted-foreground">
{description}
</span>
</span>
{isSelected ? (
<Check aria-hidden className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
</button>
);
}
@@ -32,7 +32,7 @@ import {
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { getSproutCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
import { cn } from "@/shared/lib/cn";
import type { ChannelType } from "@/shared/api/types";
import type { AgentPersona, ChannelType } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
@@ -41,6 +41,7 @@ import {
MentionAutocomplete,
type MentionSuggestion,
} from "./MentionAutocomplete";
import { AgentMentionModelSelector } from "./AgentMentionModelSelector";
import { MessageComposerToolbar } from "./MessageComposerToolbar";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
import { useMentionSendFlow } from "./useMentionSendFlow";
@@ -236,6 +237,7 @@ export function MessageComposer({
channelId,
channelLinks,
channelType,
content,
contentRef,
customEmoji,
drafts,
@@ -366,6 +368,30 @@ export function MessageComposer({
],
);
const applyPersonaMentionInsert = React.useCallback(
(persona: AgentPersona) => {
if (!richText.editor) return;
const { text, cursor } = richText.getPlainTextAndCursor();
const previousChar = text.slice(0, cursor).slice(-1);
const prefix =
cursor > 0 && previousChar && !/\s/.test(previousChar) ? " " : "";
richText.editor
.chain()
.focus()
.insertContent(`${prefix}@${persona.displayName} `)
.run();
mentions.registerMentionPersona(persona.displayName, persona.id);
setIsEmojiPickerOpen(false);
},
[
mentions.registerMentionPersona,
richText.editor,
richText.getPlainTextAndCursor,
],
);
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
const { cursor } = richText.getPlainTextAndCursor();
@@ -842,6 +868,21 @@ export function MessageComposer({
</div>
<MessageComposerToolbar
agentModelSelector={
<AgentMentionModelSelector
disabled={disabled}
error={mentionSendFlow.agentModelPromptError}
isLoading={mentionSendFlow.isLoadingAgentModelTargets}
isLoadingPersonas={
mentionSendFlow.isLoadingAgentModelPersonas
}
onModelChange={mentionSendFlow.onAgentModelChange}
onPersonaSelect={applyPersonaMentionInsert}
onTriggerMouseDown={handleCaptureSelection}
personas={mentionSendFlow.agentModelPersonas}
targets={mentionSendFlow.agentModelTargets}
/>
}
composerDisabled={disabled}
editor={richText.editor}
extraActions={toolbarExtraActions}
@@ -20,6 +20,7 @@ const presenceSpring = {
export const MessageComposerToolbar = React.memo(
function MessageComposerToolbar({
composerDisabled,
agentModelSelector,
editor,
extraActions,
formattingDisabled,
@@ -36,6 +37,7 @@ export const MessageComposerToolbar = React.memo(
sendDisabled,
}: {
composerDisabled: boolean;
agentModelSelector?: React.ReactNode;
editor: Editor | null;
extraActions?: React.ReactNode;
formattingDisabled: boolean;
@@ -235,6 +237,7 @@ export const MessageComposerToolbar = React.memo(
<TooltipContent>Formatting</TooltipContent>
</Tooltip>
</motion.div>
{agentModelSelector}
</motion.div>
)}
</AnimatePresence>
@@ -6,6 +6,7 @@ import {
useAvailableAcpRuntimes,
useCreateChannelManagedAgentMutation,
useManagedAgentsQuery,
usePersonasQuery,
useStartManagedAgentMutation,
} from "@/features/agents/hooks";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
@@ -21,10 +22,20 @@ import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types";
import { getAgentModels } from "@/shared/api/tauri";
import { meshInstalledModels } from "@/shared/api/tauriMesh";
import type {
AcpRuntime,
AgentModelInfo,
AgentModelsResponse,
AgentPersona,
ChannelType,
ManagedAgent,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames";
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
import type { AgentMentionModelTarget } from "./AgentMentionModelSelector";
type PendingNonMemberMentionSend = {
finalContent: string;
@@ -37,6 +48,12 @@ type PendingNonMemberMentionSend = {
sentDraftKey: string | null | undefined;
};
type PendingAgentMentionModelTarget = AgentMentionModelTarget & {
existingAgent: ManagedAgent | null;
existingPubkey: string | null;
persona: AgentPersona | null;
};
type SendMessageWithMentionFlowInput = {
pendingImeta: ImetaMedia[];
sentDraftKey: string | null | undefined;
@@ -47,6 +64,7 @@ type UseMentionSendFlowOptions = {
channelId: string | null;
channelLinks: Pick<UseChannelLinksResult, "clearChannels">;
channelType: ChannelType | null;
content: string;
contentRef: React.MutableRefObject<string>;
customEmoji: CustomEmoji[];
drafts: Pick<UseDraftsResult, "clearDraft">;
@@ -88,6 +106,107 @@ function uniqueNormalizedPubkeys(pubkeys: Iterable<string>) {
return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean);
}
function normalizeModelId(model: string | null | undefined) {
const trimmed = model?.trim();
return trimmed ? trimmed : null;
}
function addModelOption(
options: AgentModelInfo[],
seen: Set<string>,
model: string | null | undefined,
) {
const normalized = normalizeModelId(model);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
options.push({
id: normalized,
name: null,
description: null,
});
}
function buildModelOptions(
catalogOptions: AgentModelInfo[],
fallbackModels: Array<string | null | undefined>,
) {
const seen = new Set<string>();
const options: AgentModelInfo[] = [];
for (const model of catalogOptions) {
if (!model.id || seen.has(model.id)) {
continue;
}
seen.add(model.id);
options.push(model);
}
for (const model of fallbackModels) {
addModelOption(options, seen, model);
}
return options;
}
function didModelSelectionChange(target: PendingAgentMentionModelTarget) {
if (!target.existingAgent) {
return true;
}
return (
normalizeModelId(target.selectedModel) !==
normalizeModelId(target.existingAgent.model)
);
}
function selectedModelForTarget({
currentModel,
hasExistingAgent,
key,
modelOptions,
selections,
}: {
currentModel: string | null;
hasExistingAgent: boolean;
key: string;
modelOptions: AgentModelInfo[];
selections: Map<string, string | null>;
}) {
if (selections.has(key)) {
return selections.get(key) ?? null;
}
if (currentModel) {
return currentModel;
}
if (hasExistingAgent) {
return null;
}
return modelOptions[0]?.id ?? null;
}
function runtimeFromManagedAgent(agent: ManagedAgent): AcpRuntime {
return {
id: agent.agentCommand,
label: agent.agentCommand,
availability: "available",
command: agent.agentCommand,
binaryPath: agent.agentCommand,
defaultArgs: agent.agentArgs,
mcpCommand: agent.mcpCommand || null,
avatarUrl: "",
installHint: "",
installInstructionsUrl: "",
canAutoInstall: false,
underlyingCliPath: null,
};
}
function isManagedAgentRunning(agent: ManagedAgent) {
return agent.status === "running" || agent.status === "deployed";
}
@@ -100,6 +219,7 @@ export function useMentionSendFlow({
channelId,
channelLinks,
channelType,
content,
contentRef,
customEmoji,
drafts,
@@ -113,15 +233,33 @@ export function useMentionSendFlow({
}: UseMentionSendFlowOptions) {
const [pendingNonMemberSend, setPendingNonMemberSend] =
React.useState<PendingNonMemberMentionSend | null>(null);
const [agentModelTargets, setAgentModelTargets] = React.useState<
PendingAgentMentionModelTarget[]
>([]);
const [isLoadingAgentModelTargets, setIsLoadingAgentModelTargets] =
React.useState(false);
const [nonMemberPromptError, setNonMemberPromptError] = React.useState<
string | null
>(null);
const [agentModelPromptError, setAgentModelPromptError] = React.useState<
string | null
>(null);
const [isMentionSendPending, setIsMentionSendPending] = React.useState(false);
const [isCompleteSendPending, setIsCompleteSendPending] =
React.useState(false);
const isMentionSendPendingRef = React.useRef(false);
const isCompleteSendPendingRef = React.useRef(false);
const previousChannelIdRef = React.useRef(channelId);
const agentModelSelectionsRef = React.useRef<Map<string, string | null>>(
new Map(),
);
const agentModelCatalogCacheRef = React.useRef<
Map<string, Promise<AgentModelsResponse> | AgentModelsResponse>
>(new Map());
const globalModelCatalogCacheRef = React.useRef<
Promise<AgentModelInfo[]> | AgentModelInfo[] | null
>(null);
const agentModelTargetsRequestRef = React.useRef(0);
const addMembersMutation = useAddChannelMembersMutation(channelId);
const attachAgentMutation = useAttachManagedAgentToChannelMutation(channelId);
@@ -129,8 +267,14 @@ export function useMentionSendFlow({
useCreateChannelManagedAgentMutation(channelId);
const availableRuntimesQuery = useAvailableAcpRuntimes();
const managedAgentsQuery = useManagedAgentsQuery();
const personasQuery = usePersonasQuery();
const startAgentMutation = useStartManagedAgentMutation();
const activeAgentPersonas = React.useMemo(
() => (personasQuery.data ?? []).filter((persona) => persona.isActive),
[personasQuery.data],
);
const getManagedAgentsByPubkey = React.useCallback(async () => {
const agents =
managedAgentsQuery.data ??
@@ -142,6 +286,13 @@ export function useMentionSendFlow({
);
}, [managedAgentsQuery.data, managedAgentsQuery.refetch]);
const getPersonasById = React.useCallback(async () => {
const personas =
personasQuery.data ?? (await personasQuery.refetch()).data ?? [];
return new Map(personas.map((persona) => [persona.id, persona]));
}, [personasQuery.data, personasQuery.refetch]);
const getAvailableRuntimes = React.useCallback(async (): Promise<
AcpRuntime[]
> => {
@@ -163,6 +314,213 @@ export function useMentionSendFlow({
availableRuntimesQuery.refetch,
]);
const loadAgentModelCatalog = React.useCallback(
async (agent: ManagedAgent): Promise<AgentModelsResponse> => {
const pubkey = normalizePubkey(agent.pubkey);
const cached = agentModelCatalogCacheRef.current.get(pubkey);
if (cached) {
return await cached;
}
const request = getAgentModels(agent.pubkey);
agentModelCatalogCacheRef.current.set(pubkey, request);
try {
const catalog = await request;
agentModelCatalogCacheRef.current.set(pubkey, catalog);
return catalog;
} catch (error) {
agentModelCatalogCacheRef.current.delete(pubkey);
throw error;
}
},
[],
);
const loadGlobalModelOptions = React.useCallback(async () => {
const cached = globalModelCatalogCacheRef.current;
if (cached) {
return await cached;
}
const request = meshInstalledModels().then((models) =>
models.map((model) => ({
id: model.id,
name: model.name,
description: null,
})),
);
globalModelCatalogCacheRef.current = request;
try {
const models = await request;
globalModelCatalogCacheRef.current = models;
return models;
} catch (error) {
globalModelCatalogCacheRef.current = null;
throw error;
}
}, []);
const buildAgentModelTarget = React.useCallback(
async ({
displayName,
existingAgent,
key,
persona,
}: {
displayName: string;
existingAgent: ManagedAgent | null;
key: string;
persona: AgentPersona | null;
}): Promise<PendingAgentMentionModelTarget> => {
let catalogOptions: AgentModelInfo[] = [];
let globalModelOptions: AgentModelInfo[] = [];
let defaultModel: string | null = null;
let loadError: string | null = null;
if (existingAgent) {
try {
const catalog = await loadAgentModelCatalog(existingAgent);
catalogOptions = catalog.models;
defaultModel = normalizeModelId(catalog.agentDefaultModel);
} catch (error) {
loadError =
error instanceof Error
? error.message
: "Could not load model list.";
}
}
try {
globalModelOptions = await loadGlobalModelOptions();
} catch {
// Global model discovery is only an enhancement for first-time persona
// mentions. Existing agents still rely on their own ACP model catalog.
}
const modelOptions = buildModelOptions(
[...catalogOptions, ...globalModelOptions],
[existingAgent?.model, persona?.model, defaultModel],
);
const currentModel = existingAgent
? normalizeModelId(existingAgent.model)
: (normalizeModelId(persona?.model) ?? defaultModel);
const selectedModel = selectedModelForTarget({
currentModel,
hasExistingAgent: existingAgent !== null,
key,
modelOptions,
selections: agentModelSelectionsRef.current,
});
const hasExplicitModelSelection =
agentModelSelectionsRef.current.has(key);
const existingPubkey = existingAgent
? normalizePubkey(existingAgent.pubkey)
: null;
const isNewMention =
!existingPubkey || !mentions.memberPubkeys.has(existingPubkey);
const target: PendingAgentMentionModelTarget = {
key,
displayName,
personaId: persona?.id ?? existingAgent?.personaId ?? null,
avatarUrl: persona?.avatarUrl ?? null,
currentModel,
defaultModel,
selectedModel,
modelOptions,
loadError,
isNewMention,
showModelInTrigger: existingAgent !== null || hasExplicitModelSelection,
willCreateNewInstance: true,
existingAgent,
existingPubkey,
persona,
};
target.willCreateNewInstance = didModelSelectionChange(target);
return target;
},
[loadAgentModelCatalog, loadGlobalModelOptions, mentions.memberPubkeys],
);
const collectAgentModelTargets = React.useCallback(
async (trimmed: string): Promise<PendingAgentMentionModelTarget[]> => {
const personaMentions = mentions.extractMentionPersonas(trimmed);
const mentionedPubkeys = uniqueNormalizedPubkeys(
mentions.extractMentionPubkeys(trimmed),
);
if (personaMentions.length === 0 && mentionedPubkeys.length === 0) {
return [];
}
const [managedAgentsByPubkey, personasById] = await Promise.all([
getManagedAgentsByPubkey(),
getPersonasById(),
]);
const managedAgents = [...managedAgentsByPubkey.values()];
const targets: PendingAgentMentionModelTarget[] = [];
const seenKeys = new Set<string>();
for (const { displayName, persona } of personaMentions) {
const existingInChannel =
managedAgents.find(
(agent) =>
agent.personaId === persona.id &&
mentions.memberPubkeys.has(normalizePubkey(agent.pubkey)),
) ?? null;
const key = `persona:${persona.id}:${displayName}`;
if (seenKeys.has(key)) {
continue;
}
seenKeys.add(key);
targets.push(
await buildAgentModelTarget({
displayName,
existingAgent: existingInChannel,
key,
persona,
}),
);
}
for (const pubkey of mentionedPubkeys) {
const agent = managedAgentsByPubkey.get(pubkey);
if (!agent) {
continue;
}
const key = `agent:${pubkey}`;
if (seenKeys.has(key)) {
continue;
}
seenKeys.add(key);
const persona = agent.personaId
? (personasById.get(agent.personaId) ?? null)
: null;
targets.push(
await buildAgentModelTarget({
displayName:
mentions.getMentionDisplayName(pubkey) ??
persona?.displayName ??
agent.name,
existingAgent: agent,
key,
persona,
}),
);
}
return targets;
},
[
buildAgentModelTarget,
getManagedAgentsByPubkey,
getPersonasById,
mentions.extractMentionPersonas,
mentions.extractMentionPubkeys,
mentions.getMentionDisplayName,
mentions.memberPubkeys,
],
);
const ensureManagedAgentMentionsReady = React.useCallback(
async (mentionPubkeys: string[]) => {
if (!channelId || mentionPubkeys.length === 0) {
@@ -215,7 +573,10 @@ export function useMentionSendFlow({
);
const createMentionedPersonaAgents = React.useCallback(
async (trimmed: string) => {
async (
trimmed: string,
modelTargets: readonly PendingAgentMentionModelTarget[] = [],
) => {
const personaMentions = mentions.extractMentionPersonas(trimmed);
if (!channelId || personaMentions.length === 0) {
return {
@@ -229,6 +590,11 @@ export function useMentionSendFlow({
const errors: string[] = [];
const pubkeys: string[] = [];
const seenPersonaIds = new Set<string>();
const targetByPersonaId = new Map(
modelTargets
.filter((target) => target.persona)
.map((target) => [target.persona?.id, target]),
);
for (const { displayName, persona } of personaMentions) {
if (seenPersonaIds.has(persona.id)) {
@@ -236,11 +602,19 @@ export function useMentionSendFlow({
}
seenPersonaIds.add(persona.id);
const { runtime } = resolvePersonaRuntime(
persona.runtime,
runtimes,
defaultRuntime,
);
const target = targetByPersonaId.get(persona.id);
if (target?.existingAgent && !didModelSelectionChange(target)) {
const pubkey = normalizePubkey(target.existingAgent.pubkey);
pubkeys.push(pubkey);
mentions.registerMentionPubkey(displayName, pubkey, {
isAgent: true,
});
continue;
}
const { runtime } = target?.existingAgent
? { runtime: runtimeFromManagedAgent(target.existingAgent) }
: resolvePersonaRuntime(persona.runtime, runtimes, defaultRuntime);
if (!runtime) {
errors.push(`${displayName}: No agent runtime available.`);
continue;
@@ -253,9 +627,12 @@ export function useMentionSendFlow({
personaId: persona.id,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
model:
normalizeModelId(target ? target.selectedModel : persona.model) ??
undefined,
role: "bot",
ensureRunning: true,
forceNewInstance: true,
});
const pubkey = normalizePubkey(result.agent.pubkey);
pubkeys.push(pubkey);
@@ -286,9 +663,81 @@ export function useMentionSendFlow({
],
);
const createChangedModelAgentMentions = React.useCallback(
async (modelTargets: readonly PendingAgentMentionModelTarget[] = []) => {
if (!channelId || modelTargets.length === 0) {
return {
errors: [] as string[],
pubkeys: [] as string[],
};
}
const errors: string[] = [];
const pubkeys: string[] = [];
for (const target of modelTargets) {
if (!target.existingAgent || !didModelSelectionChange(target)) {
continue;
}
// Persona mentions are handled by createMentionedPersonaAgents so they
// can resolve runtime defaults from the persona catalog.
if (target.key.startsWith("persona:")) {
continue;
}
const selectedModel = normalizeModelId(target.selectedModel);
const persona = target.persona;
const existingAgent = target.existingAgent;
try {
const result = await createPersonaAgentMutation.mutateAsync({
runtime: runtimeFromManagedAgent(existingAgent),
name: persona?.displayName ?? existingAgent.name,
personaId: persona?.id ?? existingAgent.personaId ?? undefined,
systemPrompt:
persona?.systemPrompt ?? existingAgent.systemPrompt ?? undefined,
avatarUrl: persona?.avatarUrl ?? undefined,
model: selectedModel ?? undefined,
role: "bot",
ensureRunning: true,
backend: existingAgent.backend,
respondTo: existingAgent.respondTo,
respondToAllowlist:
existingAgent.respondTo === "allowlist"
? existingAgent.respondToAllowlist
: undefined,
forceNewInstance: true,
});
const pubkey = normalizePubkey(result.agent.pubkey);
pubkeys.push(pubkey);
mentions.registerMentionPubkey(target.displayName, pubkey, {
isAgent: true,
});
} catch (error) {
errors.push(
`${target.displayName}: ${getErrorMessage(
error,
"Could not create agent.",
)}`,
);
}
}
return {
errors,
pubkeys: uniqueNormalizedPubkeys(pubkeys),
};
},
[channelId, createPersonaAgentMutation, mentions.registerMentionPubkey],
);
const clearComposer = React.useCallback(() => {
setPendingNonMemberSend(null);
setAgentModelTargets([]);
agentModelSelectionsRef.current.clear();
setNonMemberPromptError(null);
setAgentModelPromptError(null);
setContent("");
contentRef.current = "";
richText.clearContent();
@@ -315,9 +764,56 @@ export function useMentionSendFlow({
previousChannelIdRef.current = channelId;
setPendingNonMemberSend(null);
setAgentModelTargets([]);
agentModelSelectionsRef.current.clear();
setNonMemberPromptError(null);
setAgentModelPromptError(null);
}, [channelId]);
React.useEffect(() => {
const requestId = agentModelTargetsRequestRef.current + 1;
agentModelTargetsRequestRef.current = requestId;
const trimmed = content.trim();
if (!trimmed) {
setAgentModelTargets([]);
setIsLoadingAgentModelTargets(false);
return;
}
const timeoutId = window.setTimeout(() => {
setIsLoadingAgentModelTargets(true);
void collectAgentModelTargets(trimmed)
.then((targets) => {
if (agentModelTargetsRequestRef.current !== requestId) {
return;
}
setAgentModelTargets(targets);
setAgentModelPromptError(null);
})
.catch((error) => {
if (agentModelTargetsRequestRef.current !== requestId) {
return;
}
setAgentModelTargets([]);
setAgentModelPromptError(
error instanceof Error
? error.message
: "Could not load agent models.",
);
})
.finally(() => {
if (agentModelTargetsRequestRef.current === requestId) {
setIsLoadingAgentModelTargets(false);
}
});
}, 120);
return () => window.clearTimeout(timeoutId);
}, [collectAgentModelTargets, content]);
const completeSend = React.useCallback(
async (
draft: PendingNonMemberMentionSend,
@@ -402,93 +898,98 @@ export function useMentionSendFlow({
[channelType, mentions.hasResolvedMembers, mentions.memberPubkeys],
);
const sendMessageWithMentionFlow = React.useCallback(
async ({
pendingImeta,
sentDraftKey,
trimmed,
}: SendMessageWithMentionFlowInput) => {
if (isMentionSendPendingRef.current) {
return;
}
isMentionSendPendingRef.current = true;
setIsMentionSendPending(true);
try {
const personaMentionResult =
await createMentionedPersonaAgents(trimmed);
if (personaMentionResult.errors.length > 0) {
const message =
personaMentionResult.errors.length === 1
? `Could not create agent mention: ${personaMentionResult.errors[0]}`
: `Could not create agent mentions: ${personaMentionResult.errors.join(
"; ",
)}`;
const continueSendWithAgentModels = React.useCallback(
async (
{ pendingImeta, sentDraftKey, trimmed }: SendMessageWithMentionFlowInput,
modelTargets: readonly PendingAgentMentionModelTarget[] = [],
) => {
const personaMentionResult = await createMentionedPersonaAgents(
trimmed,
modelTargets,
);
const changedModelMentionResult =
await createChangedModelAgentMentions(modelTargets);
const agentMentionErrors = [
...personaMentionResult.errors,
...changedModelMentionResult.errors,
];
if (agentMentionErrors.length > 0) {
const message =
agentMentionErrors.length === 1
? `Could not create agent mention: ${agentMentionErrors[0]}`
: `Could not create agent mentions: ${agentMentionErrors.join(
"; ",
)}`;
if (modelTargets.length > 0) {
setAgentModelPromptError(message);
} else {
setNonMemberPromptError(message);
toast.error(message);
return;
}
const createdPersonaAgentPubkeys = personaMentionResult.pubkeys;
const createdPersonaAgentPubkeySet = new Set(
createdPersonaAgentPubkeys.map(normalizePubkey),
);
const pubkeys = uniqueNormalizedPubkeys([
...mentions.extractMentionPubkeys(trimmed),
...createdPersonaAgentPubkeys,
]);
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
pendingImeta,
);
const outgoingTags = mergeOutgoingTags(
mediaTags,
buildCustomEmojiTags(finalContent, customEmoji),
);
const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys);
let promptNonMemberPubkeys = nonMemberPubkeys.filter(
(pubkey) =>
!mentions.isManagedAgentPubkey(pubkey) &&
!createdPersonaAgentPubkeySet.has(normalizePubkey(pubkey)),
);
if (promptNonMemberPubkeys.length > 0) {
try {
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
promptNonMemberPubkeys = promptNonMemberPubkeys.filter(
(pubkey) => !managedAgentsByPubkey.has(normalizePubkey(pubkey)),
);
} catch {
// Keep the hook-based managed-agent filtering even if the query
// fallback misses; ordinary non-members still get prompted.
}
}
const pendingDraft: PendingNonMemberMentionSend = {
finalContent,
mentionPubkeys: pubkeys,
nonMemberPubkeys: promptNonMemberPubkeys,
outgoingTags,
readyAgentPubkeys: createdPersonaAgentPubkeys,
savedContent: trimmed,
savedImeta: [...pendingImeta],
sentDraftKey,
};
if (promptNonMemberPubkeys.length > 0) {
setNonMemberPromptError(null);
setPendingNonMemberSend(pendingDraft);
return;
}
await completeSend(pendingDraft, pubkeys);
} finally {
isMentionSendPendingRef.current = false;
setIsMentionSendPending(false);
toast.error(message);
return false;
}
const readyAgentPubkeys = uniqueNormalizedPubkeys([
...personaMentionResult.pubkeys,
...changedModelMentionResult.pubkeys,
]);
const readyAgentPubkeySet = new Set(
readyAgentPubkeys.map(normalizePubkey),
);
const pubkeys = uniqueNormalizedPubkeys([
...mentions.extractMentionPubkeys(trimmed),
...readyAgentPubkeys,
]);
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
pendingImeta,
);
const outgoingTags = mergeOutgoingTags(
mediaTags,
buildCustomEmojiTags(finalContent, customEmoji),
);
const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys);
let promptNonMemberPubkeys = nonMemberPubkeys.filter(
(pubkey) =>
!mentions.isManagedAgentPubkey(pubkey) &&
!readyAgentPubkeySet.has(normalizePubkey(pubkey)),
);
if (promptNonMemberPubkeys.length > 0) {
try {
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
promptNonMemberPubkeys = promptNonMemberPubkeys.filter(
(pubkey) => !managedAgentsByPubkey.has(normalizePubkey(pubkey)),
);
} catch {
// Keep the hook-based managed-agent filtering even if the query
// fallback misses; ordinary non-members still get prompted.
}
}
const pendingDraft: PendingNonMemberMentionSend = {
finalContent,
mentionPubkeys: pubkeys,
nonMemberPubkeys: promptNonMemberPubkeys,
outgoingTags,
readyAgentPubkeys,
savedContent: trimmed,
savedImeta: [...pendingImeta],
sentDraftKey,
};
if (promptNonMemberPubkeys.length > 0) {
setNonMemberPromptError(null);
setPendingNonMemberSend(pendingDraft);
return true;
}
await completeSend(pendingDraft, pubkeys);
return true;
},
[
completeSend,
createChangedModelAgentMentions,
createMentionedPersonaAgents,
customEmoji,
getManagedAgentsByPubkey,
@@ -498,6 +999,26 @@ export function useMentionSendFlow({
],
);
const sendMessageWithMentionFlow = React.useCallback(
async (input: SendMessageWithMentionFlowInput) => {
if (isMentionSendPendingRef.current) {
return;
}
isMentionSendPendingRef.current = true;
setIsMentionSendPending(true);
try {
const modelTargets = await collectAgentModelTargets(input.trimmed);
setAgentModelPromptError(null);
await continueSendWithAgentModels(input, modelTargets);
} finally {
isMentionSendPendingRef.current = false;
setIsMentionSendPending(false);
}
},
[collectAgentModelTargets, continueSendWithAgentModels],
);
const pendingNonMemberNames = React.useMemo(() => {
if (!pendingNonMemberSend) return [];
@@ -506,6 +1027,32 @@ export function useMentionSendFlow({
);
}, [mentions.getMentionDisplayName, pendingNonMemberSend]);
const handleAgentModelChange = React.useCallback(
(key: string, model: string | null) => {
const selectedModel = normalizeModelId(model);
agentModelSelectionsRef.current.set(key, selectedModel);
setAgentModelTargets((current) => {
return current.map((target) => {
if (target.key !== key) {
return target;
}
const nextTarget = {
...target,
selectedModel,
showModelInTrigger: true,
};
return {
...nextTarget,
willCreateNewInstance: didModelSelectionChange(nextTarget),
};
});
});
setAgentModelPromptError(null);
},
[],
);
const handleSendWithoutInviting = React.useCallback(() => {
if (!pendingNonMemberSend) return;
@@ -610,7 +1157,12 @@ export function useMentionSendFlow({
}, []);
return {
agentModelPromptError,
dismissNonMemberPrompt,
agentModelPersonas: activeAgentPersonas,
agentModelTargets,
isLoadingAgentModelPersonas: personasQuery.isLoading,
isLoadingAgentModelTargets,
isInvitePending:
isMentionSendPending ||
isCompleteSendPending ||
@@ -624,6 +1176,7 @@ export function useMentionSendFlow({
attachAgentMutation.isPending ||
createPersonaAgentMutation.isPending ||
startAgentMutation.isPending,
onAgentModelChange: handleAgentModelChange,
nonMemberPromptError,
pendingNonMemberNames,
pendingNonMemberSend,
+24 -11
View File
@@ -36,6 +36,7 @@ type MockManagedAgentSeed = {
pubkey: string;
name: string;
personaId?: string | null;
model?: string | null;
status?: RawManagedAgent["status"];
channelNames?: string[];
channelIds?: string[];
@@ -67,6 +68,7 @@ type E2eConfig = {
profileReadError?: string;
profileUpdateError?: string;
searchProfiles?: MockSearchProfileSeed[];
meshModels?: Array<{ id: string; name: string | null }>;
updateChannelDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
@@ -940,7 +942,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent {
max_turn_duration_seconds: null,
parallelism: 1,
system_prompt: null,
model: null,
model: seed.model ?? null,
env_vars: {},
status,
pid: status === "running" ? 42000 + mockManagedAgents.length : null,
@@ -1483,10 +1485,13 @@ const mockMeshState: {
nodeMode: null,
};
function resetMockMesh() {
function resetMockMesh(config?: E2eConfig) {
mockMeshState.admitted = true;
mockMeshState.models = [
{ id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" },
mockMeshState.models = config?.mock?.meshModels ?? [
{
id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M",
name: "SmolLM2 135M",
},
];
mockMeshState.denyReason = "not a relay member";
mockMeshState.nodeState = "off";
@@ -5677,7 +5682,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockTeams();
seedMockSearchProfiles(config);
resetMockWorkflows();
resetMockMesh();
resetMockMesh(config);
resetMockUserStatuses();
mockWebsocketSendMutexWedged = false;
mockWindows("main");
@@ -6118,15 +6123,23 @@ export function maybeInstallE2eTauriMocks() {
return handleGetManagedAgentLog(
payload as Parameters<typeof handleGetManagedAgentLog>[0],
);
case "get_agent_models":
case "get_agent_models": {
const agent = getMockManagedAgent(
(payload as { pubkey: string }).pubkey,
);
return {
agentName: "mock-agent",
agentName: agent.name,
agentVersion: "0.0.0",
models: [],
agentDefaultModel: null,
selectedModel: null,
supportsSwitching: false,
models: mockMeshState.models.map((model) => ({
id: model.id,
name: model.name,
description: null,
})),
agentDefaultModel: mockMeshState.models[0]?.id ?? null,
selectedModel: agent.model,
supportsSwitching: mockMeshState.models.length > 0,
};
}
case "update_managed_agent":
return handleUpdateManagedAgent(
payload as Parameters<typeof handleUpdateManagedAgent>[0],
+111 -4
View File
@@ -144,6 +144,42 @@ test("@ trigger shows unified autocomplete with agents first", async ({
expect(bobIndex).toBeLessThan(outsiderIndex);
});
test("agent model selector starts compact and can insert a persona mention", async ({
page,
}) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:kit", "builtin:scout"],
meshModels: [
{ id: "model-a", name: "Model A" },
{ id: "model-b", name: "Model B" },
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const trigger = page.getByTestId("agent-model-selector-trigger");
await expect(trigger).toHaveAttribute("aria-label", "Add an agent");
await expect(trigger).not.toContainText("Model A");
await trigger.click();
await expect(page.getByTestId("agent-model-selector-popover")).toBeVisible();
await expect(
page.getByTestId("agent-model-selector-persona-builtin:kit"),
).toBeVisible();
await page.getByTestId("agent-model-selector-persona-builtin:kit").click();
await expect(page.getByTestId("message-input")).toContainText("@Kit");
await expect(trigger).not.toContainText("Model A");
const modelB = page.getByTestId(
"agent-model-selector-model-persona:builtin:kit:Kit-model-b",
);
await expect(modelB).toBeVisible();
await modelB.click();
await expect(page.getByTestId("agent-model-selector-popover")).toBeHidden();
await expect(trigger).toContainText("Model B");
});
test("autocomplete filters suggestions as user types", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
@@ -266,7 +302,6 @@ test("selecting a persona mention creates a channel agent before sending", async
);
await page.getByTestId("send-message").click();
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect
.poll(async () =>
@@ -346,7 +381,6 @@ test("selecting a persona mention reuses an existing persona agent", async ({
);
await page.getByTestId("send-message").click();
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect
.poll(async () =>
@@ -459,6 +493,81 @@ test("mentioning an in-channel stopped managed agent starts it before sending",
await expect(mentionChip).toBeVisible();
});
test("changing a mentioned managed agent model creates a replacement instance", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY,
name: "kit",
model: "model-a",
status: "running",
channelNames: ["general"],
},
],
meshModels: [
{ id: "model-a", name: "Model A" },
{ id: "model-b", name: "Model B" },
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("Hey @kit");
await expect(autocomplete(page).getByText("kit")).toBeVisible();
await input.press("Enter");
await page.keyboard.type(" use the bigger model");
const baselineCreateCount = commandCount(
await readCommandLog(page),
"create_managed_agent",
);
await expect(page.getByTestId("agent-model-selector-trigger")).toContainText(
"Model A",
);
await page.getByTestId("agent-model-selector-trigger").click();
await expect(page.getByTestId("agent-model-selector-popover")).toBeVisible();
await page
.getByTestId(
`agent-model-selector-model-agent:${IN_CHANNEL_MANAGED_AGENT_PUBKEY}-model-b`,
)
.click();
await expect(page.getByTestId("agent-model-selector-popover")).toBeHidden();
await expect(page.getByTestId("agent-model-selector-trigger")).toContainText(
"Model B",
);
await page.getByTestId("send-message").click();
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "create_managed_agent"),
)
.toBeGreaterThan(baselineCreateCount);
await expect
.poll(async () => {
const payloads = await readCommandPayloads(page);
return payloads.some((entry) => {
const payload = entry.payload as
| { input?: { model?: string } }
| undefined;
return (
entry.command === "create_managed_agent" &&
payload?.input?.model === "model-b"
);
});
})
.toBe(true);
const mentionChip = page
.getByTestId("message-row")
.last()
.locator("[data-mention].agent-mention-highlight", { hasText: "kit" });
await expect(mentionChip).toBeVisible();
});
test("mentioning an in-channel provider managed agent deploys it before sending", async ({
page,
}) => {
@@ -545,7 +654,6 @@ test("mentioning a non-member managed agent adds and starts it before sending",
);
await page.getByTestId("send-message").click();
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect
.poll(async () =>
@@ -606,7 +714,6 @@ test("mentioning a non-member provider managed agent deploys it before sending",
);
await page.getByTestId("send-message").click();
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect
.poll(async () =>
+2
View File
@@ -46,6 +46,7 @@ type MockManagedAgentSeed = {
pubkey: string;
name: string;
personaId?: string | null;
model?: string | null;
status?: "running" | "stopped" | "deployed" | "not_deployed";
channelNames?: string[];
channelIds?: string[];
@@ -95,6 +96,7 @@ type MockBridgeOptions = {
profileReadError?: string;
profileUpdateError?: string;
searchProfiles?: MockSearchProfileSeed[];
meshModels?: Array<{ id: string; name: string | null }>;
updateChannelDelayMs?: number;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;