Skip to content

Commit 283706e

Browse files
committed
[8369] Have the Expert open the onboarding conversation
The Expert speaks first on the onboarding page, so the frontend needs a way to start a turn the user has not typed. openConversation sends a turn with an empty query, the same shape resumeToolApprovals already uses, and the agent tells onboarding apart from ordinary support by the onboarding flag now carried on the context object. Two things it deliberately does not do. No user message is added, since the user has not said anything. And the session clock is left unstarted, so the 25 minute warning and 28 minute expiry begin when the user first replies rather than while they are still reading the opening question. The onboarding flag goes on both branches of the context getter: they build their objects separately, so a field added to one goes missing depending on load timing. It tracks the conversation rather than the deployment, staying true after the Expert moves the user into the editor and going false once onboarding is finished or skipped. Drops the hardcoded placeholder transcript that stood in while this was missing. A transcript holding only canned messages still counts as empty and gets cleared, so arriving from the drawer does not leave its greeting in the way, while a real conversation is left alone and picked up where it stopped.
1 parent b6ef4bf commit 283706e

7 files changed

Lines changed: 161 additions & 81 deletions

File tree

frontend/src/components/expert/composables/onboardingFixture.js

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

frontend/src/pages/team/Onboarding.vue

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { mapState } from 'pinia'
2424
2525
import teamApi from '@/api/team.ts'
2626
import ExpertPanel from '@/components/expert/Expert.vue'
27-
import { ONBOARDING_FIXTURE_MESSAGES } from '@/components/expert/composables/onboardingFixture.js'
2827
import Alerts from '@/services/alerts.js'
2928
import { useAccountSettingsStore } from '@/stores/account-settings.js'
3029
import { useAccountStore } from '@/stores/account.js'
@@ -50,6 +49,7 @@ export default {
5049
return {
5150
provisioning: false,
5251
teleportReady: false,
52+
conversationRequested: false,
5353
// How many turns the user had contributed when the page opened.
5454
// Anything beyond it is them engaging, which lets the seeded
5555
// transcript exist without counting as engagement.
@@ -89,7 +89,7 @@ export default {
8989
team: {
9090
immediate: true,
9191
handler () {
92-
this.seedFixtureTranscript()
92+
this.openConversation()
9393
}
9494
},
9595
userTurns: {
@@ -128,12 +128,8 @@ export default {
128128
})
129129
},
130130
methods: {
131-
// TEMPORARY until flowfuse#8369: the Expert can't open a conversation
132-
// on its own yet, so seed a placeholder transcript to work against.
133-
// A transcript holding only canned messages (`generated`, e.g. the
134-
// drawer's welcome text) counts as empty and gets replaced.
135-
seedFixtureTranscript () {
136-
if (!this.team || this.notAvailable) {
131+
openConversation () {
132+
if (!this.team || this.notAvailable || this.conversationRequested) {
137133
return
138134
}
139135
const expertStore = useProductExpertStore()
@@ -143,7 +139,8 @@ export default {
143139
if (expertStore.messages.length > 0) {
144140
useProductExpertSupportAgentStore().reset()
145141
}
146-
expertStore.hydrateMessages(ONBOARDING_FIXTURE_MESSAGES)
142+
this.conversationRequested = true
143+
expertStore.openConversation()
147144
},
148145
async skipOnboarding () {
149146
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
@@ -211,6 +211,35 @@ export const useProductExpertStore = defineStore('product-expert', {
211211
setComposerCommand (command) {
212212
this.composerCommand = command
213213
},
214+
async openConversation () {
215+
const agentStore = this._agentStore
216+
217+
if (agentStore.sessionId && this.isWaitingForResponse) {
218+
return undefined
219+
}
220+
if (!agentStore.sessionId) {
221+
agentStore.sessionId = uuidv4()
222+
}
223+
224+
agentStore.abortController = markRaw(new AbortController())
225+
try {
226+
const result = await this.sendQuery({ query: '' })
227+
if (result) {
228+
await this.handleMessageResponse(result)
229+
}
230+
return result
231+
} catch (error) {
232+
if (error.name === 'AbortError' || error.name === 'CanceledError') {
233+
return undefined
234+
}
235+
if (!this.shouldUseMqtt) {
236+
console.error('Expert API error:', error)
237+
}
238+
this.addPredefinedAiMessage('Sorry, I could not get started. Please refresh to try again.', { isError: true })
239+
} finally {
240+
agentStore.abortController = null
241+
}
242+
},
214243
async handleQuery ({ query }) {
215244
const agentStore = this._agentStore
216245

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

Lines changed: 25 additions & 14 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
}
@@ -146,40 +146,51 @@ describe('Onboarding page', () => {
146146
expect(wrapper.vm.$options.provide.call(wrapper.vm)['expert-surface']).toBe('onboarding')
147147
})
148148

149-
describe('fixture transcript', () => {
149+
describe('opening the conversation', () => {
150150
beforeEach(() => {
151-
mocks.expertStore.hydrateMessages.mockClear()
151+
mocks.expertStore.openConversation.mockClear()
152152
mocks.supportAgentStore.reset.mockClear()
153153
mocks.expertStore.messages = []
154154
})
155155

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

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

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

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

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)