Skip to content
Merged
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
148 changes: 139 additions & 9 deletions frontend/src/components/expert/components/ExpertChatInput.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<template>
<div ref="resizeTarget" class="ff-expert-input" :style="{height: heightStyle}">
<div ref="resizeTarget" class="ff-expert-input" :style="containerStyle">
<resize-bar
:is-resizing="isInputResizing"
direction="horizontal"
@mousedown="startResize"
@mousedown="onStartResize"
/>
<!-- Action buttons row -->
<div class="action-buttons">
Expand All @@ -17,6 +17,17 @@
</button>
<div class="right-buttons">
<capabilities-selector v-if="isInsightsAgent" />
<button
v-else
type="button"
class="btn-settings"
data-el="expert-settings-menu"
aria-label="Expert settings"
title="Expert settings"
@click="openSettings"
>
<Cog8ToothIcon class="btn-settings__icon" />
</button>
</div>
</div>
<div class="input-wrapper" :class="{ 'focused': isTextareaFocused }">
Expand Down Expand Up @@ -58,12 +69,35 @@
</div>
</div>
</div>

<ff-dialog
ref="settingsDialog"
header="Expert settings"
confirm-label="Done"
:can-be-canceled="false"
data-el="expert-settings-dialog"
>
<div class="expert-settings">
<div class="expert-settings__group">
<FormHeading>Follow-up questions</FormHeading>
<p>When a request needs more detail, choose how the Expert asks for it.</p>
<ff-radio-group
v-model="questionCadenceWrapper"
orientation="vertical"
:options="questionCadenceOptions"
data-el="expert-question-cadence"
/>
</div>
</div>
</ff-dialog>
</div>
</template>

<script>
import { Cog8ToothIcon } from '@heroicons/vue/20/solid'
import { mapActions, mapState } from 'pinia'

import FormHeading from '../../FormHeading.vue'
import ResizeBar from '../../ResizeBar.vue'

