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
15 changes: 13 additions & 2 deletions frontend/src/components/expert/Expert.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
:class="{ 'has-mode-switcher': isInsightsModeEnabled && isEditorContext }"
@scroll="handleScroll"
>
<info-banner />
<info-banner v-if="!isOnboardingSurface" />

<expert-messages @resizing="scrollToBottom" />

Expand Down Expand Up @@ -50,6 +50,10 @@ export default {
togglePinWithWidth: {
from: 'togglePinWithWidth',
default: () => () => {} // No-op function when not provided
},
expertSurface: {
from: 'expert-surface',
default: 'drawer'
}
},
props: {
Expand Down Expand Up @@ -85,6 +89,11 @@ export default {
// In editor context, the route name includes 'editor'
return this.$route?.name?.includes('editor') || false
},
isOnboardingSurface () {
// In onboarding, the Expert opens the conversation itself, so the
// canned welcome message and the support banner stay out of it.
return this.expertSurface === 'onboarding'
},
isInsightsModeEnabled () {
return !!this.featuresCheck?.isExpertInsightsFeatureEnabled
},
Expand All @@ -109,7 +118,9 @@ export default {
if (this.isInsightsAgent) {
await this.getCapabilities()
}
this.addWelcomeMessageIfNeeded()
if (!this.isOnboardingSurface) {
this.addWelcomeMessageIfNeeded()
}
}
},
'instance.meta.state': {
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/expert/components/ExpertChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
direction="horizontal"
@mousedown="onStartResize"
/>
<!-- Action buttons row -->
<div class="action-buttons">
<div v-if="expertSurface !== 'onboarding'" class="action-buttons">
<button
type="button"
class="btn-start-over"
Expand Down Expand Up @@ -146,6 +145,10 @@ export default {
togglePinWithWidth: {
from: 'togglePinWithWidth',
default: () => () => {} // No-op function when not provided
},
expertSurface: {
from: 'expert-surface',
default: 'drawer'
}
},
emits: ['send', 'stop'],
Expand Down Expand Up @@ -228,6 +231,9 @@ export default {
if (this.requestingPlanChange) {
return 'Describe a change to the plan, or paste an edited version'
}
if (this.expertSurface === 'onboarding') {
return 'Or just tell me in your own words'
}
return this.isInsightsAgent
? 'Tell us what you want to know about'
: 'Tell us what you need help with'
Expand Down
26 changes: 23 additions & 3 deletions frontend/src/components/expert/components/ExpertMessages.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<template>
<div ref="messagesWrapper" class="messages-wrapper">
<ul class="flex flex-col gap-3">
<li v-for="message in messages" :key="message._uuid" class="flex flex-col gap-3">
<component :is="messageTypes[message._type]" v-if="messageTypes[message._type]" v-bind="{...message}" />
<li v-for="entry in renderList" :key="entryKey(entry)" class="flex flex-col gap-3">
<collapsed-question-turn v-if="entry.kind === 'folded-turn'" :turn="entry" />
<component :is="messageTypes[entry.message._type]" v-else-if="messageTypes[entry.message._type]" v-bind="{...entry.message}" />
</li>
<li v-if="isWaitingForResponse">
<expert-loading-indicator />
Expand All @@ -16,9 +17,12 @@
import { mapState } from 'pinia'
import { markRaw } from 'vue'

import { buildCollapsedTranscript } from '../../../composables/Components/expert/collapseTranscript.js'

import ExpertLoadingIndicator from './ExpertLoadingIndicator.vue'

import AiMessage from './messages/AiMessage.vue'
import CollapsedQuestionTurn from './messages/CollapsedQuestionTurn.vue'
import HumanMessage from './messages/HumanMessage.vue'
import SystemMessage from './messages/SystemMessage.vue'

Expand All @@ -27,7 +31,13 @@ import { useProductExpertStore } from '@/stores/product-expert.js'

export default {
name: 'ExpertMessages',
components: { ExpertLoadingIndicator },
components: { CollapsedQuestionTurn, ExpertLoadingIndicator },
inject: {
expertSurface: {
from: 'expert-surface',
default: 'drawer'
}
},
emits: ['resizing'],
data () {
return {
Expand All @@ -43,6 +53,13 @@ export default {
human: markRaw(HumanMessage),
system: markRaw(SystemMessage)
}
},
renderList () {
// The onboarding surface folds answered question turns into quiet while the drawer renders the transcript as-is.
if (this.expertSurface === 'onboarding') {
return buildCollapsedTranscript(this.messages)
}
return this.messages.map(message => ({ kind: 'message', message }))
}
},
mounted () {
Expand All @@ -54,6 +71,9 @@ export default {
window.removeEventListener('keydown', this.onKeyDown)
},
methods: {
entryKey (entry) {
return entry.kind === 'folded-turn' ? entry.questionsMessage._uuid : entry.message._uuid
},
onKeyDown (e) {
if (e.altKey && e.shiftKey && e.key.toLowerCase() === 'd') {
e.preventDefault()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<template>
<div class="collapsed-question-turn" :class="{ expanded }">
<template v-if="!expanded">
<div
v-for="(entry, index) in turn.entries"
:key="index"
class="folded-question"
data-el="folded-question"
>
<button
type="button"
class="question-text"
data-action="toggle-turn"
title="Show the full question card"
@click="expanded = true"
>
{{ entry.question }}
</button>
<div class="folded-answers">
<button
v-for="(chip, chipIndex) in chipsFor(entry)"
:key="chipIndex"
type="button"
class="answer-chip"
data-action="edit-answer"
title="Load this answer into the composer to correct it"
@click="editAnswer(entry)"
>
<span class="chip-label">{{ chip }}</span>
<span class="chip-remove" aria-hidden="true">&times;</span>
</button>
<span v-if="chipsFor(entry).length === 0" class="skipped-marker">Skipped</span>
</div>
</div>
</template>
<div v-else class="expanded-messages">
<button
type="button"
class="question-text"
data-action="toggle-turn"
title="Collapse this step"
@click="expanded = false"
>
Collapse this step
</button>
<AiMessage v-bind="{ ...turn.questionsMessage }" />
<HumanMessage v-if="turn.replyMessage" v-bind="{ ...turn.replyMessage }" />
</div>
</div>
</template>

<script>
import { mapActions } from 'pinia'

import AiMessage from './AiMessage.vue'
import HumanMessage from './HumanMessage.vue'

import { useProductExpertStore } from '@/stores/product-expert.js'

export default {
name: 'CollapsedQuestionTurn',
components: {
AiMessage,
HumanMessage
},
props: {
turn: {
type: Object,
required: true
}
},
data () {
return {
expanded: false
}
},
computed: {
hasMatchedAnswers () {
return this.turn.entries.some(entry => entry.answer !== null)
}
},
methods: {
...mapActions(useProductExpertStore, ['setPendingInput']),
chipsFor (entry) {
if (entry.answer) {
return entry.answer.split(', ')
}
if (!this.hasMatchedAnswers && this.turn.replyMessage?.content) {
return [this.turn.replyMessage.content]
}
return []
},
editAnswer (entry) {
if (entry.answer) {
this.setPendingInput(`${entry.question} ${entry.answer}`)
} else if (this.turn.replyMessage?.content) {
this.setPendingInput(this.turn.replyMessage.content)
}
}
}
}
</script>

<style scoped lang="scss">
.collapsed-question-turn {
display: flex;
flex-direction: column;
gap: 1rem;
}

.folded-question {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.125rem 0 0.125rem 0.875rem;
border-left: 2px solid var(--ff-color-border);
}

.question-text {
font-size: 0.8125rem;
color: var(--ff-color-text-subtle);
text-align: left;
cursor: pointer;

&:hover {
color: var(--ff-color-text-strong);
}
}

.folded-answers {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}

.answer-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--ff-color-text-strong);
background: var(--ff-color-bg-surface);
border: 1px solid var(--ff-color-border);
border-radius: 9999px;
padding: 0.25rem 0.75rem;
cursor: pointer;
max-width: 100%;

&:hover {
border-color: var(--ff-color-accent);
}
}

.chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 22rem;
}

.chip-remove {
font-weight: 400;
color: var(--ff-color-text-subtle);
line-height: 1;
}

.skipped-marker {
font-size: 0.875rem;
font-style: italic;
color: var(--ff-color-text-subtle);
}

.expanded-messages {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
padding-left: 0.875rem;
border-left: 2px solid var(--ff-color-accent);
}
</style>
71 changes: 71 additions & 0 deletions frontend/src/composables/Components/expert/collapseTranscript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Transcript folding for the onboarding surface's collapsing-transcript
* treatment. Pure functions over the store's message list: no state.
*
* A "questions turn" is an AI message carrying a questions answer, followed
* (usually) by the human reply QuestionsList composed for it. Once the turn
* has passed, the pair folds into one entry so the transcript stays quiet.
*/

function getQuestions (message) {
if (message._type !== 'ai' || !Array.isArray(message.answer)) {
return []
}
return message.answer
.filter(answer => Array.isArray(answer.questions) && answer.questions.length > 0)
.flatMap(answer => answer.questions)
}

/**
* Match each question to its line in the composed reply. QuestionsList sends
* one "<question> <answers>" line per question; anything that doesn't match
* (a hand-typed reply, an edited answer) resolves to null and the caller
* falls back to showing the raw reply.
*/
function extractAnswers (questions, replyContent) {
const lines = (replyContent || '').split('\n')
return questions.map(q => {
const line = lines.find(l => l.startsWith(q.question))
if (!line) {
return { question: q.question, answer: null }
}
const answer = line.slice(q.question.length).trim()
return { question: q.question, answer: answer.length > 0 ? answer : null }
})
}

/**
* Build the render list for a collapsing transcript.
*
* Returns entries of two kinds:
* - { kind: 'message', message }: render as-is
* - { kind: 'folded-turn', questionsMessage, replyMessage, entries }:
* a past questions turn folded to one line per question. replyMessage is
* the absorbed human reply, or null if the user never answered.
*/
function buildCollapsedTranscript (messages) {
const result = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const questions = getQuestions(message)
const isLast = i === messages.length - 1
if (questions.length === 0 || isLast) {
result.push({ kind: 'message', message })
continue
}
const next = messages[i + 1]
const replyMessage = next && next._type === 'human' ? next : null
if (replyMessage) {
i++
}
result.push({
kind: 'folded-turn',
questionsMessage: message,
replyMessage,
entries: extractAnswers(questions, replyMessage?.content)
})
}
return result
}

export { buildCollapsedTranscript }
Loading
Loading