From f6304069123d0cbe9f6f74a8f11db2f5aa532068 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 19:33:10 +0200 Subject: [PATCH 01/11] 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 02/11] 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 500604711f6c02a7b8426db7cf47a34788489ffe Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 19:37:39 +0200 Subject: [PATCH 03/11] =?UTF-8?q?feat(expert):=20plan=20mode=20=E2=80=94?= =?UTF-8?q?=20propose=20a=20plan=20before=20acting=20(#408,=20#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an always-visible Plan mode toggle to the composer. When enabled, the Expert proposes a plan instead of making changes, rendered as a plan card with Approve, Edit, Request changes and Reject actions: - Approve exits plan mode and proceeds with the plan. - Edit loads the plan markdown into the composer for direct editing. - Request changes focuses an empty composer to describe a change in words. - Reject abandons the plan. The plan card renders its markdown through RichContent (passing the message and answer uuids it requires), and reuses the composer's pending-input and auto-grow behaviour. Plan mode and the approval signal are shipped to the agent via the expert context. --- .../expert/components/ExpertChatInput.vue | 95 +++++++++++++++- .../expert/components/chips/DefaultChip.vue | 24 +++- .../messages/components/AnswerWrapper.vue | 71 +++++++++++- .../components/resources/PlanCard.vue | 103 ++++++++++++++++++ frontend/src/stores/context.js | 6 +- frontend/src/stores/product-expert.js | 40 +++++-- 6 files changed, 315 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/expert/components/messages/components/resources/PlanCard.vue diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index d86f5f3b13..f7889ce964 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -16,6 +16,20 @@ Start over
    + + + { this.$refs.textarea.focus() - // Grow the composer so loaded content (e.g. an edited question) is readable, + // Grow the composer so loaded content (e.g. an edited plan) 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() }) } + }, + planChangeRequest () { + // The plan card's "Request changes" action: focus an empty composer and show + // the change hint, so the user can describe a change in their own words. + this.inputText = '' + this.requestingPlanChange = true + this.$nextTick(() => { + this.$refs.textarea.focus() + }) + }, + composerReset () { + // A plan was loaded into the composer (via "Edit manually") then approved or + // rejected without sending; clear the stale text and collapse the grown box. + this.inputText = '' + this.requestingPlanChange = false + if (this.composerAutoGrown) { + this.setHeight(180) + this.composerAutoGrown = false + } + }, + inputText (value) { + // Clear the plan-change hint once the user starts typing their own text. + if (value && this.requestingPlanChange) { + this.requestingPlanChange = false + } } }, mounted () { @@ -216,7 +266,7 @@ export default { }, methods: { ...mapActions(useProductAssistantStore, ['resetContextSelection']), - ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence']), + ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence', 'setPlanMode']), async handleSend () { if (!this.canSend) return @@ -239,6 +289,7 @@ export default { .catch(e => e) this.inputText = '' + this.requestingPlanChange = false // 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) { @@ -325,6 +376,42 @@ export default { align-items: center; } +// Reuses the shared DefaultChip for its bg, border, active state and theming; the only +// styling here is sizing the toggle switch in the #icon slot, since the switch has no size +// prop. It is a visual indicator only (pointer-events disabled); the chip handles the click. +.plan-mode-chip { + // DefaultChip's separator is a warning-yellow in the inactive state (intended for the + // Selection chip); neutralise it here for both states so it reads as a plain divider. + :deep(.separator), + &.active :deep(.separator) { + background: var(--ff-color-border); + } + + // DefaultChip's .text padding is asymmetric (less on the left) AND the chip adds a 5px + // flex gap between the text box and the divider, so the label sits left of centre. + // Equalise the padding and subtract the gap from the right so "Plan mode" has the same + // visual space on both sides of the divider cell. + :deep(.text) { + padding-left: 0.5rem; + padding-right: calc(0.5rem - 5px); + } + + :deep(.ff-toggle-switch) { + --ff-toggle-width: 30px; + --ff-toggle-translate: 12px; + height: 18px; + pointer-events: none; + flex-shrink: 0; + } + + :deep(.ff-toggle-switch-button) { + height: 14px; + width: 14px; + left: 2px; + bottom: 2px; + } +} + button { padding: 0.5rem 0.75rem; // py-2 px-3 border-radius: 9999px; // rounded-full diff --git a/frontend/src/components/expert/components/chips/DefaultChip.vue b/frontend/src/components/expert/components/chips/DefaultChip.vue index 057ad81615..5e6df33722 100644 --- a/frontend/src/components/expert/components/chips/DefaultChip.vue +++ b/frontend/src/components/expert/components/chips/DefaultChip.vue @@ -1,5 +1,5 @@ @@ -41,11 +43,20 @@ export default { type: String, required: false, default: '' + }, + disabled: { + type: Boolean, + required: false, + default: false } }, emits: ['toggle'], methods: { - pluralize + pluralize, + onClick () { + if (this.disabled) return + this.$emit('toggle') + } } } @@ -62,6 +73,11 @@ export default { transition: 0.3s ease-in-out; white-space: nowrap; + &.disabled { + opacity: 0.5; + cursor: not-allowed; + } + &.active { background: var(--ff-color-accent-surface); border: 1px solid var(--ff-color-accent-light); diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index e14708a7ad..e848b05b21 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 @@ @@ -103,6 +119,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 PlanCard from './resources/PlanCard.vue' import QuestionsList from './resources/QuestionsList.vue' import ResourcesList from './resources/ResourcesList.vue' import RichContent from './resources/RichContent.vue' @@ -117,6 +134,7 @@ export default { SuggestionsList, RichContent, PackagesList, + PlanCard, FlowsList, AnswerBadge, ErrorBoundary, @@ -156,7 +174,7 @@ export default { 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 + // Disable the question/plan cards 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 @@ -164,7 +182,8 @@ export default { hasGuideHeader () { // 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) + // plan answers carry their heading inside their Markdown content, not a title. + return !!(this.answer.title && !this.isChatAnswer && !this.isQuestionsAnswer && !this.isPlanAnswer) }, hasGuideSteps () { return Object.hasOwnProperty.call(this.answer, 'steps') && this.answer.steps.length > 0 @@ -185,17 +204,25 @@ export default { return this.answer.issues && this.answer.issues.length > 0 }, hasPlainContent () { - return this.answer.content && this.answer.content.length > 0 + // A plan keeps its markdown in content, but the PlanCard renders it; do not + // also render it as plain rich-content above the card. + return !this.isPlanAnswer && this.answer.content && this.answer.content.length > 0 }, hasQuestions () { return Array.isArray(this.answer.questions) && this.answer.questions.length > 0 }, + hasPlan () { + return this.isPlanAnswer && typeof this.answer.content === 'string' && this.answer.content.length > 0 + }, isChatAnswer () { return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' }, isQuestionsAnswer () { return this.answer.kind === 'questions' }, + isPlanAnswer () { + return this.answer.kind === 'plan' + }, isEditorContext () { // In editor context, the route name includes 'editor' return this.$route?.name?.includes('editor') || false @@ -263,6 +290,13 @@ export default { if (this.componentStreamingOrder.indexOf(key) === 0) return true return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) }, + shouldShowPlanList () { + const key = 'plan-list' + if (!this.componentStreamingOrder.includes(key)) return false + if (!this.hasPlan) return false + if (this.componentStreamingOrder.indexOf(key) === 0) return true + return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) + }, shouldStream () { return !this.answer._streamed } @@ -298,7 +332,7 @@ export default { } }, methods: { - ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput']), + ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput', 'requestPlanChange', 'resetComposer', 'setPlanMode']), buildStreamingOrder () { // order matters // this is where the decision of the streaming order of components is decided @@ -312,6 +346,7 @@ export default { if (this.hasIssues) this.componentStreamingOrder.push('issues-list') if (this.hasSuggestions) this.componentStreamingOrder.push('suggestions-list') if (this.hasQuestions) this.componentStreamingOrder.push('questions-list') + if (this.hasPlan) this.componentStreamingOrder.push('plan-list') }, async onComponentComplete (key) { if (!this.shouldStream) await this.waitFor(200) @@ -324,6 +359,32 @@ export default { onQuestionsEdit (text) { this.setPendingInput(text) }, + onPlanApprove () { + // Approving exits plan mode. Plan mode is strictly read-only, so we turn it off + // here rather than punching a write override through an active plan mode; the build + // then runs as a normal acting turn, and follow-up turns keep building instead of + // dropping back into planning. + this.setPlanMode(false) + // Clear the composer in case the plan was loaded into it via "Edit manually" + // and then approved without sending; the loaded text is now stale. + this.resetComposer() + this.handleQuery({ query: 'Approved. Proceed with the plan.' }) + }, + onPlanEditManual () { + // Load the plan markdown into the message box so the user can edit it directly, + // then send it back for the agent to re-propose as an updated plan to approve. + this.setPendingInput(this.answer.content) + }, + onPlanRequestChanges () { + // Focus an empty composer so the user can describe a change in their own words; + // the agent folds it in and re-proposes an updated plan to approve. + this.requestPlanChange() + }, + onPlanReject () { + // Drop any plan text loaded into the composer via "Edit manually". + this.resetComposer() + this.handleQuery({ query: 'I do not want to proceed with this plan.' }) + }, handleClick (e) { const target = e.target // - Must be in the immersive editor diff --git a/frontend/src/components/expert/components/messages/components/resources/PlanCard.vue b/frontend/src/components/expert/components/messages/components/resources/PlanCard.vue new file mode 100644 index 0000000000..f6ef580afc --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/resources/PlanCard.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/frontend/src/stores/context.js b/frontend/src/stores/context.js index c29925a01e..d22eee5f9f 100644 --- a/frontend/src/stores/context.js +++ b/frontend/src/stores/context.js @@ -58,7 +58,8 @@ export const useContextStore = defineStore('context', { rawRoute: {}, selectedNodes: null, scope: 'ff-app', - questionCadence: useProductExpertStore().questionCadence + questionCadence: useProductExpertStore().questionCadence, + planMode: useProductExpertStore().planMode } } @@ -108,7 +109,8 @@ export const useContextStore = defineStore('context', { rawRoute, selectedNodes, scope, - questionCadence: useProductExpertStore().questionCadence + questionCadence: useProductExpertStore().questionCadence, + planMode: useProductExpertStore().planMode } } }, diff --git a/frontend/src/stores/product-expert.js b/frontend/src/stores/product-expert.js index 1aea56c449..da26708210 100644 --- a/frontend/src/stores/product-expert.js +++ b/frontend/src/stores/product-expert.js @@ -30,8 +30,15 @@ export const useProductExpertStore = defineStore('product-expert', { loadingVariant: SUPPORT_AGENT, shouldWakeUpAssistant: false, questionCadence: 'all', // 'all' = ask every clarifying question at once, 'one' = one at a time + planMode: false, inFlightUpdates: [], pendingInput: '', + // Incremented to ask the chat composer to focus an empty input for a plan-change + // request (the plan card's "Request changes" action). The composer watches it. + planChangeRequest: 0, + // Incremented to ask the chat composer to clear itself (e.g. a plan was loaded via + // "Edit manually" then approved/rejected without sending). The composer watches it. + composerReset: 0, _seenTransactionIds: new Map() }), getters: { @@ -181,7 +188,18 @@ export const useProductExpertStore = defineStore('product-expert', { setPendingInput (text) { this.pendingInput = text }, - async handleQuery ({ query }) { + requestPlanChange () { + // Signal the chat composer to focus an empty input so the user can describe + // a change to a proposed plan. Bumping a counter lets the composer react each + // time, including repeated requests. + this.planChangeRequest++ + }, + resetComposer () { + // Signal the chat composer to clear its input. Bumping a counter lets the + // composer react each time, including repeated resets. + this.composerReset++ + }, + async handleQuery ({ query, contextOverrides }) { const agentStore = this._agentStore // Auto-initialize session ID if not set @@ -200,7 +218,7 @@ export const useProductExpertStore = defineStore('product-expert', { agentStore.abortController = markRaw(new AbortController()) try { - return await this.sendQuery({ query }) + return await this.sendQuery({ query, contextOverrides }) } catch (error) { if (error.name === 'AbortError' || error.name === 'CanceledError') { // User canceled request @@ -217,20 +235,21 @@ export const useProductExpertStore = defineStore('product-expert', { agentStore.abortController = null } }, - sendQuery ({ query }) { + sendQuery ({ query, contextOverrides }) { if (this.shouldUseMqtt) { - return this.sendMqttQuery({ query }) + return this.sendMqttQuery({ query, contextOverrides }) } else { - return this.sendHttpQuery({ query }) + return this.sendHttpQuery({ query, contextOverrides }) } }, - async sendHttpQuery ({ query }) { + async sendHttpQuery ({ query, contextOverrides }) { const agentStore = this._agentStore const payload = { query, context: { ...useContextStore().expert, - agent: this.agentMode + agent: this.agentMode, + ...(contextOverrides || {}) }, sessionId: agentStore.sessionId, abortController: agentStore.abortController @@ -242,7 +261,7 @@ export const useProductExpertStore = defineStore('product-expert', { return expertApi.chat(payload) }, - async sendMqttQuery ({ query } = {}) { + async sendMqttQuery ({ query, contextOverrides } = {}) { const servicesOrchestrator = getAppOrchestrator() const mqttService = servicesOrchestrator.$serviceInstances.mqtt const mqttTopicHelper = useMqttExpertTopicHelper() @@ -512,6 +531,9 @@ export const useProductExpertStore = defineStore('product-expert', { if (!['all', 'one'].includes(cadence)) return this.questionCadence = cadence }, + setPlanMode (enabled) { + this.planMode = !!enabled + }, /** * Adds a system message to the application's message store. * @@ -1076,7 +1098,7 @@ export const useProductExpertStore = defineStore('product-expert', { } }, persist: { - pick: ['shouldWakeUpAssistant', 'questionCadence'], + pick: ['shouldWakeUpAssistant', 'questionCadence', 'planMode'], storage: localStorage } }) From 25f3c2cea76b4322d0cc62577c0881c6db851ad3 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 20:46:31 +0200 Subject: [PATCH 04/11] feat(expert): scope plan mode to immersive editor only Plan mode is only meaningful inside the instance/device editor for now, so gate the composer toggle on immersive mode and force the persisted planMode off whenever the user is outside immersive (including on load), preventing a stale value from being sent in non-immersive contexts. --- .../expert/components/ExpertChatInput.vue | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index f7889ce964..f37198ed3f 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -16,8 +16,11 @@ Start over
    + Date: Tue, 30 Jun 2026 14:34:05 +0200 Subject: [PATCH 05/11] 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 07/11] 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.

    + +
    +
    +