From ee354a775828b87e4668ae32efd76afba24d1c80 Mon Sep 17 00:00:00 2001 From: Eve Date: Fri, 31 Jul 2026 15:28:24 +0800 Subject: [PATCH] fix: address runtime unbind review nits Co-authored-by: multica-agent --- .../extensions/mention-suggestion.test.tsx | 72 +++++++++++++-- .../editor/extensions/mention-suggestion.tsx | 90 +++++++++++++++---- server/internal/handler/runtime.go | 8 +- server/internal/handler/squad.go | 10 +-- server/internal/handler/workspace_test.go | 43 +++++++++ 5 files changed, 183 insertions(+), 40 deletions(-) diff --git a/packages/views/editor/extensions/mention-suggestion.test.tsx b/packages/views/editor/extensions/mention-suggestion.test.tsx index df63963be2..949c98d5db 100644 --- a/packages/views/editor/extensions/mention-suggestion.test.tsx +++ b/packages/views/editor/extensions/mention-suggestion.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createRef, type ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { workspaceKeys } from "@multica/core/workspace/queries"; @@ -10,9 +10,16 @@ import enCommon from "../../locales/en/common.json"; import enAuth from "../../locales/en/auth.json"; import enSettings from "../../locales/en/settings.json"; import enEditor from "../../locales/en/editor.json"; +import enIssues from "../../locales/en/issues.json"; const TEST_RESOURCES = { - en: { common: enCommon, auth: enAuth, settings: enSettings, editor: enEditor }, + en: { + common: enCommon, + auth: enAuth, + settings: enSettings, + editor: enEditor, + issues: enIssues, + }, }; function I18nWrapper({ children }: { children: ReactNode }) { @@ -49,6 +56,12 @@ vi.mock("@multica/core/auth", () => ({ useAuthStore: { getState: () => authState }, })); +vi.mock("../../common/actor-avatar", () => ({ + ActorAvatar: ({ actorId }: { actorId: string }) => ( + + ), +})); + import { createMentionSuggestion, MentionList, @@ -186,8 +199,9 @@ describe("createMentionSuggestion", () => { expect(items.some((i) => i.type === "agent" && i.label === "Aegis")).toBe(true); }); - it("does not offer an unbound agent as a mention target", () => { + it("keeps an unbound agent discoverable but marks it as unselectable", () => { const qc = fakeQc({ + members: [{ user_id: "u1", name: "Alice", role: "member" }], agents: [ { id: "a1", @@ -204,11 +218,49 @@ describe("createMentionSuggestion", () => { const config = createMentionSuggestion(qc); const items = config.items!(itemArgs("a")) as MentionItem[]; - expect(items.some((item) => item.type === "agent" && item.id === "a1")).toBe( - false, + expect(items).toContainEqual( + expect.objectContaining({ + type: "agent", + id: "a1", + disabledReason: "agent_runtime_required", + }), ); }); + it("does not select a runtime-required mention row by click or keyboard", () => { + const command = vi.fn<(item: MentionItem) => void>(); + const ref = createRef(); + render( + + + , + ); + + const row = screen.getByRole("button", { + name: "Aegis: This target has no runtime — bind one to run it", + }); + expect(row).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(row); + expect( + ref.current?.onKeyDown({ + event: new KeyboardEvent("keydown", { key: "Enter" }), + }), + ).toBe(true); + expect(command).not.toHaveBeenCalled(); + }); + it("loads server issue matches into the popup when the list cache misses", async () => { searchIssuesMock.mockResolvedValue({ issues: [ @@ -683,7 +735,7 @@ describe("createMentionSuggestion", () => { expect(items.some((i) => i.type === "squad" && i.label === "Archived Squad")).toBe(false); }); - it("does not offer a squad whose leader is unbound", () => { + it("keeps a squad with an unbound leader discoverable but unselectable", () => { const qc = fakeQc({ agents: [ { @@ -709,8 +761,12 @@ describe("createMentionSuggestion", () => { const config = createMentionSuggestion(qc); const items = config.items!(itemArgs("")) as MentionItem[]; - expect(items.some((item) => item.type === "squad" && item.id === "s1")).toBe( - false, + expect(items).toContainEqual( + expect.objectContaining({ + type: "squad", + id: "s1", + disabledReason: "agent_runtime_required", + }), ); }); diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx index 1a40b5d95f..be523e2e49 100644 --- a/packages/views/editor/extensions/mention-suggestion.tsx +++ b/packages/views/editor/extensions/mention-suggestion.tsx @@ -32,6 +32,11 @@ import { StatusIcon } from "../../issues/components/status-icon"; import { ProjectIcon } from "../../projects/components/project-icon"; import { useT } from "../../i18n"; import { Badge } from "@multica/ui/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@multica/ui/components/ui/tooltip"; import { cn } from "@multica/ui/lib/utils"; import type { IssueStatus, ProjectStatus } from "@multica/core/types"; import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; @@ -49,6 +54,7 @@ import { pickerNavigationDirection, } from "./suggestion-popup"; import { isTriggerArmedAt } from "./suggestion-trigger-arming"; +import { blockedReasonLabel } from "../../issues/blocked-trigger-copy"; // --------------------------------------------------------------------------- // Types @@ -68,6 +74,8 @@ export interface MentionItem { icon?: string | null; /** Project status snapshot for recent/current project rendering */ projectStatus?: ProjectStatus; + /** Present when the target should remain discoverable but cannot be selected. */ + disabledReason?: "agent_runtime_required"; } interface MentionListProps { @@ -258,18 +266,23 @@ export const MentionList = forwardRef( // yet, fall back to the first row. This self-heals across reorders and // async result arrival without ever force-resetting an active selection. const selectedIndex = useMemo(() => { - if (selectedKey === null) return 0; + const firstSelectable = orderedItems.findIndex((item) => !item.disabledReason); + if (selectedKey === null) return firstSelectable; const i = orderedItems.findIndex((it) => mentionItemKey(it) === selectedKey); - return i === -1 ? 0 : i; + return i === -1 || orderedItems[i]?.disabledReason + ? firstSelectable + : i; }, [orderedItems, selectedKey]); useEffect(() => { - itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); + if (selectedIndex >= 0) { + itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); + } }, [selectedIndex]); const selectItem = useCallback( (item: MentionItem | undefined) => { - if (!item) return; + if (!item || item.disabledReason) return; const wsId = getCurrentWsId(); if (wsId) recordMentionUsage(wsId, item); command(item); @@ -286,16 +299,25 @@ export const MentionList = forwardRef( // see pickerNavigationDirection. const direction = pickerNavigationDirection(event); if (direction !== null) { - if (orderedItems.length === 0) return true; - const delta = direction === "next" ? 1 : orderedItems.length - 1; - const next = (selectedIndex + delta) % orderedItems.length; + const selectableIndexes = orderedItems.flatMap((item, index) => + item.disabledReason ? [] : [index], + ); + if (selectableIndexes.length === 0) return true; + const current = selectableIndexes.indexOf(selectedIndex); + const delta = + direction === "next" ? 1 : selectableIndexes.length - 1; + const next = + selectableIndexes[ + ((current === -1 ? 0 : current) + delta) % + selectableIndexes.length + ]!; setSelectedKey(mentionItemKey(orderedItems[next]!)); return true; } // Enter is the canonical accept; plain Tab is an additive alias (see // isPickerAcceptKey). Shift/modifier+Tab fall through to focus nav. if (isPickerAcceptKey(event)) { - if (orderedItems.length === 0) return true; + if (selectedIndex < 0) return true; selectItem(orderedItems[selectedIndex]); return true; } @@ -396,6 +418,7 @@ function MentionRow({ buttonRef: (el: HTMLButtonElement | null) => void; }) { const { t } = useT("editor"); + const { t: issuesT } = useT("issues"); if (item.type === "issue") { // Visually dim closed issues (done/cancelled) so they're distinguishable // from active ones in the suggestion list — they're still selectable. @@ -461,13 +484,20 @@ function MentionRow({ ); } - return ( + const disabledMessage = item.disabledReason + ? blockedReasonLabel(item.disabledReason, issuesT) + : null; + const button = (