mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add composer agent model picker
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user