Skip to content

Commit d668ffe

Browse files
authored
Have the Expert open the onboarding conversation (#8467)
1 parent 94753aa commit d668ffe

7 files changed

Lines changed: 172 additions & 95 deletions

File tree

‎frontend/src/composables/Components/expert/onboardingFixture.js‎

Lines changed: 0 additions & 57 deletions
This file was deleted.

‎frontend/src/pages/team/Onboarding.vue‎

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
data-action="skip-onboarding"
1212
:disabled="provisioning"
1313
@click="skipOnboarding"
14-
>Set it up myself</ff-button>
14+
>
15+
Set it up myself
16+
</ff-button>
1517
</Teleport>
1618
<div class="onboarding-column">
1719
<ExpertPanel />
@@ -25,7 +27,6 @@ import { mapState } from 'pinia'
2527
2628
import teamApi from '@/api/team.ts'
2729
import ExpertPanel from '@/components/expert/Expert.vue'
28-
import { ONBOARDING_FIXTURE_MESSAGES } from '@/composables/Components/expert/onboardingFixture.js'
2930
import Alerts from '@/services/alerts.js'
3031
import { useAccountSettingsStore } from '@/stores/account-settings.js'
3132
import { useAccountStore } from '@/stores/account.js'
@@ -48,6 +49,7 @@ export default {
4849
return {
4950
provisioning: false,
5051
teleportReady: false,
52+
conversationRequested: false,
5153
// How many turns the user had contributed when the page opened.
5254
// Anything beyond it is them engaging, which lets the seeded
5355
// transcript exist without counting as engagement.
@@ -82,11 +84,11 @@ export default {
8284
team: {
8385
immediate: true,
8486
handler () {
85-
this.seedFixtureTranscript()
86-
// The baseline is taken here, after the seed, rather than in
87-
// the userTurns watcher: on a direct page load that watcher
88-
// fires before the team resolves and would count the seeded
89-
// turns as engagement
87+
this.openConversation()
88+
// The baseline is taken here, once the team has resolved,
89+
// rather than in the userTurns watcher: on a direct page load
90+
// that watcher fires before the team resolves, and any turns
91+
// the transcript picks up afterwards would count as engagement
9092
if (this.team && this.initialUserTurns === null) {
9193
this.initialUserTurns = this.userTurns
9294
}
@@ -121,12 +123,8 @@ export default {
121123
})
122124
},
123125
methods: {
124-
// TEMPORARY until flowfuse#8369: the Expert can't open a conversation
125-
// on its own yet, so seed a placeholder transcript to work against.
126-
// A transcript holding only canned messages (`generated`, e.g. the
127-
// drawer's welcome text) counts as empty and gets replaced.
128-
seedFixtureTranscript () {
129-
if (!this.team || this.notAvailable) {
126+
openConversation () {
127+
if (!this.team || this.notAvailable || this.conversationRequested) {
130128
return
131129
}
132130
const expertStore = useProductExpertStore()
@@ -136,7 +134,8 @@ export default {
136134
if (expertStore.messages.length > 0) {
137135
useProductExpertSupportAgentStore().reset()
138136
}
139-
expertStore.hydrateMessages(ONBOARDING_FIXTURE_MESSAGES)
137+
this.conversationRequested = true
138+
expertStore.openConversation()
140139
},
141140
async skipOnboarding () {
142141
if (this.provisioning) {

‎frontend/src/stores/context.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useDataFarmApplicationsStore } from './data-farm-applications'
1010
import { useDataFarmTeamsStore } from './data-farm-teams'
1111
import { useProductAssistantStore } from './product-assistant.js'
1212
import { useProductExpertStore } from './product-expert.js'
13+
import { useUxStore } from './ux.js'
1314

1415
import { useMqttExpertTopicHelper } from '@/composables/services/MqttExpertTopicHelper'
1516

@@ -101,7 +102,8 @@ export const useContextStore = defineStore('context', {
101102
selectedNodes: null,
102103
scope: this.isImmersive ? 'immersive' : 'ff-app',
103104
questionCadence: useProductExpertStore().questionCadence,
104-
planMode: useProductExpertStore().planMode
105+
planMode: useProductExpertStore().planMode,
106+
onboarding: useUxStore().isOnboarding
105107
}
106108
}
107109

@@ -145,6 +147,7 @@ export const useContextStore = defineStore('context', {
145147
supportsPlatformUIAutomation: useAccountSettingsStore().featuresCheck?.isExpertPlatformAutomationFeatureEnabled ?? false,
146148
questionCadence: useProductExpertStore().questionCadence,
147149
planMode: useProductExpertStore().planMode,
150+
onboarding: useUxStore().isOnboarding,
148151
// Capability flags: signal that this version can render the question,
149152
// plan, and approval cards. Older instances omit them and the agent drops
150153
// the matching tool / runs in backward-compatible mode.

‎frontend/src/stores/product-expert.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,35 @@ export const useProductExpertStore = defineStore('product-expert', {
219219
setComposerCommand (command) {
220220
this.composerCommand = command
221221
},
222+
async openConversation () {
223+
const agentStore = this._agentStore
224+
225+
if (agentStore.sessionId && this.isWaitingForResponse) {
226+
return undefined
227+
}
228+
if (!agentStore.sessionId) {
229+
agentStore.sessionId = uuidv4()
230+
}
231+
232+
agentStore.abortController = markRaw(new AbortController())
233+
try {
234+
const result = await this.sendQuery({ query: '' })
235+
if (result) {
236+
await this.handleMessageResponse(result)
237+
}
238+
return result
239+
} catch (error) {
240+
if (error.name === 'AbortError' || error.name === 'CanceledError') {
241+
return undefined
242+
}
243+
if (!this.shouldUseMqtt) {
244+
console.error('Expert API error:', error)
245+
}
246+
this.addPredefinedAiMessage('Sorry, I could not get started. Please refresh to try again.', { isError: true })
247+
} finally {
248+
agentStore.abortController = null
249+
}
250+
},
222251
async handleQuery ({ query }) {
223252
const agentStore = this._agentStore
224253

‎test/unit/frontend/pages/team/Onboarding.spec.js‎

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => {
77
contextStore: { team: null },
88
settingsStore: { featuresCheck: {} },
99
accountStore: { setTeam: vi.fn().mockResolvedValue() },
10-
expertStore: { messages: [], hydrateMessages: vi.fn() },
10+
expertStore: { messages: [], openConversation: vi.fn() },
1111
supportAgentStore: { reset: vi.fn() },
1212
uxStore: { isOnboardingIntake: true, endOnboarding: vi.fn() }
1313
}
@@ -147,40 +147,51 @@ describe('Onboarding page', () => {
147147
expect(wrapper.vm.$options.provide.call(wrapper.vm)['expert-surface']).toBe('onboarding')
148148
})
149149

150-
describe('fixture transcript', () => {
150+
describe('opening the conversation', () => {
151151
beforeEach(() => {
152-
mocks.expertStore.hydrateMessages.mockClear()
152+
mocks.expertStore.openConversation.mockClear()
153153
mocks.supportAgentStore.reset.mockClear()
154154
mocks.expertStore.messages = []
155155
})
156156

157-
test('seeds the placeholder conversation when the transcript is empty', async () => {
157+
test('asks the Expert to open the conversation when the transcript is empty', async () => {
158158
await mountPage()
159-
expect(mocks.expertStore.hydrateMessages).toHaveBeenCalledTimes(1)
160-
const seeded = mocks.expertStore.hydrateMessages.mock.calls[0][0]
161-
expect(Array.isArray(seeded)).toBe(true)
162-
expect(seeded.length).toBeGreaterThan(0)
159+
expect(mocks.expertStore.openConversation).toHaveBeenCalledTimes(1)
163160
expect(mocks.supportAgentStore.reset).not.toHaveBeenCalled()
164161
})
165162

166-
test('replaces a transcript that only holds canned messages', async () => {
163+
// Arriving from the drawer leaves its canned welcome behind; it is not a
164+
// conversation, so it gets cleared rather than opened on top of
165+
test('clears a transcript that only holds canned messages first', async () => {
167166
mocks.expertStore.messages = [{ _type: 'ai', generated: true }]
168167
await mountPage()
169168
expect(mocks.supportAgentStore.reset).toHaveBeenCalledTimes(1)
170-
expect(mocks.expertStore.hydrateMessages).toHaveBeenCalledTimes(1)
169+
expect(mocks.expertStore.openConversation).toHaveBeenCalledTimes(1)
171170
})
172171

173-
test('does not reseed a real conversation', async () => {
172+
// This is what makes the page resumable: a conversation already in
173+
// progress is picked up rather than restarted
174+
test('leaves a real conversation alone', async () => {
174175
mocks.expertStore.messages = [{ _type: 'human', content: 'hello' }]
175176
await mountPage()
176-
expect(mocks.expertStore.hydrateMessages).not.toHaveBeenCalled()
177+
expect(mocks.expertStore.openConversation).not.toHaveBeenCalled()
177178
expect(mocks.supportAgentStore.reset).not.toHaveBeenCalled()
178179
})
179180

180-
test('does not seed when the page is redirecting away', async () => {
181+
test('does not open when the page is redirecting away', async () => {
181182
mocks.settingsStore.featuresCheck = { isAiOnboardingFeatureEnabled: false }
182183
await mountPage()
183-
expect(mocks.expertStore.hydrateMessages).not.toHaveBeenCalled()
184+
expect(mocks.expertStore.openConversation).not.toHaveBeenCalled()
185+
})
186+
187+
// The team watcher can fire more than once before the opening turn comes
188+
// back, and an empty transcript would let it through every time
189+
test('only opens once even if the team resolves again', async () => {
190+
const wrapper = await mountPage()
191+
mocks.contextStore.team = { id: 't1', slug: 'ateam', instanceCount: 0 }
192+
await wrapper.vm.$nextTick()
193+
wrapper.vm.openConversation()
194+
expect(mocks.expertStore.openConversation).toHaveBeenCalledTimes(1)
184195
})
185196
})
186197

@@ -222,19 +233,14 @@ describe('Onboarding page', () => {
222233
})
223234

224235
// A direct load or refresh resolves the team after the page mounts, so
225-
// the seed lands after the turn baseline would have been captured. The
226-
// seeded turns must not read as engagement
227-
test('does not count turns seeded after a late team resolve as engagement', async () => {
236+
// a resumed transcript can land after the turn baseline would have
237+
// been captured. Turns already there must not read as engagement
238+
test('does not count turns present before the team resolves as engagement', async () => {
228239
mocks.contextStore.team = null
229240
mocks.expertStore.messages = []
230-
mocks.expertStore.hydrateMessages.mockImplementationOnce(() => {
231-
mocks.expertStore.messages = [
232-
{ _type: 'ai', generated: true },
233-
{ _type: 'human', content: 'seeded' }
234-
]
235-
})
236241
const wrapper = await mountPage()
237242

243+
mocks.expertStore.messages = [{ _type: 'human', content: 'hello again' }]
238244
mocks.contextStore.team = { id: 't1', slug: 'ateam', instanceCount: 0 }
239245
await flushPromises()
240246
expect(wrapper.find('[data-action="skip-onboarding"]').classes()).not.toContain('has-engaged')

‎test/unit/frontend/stores/context.spec.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,28 @@ describe('context store', () => {
431431
expect(expert.scope).toBe('ff-app')
432432
})
433433

434+
// Both branches build the object separately, so a field added to one
435+
// and not the other goes missing depending on load timing
436+
it('carries the onboarding flag on both the early-return and main paths', async () => {
437+
const { useUxStore } = await import('@/stores/ux.js')
438+
const store = useContextStore()
439+
const uxStore = useUxStore()
440+
441+
expect(store.route).toBe(null)
442+
expect(store.expert.onboarding).toBe(false)
443+
444+
uxStore.setNewlyCreatedUser()
445+
expect(store.expert.onboarding).toBe(true)
446+
447+
store.setTeamMembership({ role: 30 })
448+
store.updateRoute({ name: 'team', fullPath: '/team/a', params: {} })
449+
expect(store.route).not.toBe(null)
450+
expect(store.expert.onboarding).toBe(true)
451+
452+
uxStore.endOnboarding()
453+
expect(store.expert.onboarding).toBe(false)
454+
})
455+
434456
it('includes teamId and teamSlug from context team', () => {
435457
const store = useContextStore()
436458
store.setTeam({ id: 'team-42', slug: 'my-team' })

‎test/unit/frontend/stores/product-expert.spec.js‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,81 @@ describe('product-expert store', () => {
305305
})
306306
})
307307

308+
describe('openConversation', () => {
309+
it('sends a turn with no query', async () => {
310+
const store = useProductExpertStore()
311+
const sendQuery = vi.spyOn(store, 'sendQuery').mockResolvedValue(undefined)
312+
313+
await store.openConversation()
314+
315+
expect(sendQuery).toHaveBeenCalledWith({ query: '' })
316+
})
317+
318+
// The user has not said anything, so nothing of theirs belongs in the
319+
// transcript
320+
it('adds no user message', async () => {
321+
const store = useProductExpertStore()
322+
vi.spyOn(store, 'sendQuery').mockResolvedValue(undefined)
323+
324+
await store.openConversation()
325+
326+
expect(store.messages).toHaveLength(0)
327+
})
328+
329+
// The expiry window should start when the user replies, not while they
330+
// are still reading the opening question
331+
it('does not start the session clock', async () => {
332+
const store = useProductExpertStore()
333+
const supportAgent = useProductExpertSupportAgentStore()
334+
vi.spyOn(store, 'sendQuery').mockResolvedValue(undefined)
335+
336+
await store.openConversation()
337+
338+
expect(supportAgent.sessionStartTime).toBe(null)
339+
})
340+
341+
it('gives the session an id', async () => {
342+
const store = useProductExpertStore()
343+
const supportAgent = useProductExpertSupportAgentStore()
344+
vi.spyOn(store, 'sendQuery').mockResolvedValue(undefined)
345+
346+
await store.openConversation()
347+
348+
expect(supportAgent.sessionId).toBeTruthy()
349+
})
350+
351+
it('clears the abort controller when the turn settles', async () => {
352+
const store = useProductExpertStore()
353+
const supportAgent = useProductExpertSupportAgentStore()
354+
vi.spyOn(store, 'sendQuery').mockResolvedValue(undefined)
355+
356+
await store.openConversation()
357+
358+
expect(supportAgent.abortController).toBe(null)
359+
})
360+
361+
it('surfaces a failure to the user rather than leaving a blank page', async () => {
362+
const store = useProductExpertStore()
363+
vi.spyOn(store, 'sendQuery').mockRejectedValue(new Error('broker down'))
364+
365+
await store.openConversation()
366+
367+
expect(store.messages).toHaveLength(1)
368+
expect(store.messages[0].error).toBe(true)
369+
})
370+
371+
it('says nothing when the turn was aborted', async () => {
372+
const store = useProductExpertStore()
373+
const aborted = new Error('aborted')
374+
aborted.name = 'AbortError'
375+
vi.spyOn(store, 'sendQuery').mockRejectedValue(aborted)
376+
377+
await store.openConversation()
378+
379+
expect(store.messages).toHaveLength(0)
380+
})
381+
})
382+
308383
describe('reset', () => {
309384
it('calls reset on the active agent store and resets own state', () => {
310385
const store = useProductExpertStore()

0 commit comments

Comments
 (0)