Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions frontend/src/components/expert/components/ExpertChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
v-model="inputText"
class="chat-input"
:placeholder="placeholderText"
:disabled="isInputDisabled"
:disabled="isComposerDisabled"
@keydown="handleKeydown"
@focus="isTextareaFocused = true"
@blur="isTextareaFocused = false"
Expand All @@ -77,7 +77,7 @@
Stop
</button>
<button
v-else-if="!isSessionExpired"
v-else-if="!isSessionExpired || startsNewChat"
type="button"
class="btn-send"
:disabled="!canSend"
Expand Down Expand Up @@ -121,6 +121,7 @@
<script>
import { Cog8ToothIcon } from '@heroicons/vue/20/solid'
import { mapActions, mapState } from 'pinia'
import { unref } from 'vue'

import FormHeading from '../../FormHeading.vue'
import ResizeBar from '../../ResizeBar.vue'
Expand Down Expand Up @@ -158,6 +159,10 @@ export default {
expertSurface: {
from: 'expert-surface',
default: 'drawer'
},
expertChatOpen: {
from: 'expert-chat-open',
default: true
}
},
emits: ['send', 'stop'],
Expand Down Expand Up @@ -212,6 +217,7 @@ export default {
'hasMessages',
'isWaitingForResponse',
'isInputDisabled',
'isNewChatDisabled',
'pendingInput',
'composerCommand',
'questionCadence',
Expand All @@ -234,8 +240,15 @@ export default {
isDrawerPinned () {
return this.rightDrawer.fixed
},
// On the overview, a message sent while the conversation is folded away starts a new one
startsNewChat () {
return this.expertSurface === 'overview' && !unref(this.expertChatOpen) && this.hasUserTurns
},
isComposerDisabled () {
return this.startsNewChat ? this.isNewChatDisabled : this.isInputDisabled
},
canSend () {
return this.inputText.trim().length > 0 && !this.isInputDisabled
return this.inputText.trim().length > 0 && !this.isComposerDisabled
},
hasUserTurns () {
// hasMessages is true from the off, the store seeds a welcome message,
Expand All @@ -262,6 +275,9 @@ export default {
if (this.isInsightsAgent && !this.hasSelectedCapabilities) {
return 'Select a resource to get started'
}
if (this.startsNewChat && this.isWaitingForResponse && !this.isSessionExpired) {
return 'The Expert is still replying. Stop it to start a new chat'
}
if (this.requestingPlanChange) {
return 'Describe a change to the plan, or paste an edited version'
}
Expand Down Expand Up @@ -334,7 +350,7 @@ export default {
},
methods: {
...mapActions(useProductAssistantStore, ['resetContextSelection']),
...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setComposerCommand', 'setQuestionCadence', 'setPlanMode', 'fetchToolCatalog']),
...mapActions(useProductExpertStore, ['startOver', 'startNewChat', 'handleQuery', 'setPendingInput', 'setComposerCommand', 'setQuestionCadence', 'setPlanMode', 'fetchToolCatalog']),
focusInput () {
this.$refs.textarea?.focus()
},
Expand All @@ -354,7 +370,11 @@ export default {
// handleQuery renders the reply itself (see the store); the isWaitingForResponse
// watcher refocuses the input once the response completes (works for both HTTP
// and MQTT, where the promise resolves before the actual response arrives).
this.handleQuery({ query: message }).catch(e => e)
if (this.startsNewChat) {
this.startNewChat({ query: message }).catch(e => e)
} else {
this.handleQuery({ query: message }).catch(e => e)
}

this.inputText = ''
this.requestingPlanChange = false
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/pages/team/Home/Expert/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ const settingsStore = useAccountSettingsStore()
const expertStore = useProductExpertStore() as ReturnType<typeof useProductExpertStore> & {
messages: { _type: string }[]
isInputDisabled: boolean
isNewChatDisabled: boolean
handleQuery: (payload: { query: string }) => Promise<unknown>
startNewChat: (payload: { query: string }) => Promise<unknown>
}
const drawersStore = useUxDrawersStore()

Expand Down Expand Up @@ -216,7 +218,10 @@ type Stage = 'idle' | 'composing' | 'conversing'
const stage = ref<Stage>('idle')
const liveTurns = computed<number>(() => expertStore.messages.filter(message => message._type === 'human').length)

const isComposerDisabled = computed<boolean>(() => expertStore.isInputDisabled)
// With a thread to fold away, the composer and the suggestions start a new chat rather than extend it
const startsNewChat = computed<boolean>(() => liveTurns.value > 0)

const isComposerDisabled = computed<boolean>(() => startsNewChat.value ? expertStore.isNewChatDisabled : expertStore.isInputDisabled)

const canResume = computed<boolean>(() => liveTurns.value > 0 || isComposerDisabled.value)

Expand All @@ -237,6 +242,10 @@ function onSuggestion (suggestion: PromptSuggestion) {
return
}
stage.value = 'conversing'
if (startsNewChat.value) {
expertStore.startNewChat({ query: suggestion.prompt }).catch(e => e)
return
}
expertStore.handleQuery({ query: suggestion.prompt }).catch(e => e)
}

Expand Down
18 changes: 18 additions & 0 deletions frontend/src/stores/product-expert.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ export const useProductExpertStore = defineStore('product-expert', {
if (this.isWaitingForResponse) return true
return this.isInsightsAgent && !this.hasSelectedCapabilities
},
// startNewChat discards the session, so an expired one (and the response it
// left hanging) does not stand in the way.
isNewChatDisabled () {
if (this.isWaitingForResponse && !this.isSessionExpired) return true
return this.isInsightsAgent && !this.hasSelectedCapabilities
},
canImportFlows () {
const assistantStore = useProductAssistantStore()
return !!assistantStore.isImmersiveInstance && !!assistantStore.supportedActions['custom:import-flow']
Expand Down Expand Up @@ -730,6 +736,18 @@ export const useProductExpertStore = defineStore('product-expert', {
// Add welcome message for current mode
this.addWelcomeMessageIfNeeded()
},
async startNewChat ({ query }) {
if (this.isWaitingForResponse) {
this.stopInflightChat()
this.setAbortController(null)
}
// The resources picked for the old chat are the ones this query was typed against
const insightsStore = useProductExpertInsightsAgentStore()
const selectedCapabilities = insightsStore.selectedCapabilities
await this.startOver()
insightsStore.setSelectedCapabilities(selectedCapabilities)
return this.handleQuery({ query })
},
setAbortController (controller) {
this._agentStore.abortController = controller ? markRaw(controller) : null
},
Expand Down
60 changes: 58 additions & 2 deletions test/unit/frontend/components/expert/ExpertChatInput.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function shownEvents () {
return Product.capture.mock.calls.filter(([event]) => event === 'ff-expert-suggestions-shown')
}

function mountInput ({ immersive = false, editorDrawerOpen = true } = {}) {
function mountInput ({ immersive = false, editorDrawerOpen = true, provide } = {}) {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: true })

const drawers = useUxDrawersStore(pinia)
Expand All @@ -38,7 +38,7 @@ function mountInput ({ immersive = false, editorDrawerOpen = true } = {}) {
global: {
plugins: [pinia],
// the instance and device editor pages provide this
provide: immersive ? { 'expert-surface': 'immersive' } : {},
provide: provide ?? (immersive ? { 'expert-surface': 'immersive' } : {}),
stubs: {
teleport: true,
'resize-bar': true,
Expand Down Expand Up @@ -124,3 +124,59 @@ describe('ExpertChatInput prompt suggestion tracking', () => {
}, { team: 'team-1' })
})
})

describe('ExpertChatInput on the overview', () => {
function mountOverview ({ chatOpen }) {
const mounted = mountInput({ provide: { 'expert-surface': 'overview', 'expert-chat-open': chatOpen } })
mounted.expert.messages = [{ _type: 'human' }, { _type: 'ai' }]
mounted.expert.startNewChat.mockResolvedValue()
return mounted
}

beforeEach(() => {
vi.clearAllMocks()
})

test('a message sent while the conversation is folded away starts a new chat', async () => {
const { wrapper, expert } = mountOverview({ chatOpen: false })

await wrapper.find('textarea').setValue('how are my instances?')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })

expect(expert.startNewChat).toHaveBeenCalledWith({ query: 'how are my instances?' })
expect(expert.handleQuery).not.toHaveBeenCalled()
})

test('a message sent in the open conversation continues it', async () => {
const { wrapper, expert } = mountOverview({ chatOpen: true })

await wrapper.find('textarea').setValue('and the devices?')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })

expect(expert.handleQuery).toHaveBeenCalledWith({ query: 'and the devices?' })
expect(expert.startNewChat).not.toHaveBeenCalled()
})

test('an expired conversation does not lock the folded composer', async () => {
const { wrapper, expert } = mountOverview({ chatOpen: false })
expert.isSessionExpired = true
expert.isInputDisabled = true
expert.isNewChatDisabled = false
await wrapper.vm.$nextTick()

expect(wrapper.find('textarea').attributes('disabled')).toBeUndefined()
expect(wrapper.find('.btn-send').exists()).toBe(true)
})

test('a live reply locks the folded composer and says how to unlock it', async () => {
const { wrapper, expert } = mountOverview({ chatOpen: false })
expert.isWaitingForResponse = true
expert.isNewChatDisabled = true
await wrapper.vm.$nextTick()

const textarea = wrapper.find('textarea')
expect(textarea.attributes('disabled')).toBeDefined()
expect(textarea.attributes('placeholder')).toBe('The Expert is still replying. Stop it to start a new chat')
expect(wrapper.find('.btn-stop').exists()).toBe(true)
})
})
33 changes: 32 additions & 1 deletion test/unit/frontend/pages/team/Home/Expert.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ const mocks = vi.hoisted(() => ({
isInsightsAgent: false,
hasSelectedCapabilities: true,
isInputDisabled: false,
isNewChatDisabled: false,
openAssistantDrawer: vi.fn(),
handleQuery: vi.fn().mockResolvedValue(undefined),
startNewChat: vi.fn().mockResolvedValue(undefined),
setPendingInput: vi.fn(),
setAgentMode: vi.fn(),
resumeSessionTimer: vi.fn()
Expand Down Expand Up @@ -281,7 +283,7 @@ describe('TeamHomeExpert', () => {
mocks.expertStore.isInputDisabled = false
})

test('survives into composing, since a new message joins the same thread', async () => {
test('survives into composing, so the thread is still one click away', async () => {
mocks.expertStore.messages = [{ _type: 'human' }]
const wrapper = await mountPage()
await wrapper.find('textarea').setValue('how are my instances?')
Expand Down Expand Up @@ -341,6 +343,35 @@ describe('TeamHomeExpert', () => {
expect(wrapper.find('[data-el="expert-home"]').attributes('data-stage')).toBe('conversing')
})

test('with a thread behind them, a finished prompt starts a new chat', async () => {
mocks.expertStore.messages = [{ _type: 'human' }]
mocks.expertStore.handleQuery.mockClear()
mocks.expertStore.startNewChat.mockClear()
const wrapper = await mountPage()

await wrapper.findComponent({ name: 'PromptSuggestions' })
.vm.$emit('select', { title: 'x', prompt: 'how are my instances?' })

expect(mocks.expertStore.startNewChat).toHaveBeenCalledWith({ query: 'how are my instances?' })
expect(mocks.expertStore.handleQuery).not.toHaveBeenCalled()
expect(wrapper.find('[data-el="expert-home"]').attributes('data-stage')).toBe('conversing')
})

test('stay live over an expired thread, since they would start a new chat', async () => {
mocks.expertStore.messages = [{ _type: 'human' }]
mocks.expertStore.startNewChat.mockClear()
mocks.expertStore.isInputDisabled = true
const wrapper = await mountPage()

expect(wrapper.find('.ff-expert-home__suggestions').classes()).not.toContain('is-inert')

await wrapper.findComponent({ name: 'PromptSuggestions' })
.vm.$emit('select', { title: 'x', prompt: 'anything' })
expect(mocks.expertStore.startNewChat).toHaveBeenCalledWith({ query: 'anything' })

mocks.expertStore.isInputDisabled = false
})

test('a half-finished prompt waits in the composer instead', async () => {
mocks.expertStore.messages = []
mocks.expertStore.handleQuery.mockClear()
Expand Down
70 changes: 70 additions & 0 deletions test/unit/frontend/stores/product-expert.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,76 @@ describe('product-expert store', () => {
})
})

describe('isNewChatDisabled getter', () => {
it('is true while a live response is in flight', () => {
const store = useProductExpertStore()
store.setAbortController(new AbortController())
expect(store.isNewChatDisabled).toBe(true)
})

it('is false once the session has expired, even with a response left hanging', () => {
const store = useProductExpertStore()
store.setAbortController(new AbortController())
store._agentStore.sessionExpiredShown = true
expect(store.isNewChatDisabled).toBe(false)
})

it('is true for the insights agent with no capabilities selected', () => {
const store = useProductExpertStore()
store.setAgentMode(INSIGHTS_AGENT)
useProductExpertInsightsAgentStore().setSelectedCapabilities([])
expect(store.isNewChatDisabled).toBe(true)
})
})

describe('startNewChat', () => {
// startOver reloads the insights capabilities, which waits for a team
beforeEach(() => {
contextState.team = { id: 'team-1' }
})

afterEach(() => {
contextState.team = null
})

it('clears the old thread and sends the query in a new session', async () => {
const store = useProductExpertStore()
store.addUserMessage('old question')
const oldSessionId = store._agentStore.sessionId
const handleQuery = vi.spyOn(store, 'handleQuery').mockResolvedValue()

await store.startNewChat({ query: 'new question' })

expect(store.messages.some(m => m._type === 'human')).toBe(false)
expect(store._agentStore.sessionId).not.toBe(oldSessionId)
expect(handleQuery).toHaveBeenCalledWith({ query: 'new question' })
})

it('drops a response left hanging by an expired session', async () => {
const store = useProductExpertStore()
store.setAbortController(new AbortController())
store._agentStore.inFlightRequests.set('turn-1', { query: 'x', transactionId: 'turn-1' })
store._agentStore.sessionExpiredShown = true
vi.spyOn(store, 'handleQuery').mockResolvedValue()

await store.startNewChat({ query: 'new question' })

expect(store.abortController).toBeNull()
expect(store._agentStore.inFlightRequests.size).toBe(0)
})

it('keeps the insights resources the query was typed against', async () => {
const store = useProductExpertStore()
store.setAgentMode(INSIGHTS_AGENT)
useProductExpertInsightsAgentStore().setSelectedCapabilities([{ id: 'cap-1' }])
vi.spyOn(store, 'handleQuery').mockResolvedValue()

await store.startNewChat({ query: 'new question' })

expect(useProductExpertInsightsAgentStore().selectedCapabilities).toEqual([{ id: 'cap-1' }])
})
})

describe('messages getters', () => {
it('hasMessages is false when agent store has no messages', () => {
const store = useProductExpertStore()
Expand Down
Loading