From a2039675114cfa8b4fdef59c876891eed67a2a17 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Mon, 5 Oct 2026 09:41:36 -0700 Subject: [PATCH 01/12] Move old dashboard files to a new folder --- .../components/DashboardSection.vue | 0 .../components/RecentlyModifiedDevices.vue | 14 +++++----- .../components/RecentlyModifiedInstances.vue | 6 ++--- .../pages/team/Home/{ => Dashboard}/index.vue | 27 ++++++++++--------- frontend/src/pages/team/routes.js | 2 +- 5 files changed, 25 insertions(+), 24 deletions(-) rename frontend/src/pages/team/Home/{ => Dashboard}/components/DashboardSection.vue (100%) rename frontend/src/pages/team/Home/{ => Dashboard}/components/RecentlyModifiedDevices.vue (91%) rename frontend/src/pages/team/Home/{ => Dashboard}/components/RecentlyModifiedInstances.vue (96%) rename frontend/src/pages/team/Home/{ => Dashboard}/index.vue (93%) diff --git a/frontend/src/pages/team/Home/components/DashboardSection.vue b/frontend/src/pages/team/Home/Dashboard/components/DashboardSection.vue similarity index 100% rename from frontend/src/pages/team/Home/components/DashboardSection.vue rename to frontend/src/pages/team/Home/Dashboard/components/DashboardSection.vue diff --git a/frontend/src/pages/team/Home/components/RecentlyModifiedDevices.vue b/frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedDevices.vue similarity index 91% rename from frontend/src/pages/team/Home/components/RecentlyModifiedDevices.vue rename to frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedDevices.vue index ee094e8481..4e99442ce8 100644 --- a/frontend/src/pages/team/Home/components/RecentlyModifiedDevices.vue +++ b/frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedDevices.vue @@ -55,15 +55,15 @@ import { ChevronRightIcon } from '@heroicons/vue/24/outline' import { mapState } from 'pinia' -import teamAPI from '../../../../api/team.js' +import teamAPI from '@/api/team.js' -import TeamLink from '../../../../components/router-links/TeamLink.vue' -import { getTeamProperty } from '../../../../composables/TeamProperties.js' +import TeamLink from '@/components/router-links/TeamLink.vue' +import { getTeamProperty } from '@/composables/TeamProperties.js' -import DeviceActions from '../../../../mixins/DeviceActions.js' -import DeviceTile from '../../Applications/components/compact/DeviceTile.vue' -import DeviceCredentialsDialog from '../../Devices/dialogs/DeviceCredentialsDialog.vue' -import TeamDeviceCreateDialog from '../../Devices/dialogs/TeamDeviceCreateDialog.vue' +import DeviceActions from '@/mixins/DeviceActions.js' +import DeviceTile from '@/pages/team/Applications/components/compact/DeviceTile.vue' +import DeviceCredentialsDialog from '@/pages/team/Devices/dialogs/DeviceCredentialsDialog.vue' +import TeamDeviceCreateDialog from '@/pages/team/Devices/dialogs/TeamDeviceCreateDialog.vue' import { useContextStore } from '@/stores/context.js' diff --git a/frontend/src/pages/team/Home/components/RecentlyModifiedInstances.vue b/frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedInstances.vue similarity index 96% rename from frontend/src/pages/team/Home/components/RecentlyModifiedInstances.vue rename to frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedInstances.vue index f32da832b6..638c48f56d 100644 --- a/frontend/src/pages/team/Home/components/RecentlyModifiedInstances.vue +++ b/frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedInstances.vue @@ -28,9 +28,9 @@ import { ChevronRightIcon } from '@heroicons/vue/24/outline' import { mapState } from 'pinia' -import teamAPI from '../../../../api/team.js' -import TeamLink from '../../../../components/router-links/TeamLink.vue' -import InstanceTile from '../../Applications/components/compact/InstanceTile.vue' +import teamAPI from '@/api/team.js' +import TeamLink from '@/components/router-links/TeamLink.vue' +import InstanceTile from '@/pages/team/Applications/components/compact/InstanceTile.vue' import { useContextStore } from '@/stores/context.js' diff --git a/frontend/src/pages/team/Home/index.vue b/frontend/src/pages/team/Home/Dashboard/index.vue similarity index 93% rename from frontend/src/pages/team/Home/index.vue rename to frontend/src/pages/team/Home/Dashboard/index.vue index 8954b80b94..e8c46b5ae9 100644 --- a/frontend/src/pages/team/Home/index.vue +++ b/frontend/src/pages/team/Home/Dashboard/index.vue @@ -75,7 +75,7 @@ -
+
+ +
diff --git a/frontend/src/stores/ux-drawers.js b/frontend/src/stores/ux-drawers.js index 56bdf0e286..889f484e3f 100644 --- a/frontend/src/stores/ux-drawers.js +++ b/frontend/src/stores/ux-drawers.js @@ -34,6 +34,7 @@ export const useUxDrawersStore = defineStore('ux-drawers', { pinned: true, open: true }, + expertSuppressed: false, component: null, header: null, wider: false, @@ -80,6 +81,8 @@ export const useUxDrawersStore = defineStore('ux-drawers', { return } + if (this.rightDrawer.expertSuppressed && component.name === 'ExpertDrawer') return + // Don't allow opening while drawer is currently closing if (this.rightDrawer.closing) return @@ -232,6 +235,14 @@ export const useUxDrawersStore = defineStore('ux-drawers', { } }, + suppressExpertDrawer () { + this.rightDrawer.expertSuppressed = true + }, + + releaseExpertDrawer () { + this.rightDrawer.expertSuppressed = false + }, + // Editor Immersive Drawer actions toggleEditorImmersiveDrawer () { this.editorImmersiveDrawer.state = !this.editorImmersiveDrawer.state diff --git a/test/unit/frontend/components/ExpertButton.spec.js b/test/unit/frontend/components/ExpertButton.spec.js index d7c34e54e7..ce4f598337 100644 --- a/test/unit/frontend/components/ExpertButton.spec.js +++ b/test/unit/frontend/components/ExpertButton.spec.js @@ -27,7 +27,7 @@ const accountSettingsStore = reactive({ }) const uxDrawersStore = reactive({ - rightDrawer: { state: false, fixed: false, expertState: { pinned: false } } + rightDrawer: { state: false, fixed: false, expertSuppressed: false, expertState: { pinned: false } } }) const expertStore = reactive({ openAssistantDrawer: vi.fn() }) @@ -65,6 +65,7 @@ describe('ExpertButton', () => { mcpStore.status = 'off' mcpStore.clientCount = 0 contextStore.team = null + uxDrawersStore.rightDrawer.expertSuppressed = false }) afterEach(() => { @@ -125,4 +126,20 @@ describe('ExpertButton', () => { expect(mcpStore.resume).not.toHaveBeenCalled() }) }) + + describe('suppression', () => { + it('shows the Expert button normally', () => { + const wrapper = mountButton() + expect(wrapper.find('[data-el="expert-button"]').exists()).toBe(true) + }) + + it('hides the Expert button while the drawer is suppressed, keeping the MCP toggle', async () => { + const wrapper = mountButton() + uxDrawersStore.rightDrawer.expertSuppressed = true + await wrapper.vm.$nextTick() + + expect(wrapper.find('[data-el="expert-button"]').exists()).toBe(false) + expect(wrapper.find('[data-el="mcp-toggle"]').exists()).toBe(true) + }) + }) }) diff --git a/test/unit/frontend/pages/team/Home/Expert.spec.js b/test/unit/frontend/pages/team/Home/Expert.spec.js new file mode 100644 index 0000000000..b264804825 --- /dev/null +++ b/test/unit/frontend/pages/team/Home/Expert.spec.js @@ -0,0 +1,137 @@ +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterEach, describe, expect, test, vi } from 'vitest' + +enableAutoUnmount(afterEach) + +const mocks = vi.hoisted(() => ({ + accountAuthStore: { user: { name: 'Noley Holland' } }, + contextStore: { isImmersiveEditor: false }, + expertStore: { openAssistantDrawer: vi.fn() }, + drawersStore: { + rightDrawer: { state: false, expertState: { pinned: true, open: true }, expertSuppressed: false }, + suppressExpertDrawer: vi.fn(), + releaseExpertDrawer: vi.fn(), + closeRightDrawer: vi.fn() + } +})) + +vi.mock('@/stores/account-auth.js', () => ({ useAccountAuthStore: () => mocks.accountAuthStore })) +vi.mock('@/stores/context.js', () => ({ useContextStore: () => mocks.contextStore })) +vi.mock('@/stores/product-expert.js', () => ({ useProductExpertStore: () => mocks.expertStore })) +vi.mock('@/stores/ux-drawers.js', () => ({ useUxDrawersStore: () => mocks.drawersStore })) + +import ExpertPage from '../../../../../../frontend/src/pages/team/Home/Expert/index.vue' + +async function mountPage () { + const wrapper = mount(ExpertPage, { + global: { + stubs: { + // A `true` stub drops the default slot, so the whole page body would + // never render; these pass their children through + 'ff-page': { template: '
' }, + 'ff-page-header': { template: '
' }, + 'ff-nav-breadcrumb': { template: '' } + } + } + }) + await flushPromises() + return wrapper +} + +describe('TeamHomeExpert', () => { + test('renders the greeting', async () => { + const wrapper = await mountPage() + expect(wrapper.find('[data-el="greeting"]').exists()).toBe(true) + expect(wrapper.find('[data-el="greeting-text"]').text()).toContain('Noley') + }) + + test('suppresses the side panel on mount and releases it on unmount', async () => { + mocks.drawersStore.suppressExpertDrawer.mockClear() + mocks.drawersStore.releaseExpertDrawer.mockClear() + mocks.drawersStore.rightDrawer.state = true + + const wrapper = await mountPage() + expect(mocks.drawersStore.suppressExpertDrawer).toHaveBeenCalledTimes(1) + expect(mocks.drawersStore.closeRightDrawer).toHaveBeenCalledWith({ preserveExpertState: true }) + expect(mocks.drawersStore.releaseExpertDrawer).not.toHaveBeenCalled() + + wrapper.unmount() + expect(mocks.drawersStore.releaseExpertDrawer).toHaveBeenCalledTimes(1) + }) + + test('suppression is in place before anything else could reopen the panel', async () => { + const order = [] + mocks.drawersStore.suppressExpertDrawer.mockImplementation(() => order.push('suppress')) + mocks.drawersStore.closeRightDrawer.mockImplementation(() => order.push('close')) + mocks.drawersStore.rightDrawer.state = true + + await mountPage() + + // RightDrawer's own restore runs behind a 25ms timeout, so a synchronous + // suppress during mount always wins regardless of layout template order + expect(order[0]).toBe('suppress') + mocks.drawersStore.suppressExpertDrawer.mockImplementation(() => {}) + mocks.drawersStore.closeRightDrawer.mockImplementation(() => {}) + }) + + test('does not close a drawer that is not open, which would deafen the app for 300ms', async () => { + mocks.drawersStore.closeRightDrawer.mockClear() + mocks.drawersStore.rightDrawer.state = false + + await mountPage() + + expect(mocks.drawersStore.closeRightDrawer).not.toHaveBeenCalled() + }) + + test('does not hand the panel back into an immersive editor, which has none', async () => { + mocks.contextStore.isImmersiveEditor = true + mocks.drawersStore.rightDrawer.expertState = { pinned: true, open: true } + mocks.expertStore.openAssistantDrawer.mockClear() + + const wrapper = await mountPage() + wrapper.unmount() + + expect(mocks.expertStore.openAssistantDrawer).not.toHaveBeenCalled() + mocks.contextStore.isImmersiveEditor = false + }) + + test('never writes the saved pinned preference', async () => { + mocks.drawersStore.rightDrawer.expertState = { pinned: true, open: true } + + const wrapper = await mountPage() + wrapper.unmount() + + expect(mocks.drawersStore.rightDrawer.expertState).toEqual({ pinned: true, open: true }) + }) + + test('hands the panel back on the way out, pinned as the user left it', async () => { + mocks.drawersStore.rightDrawer.expertState = { pinned: true, open: true } + mocks.expertStore.openAssistantDrawer.mockClear() + + const wrapper = await mountPage() + expect(mocks.expertStore.openAssistantDrawer).not.toHaveBeenCalled() + + wrapper.unmount() + expect(mocks.expertStore.openAssistantDrawer).toHaveBeenCalledWith({ openPinned: true }) + }) + + test('reopens unpinned when that is how the user had it', async () => { + mocks.drawersStore.rightDrawer.expertState = { pinned: false, open: true } + mocks.expertStore.openAssistantDrawer.mockClear() + + const wrapper = await mountPage() + wrapper.unmount() + + expect(mocks.expertStore.openAssistantDrawer).toHaveBeenCalledWith({ openPinned: false }) + }) + + test('leaves the panel shut if the user had it shut', async () => { + mocks.drawersStore.rightDrawer.expertState = { pinned: false, open: false } + mocks.expertStore.openAssistantDrawer.mockClear() + + const wrapper = await mountPage() + wrapper.unmount() + + expect(mocks.expertStore.openAssistantDrawer).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/frontend/stores/product-expert.spec.js b/test/unit/frontend/stores/product-expert.spec.js index 857945312e..6463b605ca 100644 --- a/test/unit/frontend/stores/product-expert.spec.js +++ b/test/unit/frontend/stores/product-expert.spec.js @@ -52,11 +52,14 @@ vi.mock('@/components/drawers/expert/ExpertDrawer.vue', () => ({ default: { name: 'ExpertDrawer' } })) +const drawersState = { + rightDrawer: { expertSuppressed: false, expertState: { pinned: false, open: false } }, + openRightDrawer: vi.fn(), + setRightDrawerWider: vi.fn() +} + vi.mock('@/stores/ux-drawers.js', () => ({ - useUxDrawersStore: vi.fn(() => ({ - openRightDrawer: vi.fn(), - setRightDrawerWider: vi.fn() - })) + useUxDrawersStore: vi.fn(() => drawersState) })) const { dispatch, getToolDefinitions, invokeAction } = vi.hoisted(() => ({ @@ -907,3 +910,22 @@ describe('product-expert store', () => { }) }) }) + +describe('openAssistantDrawer promise contract', () => { + afterEach(() => { + drawersState.rightDrawer.expertSuppressed = false + }) + + it('always returns a thenable, so wakeUpAssistant can chain hydrateClient off it', async () => { + // wakeUpAssistant does openAssistantDrawer(...).then(...). Any path that returns + // undefined crashes that chain, which is how the overview page's drawer + // suppression broke the editor-to-platform conversation handoff. + const store = useProductExpertStore() + drawersState.rightDrawer.expertSuppressed = true + + const result = store.openAssistantDrawer({ openPinned: false }) + + expect(typeof result?.then).toBe('function') + await result + }) +}) diff --git a/test/unit/frontend/stores/ux-drawers.spec.js b/test/unit/frontend/stores/ux-drawers.spec.js index 605e0abdd3..4306686d02 100644 --- a/test/unit/frontend/stores/ux-drawers.spec.js +++ b/test/unit/frontend/stores/ux-drawers.spec.js @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -676,4 +679,64 @@ describe('ux-drawers store', () => { vi.advanceTimersByTime(300) }) }) + + describe('expert drawer suppression', () => { + it('defaults to not suppressed', () => { + expect(useUxDrawersStore().rightDrawer.expertSuppressed).toBe(false) + }) + + it('suppress and release toggle the flag', () => { + const store = useUxDrawersStore() + store.suppressExpertDrawer() + expect(store.rightDrawer.expertSuppressed).toBe(true) + store.releaseExpertDrawer() + expect(store.rightDrawer.expertSuppressed).toBe(false) + }) + + it('never writes the saved expert open/pinned preference', () => { + const store = useUxDrawersStore() + store.rightDrawer.expertState.pinned = true + store.rightDrawer.expertState.open = true + + store.suppressExpertDrawer() + store.releaseExpertDrawer() + + expect(store.rightDrawer.expertState).toEqual({ pinned: true, open: true }) + }) + + it('refuses to open the Expert drawer while suppressed, without touching the saved preference', () => { + const store = useUxDrawersStore() + store.rightDrawer.expertState.pinned = false + store.rightDrawer.expertState.open = false + store.suppressExpertDrawer() + + store.openRightDrawer({ component: { name: 'ExpertDrawer' }, fixed: true }) + + expect(store.rightDrawer.state).toBe(false) + expect(store.rightDrawer.component).toBe(null) + // openRightDrawer writes expertState whenever an ExpertDrawer opens; a + // suppressed open must not reach that write + expect(store.rightDrawer.expertState).toEqual({ pinned: false, open: false }) + }) + + it('still opens other drawers while the Expert drawer is suppressed', () => { + const store = useUxDrawersStore() + store.suppressExpertDrawer() + + store.openRightDrawer({ component: FakeComponent }) + + expect(store.rightDrawer.state).toBe(true) + expect(store.rightDrawer.component.name).toBe('FakeComponent') + }) + + it('excludes expertSuppressed from the persisted keys, so it cannot outlive the session', () => { + // Asserted against the store's own persist config rather than localStorage: + // pinia-plugin-persistedstate does not write under vitest/jsdom, so a + // storage-based assertion would pass no matter what was persisted. + const source = readFileSync(resolve(process.cwd(), 'frontend/src/stores/ux-drawers.js'), 'utf8') + const pick = source.slice(source.indexOf('pick: ['), source.indexOf(']', source.indexOf('pick: ['))) + expect(pick).toContain('rightDrawer.expertState') + expect(pick).not.toContain('expertSuppressed') + }) + }) }) From 9c2fd8d7bd0906f9f8e22c036f59699552027675 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Mon, 5 Oct 2026 11:44:50 -0700 Subject: [PATCH 04/12] Move the logic for switching components --- .../src/pages/team/Home/Dashboard/index.vue | 349 ---------------- .../components/DashboardSection.vue | 0 .../components/RecentlyModifiedDevices.vue | 14 +- .../components/RecentlyModifiedInstances.vue | 6 +- frontend/src/pages/team/Home/index.vue | 372 +++++++++++++++++- .../frontend/pages/team/Home/index.spec.js | 97 +++-- 6 files changed, 441 insertions(+), 397 deletions(-) delete mode 100644 frontend/src/pages/team/Home/Dashboard/index.vue rename frontend/src/pages/team/Home/{Dashboard => }/components/DashboardSection.vue (100%) rename frontend/src/pages/team/Home/{Dashboard => }/components/RecentlyModifiedDevices.vue (91%) rename frontend/src/pages/team/Home/{Dashboard => }/components/RecentlyModifiedInstances.vue (96%) diff --git a/frontend/src/pages/team/Home/Dashboard/index.vue b/frontend/src/pages/team/Home/Dashboard/index.vue deleted file mode 100644 index e8c46b5ae9..0000000000 --- a/frontend/src/pages/team/Home/Dashboard/index.vue +++ /dev/null @@ -1,349 +0,0 @@ - - - - - - - diff --git a/frontend/src/pages/team/Home/Dashboard/components/DashboardSection.vue b/frontend/src/pages/team/Home/components/DashboardSection.vue similarity index 100% rename from frontend/src/pages/team/Home/Dashboard/components/DashboardSection.vue rename to frontend/src/pages/team/Home/components/DashboardSection.vue diff --git a/frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedDevices.vue b/frontend/src/pages/team/Home/components/RecentlyModifiedDevices.vue similarity index 91% rename from frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedDevices.vue rename to frontend/src/pages/team/Home/components/RecentlyModifiedDevices.vue index 4e99442ce8..ee094e8481 100644 --- a/frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedDevices.vue +++ b/frontend/src/pages/team/Home/components/RecentlyModifiedDevices.vue @@ -55,15 +55,15 @@ import { ChevronRightIcon } from '@heroicons/vue/24/outline' import { mapState } from 'pinia' -import teamAPI from '@/api/team.js' +import teamAPI from '../../../../api/team.js' -import TeamLink from '@/components/router-links/TeamLink.vue' -import { getTeamProperty } from '@/composables/TeamProperties.js' +import TeamLink from '../../../../components/router-links/TeamLink.vue' +import { getTeamProperty } from '../../../../composables/TeamProperties.js' -import DeviceActions from '@/mixins/DeviceActions.js' -import DeviceTile from '@/pages/team/Applications/components/compact/DeviceTile.vue' -import DeviceCredentialsDialog from '@/pages/team/Devices/dialogs/DeviceCredentialsDialog.vue' -import TeamDeviceCreateDialog from '@/pages/team/Devices/dialogs/TeamDeviceCreateDialog.vue' +import DeviceActions from '../../../../mixins/DeviceActions.js' +import DeviceTile from '../../Applications/components/compact/DeviceTile.vue' +import DeviceCredentialsDialog from '../../Devices/dialogs/DeviceCredentialsDialog.vue' +import TeamDeviceCreateDialog from '../../Devices/dialogs/TeamDeviceCreateDialog.vue' import { useContextStore } from '@/stores/context.js' diff --git a/frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedInstances.vue b/frontend/src/pages/team/Home/components/RecentlyModifiedInstances.vue similarity index 96% rename from frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedInstances.vue rename to frontend/src/pages/team/Home/components/RecentlyModifiedInstances.vue index 638c48f56d..f32da832b6 100644 --- a/frontend/src/pages/team/Home/Dashboard/components/RecentlyModifiedInstances.vue +++ b/frontend/src/pages/team/Home/components/RecentlyModifiedInstances.vue @@ -28,9 +28,9 @@ import { ChevronRightIcon } from '@heroicons/vue/24/outline' import { mapState } from 'pinia' -import teamAPI from '@/api/team.js' -import TeamLink from '@/components/router-links/TeamLink.vue' -import InstanceTile from '@/pages/team/Applications/components/compact/InstanceTile.vue' +import teamAPI from '../../../../api/team.js' +import TeamLink from '../../../../components/router-links/TeamLink.vue' +import InstanceTile from '../../Applications/components/compact/InstanceTile.vue' import { useContextStore } from '@/stores/context.js' diff --git a/frontend/src/pages/team/Home/index.vue b/frontend/src/pages/team/Home/index.vue index d304294f42..899090687e 100644 --- a/frontend/src/pages/team/Home/index.vue +++ b/frontend/src/pages/team/Home/index.vue @@ -1,23 +1,371 @@ - + + + + diff --git a/test/unit/frontend/pages/team/Home/index.spec.js b/test/unit/frontend/pages/team/Home/index.spec.js index efeb7c92a1..acf7ad91a9 100644 --- a/test/unit/frontend/pages/team/Home/index.spec.js +++ b/test/unit/frontend/pages/team/Home/index.spec.js @@ -1,20 +1,26 @@ -import { enableAutoUnmount, mount } from '@vue/test-utils' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, describe, expect, test, vi } from 'vitest' import { reactive } from 'vue' enableAutoUnmount(afterEach) const mocks = vi.hoisted(() => ({ - contextStore: { team: null }, - settingsStore: { featuresCheck: {} } + contextStore: { team: { id: 't1', slug: 'ateam' } }, + settingsStore: { featuresCheck: {} }, + accountStore: { pendingTeamChange: false }, + toursStore: { tours: {} }, + teamAPI: { + getTeamInstanceCounts: vi.fn().mockResolvedValue({}), + getTeamAuditLog: vi.fn().mockResolvedValue({ log: [] }) + } })) vi.mock('@/stores/context.js', () => ({ useContextStore: () => mocks.contextStore })) vi.mock('@/stores/account-settings.js', () => ({ useAccountSettingsStore: () => mocks.settingsStore })) +vi.mock('@/stores/account.js', () => ({ useAccountStore: () => mocks.accountStore })) +vi.mock('@/stores/ux-tours.js', () => ({ useUxToursStore: () => mocks.toursStore })) +vi.mock('@/api/team.js', () => ({ default: mocks.teamAPI })) -vi.mock('@/pages/team/Home/Dashboard/index.vue', () => ({ - default: { name: 'TeamHome', template: '
' } -})) vi.mock('@/pages/team/Home/Expert/index.vue', () => ({ default: { name: 'TeamHomeExpert', template: '
' } })) @@ -24,42 +30,81 @@ import Home from '../../../../../../frontend/src/pages/team/Home/index.vue' mocks.contextStore = reactive(mocks.contextStore) mocks.settingsStore = reactive(mocks.settingsStore) -function setState ({ team = { id: 't1', slug: 'ateam' }, expert = false } = {}) { - mocks.contextStore.team = team +async function mountHome ({ expert = false } = {}) { mocks.settingsStore.featuresCheck = { isExpertAssistantFeatureEnabled: expert } + const wrapper = mount(Home, { + global: { + stubs: { + 'ff-page': { template: '
' }, + 'ff-page-header': { template: '
' }, + 'ff-nav-breadcrumb': true, + 'ff-loading': true, + 'ff-button': true, + DashboardSection: { template: '
' }, + RecentlyModifiedInstances: true, + RecentlyModifiedDevices: true, + InstanceStat: true, + AuditLog: true, + EmptyState: true, + TeamDeviceCreateDialog: true, + DeviceCredentialsDialog: true, + ConfirmInstanceDeleteDialog: true + }, + directives: { 'ff-tooltip': {} }, + mocks: { + $route: { query: {}, params: { team_slug: 'ateam' } }, + $router: { push: vi.fn(), replace: vi.fn() } + } + } + }) + await flushPromises() + return wrapper } describe('team Home variant switch', () => { - test('renders the dashboard page when the Expert is not enabled', () => { - setState({ expert: false }) - const wrapper = mount(Home) - expect(wrapper.find('[data-stub="dashboard"]').exists()).toBe(true) + test('renders the dashboard when the Expert is not enabled', async () => { + const wrapper = await mountHome({ expert: false }) + expect(wrapper.find('[data-stub="dashboard-section"]').exists()).toBe(true) expect(wrapper.find('[data-stub="expert-home"]').exists()).toBe(false) }) - test('renders the Expert page when the Expert is enabled', () => { - setState({ expert: true }) - const wrapper = mount(Home) + test('renders the Expert page when the Expert is enabled', async () => { + const wrapper = await mountHome({ expert: true }) expect(wrapper.find('[data-stub="expert-home"]').exists()).toBe(true) - expect(wrapper.find('[data-stub="dashboard"]').exists()).toBe(false) + expect(wrapper.find('[data-stub="dashboard-section"]').exists()).toBe(false) }) - test('mounts neither variant before the team resolves', () => { - setState({ team: null, expert: true }) - const wrapper = mount(Home) - expect(wrapper.find('[data-stub="expert-home"]').exists()).toBe(false) - expect(wrapper.find('[data-stub="dashboard"]').exists()).toBe(false) + test('skips the dashboard data fetches entirely on the Expert variant', async () => { + mocks.teamAPI.getTeamInstanceCounts.mockClear() + mocks.teamAPI.getTeamAuditLog.mockClear() + + await mountHome({ expert: true }) + + expect(mocks.teamAPI.getTeamInstanceCounts).not.toHaveBeenCalled() + expect(mocks.teamAPI.getTeamAuditLog).not.toHaveBeenCalled() + }) + + test('still fetches dashboard data on the dashboard variant', async () => { + mocks.teamAPI.getTeamInstanceCounts.mockClear() + mocks.teamAPI.getTeamAuditLog.mockClear() + + await mountHome({ expert: false }) + + expect(mocks.teamAPI.getTeamInstanceCounts).toHaveBeenCalled() + expect(mocks.teamAPI.getTeamAuditLog).toHaveBeenCalled() }) - test('swaps to the dashboard page when the Expert feature is turned off mid-visit', async () => { - setState({ expert: true }) - const wrapper = mount(Home) + test('swaps to the dashboard when the Expert feature is turned off mid-visit', async () => { + const wrapper = await mountHome({ expert: true }) expect(wrapper.find('[data-stub="expert-home"]').exists()).toBe(true) mocks.settingsStore.featuresCheck = { isExpertAssistantFeatureEnabled: false } - await wrapper.vm.$nextTick() + await flushPromises() - expect(wrapper.find('[data-stub="dashboard"]').exists()).toBe(true) + // the watcher must kick off the fetches the mount hook skipped, or the + // dashboard renders its loading state forever + expect(mocks.teamAPI.getTeamAuditLog).toHaveBeenCalled() + expect(wrapper.find('[data-stub="dashboard-section"]').exists()).toBe(true) expect(wrapper.find('[data-stub="expert-home"]').exists()).toBe(false) }) }) From 5793319b490a8d69bd37fcbccf9bfb80f44730e0 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Tue, 6 Oct 2026 06:54:49 -0700 Subject: [PATCH 05/12] Add expert to home page view --- frontend/src/pages/team/Home/Expert/index.vue | 255 +++++++++++++++++- frontend/src/pages/team/Home/index.vue | 2 - .../frontend/pages/team/Home/Expert.spec.js | 141 +++++++++- .../frontend/pages/team/Home/index.spec.js | 2 - .../frontend/stores/product-expert.spec.js | 4 +- test/unit/frontend/stores/ux-drawers.spec.js | 6 +- 6 files changed, 388 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/team/Home/Expert/index.vue b/frontend/src/pages/team/Home/Expert/index.vue index ed3e5750b4..07934fd909 100644 --- a/frontend/src/pages/team/Home/Expert/index.vue +++ b/frontend/src/pages/team/Home/Expert/index.vue @@ -8,28 +8,88 @@ -
- +
+ + +
+ +
+ +
+ +
+ +
+ +
diff --git a/frontend/src/pages/team/Home/index.vue b/frontend/src/pages/team/Home/index.vue index 899090687e..c832f5548c 100644 --- a/frontend/src/pages/team/Home/index.vue +++ b/frontend/src/pages/team/Home/index.vue @@ -242,8 +242,6 @@ export default { } }, watch: { - // The feature can be turned off mid-visit; the dashboard must then load the - // data its mount hook skipped, or it renders its loading state forever isExpertHome (expert) { if (!expert && this.loading) { this.loadDashboard() diff --git a/test/unit/frontend/pages/team/Home/Expert.spec.js b/test/unit/frontend/pages/team/Home/Expert.spec.js index b264804825..a7dc325134 100644 --- a/test/unit/frontend/pages/team/Home/Expert.spec.js +++ b/test/unit/frontend/pages/team/Home/Expert.spec.js @@ -1,12 +1,14 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, describe, expect, test, vi } from 'vitest' +import { reactive } from 'vue' enableAutoUnmount(afterEach) const mocks = vi.hoisted(() => ({ accountAuthStore: { user: { name: 'Noley Holland' } }, contextStore: { isImmersiveEditor: false }, - expertStore: { openAssistantDrawer: vi.fn() }, + settingsStore: { featuresCheck: { isExpertAssistantFeatureEnabled: true, isExpertInsightsFeatureEnabled: false } }, + expertStore: { messages: [], openAssistantDrawer: vi.fn(), setPendingInput: vi.fn(), setAgentMode: vi.fn(), resumeSessionTimer: vi.fn() }, drawersStore: { rightDrawer: { state: false, expertState: { pinned: true, open: true }, expertSuppressed: false }, suppressExpertDrawer: vi.fn(), @@ -17,17 +19,26 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/stores/account-auth.js', () => ({ useAccountAuthStore: () => mocks.accountAuthStore })) vi.mock('@/stores/context.js', () => ({ useContextStore: () => mocks.contextStore })) +vi.mock('@/stores/account-settings.js', () => ({ useAccountSettingsStore: () => mocks.settingsStore })) vi.mock('@/stores/product-expert.js', () => ({ useProductExpertStore: () => mocks.expertStore })) vi.mock('@/stores/ux-drawers.js', () => ({ useUxDrawersStore: () => mocks.drawersStore })) +vi.mock('@/components/expert/Expert.vue', () => ({ + default: { name: 'ExpertPanel', template: '
' } +})) +vi.mock('@/components/expert/components/ExpertModeSwitcher.vue', () => ({ + default: { name: 'ExpertModeSwitcher', template: '
' } +})) + import ExpertPage from '../../../../../../frontend/src/pages/team/Home/Expert/index.vue' +mocks.expertStore = reactive(mocks.expertStore) + async function mountPage () { const wrapper = mount(ExpertPage, { global: { stubs: { - // A `true` stub drops the default slot, so the whole page body would - // never render; these pass their children through + // a `true` stub drops the default slot, so nothing below would render 'ff-page': { template: '
' }, 'ff-page-header': { template: '
' }, 'ff-nav-breadcrumb': { template: '' } @@ -67,8 +78,7 @@ describe('TeamHomeExpert', () => { await mountPage() - // RightDrawer's own restore runs behind a 25ms timeout, so a synchronous - // suppress during mount always wins regardless of layout template order + // RightDrawer's restore is behind a 25ms timeout, so a sync suppress always wins expect(order[0]).toBe('suppress') mocks.drawersStore.suppressExpertDrawer.mockImplementation(() => {}) mocks.drawersStore.closeRightDrawer.mockImplementation(() => {}) @@ -134,4 +144,125 @@ describe('TeamHomeExpert', () => { expect(mocks.expertStore.openAssistantDrawer).not.toHaveBeenCalled() }) + + test('lands idle: greeting, composer, no back control', async () => { + mocks.expertStore.messages = [] + const wrapper = await mountPage() + + expect(wrapper.find('[data-el="expert-home"]').attributes('data-stage')).toBe('idle') + expect(wrapper.find('[data-el="greeting"]').exists()).toBe(true) + expect(wrapper.find('[data-stub="expert-panel"]').exists()).toBe(true) + }) + + test('typing moves to composing, which fades the surroundings but sends nothing', async () => { + mocks.expertStore.messages = [] + const wrapper = await mountPage() + + await wrapper.find('[data-el="expert-home-surface"]').trigger('input') + + const root = wrapper.find('[data-el="expert-home"]') + expect(root.attributes('data-stage')).toBe('composing') + expect(root.classes()).toContain('is-composing') + expect(root.classes()).not.toContain('is-conversing') + }) + + test('sending is what opens the transcript, not typing', async () => { + mocks.expertStore.messages = [] + const wrapper = await mountPage() + await wrapper.find('[data-el="expert-home-surface"]').trigger('input') + expect(wrapper.find('[data-el="expert-home"]').classes()).not.toContain('is-conversing') + + mocks.expertStore.messages = [{ _type: 'human' }] + await wrapper.vm.$nextTick() + + const root = wrapper.find('[data-el="expert-home"]') + expect(root.attributes('data-stage')).toBe('conversing') + expect(root.classes()).toContain('is-conversing') + }) + + test('back returns to idle from either stage', async () => { + mocks.expertStore.messages = [] + const wrapper = await mountPage() + await wrapper.find('[data-el="expert-home-surface"]').trigger('input') + + await wrapper.find('[data-action="collapse-expert"]').trigger('click') + expect(wrapper.find('[data-el="expert-home"]').attributes('data-stage')).toBe('idle') + + mocks.expertStore.messages = [{ _type: 'human' }] + await wrapper.vm.$nextTick() + expect(wrapper.find('[data-el="expert-home"]').attributes('data-stage')).toBe('conversing') + + await wrapper.find('[data-action="collapse-expert"]').trigger('click') + expect(wrapper.find('[data-el="expert-home"]').attributes('data-stage')).toBe('idle') + }) + + test('the composer is never unmounted across any stage change', async () => { + mocks.expertStore.messages = [] + const wrapper = await mountPage() + const before = wrapper.findComponent({ name: 'ExpertPanel' }) + + await wrapper.find('[data-el="expert-home-surface"]').trigger('input') + mocks.expertStore.messages = [{ _type: 'human' }] + await wrapper.vm.$nextTick() + await wrapper.find('[data-action="collapse-expert"]').trigger('click') + + expect(wrapper.findComponent({ name: 'ExpertPanel' }).vm).toBe(before.vm) + }) + + test('keeps the back control mounted so the column does not reflow mid-animation', async () => { + mocks.expertStore.messages = [] + const wrapper = await mountPage() + expect(wrapper.find('[data-action="collapse-expert"]').exists()).toBe(true) + + await wrapper.find('[data-el="expert-home-surface"]').trigger('input') + expect(wrapper.find('[data-action="collapse-expert"]').exists()).toBe(true) + }) + + test('forces Support mode only when Insights is unavailable', async () => { + mocks.settingsStore.featuresCheck = { + isExpertAssistantFeatureEnabled: true, + isExpertInsightsFeatureEnabled: false + } + mocks.expertStore.setAgentMode.mockClear() + await mountPage() + expect(mocks.expertStore.setAgentMode).toHaveBeenCalledWith('support-agent') + }) + + test('leaves the mode alone when both are available, matching openAssistantDrawer', async () => { + // agentMode persists to sessionStorage and this page offers no toggle, so forcing + // it here would strand an Insights user in Support everywhere + mocks.settingsStore.featuresCheck = { + isExpertAssistantFeatureEnabled: true, + isExpertInsightsFeatureEnabled: true + } + mocks.expertStore.setAgentMode.mockClear() + await mountPage() + expect(mocks.expertStore.setAgentMode).not.toHaveBeenCalled() + }) + + test('resumes the session expiry timer, which only openAssistantDrawer otherwise does', async () => { + // Expert.vue clears the interval on unmount and sessionStartTime is persisted, so + // without this a returning user types into a session that has silently expired + mocks.expertStore.resumeSessionTimer.mockClear() + await mountPage() + expect(mocks.expertStore.resumeSessionTimer).toHaveBeenCalledTimes(1) + }) + + test('offers the mode toggle when both agents are available', async () => { + mocks.settingsStore.featuresCheck = { + isExpertAssistantFeatureEnabled: true, + isExpertInsightsFeatureEnabled: true + } + const wrapper = await mountPage() + expect(wrapper.find('[data-stub="mode-switcher"]').exists()).toBe(true) + }) + + test('hides the mode toggle when there is only one agent to pick', async () => { + mocks.settingsStore.featuresCheck = { + isExpertAssistantFeatureEnabled: true, + isExpertInsightsFeatureEnabled: false + } + const wrapper = await mountPage() + expect(wrapper.find('[data-stub="mode-switcher"]').exists()).toBe(false) + }) }) diff --git a/test/unit/frontend/pages/team/Home/index.spec.js b/test/unit/frontend/pages/team/Home/index.spec.js index acf7ad91a9..75f413f98f 100644 --- a/test/unit/frontend/pages/team/Home/index.spec.js +++ b/test/unit/frontend/pages/team/Home/index.spec.js @@ -101,8 +101,6 @@ describe('team Home variant switch', () => { mocks.settingsStore.featuresCheck = { isExpertAssistantFeatureEnabled: false } await flushPromises() - // the watcher must kick off the fetches the mount hook skipped, or the - // dashboard renders its loading state forever expect(mocks.teamAPI.getTeamAuditLog).toHaveBeenCalled() expect(wrapper.find('[data-stub="dashboard-section"]').exists()).toBe(true) expect(wrapper.find('[data-stub="expert-home"]').exists()).toBe(false) diff --git a/test/unit/frontend/stores/product-expert.spec.js b/test/unit/frontend/stores/product-expert.spec.js index 6463b605ca..3cba45fa35 100644 --- a/test/unit/frontend/stores/product-expert.spec.js +++ b/test/unit/frontend/stores/product-expert.spec.js @@ -917,9 +917,7 @@ describe('openAssistantDrawer promise contract', () => { }) it('always returns a thenable, so wakeUpAssistant can chain hydrateClient off it', async () => { - // wakeUpAssistant does openAssistantDrawer(...).then(...). Any path that returns - // undefined crashes that chain, which is how the overview page's drawer - // suppression broke the editor-to-platform conversation handoff. + // wakeUpAssistant chains .then() off this; returning undefined crashes it const store = useProductExpertStore() drawersState.rightDrawer.expertSuppressed = true diff --git a/test/unit/frontend/stores/ux-drawers.spec.js b/test/unit/frontend/stores/ux-drawers.spec.js index 4306686d02..2efb7666bb 100644 --- a/test/unit/frontend/stores/ux-drawers.spec.js +++ b/test/unit/frontend/stores/ux-drawers.spec.js @@ -714,8 +714,6 @@ describe('ux-drawers store', () => { expect(store.rightDrawer.state).toBe(false) expect(store.rightDrawer.component).toBe(null) - // openRightDrawer writes expertState whenever an ExpertDrawer opens; a - // suppressed open must not reach that write expect(store.rightDrawer.expertState).toEqual({ pinned: false, open: false }) }) @@ -730,9 +728,7 @@ describe('ux-drawers store', () => { }) it('excludes expertSuppressed from the persisted keys, so it cannot outlive the session', () => { - // Asserted against the store's own persist config rather than localStorage: - // pinia-plugin-persistedstate does not write under vitest/jsdom, so a - // storage-based assertion would pass no matter what was persisted. + // pinia-plugin-persistedstate writes nothing under vitest, so assert the config const source = readFileSync(resolve(process.cwd(), 'frontend/src/stores/ux-drawers.js'), 'utf8') const pick = source.slice(source.indexOf('pick: ['), source.indexOf(']', source.indexOf('pick: ['))) expect(pick).toContain('rightDrawer.expertState') From d7c5b1fce166ad33fa08c7740a446e32d69a638a Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Tue, 6 Oct 2026 07:08:13 -0700 Subject: [PATCH 06/12] Convert new files to typescript --- .../team/Home/Expert/components/HomeGreeting.vue | 8 +++++--- frontend/src/pages/team/Home/Expert/index.vue | 16 ++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/team/Home/Expert/components/HomeGreeting.vue b/frontend/src/pages/team/Home/Expert/components/HomeGreeting.vue index a9112574f4..25c370b330 100644 --- a/frontend/src/pages/team/Home/Expert/components/HomeGreeting.vue +++ b/frontend/src/pages/team/Home/Expert/components/HomeGreeting.vue @@ -5,7 +5,7 @@
- - - diff --git a/frontend/src/pages/team/Home/Expert/index.vue b/frontend/src/pages/team/Home/Expert/index.vue index 2a268da319..98dd471b9c 100644 --- a/frontend/src/pages/team/Home/Expert/index.vue +++ b/frontend/src/pages/team/Home/Expert/index.vue @@ -24,10 +24,6 @@ Back -
- -
-
@@ -64,8 +60,6 @@ import { ChevronLeftIcon } from '@heroicons/vue/20/solid' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' -import HomeGreeting from './components/HomeGreeting.vue' - import ExpertPanel from '@/components/expert/Expert.vue' import ExpertModeSwitcher from '@/components/expert/components/ExpertModeSwitcher.vue' import { useAccountSettingsStore } from '@/stores/account-settings.js' @@ -220,32 +214,6 @@ $ff-column: 820px; pointer-events: auto; } - &__intro { - flex: 0 0 auto; - display: grid; - grid-template-rows: 1fr; - transition: grid-template-rows $ff-expand $ff-ease, opacity 260ms ease; - - > * { - min-height: 0; - overflow: hidden; - transition: padding-top $ff-expand $ff-ease; - } - } - - &.is-composing &__intro { - opacity: 0; - pointer-events: none; - } - - &.is-conversing &__intro { - grid-template-rows: 0fr; - - > * { - padding-top: 0; - } - } - &__expert { display: flex; flex-direction: column; @@ -344,8 +312,6 @@ $ff-column: 820px; .ff-expert-home__back, .ff-expert-home__resume, .ff-expert-home__mode, - .ff-expert-home__intro, - .ff-expert-home__intro > *, .ff-expert-home__expert, .ff-expert-home__expert :deep(.messages-container), .ff-expert-home__expert :deep(.action-buttons) { diff --git a/test/unit/frontend/pages/team/Home/Expert.spec.js b/test/unit/frontend/pages/team/Home/Expert.spec.js index d48d61d159..2e9f5c558f 100644 --- a/test/unit/frontend/pages/team/Home/Expert.spec.js +++ b/test/unit/frontend/pages/team/Home/Expert.spec.js @@ -115,12 +115,11 @@ describe('TeamHomeExpert', () => { expect(mocks.expertStore.openAssistantDrawer).not.toHaveBeenCalled() }) - test('lands idle: greeting, composer, no back control', async () => { + test('lands idle with the composer mounted', async () => { mocks.expertStore.messages = [] const wrapper = await mountPage() expect(wrapper.find('[data-el="expert-home"]').attributes('data-stage')).toBe('idle') - expect(wrapper.find('[data-el="greeting"]').exists()).toBe(true) expect(wrapper.find('[data-stub="expert-panel"]').exists()).toBe(true) }) From 27d49d881d86ae267fcd8aef13299ded84071dae Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Tue, 6 Oct 2026 11:54:20 -0700 Subject: [PATCH 11/12] Add expert-surface prop --- .../src/components/expert/components/ExpertChatInput.vue | 2 ++ frontend/src/pages/team/Home/Expert/index.vue | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index 59afcb851f..bf0be8dbf9 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -1,6 +1,7 @@