diff --git a/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/__tests__/index.test.tsx b/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/__tests__/index.test.tsx index fefe0c9f..b250cc0e 100644 --- a/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/__tests__/index.test.tsx +++ b/app/platform/[tenantKey]/[mentorId]/_components/nav-bar/__tests__/index.test.tsx @@ -1272,7 +1272,6 @@ const buildContext = ( isMemsearchEnabled: true, isMemoryComponentEnabled: true, isClawEnabled: false, - clawConfigExists: false, isScreenshareEnabled: false, isVoiceCallEnabled: true, isPrivacyEnabled: false, @@ -1524,7 +1523,6 @@ describe('NavBar - Menu Filtering Logic (filterMentorSegments)', () => { isMemsearchEnabled: false, isMemoryComponentEnabled: true, isClawEnabled: false, - clawConfigExists: false, isScreenshareEnabled: false, isVoiceCallEnabled: true, isPrivacyEnabled: false, @@ -1547,7 +1545,6 @@ describe('NavBar - Menu Filtering Logic (filterMentorSegments)', () => { isMemsearchEnabled: true, isMemoryComponentEnabled: true, isClawEnabled: false, - clawConfigExists: false, isScreenshareEnabled: false, isVoiceCallEnabled: true, isPrivacyEnabled: false, @@ -1570,7 +1567,6 @@ describe('NavBar - Menu Filtering Logic (filterMentorSegments)', () => { isMemsearchEnabled: true, isMemoryComponentEnabled: true, isClawEnabled: false, - clawConfigExists: false, isScreenshareEnabled: false, isVoiceCallEnabled: true, isPrivacyEnabled: false, @@ -1589,7 +1585,6 @@ describe('NavBar - Menu Filtering Logic (filterMentorSegments)', () => { isMemsearchEnabled: false, isMemoryComponentEnabled: true, isClawEnabled: false, - clawConfigExists: false, isScreenshareEnabled: false, isVoiceCallEnabled: true, isPrivacyEnabled: false, diff --git a/components/modals/edit-mentor-modal/__tests__/index.test.tsx b/components/modals/edit-mentor-modal/__tests__/index.test.tsx index 015c83df..8f694504 100644 --- a/components/modals/edit-mentor-modal/__tests__/index.test.tsx +++ b/components/modals/edit-mentor-modal/__tests__/index.test.tsx @@ -253,6 +253,9 @@ vi.mock('../tabs', () => ({ ScreenShareTab: () => (
Screen Share Tab
), + HumanSupportTab: () => ( +
Human Support Tab
+ ), LtiTab: () =>
LTI Tab
, AnalyticsTab: () =>
Analytics Tab
, })); diff --git a/components/modals/edit-mentor-modal/edit-mentor-modal.test.tsx b/components/modals/edit-mentor-modal/edit-mentor-modal.test.tsx index bbbbcc0d..66b48b39 100644 --- a/components/modals/edit-mentor-modal/edit-mentor-modal.test.tsx +++ b/components/modals/edit-mentor-modal/edit-mentor-modal.test.tsx @@ -210,6 +210,9 @@ vi.mock('./tabs', () => ({ ScreenShareTab: () => (
Screen Share Tab
), + HumanSupportTab: () => ( +
Human Support Tab
+ ), LtiTab: () =>
LTI Tab
, AnalyticsTab: () =>
Analytics Tab
, })); diff --git a/components/modals/edit-mentor-modal/index.tsx b/components/modals/edit-mentor-modal/index.tsx index e457e2af..1f7e5731 100644 --- a/components/modals/edit-mentor-modal/index.tsx +++ b/components/modals/edit-mentor-modal/index.tsx @@ -32,6 +32,7 @@ import { AuditLogTab, VoiceTab, ScreenShareTab, + HumanSupportTab, LtiTab, AnalyticsTab, } from './tabs'; @@ -78,6 +79,7 @@ export const EDIT_MENTOR_TAB_COMPONENTS: Record = { [MODALS.EDIT_MENTOR.tabs.mcp]: , [MODALS.EDIT_MENTOR.tabs.memory]: , [MODALS.EDIT_MENTOR.tabs.history]: , + [MODALS.EDIT_MENTOR.tabs.human_support]: , [MODALS.EDIT_MENTOR.tabs.audit_log]: , [MODALS.EDIT_MENTOR.tabs.datasets]: , [MODALS.EDIT_MENTOR.tabs.evaluation]: , diff --git a/components/modals/edit-mentor-modal/tabs/human-support-tab.test.tsx b/components/modals/edit-mentor-modal/tabs/human-support-tab.test.tsx new file mode 100644 index 00000000..63ba3a9b --- /dev/null +++ b/components/modals/edit-mentor-modal/tabs/human-support-tab.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +import { HumanSupportTab } from './human-support-tab'; + +// ============================================================================ +// MOCKS +// ============================================================================ + +const mockUseParams = vi.fn(); +const mockGetMentorId = vi.fn(); +const mockUseUsername = vi.fn(); +const mockEnableRBAC = vi.fn(); +const mockAgentSettingsProvider = vi.fn(); +const mockAgentHumanSupportTab = vi.fn(); + +vi.mock('next/navigation', () => ({ + useParams: () => mockUseParams(), +})); + +vi.mock('@/hooks/user-navigate', () => ({ + useNavigate: () => ({ + getMentorId: mockGetMentorId, + }), +})); + +vi.mock('@/hooks/use-user', () => ({ + useUsername: () => mockUseUsername(), +})); + +vi.mock('@/lib/config', () => ({ + config: { + enableRBAC: () => mockEnableRBAC(), + }, +})); + +// HumanSupportTab imports from `@iblai/iblai-js/web-containers/next` (the +// Next-only entry — that's where AgentHumanSupportTab / AgentSettingsProvider +// are actually exported). Vitest keys mocks by module specifier, so we mock +// the exact path the source uses. +vi.mock('@iblai/iblai-js/web-containers/next', () => ({ + AgentSettingsProvider: ({ + children, + ...value + }: { + children: React.ReactNode; + tenantKey: string; + mentorId: string; + username: string; + enableRBAC: boolean; + }) => { + mockAgentSettingsProvider(value); + return ( +
+ {children} +
+ ); + }, + AgentHumanSupportTab: (props: unknown) => { + mockAgentHumanSupportTab(props); + return ( +
AgentHumanSupportTab
+ ); + }, +})); + +// ============================================================================ +// TESTS +// ============================================================================ + +describe('HumanSupportTab', () => { + beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + + mockUseParams.mockReturnValue({ + tenantKey: 'test-tenant', + mentorId: 'test-mentor', + }); + mockGetMentorId.mockReturnValue(null); + mockUseUsername.mockReturnValue('test-user'); + mockEnableRBAC.mockReturnValue(false); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Rendering', () => { + it('wraps AgentHumanSupportTab in an AgentSettingsProvider with the resolved identity', () => { + render(); + + const provider = screen.getByTestId('agent-settings-provider'); + expect(provider).toHaveAttribute('data-tenant-key', 'test-tenant'); + expect(provider).toHaveAttribute('data-mentor-id', 'test-mentor'); + expect(provider).toHaveAttribute('data-username', 'test-user'); + expect(provider).toHaveAttribute('data-enable-rbac', 'false'); + + expect(screen.getByTestId('agent-human-support-tab')).toBeInTheDocument(); + expect(mockAgentSettingsProvider).toHaveBeenCalledWith({ + tenantKey: 'test-tenant', + mentorId: 'test-mentor', + username: 'test-user', + enableRBAC: false, + }); + }); + + it('does not pass a labels override so the SDK i18n catalog stays authoritative', () => { + render(); + + expect(mockAgentHumanSupportTab).toHaveBeenCalledWith( + expect.not.objectContaining({ labels: expect.anything() }), + ); + }); + + it('forwards the config-derived enableRBAC flag to the provider', () => { + mockEnableRBAC.mockReturnValue(true); + + render(); + + expect(screen.getByTestId('agent-settings-provider')).toHaveAttribute( + 'data-enable-rbac', + 'true', + ); + expect(mockAgentSettingsProvider).toHaveBeenCalledWith( + expect.objectContaining({ enableRBAC: true }), + ); + }); + }); + + describe('Active mentor id resolution', () => { + it('prefers getMentorId() from navigate hook when provided', () => { + mockGetMentorId.mockReturnValue('nav-mentor-xyz'); + + render(); + + expect(mockAgentSettingsProvider).toHaveBeenCalledWith( + expect.objectContaining({ mentorId: 'nav-mentor-xyz' }), + ); + }); + + it('falls back to params.mentorId when getMentorId() returns null', () => { + mockGetMentorId.mockReturnValue(null); + + render(); + + expect(mockAgentSettingsProvider).toHaveBeenCalledWith( + expect.objectContaining({ mentorId: 'test-mentor' }), + ); + }); + }); + + describe('Guard clauses', () => { + it('renders nothing when tenantKey is missing', () => { + mockUseParams.mockReturnValue({ + tenantKey: undefined, + mentorId: 'test-mentor', + }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + expect(mockAgentSettingsProvider).not.toHaveBeenCalled(); + expect(mockAgentHumanSupportTab).not.toHaveBeenCalled(); + }); + + it('renders nothing when both mentorId and getMentorId() are missing', () => { + mockUseParams.mockReturnValue({ + tenantKey: 'test-tenant', + mentorId: undefined, + }); + mockGetMentorId.mockReturnValue(null); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + expect(mockAgentSettingsProvider).not.toHaveBeenCalled(); + expect(mockAgentHumanSupportTab).not.toHaveBeenCalled(); + }); + + it('renders nothing when username is missing', () => { + mockUseUsername.mockReturnValue(undefined); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + expect(mockAgentSettingsProvider).not.toHaveBeenCalled(); + expect(mockAgentHumanSupportTab).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/components/modals/edit-mentor-modal/tabs/human-support-tab.tsx b/components/modals/edit-mentor-modal/tabs/human-support-tab.tsx new file mode 100644 index 00000000..00528864 --- /dev/null +++ b/components/modals/edit-mentor-modal/tabs/human-support-tab.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { + AgentHumanSupportTab, + AgentSettingsProvider, +} from '@iblai/iblai-js/web-containers/next'; + +import { useNavigate } from '@/hooks/user-navigate'; +import { useUsername } from '@/hooks/use-user'; +import { config } from '@/lib/config'; +import { TenantKeyMentorIdParams } from '@/lib/types'; + +export function HumanSupportTab() { + const { tenantKey, mentorId } = useParams(); + const { getMentorId } = useNavigate(); + const username = useUsername(); + const activeMentorId = getMentorId() ?? mentorId; + + if (!tenantKey || !activeMentorId || !username) return null; + + // AgentHumanSupportTab reads tenant/mentor/username from the nearest + // AgentSettingsProvider (like AgentTasksTab), so the provider is required + // here. No `labels` override is passed — the SDK resolves every string + // through its own i18n catalog, which follows the app locale via the + // WebContainersI18nProvider bridge, so all copy stays translated. + return ( + + + + ); +} diff --git a/components/modals/edit-mentor-modal/tabs/index.ts b/components/modals/edit-mentor-modal/tabs/index.ts index aebaac72..28255519 100644 --- a/components/modals/edit-mentor-modal/tabs/index.ts +++ b/components/modals/edit-mentor-modal/tabs/index.ts @@ -17,5 +17,6 @@ export * from './skills-tab'; export * from './audit-log-tab'; export * from './voice-tab'; export * from './screenshare-tab'; +export * from './human-support-tab'; export * from './lti-tab'; export * from './analytics-tab'; diff --git a/e2e/journeys/65-mentor-human-support-tab.spec.ts b/e2e/journeys/65-mentor-human-support-tab.spec.ts new file mode 100644 index 00000000..b171c1cf --- /dev/null +++ b/e2e/journeys/65-mentor-human-support-tab.spec.ts @@ -0,0 +1,282 @@ +import { test, expect } from '../fixtures/mentor-test'; +import { + navigateToMentorApp, + checkAdminStatus, + getPlatformContext, +} from '../utils/auth'; +import { waitForPageReady } from '../utils/resilient'; +import { MentorTracker } from '../utils/mentor-cleanup'; +import { + sendAgentChatMessage, + waitForAgentChatResponse, +} from '@iblai/iblai-js/playwright'; + +/** + * Journey 65 — Mentor Human Support Tab. + * + * The Support tab (host sidebar label "Support"; SDK panel value + * `human_support`) is rendered by the SDK's `AgentHumanSupportTab` + * (`@iblai/iblai-js/web-containers/next`) inside + * `components/modals/edit-mentor-modal/tabs/human-support-tab.tsx`. It is an + * admin-only support-ticket inbox: a filter bar (user search + status + * select), a ticket list, a detail pane (status select, description, + * conversation, reply composer). Tickets are filed by the AGENT during chat + * via a support tool — an admin asks the agent to open a ticket, the agent + * runs the tool server-side, and the ticket then appears here. + * + * All DOM access flows through `HumanSupportTab` + * (`e2e/page-objects/edit-mentor/human-support.tab.ts`), which delegates + * every locator/action to the SDK's `human-support-tab-helpers` module + * (`@iblai/iblai-js/playwright`) — no hand-rolled selectors. The chat step + * uses `sendAgentChatMessage`/`waitForAgentChatResponse` directly (not the + * SDK's `requestSupportTicketViaChat`): that helper's prompt phrasing + * ("create a support ticket … Description: …") reads like a doc-authoring + * request to the model, which made the agent ALSO invoke the canvas tool — + * the canvas panel then streamed a document, the stop-streaming button never + * unmounted, and `waitForAgentChatResponse` timed out. The inline prompt + * below pins the flow to the plain chat: call the support tool, reply in + * plain text, no canvas/document/artifact. + * + * CATEGORY: Support lives under the modal's **Runtime** sidebar category + * (alongside Tasks / Memory / History / Audit), so `editMentorPage.open('Support')` + * activates that category before the segment mounts. + * + * AVAILABILITY GUARD: the info-box toggle that mirrors the human-support + * tool's on/off state is HIDDEN when the tenant's tool catalog has no + * human-support tool. `HumanSupportTab.hasToggle()` guards this — the + * chat-driven lifecycle test skips (rather than fails) when the toggle never + * renders, since the agent has no tool to call in that case. + * + * ── Isolation ──────────────────────────────────────────────────────────── + * + * This journey mutates mentor settings (the support availability toggle) and + * creates support tickets — per house style (see journey 47), destructive + * admin tests must not run against the shared most-recently-accessed mentor. + * Every test gets its own freshly-created mentor in `beforeEach`, and the + * whole file runs serially so no other test/worker can touch it mid-test. + * Created mentors are tracked and deleted in `afterAll` via `MentorTracker`. + */ + +test.describe.configure({ mode: 'serial' }); + +test.describe('Journey 65: Mentor Human Support Tab', () => { + const tracker = new MentorTracker(); + + test.beforeEach(async ({ page, editMentorPage, createMentorPage }) => { + await navigateToMentorApp(page); + const isAdmin = await checkAdminStatus(page); + if (!isAdmin) { + test.skip(true, 'Human Support tab requires admin access'); + return; + } + + // Dedicated mentor per test — see the isolation note above. + await createMentorPage.openAndCreate(); + const { mentorId } = await getPlatformContext(page); + tracker.add(mentorId); + }); + + test.afterAll(async ({ browser }, testInfo) => { + await tracker.deleteAll(browser, testInfo); + }); + + // ── support-01: cheap render contract ──────────────────────────────────── + // Non-chat checkpoint: the tab loads and renders its header plus either the + // ticket list or the empty state — whichever the fresh mentor's (empty) + // ticket set produces. Both are correct; this guards the render/API + // contract without depending on chat/LLM latency. + test('admin opens the Human Support tab and the ticket surface renders (list or empty state)', async ({ + page, + editMentorPage, + }) => { + await editMentorPage.open('Support'); + await waitForPageReady(page); + + await expect(editMentorPage.humanSupport.heading()).toBeVisible({ + timeout: 15_000, + }); + await expect(editMentorPage.humanSupport.headerDescription()).toBeVisible({ + timeout: 10_000, + }); + + // A freshly-created mentor has no tickets yet, so the empty state is the + // expected branch here — tolerate either to keep this checkpoint honest + // about the render contract rather than the specific data state. + await expect( + editMentorPage.humanSupport + .ticketList() + .or(editMentorPage.humanSupport.noTicketsMessage()) + .first(), + ).toBeVisible({ timeout: 15_000 }); + + await editMentorPage.close(); + }); + + // ── support-02: status filter round-trip ───────────────────────────────── + // Non-chat checkpoint: cycling the status filter through every value and + // back to "All" re-queries the backend each time without erroring, and the + // surface keeps rendering a valid state (list or empty) after every change. + test('admin round-trips the Human Support status filter', async ({ + page, + editMentorPage, + }) => { + await editMentorPage.open('Support'); + await waitForPageReady(page); + + await expect(editMentorPage.humanSupport.statusFilter()).toBeVisible({ + timeout: 15_000, + }); + + for (const status of ['open', 'inProgress', 'closed', 'all'] as const) { + await editMentorPage.humanSupport.filterByStatus(status); + await expect( + editMentorPage.humanSupport + .ticketList() + .or(editMentorPage.humanSupport.noTicketsMessage()) + .first(), + ).toBeVisible({ timeout: 15_000 }); + } + + await editMentorPage.close(); + }); + + // ── support-03..support-08: full chat-driven ticket lifecycle ──────────── + test('admin runs the full support ticket lifecycle through agent chat: create, reply, In Progress, Closed', async ({ + page, + editMentorPage, + chatPage, + }) => { + // Chat + LLM tool execution is slow (up to 90s per turn) and this test + // chains two chat-adjacent waits (creation reply, ticket-list poll) plus + // two Edit Agent modal hydrations (~30-90s each) — generous budget. + test.setTimeout(600_000); + test.slow(); + + await editMentorPage.open('Support'); + await waitForPageReady(page); + + // support-03: ensure the support tool is available and enabled. The + // toggle is hidden entirely when the tenant's tool catalog has no + // human-support tool — skip the chat-driven lifecycle rather than fail, + // since the agent has no tool to call in that case. + const hasToggle = await editMentorPage.humanSupport.hasToggle(); + test.skip( + !hasToggle, + 'human-support tool not available in this tenant catalog — Support toggle not rendered', + ); + await editMentorPage.humanSupport.setEnabled(true); + await editMentorPage.close(); + + // Round-trip verification that the toggle PERSISTED before any chat: + // reload (drops every client cache), re-open the tab, and assert the + // switch reads back as enabled from the server. + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForPageReady(page); + await editMentorPage.open('Support'); + await waitForPageReady(page); + expect(await editMentorPage.humanSupport.isEnabled()).toBe(true); + await editMentorPage.close(); + + // CRITICAL: the chat session snapshots the mentor's tool list when the + // session is CREATED, and the page creates/restores its session on + // boot — BEFORE the toggle above was flipped. A reload alone does not + // help: it restores the same session id (trace-confirmed: session + // POSTed at 15:37:39, toggle PUT at 15:37:50, post-reload GETs still + // on the same session; the model then had only `write_todos`, put + // "create a support ticket" on its todo list, and REPLIED "created + // successfully" without any ticket existing). Start a NEW chat so the + // message rides a fresh session that includes the support tool. + await chatPage.startNewChat(); + await waitForPageReady(page); + + // Identity guard: the message must go to the SAME dedicated agent whose + // Support tab we verify below. Both the chat surface and the Edit Agent + // modal bind to the page's URL mentor, so pin it here and re-check it + // after the chat before asserting on the ticket list. + const { mentorId: chatMentorId } = await getPlatformContext(page); + + // support-04: ask the agent (main chat) to open a ticket with a unique, + // timestamped subject, then verify it lands in the Human Support list. + // The prompt explicitly forbids canvas/document output: without that, + // the model treats the subject+description phrasing as doc-authoring + // and opens a streaming canvas artifact alongside the support tool + // call, which keeps the stop-streaming button mounted forever and + // times out `waitForAgentChatResponse` (observed in the first run of + // this journey — the ticket itself was created fine). + const subject = `E2E Support ${Date.now()}`; + await sendAgentChatMessage( + page, + `Use your support ticket tool to create a support ticket now. ` + + `Use exactly this subject: "${subject}". ` + + `Description: E2E automated ticket lifecycle test — please assist. ` + + `Reply with a short plain-text confirmation only — do NOT create ` + + `a canvas, document, file, or any other artifact.`, + ); + // Best-effort: if the model disobeys and opens a canvas anyway, the + // stop-streaming button can stay mounted past the deadline even though + // the support tool already ran. The authoritative assertion is the + // ticket appearing in the list below, so a response-wait timeout is + // logged but not fatal — genuine chat breakage still fails the test + // because no ticket ever lands. + await waitForAgentChatResponse(page).catch(() => {}); + + // Re-check the identity guard: the page must still be on the mentor the + // message was sent to — the Support tab below lists tickets for the + // page's URL mentor, so any drift here would verify the wrong agent. + const { mentorId: listMentorId } = await getPlatformContext(page); + expect(listMentorId).toBe(chatMentorId); + + await editMentorPage.open('Support'); + await waitForPageReady(page); + // The default refresh (status-filter bounce) only refetches the FIRST + // bounce — after that both filter combos sit in the RTK Query cache and + // later bounces produce zero network calls (confirmed via trace: two + // tickets GETs total across a 2.6-minute poll). A full page reload is + // the only host-side refresh that guarantees a fresh query, so each + // poll closes the modal, reloads, and re-opens the Support tab. + await editMentorPage.humanSupport.waitForTicketInList(subject, { + timeoutMs: 120_000, + refresh: async () => { + await editMentorPage.close(); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForPageReady(page); + await editMentorPage.open('Support'); + await waitForPageReady(page); + }, + }); + await editMentorPage.humanSupport.expectTicketStatusInList(subject, 'open'); + + // support-05: open the ticket and confirm real (agent-authored) content + // rendered. Not asserting exact wording — the description is LLM + // output, so only its presence is a stable signal (same + // non-empty-content pattern used for LTI key material in journey 60). + await editMentorPage.humanSupport.openTicket(subject); + const descriptionText = + (await editMentorPage.humanSupport.description().textContent()) ?? ''; + expect(descriptionText.trim().length).toBeGreaterThan(0); + + // support-06: admin replies from the detail pane; the message must + // appear in the conversation. + const replyMessage = `Admin reply ${Date.now()} — we're looking into this.`; + await editMentorPage.humanSupport.reply(replyMessage); + await editMentorPage.humanSupport.expectMessageInConversation(replyMessage); + + // support-07: move the ticket to "In Progress"; the list badge updates. + await editMentorPage.humanSupport.setStatus('inProgress'); + await editMentorPage.humanSupport.expectTicketStatusInList( + subject, + 'inProgress', + ); + + // support-08: close the ticket; the closed notice replaces the composer + // and the list badge reflects the closed status. + await editMentorPage.humanSupport.setStatus('closed'); + await editMentorPage.humanSupport.expectClosedNotice(); + await editMentorPage.humanSupport.expectTicketStatusInList( + subject, + 'closed', + ); + + await editMentorPage.close(); + }); +}); diff --git a/e2e/page-objects/edit-mentor/edit-mentor.page.ts b/e2e/page-objects/edit-mentor/edit-mentor.page.ts index aa3cca14..14599bc0 100644 --- a/e2e/page-objects/edit-mentor/edit-mentor.page.ts +++ b/e2e/page-objects/edit-mentor/edit-mentor.page.ts @@ -19,6 +19,7 @@ import { PrivacyTab } from './privacy.tab'; import { TasksTab } from './tasks.tab'; import { VoiceTab } from './voice.tab'; import { ScreenShareTab } from './screenshare.tab'; +import { HumanSupportTab } from './human-support.tab'; import { LtiTab } from './lti.tab'; /** @@ -58,6 +59,9 @@ const TAB_CATEGORY: Record< Tasks: 'Runtime', Memory: 'Runtime', History: 'Runtime', + // Host sidebar label for the human-support segment is "Support" (see + // hooks/use-mentor-segments.ts), not "Human Support". + Support: 'Runtime', Audit: 'Runtime', Analytics: 'Runtime', Evals: 'Runtime', @@ -85,6 +89,7 @@ export class EditMentorPage { readonly tasks: TasksTab; readonly voice: VoiceTab; readonly screenshare: ScreenShareTab; + readonly humanSupport: HumanSupportTab; readonly lti: LtiTab; readonly copyMentorDialog: CopyMentorPage; @@ -112,6 +117,7 @@ export class EditMentorPage { this.tasks = new TasksTab(page, this.dialog); this.voice = new VoiceTab(page, this.dialog); this.screenshare = new ScreenShareTab(page, this.dialog); + this.humanSupport = new HumanSupportTab(page, this.dialog); this.lti = new LtiTab(page, this.dialog); this.copyMentorDialog = new CopyMentorPage(page); @@ -129,6 +135,10 @@ export class EditMentorPage { // modal sits on its default Configurations view. Binding the tab nav lets // `TasksTab.switchToTab()` activate Runtime first. this.tasks.bindTabNav(this.navigateToTab.bind(this)); + + // Human Support has the identical problem — it also lives in the Runtime + // category and its SDK helper `switchToSupportTab` is category-blind. + this.humanSupport.bindTabNav(this.navigateToTab.bind(this)); } /** diff --git a/e2e/page-objects/edit-mentor/human-support.tab.ts b/e2e/page-objects/edit-mentor/human-support.tab.ts new file mode 100644 index 00000000..a2dc9f95 --- /dev/null +++ b/e2e/page-objects/edit-mentor/human-support.tab.ts @@ -0,0 +1,367 @@ +import { Page, Locator } from '@playwright/test'; +import { + SUPPORT_LABELS, + getSupportTabTrigger, + isSupportTabVisible, + switchToSupportTab, + getSupportInfoBox, + getSupportToggle, + isSupportEnabled, + setSupportEnabled, + enableSupport, + disableSupport, + getTicketList, + getTicketRow, + getTicketRowByIndex, + expectTicketInList, + expectNoTickets, + expectTicketStatusInList, + getStatusFilter, + filterTicketsByStatus, + getUserFilter, + filterTicketsByUser, + clearUserFilter, + refreshTickets, + waitForTicketInList, + getTicketDetail, + getTicketDescription, + openTicket, + expectTicketDescriptionContains, + setTicketStatus, + expectTicketClosedNotice, + getReplyComposer, + replyToTicket, + expectMessageInConversation, + expectNoRepliesYet, + createSupportTicketViaChatAndVerify, + type SupportTicketStatus, +} from '@iblai/iblai-js/playwright'; + +export type { SupportTicketStatus }; + +/** + * Page object for the Human Support tab inside the Edit Mentor (Agent) modal. + * + * Rendered by the SDK's `AgentHumanSupportTab` + * (`@iblai/iblai-js/web-containers/next`), wrapped in `AgentSettingsProvider` + * by `components/modals/edit-mentor-modal/tabs/human-support-tab.tsx`. The tab + * is admin-only (`userTypes: [UserType.ADMIN]`) and lives under the + * **Runtime** sidebar category (`hooks/use-mentor-segments.ts`), alongside + * Tasks / Memory / History / Audit. The host sidebar renders the segment's + * label as **"Support"** (not "Human Support") — `SUPPORT_LABELS.tabName` + * mirrors this; the SDK still calls its own internal export "Support" too, so + * host and SDK never disagree on the trigger's accessible name. + * + * All DOM access goes through the semantic helpers exported from + * `@iblai/iblai-js/playwright`'s `human-support-tab-helpers` module — resolved + * via stable `data-testid` attributes, accessible roles, and aria-labels + * emitted by the SDK. Selector changes in the SDK are absorbed by bumping + * `@iblai/iblai-js`; no hand-rolled selectors live here beyond the couple of + * label-derived locators the SDK doesn't itself export a getter for (heading, + * empty-state text) — same convention as `TasksTab`/`VoiceTab`. + * + * CATEGORY: the modal sidebar only mounts the active category's segment + * triggers (see `EditMentorPage.navigateToTab`). Support lives under + * **Runtime**, so `switchToTab()` activates that category first via the + * `bindTabNav` callback injected by `EditMentorPage` — mirrors `TasksTab`, + * which has the identical problem (its SDK helper `switchToTasksTab` + * predates the category strip and is category-blind). + * + * AVAILABILITY TOGGLE: `getSupportToggle`/`isSupportEnabled`/ + * `setSupportEnabled` resolve the info-box switch that mirrors the + * human-support tool's on/off state (the same tool exposed on the Tools tab). + * It is HIDDEN when the tenant's tool catalog has no human-support tool — + * callers must guard with `hasToggle()` before calling `setEnabled`. + * + * The instance scopes every helper to the Edit Mentor `dialog` Locator so + * other portaled dialogs in the same page (toasts, confirm dialogs, etc.) + * cannot interfere with SDK locators. + */ +export class HumanSupportTab { + readonly page: Page; + readonly dialog: Locator; + + /** Default labels shipped with the SDK — use for text assertions. */ + static readonly LABELS = SUPPORT_LABELS; + + /** + * Category-aware tab navigation injected by `EditMentorPage` (see + * `bindTabNav` there). The Support segment lives in the modal's Runtime + * category, and the sidebar only mounts the ACTIVE category's segment + * triggers — so the SDK's `switchToSupportTab` (which predates the + * category strip and expects the trigger to already be in the DOM) can't + * find it while the modal sits on its default Configurations view. + */ + private navigateToTab?: (tabName: string) => Promise; + + bindTabNav(navigateToTab: (tabName: string) => Promise): void { + this.navigateToTab = navigateToTab; + } + + constructor(page: Page, dialog: Locator) { + this.page = page; + this.dialog = dialog; + } + + // --------------------------------------------------------------------------- + // Navigation + // --------------------------------------------------------------------------- + + /** True when the Support tab trigger is rendered (admin-only segment guard). */ + isTabVisible(): Promise { + return isSupportTabVisible(this.page); + } + + /** The host sidebar trigger for the Support segment. */ + tabLink(): Locator { + return getSupportTabTrigger(this.dialog); + } + + /** + * Click the Support tab inside the Edit Mentor modal. + * + * Activates the Runtime category first when the tab nav is bound (the + * default via `EditMentorPage`'s constructor), then delegates to the SDK + * helper — its click on the now-active trigger is a no-op, but its + * wait-for-panel-ready assertion is still the readiness signal callers + * rely on. + */ + async switchToTab(): Promise { + if (this.navigateToTab) { + await this.navigateToTab(SUPPORT_LABELS.tabName); + } + await switchToSupportTab(this.page); + } + + // --------------------------------------------------------------------------- + // Header (no dedicated SDK getter — built directly from SUPPORT_LABELS, + // same convention as TasksTab.heading()/description()). + // --------------------------------------------------------------------------- + + /** The "Support" heading at the top of the tab panel. Resolved by + * `role="heading"` so it never collides with the sidebar `tab` element + * that shares the same accessible name. */ + heading(): Locator { + return this.dialog.getByRole('heading', { + name: SUPPORT_LABELS.header.title, + exact: true, + }); + } + + headerDescription(): Locator { + return this.dialog.getByText(SUPPORT_LABELS.header.description, { + exact: false, + }); + } + + // --------------------------------------------------------------------------- + // Availability toggle (info box) + // --------------------------------------------------------------------------- + + infoBox(): Locator { + return getSupportInfoBox(this.dialog); + } + + toggle(): Locator { + return getSupportToggle(this.dialog); + } + + /** + * Whether the availability toggle is present at all. Hidden when the + * tenant's tool catalog has no human-support tool — callers must check + * this before `setEnabled`/`enable`/`disable`, which otherwise hang + * waiting for a control that will never render. + */ + async hasToggle(): Promise { + let visible = false; + try { + await this.toggle().waitFor({ state: 'visible', timeout: 5_000 }); + visible = true; + } catch { + visible = false; + } + return visible; + } + + isEnabled(): Promise { + return isSupportEnabled(this.dialog); + } + + setEnabled(enabled: boolean): Promise { + return setSupportEnabled(this.dialog, enabled); + } + + enable(): Promise { + return enableSupport(this.dialog); + } + + disable(): Promise { + return disableSupport(this.dialog); + } + + // --------------------------------------------------------------------------- + // Ticket list + // --------------------------------------------------------------------------- + + ticketList(): Locator { + return getTicketList(this.dialog); + } + + /** The empty-state message rendered when there are no support tickets. */ + noTicketsMessage(): Locator { + return this.dialog.getByText(SUPPORT_LABELS.list.noTickets, { + exact: true, + }); + } + + ticketRow(subject: string | RegExp): Locator { + return getTicketRow(this.dialog, subject); + } + + ticketRowByIndex(index: number): Locator { + return getTicketRowByIndex(this.dialog, index); + } + + expectTicketInList( + subject: string | RegExp, + timeoutMs?: number, + ): Promise { + return expectTicketInList(this.dialog, subject, timeoutMs); + } + + expectNoTickets(): Promise { + return expectNoTickets(this.dialog); + } + + expectTicketStatusInList( + subject: string | RegExp, + status: SupportTicketStatus, + ): Promise { + return expectTicketStatusInList(this.dialog, subject, status); + } + + statusFilter(): Locator { + return getStatusFilter(this.dialog); + } + + filterByStatus(status: SupportTicketStatus | 'all'): Promise { + return filterTicketsByStatus(this.dialog, status); + } + + userFilter(): Locator { + return getUserFilter(this.dialog); + } + + filterByUser(search: string, optionText?: string | RegExp): Promise { + return filterTicketsByUser(this.dialog, search, optionText); + } + + clearUserFilter(): Promise { + return clearUserFilter(this.dialog); + } + + /** Force the ticket list to re-query (bounces the status filter). */ + refresh(): Promise { + return refreshTickets(this.dialog); + } + + /** + * Wait for a ticket to appear in the list, refreshing between polls. + * Agent-created tickets land after the chat tool call completes + * server-side, so allow a generous deadline. + */ + waitForTicketInList( + subject: string | RegExp, + opts?: { timeoutMs?: number; refresh?: () => Promise }, + ): Promise { + return waitForTicketInList(this.dialog, subject, opts); + } + + // --------------------------------------------------------------------------- + // Ticket detail + // --------------------------------------------------------------------------- + + detailPane(): Locator { + return getTicketDetail(this.dialog); + } + + /** The "Select a ticket to view details." prompt shown before any ticket + * is selected. */ + detailSelectPrompt(): Locator { + return this.dialog.getByText(SUPPORT_LABELS.detail.selectPrompt, { + exact: true, + }); + } + + description(): Locator { + return getTicketDescription(this.dialog); + } + + /** Open a ticket from the list and wait for its detail to render. */ + openTicket(subject: string | RegExp): Promise { + return openTicket(this.dialog, subject); + } + + expectDescriptionContains(text: string | RegExp): Promise { + return expectTicketDescriptionContains(this.dialog, text); + } + + /** + * Change the opened ticket's status via the detail pane's Status select. + * Selecting "closed" routes through the dedicated close endpoint and + * replaces the composer with the closed notice. + */ + setStatus(status: SupportTicketStatus): Promise { + return setTicketStatus(this.dialog, status); + } + + expectClosedNotice(): Promise { + return expectTicketClosedNotice(this.dialog); + } + + replyComposer(): Locator { + return getReplyComposer(this.dialog); + } + + /** Send a reply on the opened ticket; waits for the composer to clear. */ + reply(message: string): Promise { + return replyToTicket(this.dialog, message); + } + + expectMessageInConversation(message: string | RegExp): Promise { + return expectMessageInConversation(this.dialog, message); + } + + expectNoRepliesYet(): Promise { + return expectNoRepliesYet(this.dialog); + } + + // --------------------------------------------------------------------------- + // Chat-driven ticket creation + // --------------------------------------------------------------------------- + + /** + * Full flow: with support enabled, ask the agent (via the main chat + * surface) for a ticket, then verify it shows up in this tab. + * + * The Edit Agent modal is host-specific to open/close, so the caller + * supplies `openEditAgentModal` (and optionally `closeEditAgentModal`, + * used to get the modal out of the chat's way before sending). Steps: + * + * 1. `openEditAgentModal()` → Support tab → ensure the toggle is ON. + * 2. `closeEditAgentModal()` (when provided) → chat-request the ticket. + * 3. `openEditAgentModal()` → Support tab → wait for the ticket row. + * + * Returns the dialog locator with the Support tab open and the new ticket + * listed, so specs can keep asserting (open it, reply, close it). + */ + createTicketViaChatAndVerify(opts: { + subject: string; + description?: string; + openEditAgentModal: () => Promise; + closeEditAgentModal?: () => Promise; + listTimeoutMs?: number; + }): Promise { + return createSupportTicketViaChatAndVerify(this.page, opts); + } +} diff --git a/hooks/__tests__/use-mentor-segments-screenshare.test.ts b/hooks/__tests__/use-mentor-segments-screenshare.test.ts index 2553e1ba..52aa3c43 100644 --- a/hooks/__tests__/use-mentor-segments-screenshare.test.ts +++ b/hooks/__tests__/use-mentor-segments-screenshare.test.ts @@ -101,7 +101,6 @@ const screenshareSegment = MENTOR_SEGMENTS.find( const baseFlags = { isMemsearchEnabled: false, isClawEnabled: false, - clawConfigExists: false, isMemoryComponentEnabled: false, isScreenshareEnabled: false, // Voice calls default on so the Screen Share gating tests aren't perturbed diff --git a/hooks/__tests__/use-mentor-segments.test.tsx b/hooks/__tests__/use-mentor-segments.test.tsx index 0a33b9d4..5b54e054 100644 --- a/hooks/__tests__/use-mentor-segments.test.tsx +++ b/hooks/__tests__/use-mentor-segments.test.tsx @@ -20,7 +20,11 @@ import { EDIT_MENTOR_TAB_COMPONENTS } from '@/components/modals/edit-mentor-moda const mockMentorSettings = vi.fn(); const mockMemsearchEnabled = vi.fn(); -const mockClawMentorConfig = vi.fn(); +// Call-tracking guard: the segments hook must NOT fetch the claw-config — +// Sandbox/Skills are always visible, and fetching here would fire on every +// page through the NavBar. Only the Sandbox/Prompts tab contents (mounted on +// demand) may query it. +const mockUseGetClawMentorConfigQuery = vi.fn(() => ({ data: null })); const mockGetMentorId = vi.fn(); const mockTenantKey = vi.fn(); const mockMentorIdParam = vi.fn(); @@ -46,7 +50,8 @@ vi.mock('@iblai/iblai-js/data-layer', () => ({ useGetMemsearchStatusQuery: () => ({ data: { enable_memsearch: mockMemsearchEnabled() }, }), - useGetClawMentorConfigQuery: () => ({ data: mockClawMentorConfig() }), + useGetClawMentorConfigQuery: (...args: unknown[]) => + mockUseGetClawMentorConfigQuery(...(args as [])), })); vi.mock('@/hooks/use-user', () => ({ @@ -105,6 +110,7 @@ vi.mock('@iblai/iblai-js/web-containers', () => ({ vi.mock('@iblai/iblai-js/web-containers/next', () => ({ AgentPrivacyTab: () => null, AgentTasksTab: () => null, + AgentHumanSupportTab: () => null, AgentSettingsProvider: () => null, })); @@ -124,10 +130,7 @@ const setupDefaults = () => { ); mockUserType.mockReturnValue(UserType.ADMIN); mockMemsearchEnabled.mockReturnValue(true); - // Default: a wired claw-config exists, so Skills tab gating only depends on - // isClawEnabled in most existing tests. Tests that need the unwired state - // override this in their own arrangement. - mockClawMentorConfig.mockReturnValue({ id: 1, enabled: true, server: 7 }); + mockUseGetClawMentorConfigQuery.mockClear(); mockMentorSettings.mockReturnValue({ platform_key: 'custom-tenant', mentor_visibility: MentorVisibilityEnum.VIEWABLE_BY_TENANT_ADMINS, @@ -147,12 +150,13 @@ describe('useMentorSegments', () => { setupDefaults(); }); - it('returns the canonical 22 mentor segments unfiltered', () => { + it('returns the canonical 24 mentor segments unfiltered', () => { const { result } = renderHook(() => useMentorSegments()); expect(result.current.segments).toBe(MENTOR_SEGMENTS); // 17 original + Voice + Screen Share (feat/mentor/1763) + Tasks - // (feat/mentor/715) + LTI + Analytics hub (feat/2040). - expect(MENTOR_SEGMENTS).toHaveLength(23); + // (feat/mentor/715) + LTI + Analytics hub (feat/2040) + Human Support + // (feat/2081) + Evals (feat/1178). + expect(MENTOR_SEGMENTS).toHaveLength(24); }); it('places the Sandbox segment right after Settings', () => { @@ -586,20 +590,8 @@ describe('useMentorSegments', () => { enable_claw: true, }; - it('shows both Sandbox and Skills when claw is enabled but no config wired', () => { + it('shows both Sandbox and Skills when claw is enabled', () => { mockMentorSettings.mockReturnValue(adminClawEnabledSettings); - mockClawMentorConfig.mockReturnValue(null); // 404 → null - - const { result } = renderHook(() => useMentorSegments()); - const labels = result.current.filteredSegments.map((s) => s.label); - - expect(labels).toContain('Sandbox'); - expect(labels).toContain('Skills'); - }); - - it('shows both Sandbox and Skills when claw is enabled AND a config is wired', () => { - mockMentorSettings.mockReturnValue(adminClawEnabledSettings); - mockClawMentorConfig.mockReturnValue({ id: 1, enabled: true, server: 7 }); const { result } = renderHook(() => useMentorSegments()); const labels = result.current.filteredSegments.map((s) => s.label); @@ -613,7 +605,6 @@ describe('useMentorSegments', () => { ...adminClawEnabledSettings, enable_claw: false, }); - mockClawMentorConfig.mockReturnValue(null); const { result } = renderHook(() => useMentorSegments()); const labels = result.current.filteredSegments.map((s) => s.label); @@ -621,6 +612,17 @@ describe('useMentorSegments', () => { expect(labels).toContain('Skills'); expect(labels).toContain('Sandbox'); }); + + it('never fetches the claw-config, even when claw is enabled', () => { + // The hook runs on every page via the NavBar; visibility no longer + // depends on a wired ClawMentorConfig, so the query must not fire here. + // The Sandbox/Prompts tabs fetch it themselves when they mount. + mockMentorSettings.mockReturnValue(adminClawEnabledSettings); + + renderHook(() => useMentorSegments()); + + expect(mockUseGetClawMentorConfigQuery).not.toHaveBeenCalled(); + }); }); }); diff --git a/hooks/use-mentor-segments.ts b/hooks/use-mentor-segments.ts index 1ee87d6e..57932cd3 100644 --- a/hooks/use-mentor-segments.ts +++ b/hooks/use-mentor-segments.ts @@ -13,6 +13,7 @@ import { CalendarClock, Clock, Grid, + Headset, FlaskConical, GraduationCap, Key, @@ -32,7 +33,6 @@ import { MentorVisibilityEnum } from '@iblai/iblai-api'; import { useGetMentorSettingsQuery, useGetMemsearchStatusQuery, - useGetClawMentorConfigQuery, } from '@iblai/iblai-js/data-layer'; import { MODALS, UserType } from '@/lib/constants'; @@ -55,8 +55,6 @@ import { config } from '@/lib/config'; export type MentorSegmentConfigFlags = { isMemsearchEnabled: boolean; isClawEnabled: boolean; - /** True when a ClawMentorConfig exists for this mentor (sandbox wired to an instance). */ - clawConfigExists: boolean; isMemoryComponentEnabled: boolean; /** True when `enable_privacy_router` is on for this mentor. */ isPrivacyEnabled: boolean; @@ -397,6 +395,23 @@ export const MENTOR_SEGMENTS: MentorSegment[] = [ ], navCategory: 'runtime', }, + { + value: MODALS.EDIT_MENTOR.tabs.human_support, + label: 'Support', + labelKey: 'support', + icon: Headset, + // Admin-only ticket inbox (view / reply / close support requests). + // No `rbacResource` yet — the backend doesn't expose one for support + // tickets, so the userTypes filter alone gates visibility (mirroring + // Tasks / Sandbox / Access). + userTypes: [UserType.ADMIN], + permissionFieldsCheck: [], + mentorVisibility: [ + MentorVisibilityEnum.VIEWABLE_BY_TENANT_ADMINS, + MentorVisibilityEnum.VIEWABLE_BY_TENANT_STUDENTS, + ], + navCategory: 'runtime', + }, { value: MODALS.EDIT_MENTOR.tabs.audit_log, label: 'Audit', @@ -629,21 +644,11 @@ export function useMentorSegments(options: UseMentorSegmentsOptions = {}) { // @ts-expect-error enable_claw is not yet in the MentorSettingsPublic type const isClawEnabled: boolean = mentorSettings?.enable_claw ?? false; - // The claw-config endpoint is keyed by the mentor's UUID. Use the value from - // mentor settings; fall back to the resolved id (which may already be a UUID - // when navigating directly). - const mentorUuid: string | undefined = - mentorSettings?.mentor_unique_id ?? resolvedMentorId; - - // The data-layer normalises 404 → null, so a non-null result means the - // mentor has a wired ClawMentorConfig (sandbox connected to an instance). - // Skip the query until we know claw is enabled — there's no point fetching - // the config when we'd never gate on it. - const { data: clawMentorConfig } = useGetClawMentorConfigQuery( - { org: tenantKey!, mentorUniqueId: mentorUuid! }, - { skip: !isClawEnabled || !tenantKey || !mentorUuid }, - ); - const clawConfigExists = !!clawMentorConfig; + // NOTE: no claw-config fetch here. The Sandbox and Skills tabs are always + // visible, so nothing in the segment filter needs to know whether a + // ClawMentorConfig is wired. The tabs that do care fetch it themselves when + // they mount (Sandbox via , Prompts via its own query) — + // fetching it here would fire on every page through the NavBar. const isMemoryComponentEnabled = // @ts-ignore - enable_memory_component exists on API but not typed @@ -684,7 +689,6 @@ export function useMentorSegments(options: UseMentorSegmentsOptions = {}) { isMemsearchEnabled, isMemoryComponentEnabled, isClawEnabled, - clawConfigExists, isPrivacyEnabled, isScreenshareEnabled, isVoiceCallEnabled, @@ -699,7 +703,6 @@ export function useMentorSegments(options: UseMentorSegmentsOptions = {}) { userType, isMemsearchEnabled, isClawEnabled, - clawConfigExists, isMemoryComponentEnabled, isPrivacyEnabled, isScreenshareEnabled, diff --git a/lib/constants.ts b/lib/constants.ts index 4b55fefa..161571e1 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -65,6 +65,7 @@ export const MODALS = { audit_log: 'audit_log', voice: 'voice', screenshare: 'screenshare', + human_support: 'human_support', analytics: 'analytics', }, }, diff --git a/messages/en.json b/messages/en.json index c35e3040..36b4c589 100644 --- a/messages/en.json +++ b/messages/en.json @@ -810,6 +810,7 @@ "skills": "Skills", "privacy": "Privacy", "tasks": "Tasks", + "support": "Support", "disclaimers": "Disclaimers", "memory": "Memory", "evals": "Evals", diff --git a/messages/es.json b/messages/es.json index cb756cd3..90d6daf0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -810,6 +810,7 @@ "skills": "Habilidades", "privacy": "Privacidad", "tasks": "Tareas", + "support": "Soporte", "disclaimers": "Avisos legales", "memory": "Memoria", "evals": "Evaluaciones", diff --git a/messages/fr.json b/messages/fr.json index 85099eb6..3948ddf7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -810,6 +810,7 @@ "skills": "Compétences", "privacy": "Confidentialité", "tasks": "Tâches", + "support": "Support", "disclaimers": "Avertissements", "memory": "Mémoire", "evals": "Évaluations", diff --git a/messages/zh.json b/messages/zh.json index b283ec30..72598e65 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -810,6 +810,7 @@ "skills": "技能", "privacy": "隐私", "tasks": "任务", + "support": "支持", "disclaimers": "免责声明", "memory": "记忆", "evals": "评估", diff --git a/package.json b/package.json index 7ce73c5b..97dfe2b7 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@tiptap/pm": "2.11.7", "@tiptap/react": "2.11.7", "@tiptap/starter-kit": "2.11.7", + "axios": "1.18.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d35bad33..e9a71c29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,9 @@ importers: '@tiptap/starter-kit': specifier: 2.11.7 version: 2.11.7 + axios: + specifier: '>=1.16.0' + version: 1.18.1 class-variance-authority: specifier: 0.7.1 version: 0.7.1