Skip to content
Open
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
105 changes: 102 additions & 3 deletions frontend/src/components/expert/components/ExpertChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@
Start over
</button>
<div class="right-buttons">
<!-- TODO: plan mode is currently scoped to immersive mode (instance/device
editor) only. Make it available app-wide once the FlowFuse platform
automations are introduced, so the agent can also plan against those. -->
<default-chip
v-if="!isInsightsAgent && isImmersive"
class="plan-mode-chip"
text="Plan mode"
:modelValue="planMode"
:disabled="isWaitingForResponse"
title="Plan mode: the Expert proposes a plan before making any changes, and acts only once you approve it."
data-el="expert-plan-mode-toggle"
@toggle="setPlanMode(!planMode)"
>
<template #icon>
<ff-toggle-switch :modelValue="planMode" tabindex="-1" />
</template>
</default-chip>
<capabilities-selector v-if="isInsightsAgent" />
<button
v-else
Expand Down Expand Up @@ -101,6 +118,7 @@ import FormHeading from '../../FormHeading.vue'
import ResizeBar from '../../ResizeBar.vue'

import CapabilitiesSelector from './CapabilitiesSelector.vue'
import DefaultChip from './chips/DefaultChip.vue'
import ContextSelector from './context-selection/index.vue'

import { useResizingHelper } from '@/composables/ResizingHelper.js'
Expand All @@ -115,6 +133,7 @@ export default {
CapabilitiesSelector,
Cog8ToothIcon,
ContextSelector,
DefaultChip,
FormHeading,
ResizeBar
},
Expand Down Expand Up @@ -147,6 +166,8 @@ export default {
inputText: '',
includeSelection: true,
isTextareaFocused: false,
// true after "Request changes" on a plan, until the user types or sends; drives the hint placeholder
requestingPlanChange: false,
// The composer auto-sizes to its content via CSS (see .chat-input field-sizing).
// Only once the user drag-resizes do we pin it to an explicit height.
userResized: false
Expand All @@ -166,7 +187,10 @@ export default {
'hasMessages',
'isWaitingForResponse',
'pendingInput',
'questionCadence'
'planChangeRequest',
'composerReset',
'questionCadence',
'planMode'
]),
questionCadenceOptions () {
return [
Expand Down Expand Up @@ -197,6 +221,9 @@ export default {
if (this.isInsightsAgent && !this.hasSelectedCapabilities) {
return 'Select a resource to get started'
}
if (this.requestingPlanChange) {
return 'Describe a change to the plan, or paste an edited version'
}
return this.isInsightsAgent
? 'Tell us what you want to know about'
: 'Tell us what you need help with'
Expand All @@ -211,16 +238,51 @@ export default {
}
},
watch: {
isImmersive: {
immediate: true,
handler (immersive) {
// Plan mode is only offered in immersive mode, but planMode is persisted.
// Force it off when leaving immersive (and on load outside immersive) so a
// stale "on" value isn't carried into (and sent from) non-immersive contexts
// where the toggle is hidden.
if (!immersive && this.planMode) {
this.setPlanMode(false)
}
}
},
pendingInput (text) {
if (text) {
this.inputText = text
this.requestingPlanChange = false
this.setPendingInput('')
this.$nextTick(() => {
this.$refs.textarea.focus()
// No manual sizing needed: the textarea auto-grows to fit the loaded content
// (e.g. an edited question) via CSS, capped by its max-height.
// (e.g. an edited question or plan) via CSS, capped by its max-height.
})
}
},
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. The composer auto-collapses to
// fit its (now empty) content via CSS, so no manual height reset is needed.
this.inputText = ''
this.requestingPlanChange = false
},
inputText (value) {
// Clear the plan-change hint once the user starts typing their own text.
if (value && this.requestingPlanChange) {
this.requestingPlanChange = false
}
}
},
mounted () {
Expand All @@ -233,7 +295,7 @@ export default {
},
methods: {
...mapActions(useProductAssistantStore, ['resetContextSelection']),
...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setQuestionCadence']),
...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setQuestionCadence', 'setPlanMode']),
openSettings () {
this.$refs.settingsDialog.show()
},
Expand All @@ -258,6 +320,7 @@ export default {
.catch(e => e)

this.inputText = ''
this.requestingPlanChange = false
},
onStartResize (event) {
// Seed the drag from the composer's current rendered height (it may have auto-grown to
Expand Down Expand Up @@ -321,6 +384,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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:deep() into .ff-toggle-switch-button to resize it feels fragile. This'll break quietly if ff-toggle-switch ever changes. Since it has no size option, could we add a small/size variant to the component itself instead of reaching into its guts from the 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
Expand Down
24 changes: 20 additions & 4 deletions frontend/src/components/expert/components/chips/DefaultChip.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="chip" :class="{active: modelValue}" :title="title" @click="$emit('toggle')">
<div class="chip" :class="{active: modelValue, disabled}" :title="title" @click="onClick">
<div class="text">
<slot name="text">
<span>{{ text }}</span>
Expand All @@ -9,8 +9,10 @@
<span class="separator" />

<div class="icon-wrapper">
<XMarkIcon v-if="modelValue" class="ff-icon ff-icon-sm" />
<PlusIcon v-else class="ff-icon ff-icon-sm" />
<slot name="icon" :active="modelValue">
<XMarkIcon v-if="modelValue" class="ff-icon ff-icon-sm" />
<PlusIcon v-else class="ff-icon ff-icon-sm" />
</slot>
</div>
</div>
</template>
Expand Down Expand Up @@ -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')
}
}
}
</script>
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<message-bubble ref="messageBubble" type="ai">
<answer-badge v-if="!isChatAnswer && !isQuestionsAnswer" :kind="answer.kind" />
<answer-badge v-if="!isChatAnswer && !isQuestionsAnswer && !isPlanAnswer" :kind="answer.kind" />

<rich-content
v-if="shouldShowRichContent"
Expand Down Expand Up @@ -77,6 +77,21 @@
@edit="onQuestionsEdit"
@streaming-complete="onComponentComplete('questions-list')"
/>

<plan-card
v-if="shouldShowPlanList"
:plan="answer.content"
:message-uuid="messageUuid"
:answer-uuid="answer._uuid"
:disabled="interactionDisabled"
:should-stream="shouldStream"
class="mb-3"
@approve="onPlanApprove"
@edit-manual="onPlanEditManual"
@request-changes="onPlanRequestChanges"
@reject="onPlanReject"
@streaming-complete="onComponentComplete('plan-list')"
/>
</message-bubble>
</template>

Expand All @@ -93,6 +108,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'
Expand All @@ -107,6 +123,7 @@ export default {
SuggestionsList,
RichContent,
PackagesList,
PlanCard,
FlowsList,
AnswerBadge,
ResourcesList,
Expand Down Expand Up @@ -145,15 +162,16 @@ 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
},
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
Expand All @@ -174,17 +192,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
Expand Down Expand Up @@ -252,6 +278,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
}
Expand Down Expand Up @@ -287,7 +320,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
Expand All @@ -301,6 +334,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)
Expand All @@ -313,6 +347,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
Expand Down
Loading
Loading