From f6304069123d0cbe9f6f74a8f11db2f5aa532068 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 19:33:10 +0200 Subject: [PATCH 1/6] fix(expert): contain render errors per-section and fix resource card crash Add an ErrorBoundary and wrap each answer item in it, so a failure in one section degrades only that section instead of blanking the whole message. Also guard the optional streamable chain in StandardResourceCard that could throw on a null value and take down the message. --- .../expert/components/messages/AiMessage.vue | 17 +++++--- .../messages/components/ErrorBoundary.vue | 39 +++++++++++++++++++ .../resource-cards/StandardResourceCard.vue | 2 +- 3 files changed, 51 insertions(+), 7 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 213b352f98d3b15f92f50a866117a35053d2e6f5 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 19:33:35 +0200 Subject: [PATCH 2/6] feat(expert): grouped clarifying questions UI with cadence control (#407) The Expert can ask 1-4 clarifying questions in a single turn, each rendered as its own single- or multi-select option card; all answers are collected before the turn is submitted. Answered cards can be edited and resubmitted, and a card from a past turn is disabled once a newer message arrives. Adds a follow-up-questions cadence setting (all at once vs one at a time) in the composer settings menu, shipped to the agent via the expert context. --- .../expert/components/ExpertChatInput.vue | 138 +++++++++- .../messages/components/AnswerWrapper.vue | 179 +++++++----- .../components/resources/QuestionsList.vue | 259 ++++++++++++++++++ frontend/src/stores/context.js | 6 +- frontend/src/stores/product-expert.js | 15 +- 5 files changed, 528 insertions(+), 69 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..d86f5f3b13 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -17,6 +17,26 @@
+ +
  • + Follow-up questions +

    + When a request needs more detail, choose how the Expert asks for it. +

    + +
  • +
    @@ -65,6 +85,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 +101,8 @@ export default { components: { CapabilitiesSelector, ContextSelector, - ResizeBar + ResizeBar, + ToggleButtonGroup }, inject: { togglePinWithWidth: { @@ -94,6 +116,7 @@ export default { startResize, heightStyle, bindResizer, + setHeight, isResizing: isInputResizing } = useResizingHelper() @@ -101,6 +124,7 @@ export default { startResize, bindResizer, heightStyle, + setHeight, isInputResizing } }, @@ -108,7 +132,10 @@ export default { return { inputText: '', includeSelection: true, - isTextareaFocused: false + isTextareaFocused: false, + // true after we grow the composer to fit loaded content (e.g. an edited question), + // so we can collapse it back to the default height once the message is sent + composerAutoGrown: false } }, computed: { @@ -123,8 +150,24 @@ export default { 'isInsightsAgent', 'hasSelectedCapabilities', 'hasMessages', - 'isWaitingForResponse' + 'isWaitingForResponse', + 'pendingInput', + 'questionCadence' ]), + questionCadenceButtons () { + return [ + { title: 'All at once', value: 'all' }, + { title: 'One at a time', value: 'one' } + ] + }, + questionCadenceWrapper: { + get () { + return this.questionCadence + }, + set (value) { + this.setQuestionCadence(value) + } + }, isInputDisabled () { if (this.isSessionExpired) return true if (this.isWaitingForResponse) return true @@ -148,6 +191,21 @@ export default { return this.isImmersiveDevice || this.isImmersiveInstance } }, + watch: { + pendingInput (text) { + if (text) { + this.inputText = text + this.setPendingInput('') + this.$nextTick(() => { + this.$refs.textarea.focus() + // Grow the composer so loaded content (e.g. an edited question) is readable, + // instead of being crammed into the default-height box. The CSS max-height + // (40vh) caps it; short content stays near the minimum. + this.growComposerToContent() + }) + } + } + }, mounted () { this.bindResizer({ component: this.$refs.resizeTarget, @@ -158,7 +216,7 @@ export default { }, methods: { ...mapActions(useProductAssistantStore, ['resetContextSelection']), - ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse']), + ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence']), async handleSend () { if (!this.canSend) return @@ -181,6 +239,38 @@ export default { .catch(e => e) this.inputText = '' + // Collapse the composer back to its default height if we had grown it + // (180 matches the CSS min-height of .ff-expert-input). + if (this.composerAutoGrown) { + this.setHeight(180) + this.composerAutoGrown = false + } + }, + growComposerToContent () { + const textarea = this.$refs.textarea + const container = this.$refs.resizeTarget + if (!textarea || !container) return + // Height of everything in the composer that isn't the textarea — the action-buttons + // row, the Send/context row, the container's padding, gaps and border. Only the + // textarea flex-grows, so this difference is invariant to the current height. Measure + // it at runtime (before collapsing the textarea below) rather than hard-coding it, so + // it stays correct if those rows change. + const chromeHeight = container.offsetHeight - textarea.clientHeight + // The textarea has flex: 1, so it stretches to fill the container. scrollHeight is + // floored at the element's client height, so reading it while stretched returns the + // current (possibly already-grown) box height rather than the text's true height — + // which would ratchet the composer taller on every call. Briefly take the textarea + // out of the flex stretch and collapse it so scrollHeight reflects only the content, + // then restore the inline styles. + const prevFlex = textarea.style.flex + const prevHeight = textarea.style.height + textarea.style.flex = '0 0 auto' + textarea.style.height = '0px' + const contentHeight = textarea.scrollHeight + textarea.style.flex = prevFlex + textarea.style.height = prevHeight + this.setHeight(contentHeight + chromeHeight) + this.composerAutoGrown = true }, handleStop () { this.$emit('stop') @@ -232,6 +322,7 @@ export default { .right-buttons { display: flex; gap: 0.5rem; + align-items: center; } button { @@ -350,3 +441,42 @@ button { } } + + + diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index ce26544b8c..e14708a7ad 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -1,71 +1,91 @@ @@ -76,12 +96,14 @@ import { mapActions, mapState } from 'pinia' import useTimerHelper from '../../../../../composables/TimerHelper.js' import AnswerBadge from './AnswerBadge.vue' +import ErrorBoundary from './ErrorBoundary.vue' import GuideHeader from './GuideHeader.vue' import MessageBubble from './MessageBubble.vue' 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' @@ -97,7 +119,9 @@ export default { PackagesList, FlowsList, AnswerBadge, + ErrorBoundary, ResourcesList, + QuestionsList, GuideStepsList, MessageBubble, GuideHeader, @@ -126,10 +150,21 @@ export default { }, computed: { ...mapState(useProductAssistantStore, ['supportedActions']), - ...mapState(useProductExpertStore, ['agentMode']), + ...mapState(useProductExpertStore, ['agentMode', 'isWaitingForResponse', 'messages']), + isLatestMessage () { + const msgs = this.messages || [] + return msgs.length > 0 && msgs[msgs.length - 1]?._uuid === this.messageUuid + }, + interactionDisabled () { + // Disable the questions card while a response is in flight, and once the turn + // has passed — i.e. any message has arrived after this one — so a stale card from + // an earlier turn can no longer be answered. + return this.isWaitingForResponse || !this.isLatestMessage + }, hasGuideHeader () { - // chat answers contain generic titles, they don't need to be displayed - return !!(this.answer.title && !this.isChatAnswer) + // chat answers contain generic titles, they don't need to be displayed. + // questions answers carry no guide title either. + return !!(this.answer.title && !this.isChatAnswer && !this.isQuestionsAnswer) }, hasGuideSteps () { return Object.hasOwnProperty.call(this.answer, 'steps') && this.answer.steps.length > 0 @@ -152,9 +187,15 @@ 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' }, + isQuestionsAnswer () { + return this.answer.kind === 'questions' + }, isEditorContext () { // In editor context, the route name includes 'editor' return this.$route?.name?.includes('editor') || false @@ -215,6 +256,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 +298,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 +311,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..c9c53932a0 --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/frontend/src/stores/context.js b/frontend/src/stores/context.js index cc89ea8c69..c29925a01e 100644 --- a/frontend/src/stores/context.js +++ b/frontend/src/stores/context.js @@ -57,7 +57,8 @@ export const useContextStore = defineStore('context', { pageName: null, rawRoute: {}, selectedNodes: null, - scope: 'ff-app' + scope: 'ff-app', + questionCadence: useProductExpertStore().questionCadence } } @@ -106,7 +107,8 @@ export const useContextStore = defineStore('context', { nodeRedVersion: assistantStore.nodeRedVersion, rawRoute, selectedNodes, - scope + scope, + questionCadence: useProductExpertStore().questionCadence } } }, diff --git a/frontend/src/stores/product-expert.js b/frontend/src/stores/product-expert.js index a1925a0ed6..1aea56c449 100644 --- a/frontend/src/stores/product-expert.js +++ b/frontend/src/stores/product-expert.js @@ -29,7 +29,9 @@ export const useProductExpertStore = defineStore('product-expert', { agentMode: SUPPORT_AGENT, // support-agent or insights-agent loadingVariant: SUPPORT_AGENT, shouldWakeUpAssistant: false, + questionCadence: 'all', // 'all' = ask every clarifying question at once, 'one' = one at a time inFlightUpdates: [], + pendingInput: '', _seenTransactionIds: new Map() }), getters: { @@ -176,6 +178,9 @@ export const useProductExpertStore = defineStore('product-expert', { .then(() => { this.loadingVariant = this.agentMode }) } }, + setPendingInput (text) { + this.pendingInput = text + }, async handleQuery ({ query }) { const agentStore = this._agentStore @@ -499,6 +504,14 @@ export const useProductExpertStore = defineStore('product-expert', { this.agentMode = mode this.loadingVariant = mode }, + /** + * Sets how clarifying questions are asked: all at once or one at a time. + * @param {'all' | 'one'} cadence + */ + setQuestionCadence (cadence) { + if (!['all', 'one'].includes(cadence)) return + this.questionCadence = cadence + }, /** * Adds a system message to the application's message store. * @@ -1063,7 +1076,7 @@ export const useProductExpertStore = defineStore('product-expert', { } }, persist: { - pick: ['shouldWakeUpAssistant'], + pick: ['shouldWakeUpAssistant', 'questionCadence'], storage: localStorage } }) From 2905cf1730144389d1cfc625ed1f3322cb2697aa Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 30 Jun 2026 14:34:05 +0200 Subject: [PATCH 3/6] refactor(expert): address review feedback on clarifying-questions UI - Guard the optional streamable chain in FlowResourceCard directly instead of relying on a render boundary to mask the throw. Reduce ErrorBoundary to a single last-resort backstop per answer item in AiMessage; drop the per-section boundary wrappers in AnswerWrapper. - Rewrite QuestionsList on top of the existing ff-radio-group (single-select) and ff-checkbox (multi-select) components so options look like standard, clickable form controls and stay consistent with the rest of the app. - Replace the imperative growComposerToContent DOM measuring with CSS field-sizing on the textarea; drop the manual reflows and the auto-grown flag. The composer auto-sizes to content and pins to an explicit height only after a drag-resize. --- .../expert/components/ExpertChatInput.vue | 65 +++---- .../messages/components/AnswerWrapper.vue | 145 +++++++--------- .../messages/components/ErrorBoundary.vue | 8 +- .../resource-cards/FlowResourceCard.vue | 28 ++- .../components/resources/QuestionsList.vue | 164 +++++------------- 5 files changed, 162 insertions(+), 248 deletions(-) diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index d86f5f3b13..5610d57a90 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -1,9 +1,9 @@ @@ -96,7 +87,6 @@ import { mapActions, mapState } from 'pinia' import useTimerHelper from '../../../../../composables/TimerHelper.js' import AnswerBadge from './AnswerBadge.vue' -import ErrorBoundary from './ErrorBoundary.vue' import GuideHeader from './GuideHeader.vue' import MessageBubble from './MessageBubble.vue' import FlowsList from './resources/FlowsList.vue' @@ -119,7 +109,6 @@ export default { PackagesList, FlowsList, AnswerBadge, - ErrorBoundary, ResourcesList, QuestionsList, GuideStepsList, diff --git a/frontend/src/components/expert/components/messages/components/ErrorBoundary.vue b/frontend/src/components/expert/components/messages/components/ErrorBoundary.vue index 5afb1a90b6..30797769c7 100644 --- a/frontend/src/components/expert/components/messages/components/ErrorBoundary.vue +++ b/frontend/src/components/expert/components/messages/components/ErrorBoundary.vue @@ -15,9 +15,11 @@ export default { } }, errorCaptured (err) { - // A descendant threw during render/lifecycle (e.g. an answer with a malformed - // resource missing its url/metadata). Contain it here so a single bad answer - // degrades to a small fallback instead of blanking the entire chat message. + // Last-resort backstop: known throws are guarded at their source (e.g. the optional + // streamable chains in the resource cards). This only catches a genuinely unexpected + // render/lifecycle failure so one bad answer item degrades to a small fallback instead + // of an uncaught error tearing down the whole chat. It is intentionally used once, per + // answer item, in AiMessage — not as a per-section wrapper. // Returning false stops the error from propagating further up the tree. // eslint-disable-next-line no-console console.error('[Expert] render error contained by ErrorBoundary:', err) diff --git a/frontend/src/components/expert/components/messages/components/resource-cards/FlowResourceCard.vue b/frontend/src/components/expert/components/messages/components/resource-cards/FlowResourceCard.vue index c39a21aa78..e324c8fe06 100644 --- a/frontend/src/components/expert/components/messages/components/resource-cards/FlowResourceCard.vue +++ b/frontend/src/components/expert/components/messages/components/resource-cards/FlowResourceCard.vue @@ -29,18 +29,18 @@ Import
    -
    +
    -
    - +
    +
    @@ -81,9 +81,18 @@ export default { }, computed: { ...mapState(useProductExpertStore, ['canImportFlows']), + // The streamable payload is optional and can be malformed (missing entirely, or + // present without flows/category). Read it defensively so a bad value renders as + // "nothing to show" instead of throwing and taking down the whole message. + flowData () { + return this.flowMetadata?.streamable?.flows ?? null + }, + flowCategory () { + return this.flowMetadata?.streamable?.category ?? null + }, flowsJson () { - if (!this.flowMetadata) return '' - return JSON.stringify(this.flowMetadata.streamable.flows, null, 2) + if (!this.flowData) return '' + return JSON.stringify(this.flowData, null, 2) } }, watch: { @@ -95,6 +104,13 @@ export default { } } }, + mounted () { + // The category line is what normally drives completeStreaming. When there's no + // category to stream, signal completion here so the parent streaming list still advances. + if (!this.flowCategory) { + this.$nextTick(() => this.completeStreaming()) + } + }, methods: { ...mapActions(useProductAssistantStore, ['sendFlowsToImport']), importFlows () { diff --git a/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue index c9c53932a0..b9ed29d708 100644 --- a/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue +++ b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue @@ -5,29 +5,26 @@

    {{ q.question }}

    {{ q.multiSelect ? 'Select all that apply' : 'Select one' }} -
    - +
    @@ -53,11 +50,8 @@ - - From b62528c6db172484e73afb7a38f684afdd88ce7c Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 30 Jun 2026 15:05:16 +0200 Subject: [PATCH 5/6] feat(expert): move composer settings into a dialog Replace the composer kebab menu with a settings gear that opens an ff-dialog. The follow-up-questions cadence control now lives in the dialog as an ff-radio-group, with a FormHeading per section so the panel can grow as more settings are added. --- .../expert/components/ExpertChatInput.vue | 120 ++++++++++-------- 1 file changed, 68 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index 5610d57a90..f28d6ed49f 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -17,26 +17,17 @@
    - -
  • - Follow-up questions -

    - When a request needs more detail, choose how the Expert asks for it. -

    - -
  • -
    + +
    @@ -78,14 +69,36 @@
    + + +
    +
    + Follow-up questions +

    When a request needs more detail, choose how the Expert asks for it.

    + +
    +
    +