diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 7ea73da104..46beed450a 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -332,6 +332,7 @@ impl Default for CommitWindowState { fn resolve_settings_route(section: Option<&str>) -> &'static str { match section { Some("appearance") => "settings/appearance", + Some("general") => "settings/general", Some("agents") => "settings/agents", Some("mcp") => "settings/mcp", Some("skills") => "settings/skills", diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index f2da50999c..049f5b4d8a 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -314,6 +314,7 @@ pub async fn open_settings_window( ) -> Result, AppCommandError> { let route = match params.section.as_deref() { Some("appearance") => "settings/appearance", + Some("general") => "settings/general", Some("agents") => "settings/agents", Some("mcp") => "settings/mcp", Some("skills") => "settings/skills", diff --git a/src/components/chat/composer/agent-mention-hint.test.ts b/src/components/chat/composer/agent-mention-hint.test.ts new file mode 100644 index 0000000000..a0208a5be5 --- /dev/null +++ b/src/components/chat/composer/agent-mention-hint.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { getSettingsMock } = vi.hoisted(() => ({ + getSettingsMock: vi.fn(), +})) + +vi.mock("@/lib/api", () => ({ + getDelegationSettings: (...args: unknown[]) => getSettingsMock(...args), +})) + +import type { AcpAgentInfo } from "@/lib/types" + +import { + extractMentionedAgentTypes, + findBlockedAgentMentions, + mentionedAgentTypesFromBlocks, +} from "./agent-mention-hint" + +describe("extractMentionedAgentTypes", () => { + it("returns empty for text without agent mentions", () => { + expect( + extractMentionedAgentTypes("plain @text and [a link](file://x)") + ).toEqual([]) + }) + + it("does not fire on free-standing @label prose without a routing uri", () => { + expect(extractMentionedAgentTypes("ask @codex about it")).toEqual([]) + }) + + it("extracts the agent_type from a serialized mention", () => { + expect( + extractMentionedAgentTypes("请 [@Codex](codeg://agent/codex) 看看") + ).toEqual(["codex"]) + }) + + it("deduplicates repeat mentions and keeps order of first appearance", () => { + const text = [ + "[@Codex](codeg://agent/codex)", + "[@Antigravity](codeg://agent/antigravity)", + "[@Codex again](codeg://agent/codex)", + ].join(" then ") + expect(extractMentionedAgentTypes(text)).toEqual(["codex", "antigravity"]) + }) + + it("matches the wire form embedded in larger prose", () => { + expect( + extractMentionedAgentTypes( + "see [@Claude](codeg://agent/claude_code) and [@pi](codeg://agent/pi)" + ) + ).toEqual(["claude_code", "pi"]) + }) + + it("scans only text blocks when reading prompt blocks", () => { + expect( + mentionedAgentTypesFromBlocks([ + { type: "text", text: "check [@Codex](codeg://agent/codex)" }, + { type: "image", data: "codeg://agent/fake", mime_type: "image/png" }, + { type: "resource", uri: "codeg://agent/also-fake" }, + ]) + ).toEqual(["codex"]) + }) +}) + +describe("findBlockedAgentMentions", () => { + // Only the shape the classifier reads; the registry snapshot is supplied by + // the caller (`useAcpAgents`), never fetched here. + const AGENTS = [ + { agent_type: "codex", name: "Codex", enabled: true }, + { agent_type: "qoder", name: "Qoder", enabled: false }, + ] as unknown as AcpAgentInfo[] + + beforeEach(() => { + vi.clearAllMocks() + getSettingsMock.mockResolvedValue({ enabled: true }) + }) + + it("short-circuits without mentions and never calls the backend", async () => { + const blocked = await findBlockedAgentMentions([], AGENTS) + expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] }) + expect(getSettingsMock).not.toHaveBeenCalled() + }) + + it("reports delegationOff alone when multi-agent delegation is disabled", async () => { + getSettingsMock.mockResolvedValue({ enabled: false }) + const blocked = await findBlockedAgentMentions(["qoder", "codex"], AGENTS) + expect(blocked.delegationOff).toBe(true) + expect(blocked.disabledAgents).toEqual([]) + }) + + it("lists only the mentioned agents that are disabled", async () => { + const blocked = await findBlockedAgentMentions(["qoder", "codex"], AGENTS) + expect(blocked).toEqual({ + delegationOff: false, + disabledAgents: [{ type: "qoder", label: "Qoder" }], + }) + }) + + it("returns nothing when every mentioned agent is enabled", async () => { + const blocked = await findBlockedAgentMentions(["codex"], AGENTS) + expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] }) + }) + + it("re-reads the delegation toggle on every call, so flipping it on stops the hint immediately", async () => { + getSettingsMock.mockResolvedValueOnce({ enabled: false }) + expect( + (await findBlockedAgentMentions(["codex"], AGENTS)).delegationOff + ).toBe(true) + // The user opened the deep link and turned the switch on. The very next + // send must not repeat the warning — no TTL window may hide the new value. + expect( + (await findBlockedAgentMentions(["codex"], AGENTS)).delegationOff + ).toBe(false) + expect(getSettingsMock).toHaveBeenCalledTimes(2) + }) + + it("reports nothing disabled against a cold/empty registry snapshot", async () => { + const blocked = await findBlockedAgentMentions(["qoder"], []) + expect(blocked).toEqual({ delegationOff: false, disabledAgents: [] }) + }) +}) diff --git a/src/components/chat/composer/agent-mention-hint.ts b/src/components/chat/composer/agent-mention-hint.ts new file mode 100644 index 0000000000..7841c40096 --- /dev/null +++ b/src/components/chat/composer/agent-mention-hint.ts @@ -0,0 +1,91 @@ +import { getDelegationSettings } from "@/lib/api" + +import type { AcpAgentInfo, PromptInputBlock } from "@/lib/types" + +/** + * An `@` mention delegates work to another agent only when BOTH gates + * are open: multi-agent delegation is enabled in settings (the tool is + * injected per connection), and the target agent itself is enabled. When a + * gate is closed the host model silently answers the mention itself — to the + * sender `@` just looks broken (upstream issue #545). These helpers let the + * send path surface that as a hint at the exact moment it becomes true. + */ + +/** + * Agent mentions serialize as `[@label](codeg://agent/)` (see + * `reference-text.ts`), so we anchor on the routing URI — free-standing + * `@label` prose the user typed must not trigger the hint. Clean URIs stay + * unescaped in the destination; ids containing spaces/parens (rare custom + * agents) end up in the `<…>` form whose captures we don't attempt to parse. + */ +const AGENT_MENTION_URI = /codeg:\/\/agent\/([^)\s\\]+)/g + +export function extractMentionedAgentTypes(text: string): string[] { + const found = new Set() + for (const match of text.matchAll(AGENT_MENTION_URI)) { + if (match[1]) found.add(match[1]) + } + return [...found] +} + +/** Scan the SEND wire blocks — agent mentions live in the prose text block. */ +export function mentionedAgentTypesFromBlocks( + blocks: PromptInputBlock[] +): string[] { + const prose = blocks + .flatMap((block) => (block.type === "text" ? [block.text] : [])) + .join("\n") + return extractMentionedAgentTypes(prose) +} + +export interface BlockedAgentMentions { + /** Multi-agent delegation is off — no mention can delegate. */ + delegationOff: boolean + /** Delegation is on, but these mentioned agents are disabled in settings. */ + disabledAgents: Array<{ type: string; label: string }> +} + +/** + * Best-effort classification of the mentions in a sent draft. + * + * `agents` is the caller's already-subscribed registry snapshot + * (`useAcpAgents`) rather than a fetch of our own: `acp_list_agents` probes npm + * prefixes and shells out for binary versions, and the app deliberately + * coalesces it behind ONE ref-counted store that reloads on window focus, + * `app://acp-agents-updated` and transport reconnect. Reading that store keeps + * this free AND fresh — a private TTL cache would re-nag with stale gates for + * the whole TTL right after the user flipped the toggle the hint sent them to. + * An empty/cold list simply reports nothing disabled, which is the fail-safe + * direction (a missed hint, never a false one). + * + * The delegation toggle IS read per call: it is a handful of `app_metadata` + * reads, it has no change event to invalidate against, and this runs + * fire-and-forget off the send path. Throws only if that lookup fails — + * callers are expected to swallow that (a missing hint must never break the + * send). + */ +export async function findBlockedAgentMentions( + mentionedTypes: string[], + agents: AcpAgentInfo[] +): Promise { + if (mentionedTypes.length === 0) { + return { delegationOff: false, disabledAgents: [] } + } + const settings = await getDelegationSettings() + if (!settings.enabled) { + return { delegationOff: true, disabledAgents: [] } + } + /** `agent_type → display name` for every agent disabled in Agents 管理. */ + const disabled = new Map() + for (const agent of agents) { + if (!agent.enabled) { + disabled.set(agent.agent_type, agent.name || agent.agent_type) + } + } + return { + delegationOff: false, + disabledAgents: mentionedTypes + .filter((type) => disabled.has(type)) + .map((type) => ({ type, label: disabled.get(type) ?? type })), + } +} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 23abc40225..a1908dbaf9 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -48,6 +48,11 @@ import { imageFilesFromClipboardApi } from "@/lib/clipboard-images" import { toErrorMessage } from "@/lib/app-error" import { isNoActiveTurnRejection } from "@/lib/turn-busy" import { ServerFileBrowserDialog } from "@/components/shared/server-file-browser-dialog" +import { + findBlockedAgentMentions, + mentionedAgentTypesFromBlocks, +} from "@/components/chat/composer/agent-mention-hint" +import { openSettingsWindow } from "@/lib/api" import { toast } from "sonner" import type { AgentSkillItem, @@ -92,6 +97,7 @@ import { MODEL_LIST_VIRTUALIZE_THRESHOLD, type ModelOptionGroup, } from "@/lib/model-config-groups" +import { acpAgentsSnapshot } from "@/hooks/use-acp-agents" import { useAgentSkills } from "@/hooks/use-agent-skills" import { useScrollbarSafeDismiss } from "@/hooks/use-scrollbar-safe-dismiss" import { @@ -155,6 +161,9 @@ export interface ComposerInjectContent { mode?: "replace" | "append" } +/** Shared sonner id for the "@-mention can't delegate" warning (see `handleSend`). */ +const MENTION_DELEGATION_HINT_TOAST_ID = "agent-mention-delegation-hint" + interface MessageInputProps { onSend: (draft: PromptDraft, modeId?: string | null) => void placeholder?: string @@ -1222,6 +1231,51 @@ export function MessageInput({ return } + // An @-mention only delegates when multi-agent delegation is on and the + // target agent is enabled; with a gate closed the host model otherwise + // answers the mention itself and the sender never learns why (upstream + // #545). Best-effort and fire-and-forget — a lookup failure must never + // block or break the send. + const mentionedTypes = mentionedAgentTypesFromBlocks(draft.blocks) + if (mentionedTypes.length > 0) { + void findBlockedAgentMentions(mentionedTypes, acpAgentsSnapshot()) + .then((blocked) => { + // One stable id for both branches: they are mutually exclusive, and a + // user who keeps delegation off would otherwise stack an identical + // warning per send. Same id → sonner replaces rather than piles up. + if (blocked.delegationOff) { + toast.warning(t("mentionDelegateOffHint"), { + id: MENTION_DELEGATION_HINT_TOAST_ID, + action: { + label: t("mentionHintOpenSettings"), + onClick: () => void openSettingsWindow("general"), + }, + }) + return + } + const firstDisabled = blocked.disabledAgents[0] + if (firstDisabled) { + const names = blocked.disabledAgents + .map((agent) => agent.label) + .join(", ") + toast.warning( + t("mentionDelegateDisabledAgentHint", { agent: names }), + { + id: MENTION_DELEGATION_HINT_TOAST_ID, + action: { + label: t("mentionHintOpenSettings"), + onClick: () => + void openSettingsWindow("agents", { + agentType: firstDisabled.type, + }), + }, + } + ) + } + }) + .catch(() => {}) + } + // Prompting mode: enqueue instead of sending if (isPrompting && onEnqueue) { onEnqueue(draft, showModeSelector ? effectiveModeId : null) @@ -1238,6 +1292,7 @@ export function MessageInput({ disabled, hasUploadingImage, tAttach, + t, buildDraft, isEditingQueueItem, isPrompting, diff --git a/src/hooks/use-acp-agents.ts b/src/hooks/use-acp-agents.ts index a15b01ba63..19bcb9b120 100644 --- a/src/hooks/use-acp-agents.ts +++ b/src/hooks/use-acp-agents.ts @@ -205,6 +205,18 @@ export function useAcpAgents(): UseAcpAgentsResult { return { agents, fresh, refresh } } +/** + * Read the shared registry WITHOUT subscribing — for event handlers (a send, a + * menu action) that need the current list at the moment they run and must not + * re-render their host on every reload. In the running app TabProvider + + * SidebarConversationList hold the refcount ≥ 1 for the whole session, so this + * is warm; a cold store answers `[]`, which callers must read as "registry + * unknown" rather than "no agents exist". + */ +export function acpAgentsSnapshot(): AcpAgentInfo[] { + return useAcpAgentsStore.getState().agents +} + /** Test-only: reset the shared store + module race/refcount state to a clean * slate (disposing any live subscription). */ export function resetAcpAgentsStore(): void { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 7e2e02d290..e806bb4a14 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2758,6 +2758,9 @@ "sendMessage": "أرسل رسالة..." }, "messageInput": { + "mentionDelegateOffHint": "لقد أشرت إلى وكيل آخر عبر @، لكن «تعاون متعدد الوكلاء» معطّل حاليًا، لذا لن يُفوَّض هذا الطلب فعليًا. يمكنك تفعيله من الإعدادات ← عام ← تعاون متعدد الوكلاء (يسري على الجلسات الجديدة).", + "mentionDelegateDisabledAgentHint": "لقد أشرت إلى {agent}: هذا الوكيل معطّل في الإعدادات ← الوكلاء، لذا لن يُفوَّض إليه الطلب. يمكنك إعادة تفعيله من هناك (يسري على الجلسات الجديدة).", + "mentionHintOpenSettings": "فتح الإعدادات", "askAnything": "اسأل أي شيء...", "removeAttachmentAria": "إزالة {name}", "attachFiles": "إرفاق ملفات", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 5a21b00075..e8d2c86676 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2758,6 +2758,9 @@ "sendMessage": "Nachricht senden..." }, "messageInput": { + "mentionDelegateOffHint": "Du hast einen anderen Agenten mit @ erwähnt, aber die Multi-Agent-Zusammenarbeit ist derzeit deaktiviert – die Nachricht wird nicht wirklich delegiert. Aktiviere sie unter Einstellungen → Allgemein → Multi-Agent-Zusammenarbeit (wirkt für neue Sitzungen).", + "mentionDelegateDisabledAgentHint": "Du hast {agent} erwähnt: Dieser Agent ist unter Einstellungen → Agenten deaktiviert, daher wird die Nachricht nicht delegiert. Dort wieder aktivieren (wirkt für neue Sitzungen).", + "mentionHintOpenSettings": "Einstellungen öffnen", "askAnything": "Fragen Sie alles...", "removeAttachmentAria": "{name} entfernen", "attachFiles": "Dateien anhängen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index cbeadcb02a..96f930d617 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2758,6 +2758,9 @@ "sendMessage": "Send a message..." }, "messageInput": { + "mentionDelegateOffHint": "You @-mentioned another agent, but Multi-Agent Collaboration is currently off, so this message won't actually be handed off. Enable it under Settings → General → Multi-Agent Collaboration (takes effect for new sessions).", + "mentionDelegateDisabledAgentHint": "You @-mentioned {agent}: that agent is disabled under Settings → Agents, so the message won't be delegated to it. Re-enable it there (takes effect for new sessions).", + "mentionHintOpenSettings": "Open Settings", "askAnything": "Ask anything...", "removeAttachmentAria": "Remove {name}", "attachFiles": "Attach files", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e665c5e125..00e7ee8d25 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2758,6 +2758,9 @@ "sendMessage": "Enviar un mensaje..." }, "messageInput": { + "mentionDelegateOffHint": "Has mencionado a otro agente con @, pero la Colaboración multiagente está desactivada, por lo que el mensaje no se delegará realmente. Actívala en Configuración → General → Colaboración multiagente (surte efecto en sesiones nuevas).", + "mentionDelegateDisabledAgentHint": "Has mencionado a {agent}: ese agente está desactivado en Configuración → Agentes, por lo que el mensaje no se le delegará. Reactívalo allí (surte efecto en sesiones nuevas).", + "mentionHintOpenSettings": "Abrir Configuración", "askAnything": "Pregunta lo que sea...", "removeAttachmentAria": "Quitar {name}", "attachFiles": "Adjuntar archivos", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index abb2187e3b..9f53afbf00 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2758,6 +2758,9 @@ "sendMessage": "Envoyer un message..." }, "messageInput": { + "mentionDelegateOffHint": "Vous avez mentionné un autre agent avec @, mais la Collaboration multi-agent est actuellement désactivée : le message ne sera pas réellement délégué. Activez-la dans Paramètres → Général → Collaboration multi-agent (effectif pour les nouvelles sessions).", + "mentionDelegateDisabledAgentHint": "Vous avez mentionné {agent} : cet agent est désactivé dans Paramètres → Agents IA, le message ne lui sera pas délégué. Réactivez-le là (effectif pour les nouvelles sessions).", + "mentionHintOpenSettings": "Ouvrir les paramètres", "askAnything": "Posez n'importe quelle question...", "removeAttachmentAria": "Retirer {name}", "attachFiles": "Joindre des fichiers", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fe1a9dd9a2..563fa5ed26 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2758,6 +2758,9 @@ "sendMessage": "メッセージを送信..." }, "messageInput": { + "mentionDelegateOffHint": "エージェントを @ でメンションしていますが、「マルチエージェント連携」が無効のため、メッセージは実際には委譲されません。設定 → 一般 → マルチエージェント連携 で有効にできます(新しいセッションから有効)。", + "mentionDelegateDisabledAgentHint": "{agent} を @ でメンションしていますが、このエージェントは 設定 → エージェント で無効になっているため委譲されません。そちらで再度有効にしてください(新しいセッションから有効)。", + "mentionHintOpenSettings": "設定を開く", "askAnything": "何でも質問してください...", "removeAttachmentAria": "{name} を削除", "attachFiles": "ファイルを添付", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 1c006a6f6d..52a6d63cf5 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2758,6 +2758,9 @@ "sendMessage": "메시지 보내기..." }, "messageInput": { + "mentionDelegateOffHint": "에이전트를 @ 멘션했지만 다중 에이전트 협업이 꺼져 있어 메시지가 실제로 위임되지 않습니다. 설정 → 일반 → 다중 에이전트 협업에서 켤 수 있습니다(새 세션부터 적용).", + "mentionDelegateDisabledAgentHint": "{agent}을(를) @ 멘션했지만 해당 에이전트가 설정 → 에이전트에서 비활성화되어 위임되지 않습니다. 설정 → 에이전트에서 다시 활성화할 수 있습니다(새 세션부터 적용).", + "mentionHintOpenSettings": "설정 열기", "askAnything": "무엇이든 물어보세요...", "removeAttachmentAria": "{name} 제거", "attachFiles": "파일 첨부", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 086c801c01..5c6d38c3ae 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2758,6 +2758,9 @@ "sendMessage": "Envie uma mensagem..." }, "messageInput": { + "mentionDelegateOffHint": "Você mencionou outro agente com @, mas a Colaboração multiagente está desativada, então a mensagem não será realmente delegada. Ative em Configurações → Geral → Colaboração multiagente (válido para novas sessões).", + "mentionDelegateDisabledAgentHint": "Você mencionou {agent}: esse agente está desativado em Configurações → Agentes, então a mensagem não será delegada a ele. Reative-o lá (válido para novas sessões).", + "mentionHintOpenSettings": "Abrir configurações", "askAnything": "Pergunte qualquer coisa...", "removeAttachmentAria": "Remover {name}", "attachFiles": "Anexar arquivos", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6a8e8e292a..c275d5dd23 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2758,6 +2758,9 @@ "sendMessage": "发送消息..." }, "messageInput": { + "mentionDelegateOffHint": "已 @ 其他智能体:当前「多智能体协同」未开启,消息不会真正委托给对方。可在 设置 → 常规 → 多智能体协同 中开启(新会话生效)。", + "mentionDelegateDisabledAgentHint": "已 @ {agent}:该智能体已在 设置 → 智能体 中禁用,消息不会委托给它。可前往重新启用(新会话生效)。", + "mentionHintOpenSettings": "前往设置", "askAnything": "请开始输入...", "removeAttachmentAria": "移除 {name}", "attachFiles": "附加文件", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 7dec729e77..9cbc694616 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2758,6 +2758,9 @@ "sendMessage": "傳送訊息..." }, "messageInput": { + "mentionDelegateOffHint": "已 @ 其他智慧體:目前「多智慧體協同」未開啟,訊息不會真正委派給對方。可在 設定 → 一般 → 多智慧體協同 中開啟(新會話生效)。", + "mentionDelegateDisabledAgentHint": "已 @ {agent}:該智慧體已在 設定 → 智能體 中停用,訊息不會委派給它。可前往重新啟用(新會話生效)。", + "mentionHintOpenSettings": "前往設定", "askAnything": "請開始輸入...", "removeAttachmentAria": "移除 {name}", "attachFiles": "附加檔案", diff --git a/src/lib/api.ts b/src/lib/api.ts index 57fa6f9e21..87d36e8b70 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2787,6 +2787,7 @@ export async function openCommitWindow(folderId: number): Promise { export type SettingsSection = | "appearance" + | "general" | "agents" | "mcp" | "skills"