import CapabilitiesSelector from './CapabilitiesSelector.vue'
Expand All @@ -79,7 +113,9 @@ export default {
name: 'ExpertChatInput',
components: {
CapabilitiesSelector,
Cog8ToothIcon,
ContextSelector,
FormHeading,
ResizeBar
},
inject: {
Expand All @@ -94,21 +130,26 @@ export default {
startResize,
heightStyle,
bindResizer,
setHeight,
isResizing: isInputResizing
} = useResizingHelper()

return {
startResize,
bindResizer,
heightStyle,
setHeight,
isInputResizing
}
},
data () {
return {
inputText: '',
includeSelection: true,
isTextareaFocused: false
isTextareaFocused: 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
}
},
computed: {
Expand All @@ -123,8 +164,24 @@ export default {
'isInsightsAgent',
'hasSelectedCapabilities',
'hasMessages',
'isWaitingForResponse'
'isWaitingForResponse',
'pendingInput',
'questionCadence'
]),
questionCadenceOptions () {
return [
{ label: 'All at once', value: 'all', description: 'Asks every open question together in a single turn.' },
{ label: 'One at a time', value: 'one', description: 'Asks one question, then follows up based on your answer.' }
]
},
questionCadenceWrapper: {
get () {
return this.questionCadence
},
set (value) {
this.setQuestionCadence(value)
}
},
isInputDisabled () {
if (this.isSessionExpired) return true
if (this.isWaitingForResponse) return true
Expand All @@ -146,6 +203,24 @@ export default {
},
isImmersive () {
return this.isImmersiveDevice || this.isImmersiveInstance
},
containerStyle () {
// Until the user drag-resizes, let the composer size itself to its content (capped by
// the CSS max-height). Once they drag, pin it to the chosen height.
return this.userResized ? { height: this.heightStyle } : {}
}
},
watch: {
pendingInput (text) {
if (text) {
this.inputText = text
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.
})
}
}
},
mounted () {
Expand All @@ -158,7 +233,10 @@ export default {
},
methods: {
...mapActions(useProductAssistantStore, ['resetContextSelection']),
...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse']),
...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setQuestionCadence']),
openSettings () {
this.$refs.settingsDialog.show()
},
async handleSend () {
if (!this.canSend) return

Expand All @@ -169,10 +247,9 @@ export default {
this.togglePinWithWidth()
}

// Call Vuex action to handle API logic
// handleQuery renders the reply itself (see the store); the composer only needs
// to refocus the input once the turn is on its way.
this.handleQuery({ query: message })
// Handle UI-specific processing if successful
.then((result) => this.handleMessageResponse(result))
.then(() => {
this.$nextTick(() => {
this.$refs.textarea.focus()
Expand All @@ -182,6 +259,15 @@ export default {

this.inputText = ''
},
onStartResize (event) {
// Seed the drag from the composer's current rendered height (it may have auto-grown to
// fit its content) so the resize continues smoothly from where it is, then hand off to
// the resize helper. From here on the chosen height wins over the content auto-size.
const container = this.$refs.resizeTarget
if (container) this.setHeight(container.offsetHeight)
this.userResized = true
this.startResize(event)
},
handleStop () {
this.$emit('stop')
},
Expand Down Expand Up @@ -232,6 +318,7 @@ export default {
.right-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}

button {
Expand Down Expand Up @@ -295,6 +382,26 @@ button {
}
}

.btn-settings {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: 5px;
background-color: transparent;
color: var(--ff-color-text-subtle);

&:hover:not(:disabled) {
background-color: var(--ff-color-bg-surface); // gray-50
color: var(--ff-color-text-strong);
}

&__icon {
width: 1.25rem;
height: 1.25rem;
}
}

.input-wrapper {
flex: 1;
display: flex;
Expand All @@ -308,7 +415,12 @@ button {
}

.chat-input {
flex: 1;
// 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: content;
flex: 1 1 auto;
width: 100%;
padding: 1rem; // p-4
box-sizing: border-box;
Expand Down Expand Up @@ -350,3 +462,21 @@ button {
}
}
</style>

<!--
Unscoped: ff-dialog teleports its content to <body>, so keep these selectors global rather
than relying on scoped data attributes reaching the teleported subtree.
-->
<style lang="scss">
.expert-settings {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 18rem;

&__group {
display: flex;
flex-direction: column;
}
}
</style>
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" :kind="answer.kind" />
<answer-badge v-if="!isChatAnswer && !isQuestionsAnswer" :kind="answer.kind" />

<rich-content
v-if="shouldShowRichContent"
Expand Down Expand Up @@ -66,6 +66,17 @@
:should-stream="shouldStream"
@streaming-complete="onComponentComplete('suggestions-list')"
/>

<questions-list
v-if="shouldShowQuestionsList"
:questions="answer.questions"
:disabled="interactionDisabled"
:should-stream="shouldStream"
class="mb-3"
@select="onQuestionsSubmit"
@edit="onQuestionsEdit"
@streaming-complete="onComponentComplete('questions-list')"
/>
</message-bubble>
</template>

Expand All @@ -82,6 +93,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 QuestionsList from './resources/QuestionsList.vue'
import ResourcesList from './resources/ResourcesList.vue'
import RichContent from './resources/RichContent.vue'
import SuggestionsList from './resources/SuggestionsList.vue'
Expand All @@ -98,6 +110,7 @@ export default {
FlowsList,
AnswerBadge,
ResourcesList,
QuestionsList,
GuideStepsList,
MessageBubble,
GuideHeader,
Expand Down Expand Up @@ -126,10 +139,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
Expand All @@ -152,9 +176,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
Expand Down Expand Up @@ -215,6 +245,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
}
Expand Down Expand Up @@ -250,7 +287,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
Expand All @@ -263,12 +300,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
Expand Down
Loading
Loading