From 500604711f6c02a7b8426db7cf47a34788489ffe Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 19:37:39 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(expert):=20plan=20mode=20=E2=80=94=20p?= =?UTF-8?q?ropose=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 2/2] 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
+