Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
86 changes: 75 additions & 11 deletions frontend/src/components/expert/components/ExpertChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@
Start over
</button>
<div class="right-buttons">
<default-chip
v-if="!isInsightsAgent"
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" size="small" tabindex="-1" />
</template>
</default-chip>
<capabilities-selector v-if="isInsightsAgent" />
<button
v-else
Expand Down Expand Up @@ -101,6 +115,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 +130,7 @@ export default {
CapabilitiesSelector,
Cog8ToothIcon,
ContextSelector,
DefaultChip,
FormHeading,
ResizeBar
},
Expand Down Expand Up @@ -147,6 +163,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 +184,9 @@ export default {
'hasMessages',
'isWaitingForResponse',
'pendingInput',
'questionCadence'
'composerCommand',
'questionCadence',
'planMode'
]),
questionCadenceOptions () {
return [
Expand Down Expand Up @@ -197,6 +217,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 @@ -214,12 +237,26 @@ export default {
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.
})
this.$nextTick(() => this.$refs.textarea.focus())
}
},
composerCommand (command) {
if (!command) return
// Plan card actions: focus an empty composer with a change hint, or clear a
// plan loaded via "Edit manually" that was approved/rejected without sending.
this.inputText = ''
this.requestingPlanChange = command === 'request-plan-change'
if (this.requestingPlanChange) {
this.$nextTick(() => this.$refs.textarea.focus())
}
this.setComposerCommand(null)
},
inputText (value) {
// Clear the plan-change hint once the user starts typing their own text.
if (value && this.requestingPlanChange) {
this.requestingPlanChange = false
}
}
},
Expand All @@ -233,7 +270,7 @@ export default {
},
methods: {
...mapActions(useProductAssistantStore, ['resetContextSelection']),
...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setQuestionCadence']),
...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setComposerCommand', 'setQuestionCadence', 'setPlanMode']),
openSettings () {
this.$refs.settingsDialog.show()
},
Expand All @@ -258,6 +295,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 +359,28 @@ export default {
align-items: center;
}

// Reuses the shared DefaultChip; only tweaks needed here are neutralising the chip's
// warning-yellow separator and centring the label around the divider.
.plan-mode-chip {
Comment thread
n-lark marked this conversation as resolved.
:deep(.separator),
&.active :deep(.separator) {
background: var(--ff-color-border);
}

// DefaultChip's .text padding is asymmetric and it adds a 5px flex gap before the
// divider; equalise so "Plan mode" sits centred on both sides of the divider cell.
:deep(.text) {
padding-left: 0.5rem;
padding-right: calc(0.5rem - 5px);
}

// The switch is a visual indicator only; the chip handles the click.
:deep(.ff-toggle-switch) {
pointer-events: none;
flex-shrink: 0;
}
}

button {
padding: 0.5rem 0.75rem; // py-2 px-3
border-radius: 9999px; // rounded-full
Expand Down Expand Up @@ -406,6 +466,10 @@ button {
flex: 1;
display: flex;
flex-direction: column;
// min-height: 0 must be on every ancestor in this flex chain, not just .chat-input:
// without it this wrapper keeps its content (min-content) height, so a long plan loaded
// via "Edit" pushes the whole composer tall instead of letting the textarea scroll.
min-height: 0;
border: 2px solid var(--ff-color-border-strong); // border-2 border-gray-300
border-radius: 0.5rem; // rounded-lg
transition: border-color 0.2s ease;
Expand All @@ -415,12 +479,12 @@ button {
}

.chat-input {
// field-sizing lets the textarea grow with its content (typed or loaded, e.g. an edited
// question) up to the composer's max-height, where it scrolls — no JS measuring needed.
// flex-basis auto so it sizes to that content but still fills the box when it's taller
// (an empty composer, or after a drag-resize).
// field-sizing grows the textarea with its content up to the composer's max-height.
// min-height: 0 lets it shrink within the flex box past its content height (loading a
// long plan via "Edit manually") so overflow-y scrolls instead of overflowing the chat.
field-sizing: content;
flex: 1 1 auto;
min-height: 0;
width: 100%;
padding: 1rem; // p-4
box-sizing: border-box;
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', 'setComposerCommand', '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,24 @@ export default {
onQuestionsEdit (text) {
this.setPendingInput(text)
},
onPlanApprove () {
// Approving exits read-only plan mode so the build runs as a normal acting turn,
// and clears any plan text loaded into the composer via "Edit manually".
this.setPlanMode(false)
this.setComposerCommand('reset')
this.handleQuery({ query: 'Approved. Proceed with the plan.' })
},
onPlanEditManual () {
// Load the plan markdown into the composer to edit and resubmit for a re-proposal.
this.setPendingInput(this.answer.content)
},
onPlanRequestChanges () {
this.setComposerCommand('request-plan-change')
},
onPlanReject () {
this.setComposerCommand('reset')
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