diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 3ccbcfa74d1..bb36f1d48eb 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -20,18 +20,20 @@ import { heartbeatsApi } from "../api/heartbeats"; import { LiveRunWidget } from "../components/LiveRunWidget"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { accessApi } from "../api/access"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; +import { buildMarkdownMentionOptions } from "../lib/company-members"; import { timeAgo } from "../lib/timeAgo"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; -import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; +import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor"; import { RoutineRunVariablesDialog, type RoutineRunDialogSubmitData, @@ -350,6 +352,11 @@ export function RoutineDetail() { queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const routineDefaults = useMemo( () => @@ -649,6 +656,13 @@ export function RoutineDetail() { })), [projects], ); + const mentionOptions = useMemo(() => { + return buildMarkdownMentionOptions({ + agents, + projects, + members: companyMembers?.users, + }); + }, [agents, companyMembers?.users, projects]); const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null; const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null; @@ -882,6 +896,7 @@ export function RoutineDetail() { placeholder="Add instructions..." bordered={false} contentClassName="min-h-[120px] text-[15px] leading-7" + mentions={mentionOptions} onSubmit={() => { if (!saveRoutine.isPending && editDraft.title.trim()) { saveRoutine.mutate(); diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 50e017aa3ea..14f07574949 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -12,6 +12,7 @@ let currentSearch = ""; const navigateMock = vi.fn(); const routinesListMock = vi.fn<(companyId: string) => Promise>(); const issuesListMock = vi.fn<(companyId: string, filters?: Record) => Promise>(); +const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props); const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
{issues.map((issue) => issue.title).join(", ")}
)); @@ -158,6 +159,24 @@ vi.mock("../api/projects", () => ({ }, })); +vi.mock("../api/access", () => ({ + accessApi: { + listUserDirectory: vi.fn(async () => ({ + users: [ + { + principalId: "user-1", + status: "active", + user: { + name: "Taylor", + email: "taylor@example.com", + image: null, + }, + }, + ], + })), + }, +})); + vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })), @@ -186,7 +205,10 @@ vi.mock("@/components/ui/tabs", () => ({ })); vi.mock("../components/MarkdownEditor", () => ({ - MarkdownEditor: () =>
, + MarkdownEditor: (props: { mentions?: Array<{ id: string; name: string }> }) => { + markdownEditorRenderMock(props); + return
; + }, })); vi.mock("../components/InlineEntitySelector", () => ({ @@ -303,6 +325,7 @@ describe("Routines page", () => { navigateMock.mockReset(); routinesListMock.mockReset(); issuesListMock.mockReset(); + markdownEditorRenderMock.mockClear(); issuesListRenderMock.mockClear(); localStorage.clear(); }); @@ -425,6 +448,70 @@ describe("Routines page", () => { }); }); + it("passes company mention options to the routine description editor", async () => { + routinesListMock.mockResolvedValue([]); + issuesListMock.mockResolvedValue([]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + + let createButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Create routine"), + ); + for (let attempts = 0; attempts < 5 && !createButton; attempts += 1) { + await act(async () => { + await flush(); + }); + createButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Create routine"), + ); + } + + expect(createButton).toBeTruthy(); + + await act(async () => { + createButton?.click(); + await flush(); + }); + + for (let attempts = 0; attempts < 5; attempts += 1) { + const hasMentionOptions = markdownEditorRenderMock.mock.calls.some(([props]) => (props.mentions ?? []).length > 0); + if (hasMentionOptions) break; + await act(async () => { + await flush(); + }); + } + + const callsWithMentions = markdownEditorRenderMock.mock.calls + .map(([props]) => props.mentions ?? []) + .filter((mentions) => mentions.length > 0); + + expect(callsWithMentions.at(-1)?.map((mention) => mention.id)).toEqual([ + "user:user-1", + "agent:agent-1", + "agent:agent-2", + "project:project-1", + "project:project-2", + ]); + + await act(async () => { + root.unmount(); + }); + }); + it("shows recent runs through the issues list scoped to routine execution issues", async () => { currentSearch = "tab=runs"; routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index e454b999289..72b15b9f8a5 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -7,9 +7,11 @@ import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; import { heartbeatsApi } from "../api/heartbeats"; +import { accessApi } from "../api/access"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; +import { buildMarkdownMentionOptions } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; @@ -23,7 +25,7 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { AgentIcon } from "../components/AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; -import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; +import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor"; import { RoutineRunVariablesDialog, type RoutineRunDialogSubmitData, @@ -394,6 +396,11 @@ export function Routines() { queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"], queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }), @@ -410,6 +417,14 @@ export function Routines() { autoResizeTextarea(titleInputRef.current); }, [draft.title, composerOpen]); + const mentionOptions = useMemo(() => { + return buildMarkdownMentionOptions({ + agents, + projects, + members: companyMembers?.users, + }); + }, [agents, companyMembers?.users, projects]); + const createRoutine = useMutation({ mutationFn: () => routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)), @@ -896,6 +911,7 @@ export function Routines() { placeholder="Add instructions..." bordered={false} contentClassName="min-h-[160px] text-sm text-muted-foreground" + mentions={mentionOptions} onSubmit={() => { if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) { createRoutine.mutate();