From 2d01ecfc53cb94abe3735b8490caa6b5228f5bc7 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 17 Jun 2026 17:37:42 +0200 Subject: [PATCH 1/5] feat(expert): grouped clarifying questions UI (#407) Replace the flat quick-replies renderer with QuestionsList, which renders 1-4 grouped clarifying questions, each with 2-4 options. Single-select questions show radio indicators, multi-select show checkboxes, with a Select one / Select all that apply hint. A Send button (enabled once every question is answered) submits the composed reply; a pencil button sends it to the input for editing first. - New QuestionsList.vue (themed with FlowFuse accent tokens) - AnswerWrapper wired to render questions and handle submit/edit - pendingInput store state + setPendingInput action for edit-first flow - ExpertChatInput watches pendingInput to prefill the textarea --- .../expert/components/ExpertChatInput.vue | 16 +- .../messages/components/AnswerWrapper.vue | 34 +- .../components/resources/QuestionsList.vue | 304 ++++++++++++++++++ frontend/src/stores/product-expert.js | 4 + 4 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index 5d165ee52c..e9941d8e4d 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -123,7 +123,8 @@ export default { 'isInsightsAgent', 'hasSelectedCapabilities', 'hasMessages', - 'isWaitingForResponse' + 'isWaitingForResponse', + 'pendingInput' ]), isInputDisabled () { if (this.isSessionExpired) return true @@ -148,6 +149,17 @@ export default { return this.isImmersiveDevice || this.isImmersiveInstance } }, + watch: { + pendingInput (text) { + if (text) { + this.inputText = text + this.setPendingInput('') + this.$nextTick(() => { + this.$refs.textarea.focus() + }) + } + } + }, mounted () { this.bindResizer({ component: this.$refs.resizeTarget, @@ -158,7 +170,7 @@ export default { }, methods: { ...mapActions(useProductAssistantStore, ['resetContextSelection']), - ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse']), + ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput']), async handleSend () { if (!this.canSend) return diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index ce26544b8c..9530bf0fda 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -66,6 +66,17 @@ :should-stream="shouldStream" @streaming-complete="onComponentComplete('suggestions-list')" /> + + @@ -82,6 +93,7 @@ import FlowsList from './resources/FlowsList.vue' import GuideStepsList from './resources/GuideStepsList.vue' import IssuesList from './resources/IssuesList.vue' import PackagesList from './resources/PackagesList.vue' +import QuestionsList from './resources/QuestionsList.vue' import ResourcesList from './resources/ResourcesList.vue' import RichContent from './resources/RichContent.vue' import SuggestionsList from './resources/SuggestionsList.vue' @@ -98,6 +110,7 @@ export default { FlowsList, AnswerBadge, ResourcesList, + QuestionsList, GuideStepsList, MessageBubble, GuideHeader, @@ -126,7 +139,7 @@ export default { }, computed: { ...mapState(useProductAssistantStore, ['supportedActions']), - ...mapState(useProductExpertStore, ['agentMode']), + ...mapState(useProductExpertStore, ['agentMode', 'isWaitingForResponse']), hasGuideHeader () { // chat answers contain generic titles, they don't need to be displayed return !!(this.answer.title && !this.isChatAnswer) @@ -152,6 +165,9 @@ export default { hasPlainContent () { return this.answer.content && this.answer.content.length > 0 }, + hasQuestions () { + return Array.isArray(this.answer.questions) && this.answer.questions.length > 0 + }, isChatAnswer () { return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' }, @@ -215,6 +231,13 @@ export default { if (this.componentStreamingOrder.indexOf(key) === 0) return true return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) }, + shouldShowQuestionsList () { + const key = 'questions-list' + if (!this.componentStreamingOrder.includes(key)) return false + if (!this.hasQuestions) return false + if (this.componentStreamingOrder.indexOf(key) === 0) return true + return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) + }, shouldStream () { return !this.answer._streamed } @@ -250,7 +273,7 @@ export default { } }, methods: { - ...mapActions(useProductExpertStore, ['updateAnswerStreamedState']), + ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput']), buildStreamingOrder () { // order matters // this is where the decision of the streaming order of components is decided @@ -263,12 +286,19 @@ export default { if (this.hasNodePackages) this.componentStreamingOrder.push('packages-list') if (this.hasIssues) this.componentStreamingOrder.push('issues-list') if (this.hasSuggestions) this.componentStreamingOrder.push('suggestions-list') + if (this.hasQuestions) this.componentStreamingOrder.push('questions-list') }, async onComponentComplete (key) { if (!this.shouldStream) await this.waitFor(200) this.streamedComponents.push(key) }, + onQuestionsSubmit (text) { + this.handleQuery({ query: text }) + }, + onQuestionsEdit (text) { + this.setPendingInput(text) + }, handleClick (e) { const target = e.target // - Must be in the immersive editor diff --git a/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue new file mode 100644 index 0000000000..00a0daf5a1 --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/frontend/src/stores/product-expert.js b/frontend/src/stores/product-expert.js index a1925a0ed6..8ef8f24847 100644 --- a/frontend/src/stores/product-expert.js +++ b/frontend/src/stores/product-expert.js @@ -30,6 +30,7 @@ export const useProductExpertStore = defineStore('product-expert', { loadingVariant: SUPPORT_AGENT, shouldWakeUpAssistant: false, inFlightUpdates: [], + pendingInput: '', _seenTransactionIds: new Map() }), getters: { @@ -176,6 +177,9 @@ export const useProductExpertStore = defineStore('product-expert', { .then(() => { this.loadingVariant = this.agentMode }) } }, + setPendingInput (text) { + this.pendingInput = text + }, async handleQuery ({ query }) { const agentStore = this._agentStore From 7675e56434ab53982dde3a11599c874350e7c951 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Thu, 18 Jun 2026 11:49:31 +0200 Subject: [PATCH 2/5] fix(expert): contain render errors per-section and fix resource card crash Add ErrorBoundary component and wrap each answer section so a failure in one section (e.g. a malformed resource) degrades only that section instead of blanking the whole message. Guard the optional streamable chain in StandardResourceCard that previously threw on null. --- .../expert/components/messages/AiMessage.vue | 17 +- .../messages/components/AnswerWrapper.vue | 145 ++++++++++-------- .../messages/components/ErrorBoundary.vue | 39 +++++ .../resource-cards/StandardResourceCard.vue | 2 +- 4 files changed, 129 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/expert/components/messages/components/ErrorBoundary.vue diff --git a/frontend/src/components/expert/components/messages/AiMessage.vue b/frontend/src/components/expert/components/messages/AiMessage.vue index 0fc8817324..2169233b5c 100644 --- a/frontend/src/components/expert/components/messages/AiMessage.vue +++ b/frontend/src/components/expert/components/messages/AiMessage.vue @@ -1,18 +1,23 @@ + + diff --git a/frontend/src/components/expert/components/messages/components/resource-cards/StandardResourceCard.vue b/frontend/src/components/expert/components/messages/components/resource-cards/StandardResourceCard.vue index 11a0f45c01..52fbf44d95 100644 --- a/frontend/src/components/expert/components/messages/components/resource-cards/StandardResourceCard.vue +++ b/frontend/src/components/expert/components/messages/components/resource-cards/StandardResourceCard.vue @@ -43,7 +43,7 @@ export default { emits: ['streaming-complete'], data () { return { - resourceUrl: this.resource.metadata?.streamable.source || this.resource.streamable.url, + resourceUrl: this.resource.metadata?.streamable?.source || this.resource.url?.streamable, resourceTitle: { ...this.resource.title }, resourceMetadataSource: this.resource.metadata?.source } From 545c063262aa613be7017074d48313f0092a91e8 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 23 Jun 2026 16:10:42 +0200 Subject: [PATCH 3/5] feat: add clarifying-question cadence control to Expert Add an All/One toggle for how the Expert asks clarifying questions, surfaced via a kebab settings menu in the chat input (visible in both the app drawer and the immersive editor). The cadence persists to localStorage and ships in the request context so the agent can ask every blocking question at once or one at a time. - product-expert store: questionCadence state, setQuestionCadence action, persisted - context store: include questionCadence in the expert request context - ExpertChatInput: kebab menu hosting the cadence toggle - AnswerWrapper: render questions answers without the guide badge/title --- .../expert/components/ExpertChatInput.vue | 68 ++++++++++++++++++- .../messages/components/AnswerWrapper.vue | 10 ++- frontend/src/stores/context.js | 6 +- frontend/src/stores/product-expert.js | 11 ++- 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index e9941d8e4d..a5e194699a 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -17,6 +17,23 @@
+ +
  • + Clarifying questions + +
  • +
    @@ -65,6 +82,7 @@ import { mapActions, mapState } from 'pinia' import ResizeBar from '../../ResizeBar.vue' +import ToggleButtonGroup from '../../elements/ToggleButtonGroup.vue' import CapabilitiesSelector from './CapabilitiesSelector.vue' import ContextSelector from './context-selection/index.vue' @@ -80,7 +98,8 @@ export default { components: { CapabilitiesSelector, ContextSelector, - ResizeBar + ResizeBar, + ToggleButtonGroup }, inject: { togglePinWithWidth: { @@ -124,8 +143,23 @@ export default { 'hasSelectedCapabilities', 'hasMessages', 'isWaitingForResponse', - 'pendingInput' + 'pendingInput', + 'questionCadence' ]), + questionCadenceButtons () { + return [ + { title: 'All', value: 'all' }, + { title: 'One', value: 'one' } + ] + }, + questionCadenceWrapper: { + get () { + return this.questionCadence + }, + set (value) { + this.setQuestionCadence(value) + } + }, isInputDisabled () { if (this.isSessionExpired) return true if (this.isWaitingForResponse) return true @@ -170,7 +204,7 @@ export default { }, methods: { ...mapActions(useProductAssistantStore, ['resetContextSelection']), - ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput']), + ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence']), async handleSend () { if (!this.canSend) return @@ -244,6 +278,7 @@ export default { .right-buttons { display: flex; gap: 0.5rem; + align-items: center; } button { @@ -362,3 +397,30 @@ button { } } + + + diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index fb584c46af..023bb4e4d0 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -1,6 +1,6 @@