Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9c1302d
Route streamed activity on the Expert status topic into the reply bubble
Oct 13, 2026
5e21fe3
Render Expert tool calls as collapsible cards
Oct 13, 2026
76b80b8
Show streamed Expert reasoning as a collapsible block
Oct 13, 2026
16759a1
Show intermediate Expert messages as assistant bubbles in order
Oct 13, 2026
4691884
Store streamed tool records under their own record kind
Oct 13, 2026
679afd5
Cover tool record cards for Insights and saved answers
Oct 13, 2026
a95e217
Remove the unused Expert tool-call strip components
Oct 13, 2026
23655d4
Hide the Expert loading indicator once the turn streams activity
Oct 13, 2026
d4c632b
Advertise streamed Expert activity with one capability flag
Oct 13, 2026
fe2b006
Lead Expert tool cards with the reason for the call
Oct 13, 2026
fee3cbc
Keep streaming Expert reasoning on one line instead of opening it
Oct 13, 2026
2b1551f
Keep Expert tool card reasons on one line and show them in full when …
Oct 13, 2026
61bcac2
Show one line of Expert reasoning under its header
Oct 13, 2026
55ef515
Ignore late streamed updates that would roll back Expert activity
Oct 13, 2026
9133790
Keep the Expert loading dots while a turn streams, without status lines
Oct 13, 2026
68bd792
Merge branch 'main' into feat/expert-streamed-activity
andypalmi Oct 14, 2026
3c31571
Keep an updated streamed item at its first seq
Oct 14, 2026
45ef699
Make the collapsible header a button
Oct 14, 2026
e823c2b
Use JsonViewer for tool card input and output
Oct 14, 2026
5bd0a12
Use ff-button for the collapsible header
Oct 14, 2026
b3aa0d0
Keep default ff-button markup unchanged
Oct 14, 2026
d7a1553
Finish open streamed items on stop and keep activity in order
Oct 15, 2026
e8883b7
Tidy tool card toggles, gateway names and turn endings
Oct 15, 2026
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
6 changes: 5 additions & 1 deletion frontend/src/components/JsonViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<div v-if="showHeader" class="json-viewer__header">
<ff-button
v-if="collapsible"
kind="tertiary"
:kind="toggleKind"
size="small"
class="json-viewer__collapse-toggle"
:title="collapsed ? 'Expand' : 'Collapse'"
Expand Down Expand Up @@ -67,6 +67,10 @@ export default {
defaultCollapsed: {
type: Boolean,
default: false
},
toggleKind: {
type: String,
default: 'tertiary'
}
},
data () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<div class="loading-dot" />
<div class="loading-dot" />
</div>
<div v-if="showMessage" class="loading-message">
<div v-if="showMessages && showMessage" class="loading-message">
{{ currentMessage }}
</div>
</div>
Expand All @@ -20,6 +20,13 @@ import { useProductExpertStore } from '@/stores/product-expert.js'

export default {
name: 'ExpertLoadingIndicator',
props: {
// Off once the turn streams its own activity, which says more than these lines.
showMessages: {
type: Boolean,
default: true
}
},
data () {
return {
showMessage: false,
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/expert/components/ExpertMessages.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<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 />
<expert-loading-indicator :show-messages="!hasStreamedActivity" />

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.

Nit: after Stop, hasStreamedActivity goes false as soon as the in-flight map clears, so the loader comes back with the generic rotating lines under a bubble that was just streaming. Cosmetic, and the stopping indicator predates this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e8883b7. A turn that is still stopping keeps the loader to its dots.

</li>
</ul>
</div>
Expand Down Expand Up @@ -48,7 +48,7 @@ export default {
}
},
computed: {
...mapState(useProductExpertStore, ['messages', 'isWaitingForResponse']),
...mapState(useProductExpertStore, ['messages', 'isWaitingForResponse', 'hasStreamedActivity']),
messageTypes () {
return {
ai: markRaw(AiMessage),
Expand Down
68 changes: 29 additions & 39 deletions frontend/src/components/expert/components/messages/AiMessage.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<template>
<div class="flex flex-col gap-3">
<tool-calls v-if="toolCalls" :message="toolCalls" />
<template v-for="item in activity" :key="item._uuid">
<reasoning-block v-if="item.kind === 'reasoning'" :item="item" :message-uuid="_uuid" />
<agent-message v-else-if="item.kind === 'message'" :item="item" :message-uuid="_uuid" />
<timeline-item v-else :item="item" />
</template>
<answer-wrapper
v-for="(fAnswer, key) in visibleItems"
:key="slugify(`${fAnswer.kind}-${fAnswer.title}-${fAnswer.summary}-${fAnswer._uuid}`)"
Expand All @@ -13,16 +17,18 @@
</template>

<script>
import AgentMessage from './components/AgentMessage.vue'
import AnswerWrapper from './components/AnswerWrapper.vue'
import ReasoningBlock from './components/ReasoningBlock.vue'
import TimelineItem from './components/TimelineItem.vue'

import ToolCalls from './components/ToolCalls.vue'

import { ACTIVITY_KINDS } from '@/composables/Components/expert/streamedActivity.js'
import useStreamingList from '@/composables/StreamingListHelper.js'
import { slugify } from '@/composables/strings/String.js'

export default {
name: 'AiMessage',
components: { ToolCalls, AnswerWrapper },
components: { AgentMessage, AnswerWrapper, ReasoningBlock, TimelineItem },
props: {
query: {
type: String,
Expand Down Expand Up @@ -58,6 +64,12 @@ export default {
required: true,
type: Boolean
},
// eslint-disable-next-line vue/prop-name-casing
_transactionId: {
required: false,
type: String,
default: null
},
instant: {
type: Boolean,
default: false
Expand All @@ -69,45 +81,23 @@ export default {
return { initStreamer, setSubItemStreamedState, visibleItems, items }
},
computed: {
toolCalls () {
// todo we need to reconsider how we serve data to the tool calls component. This is ok for now as it
// only impacts the immediate child component (was hacked out of the expert store)
const mcpItems = this.answer.filter(answer => ['mcp_tool', 'mcp_resource', 'mcp_resource_template', 'mcp_prompt'].includes(answer.kind))

// Handle MCP calls if present - includes tools, resources, and prompts
if (mcpItems.length > 0) {
const toolCalls = mcpItems.map(item => ({
id: item.toolId,
name: item.toolName,
title: item.toolTitle || item.toolName,
kind: item.kind,
args: item.input,
output: item.output,
durationMs: item.durationMs
}))

// Calculate total duration in seconds
const totalDurationMs = mcpItems.reduce((sum, item) => sum + (item.durationMs || 0), 0)
const totalDurationSec = (totalDurationMs / 1000).toFixed(2)

return {
type: 'ai',
kind: 'tool_calls',
toolCalls,
duration: totalDurationSec,
content: `${toolCalls.length} tool call(s)`,
timestamp: Date.now()
}
}

return null
activity () {

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.

Small UX note, not a bug: a paginated turn gave me ten identical "List Hosted Instances" cards with nothing to expand, ten rows where the old strip was one collapsed line. Could be worth folding consecutive identical cards without a reason into one, or leaving it as a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Every tool call now has to carry a reason, in support and Insights alike, so each paged call should get its own card with its reason and something to expand. Cards with nothing to expand would come from a turn on an Expert from before that change, or from an older saved conversation. Could you check which it was? If it still happens on a fresh turn, I'll look into it.

@andypalmi andypalmi Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Try with Expert on Cloud, it should be there @cstns

return this.answer.filter(answer => ACTIVITY_KINDS.includes(answer.kind))
},
filteredAnswers () {
return this.answer.filter(answer => !['mcp_tool', 'mcp_resource', 'mcp_resource_template', 'mcp_prompt'].includes(answer.kind))
terminalAnswers () {
return this.answer.filter(answer => !ACTIVITY_KINDS.includes(answer.kind))
}
},
watch: {
// A streamed bubble mounts holding only activity; the final reply merges in later.
terminalAnswers (answers) {
if (answers.length > this.visibleItems.length) {
this.initStreamer(answers, { shouldStream: !this.instant && !this._streamed })
}
}
},
async mounted () {
await this.initStreamer(this.filteredAnswers, { shouldStream: !this.instant && !this._streamed })
await this.initStreamer(this.terminalAnswers, { shouldStream: !this.instant && !this._streamed })
},
methods: { slugify }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<template>
<message-bubble type="ai" class="ff-expert-agent-message">
<rich-content
:content="item.text || ''"
:message-uuid="messageUuid"
:answer-uuid="item._uuid"
:should-stream="false"
/>
</message-bubble>
</template>

<script>
import MessageBubble from './MessageBubble.vue'
import RichContent from './resources/RichContent.vue'

export default {
name: 'AgentMessage',
components: { MessageBubble, RichContent },
props: {
item: {
type: Object,
required: true
},
messageUuid: {
type: String,
required: true
}
}
}
</script>

<style scoped lang="scss">
.ff-expert-agent-message {
align-self: flex-start;
max-width: 90%;
}
</style>
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
<template>
<div class="ff-collapsible" :class="{ 'is-up': openUp }">
<div class="ff-collapsible--header" @click="toggle">
<ff-button
v-if="collapsible"
kind="plain"
align="left"
class="ff-collapsible--header"
:aria-expanded="expanded ? 'true' : 'false'"
@click="toggle"
>
<ChevronRightIcon class="ff-collapsible--chevron" :class="{ rotated: expanded }" />
<slot name="header" />
</ff-button>
<div v-else class="ff-collapsible--header is-static">
<span class="ff-collapsible--chevron" />
<slot name="header" />
</div>
<div class="ff-collapsible--content" :class="{ expanded }">
<slot name="summary" :expanded="expanded" />
<div v-if="collapsible" class="ff-collapsible--content" :class="{ expanded }">
<div class="ff-collapsible--inner" :inert="!expanded">
<slot />
</div>
Expand All @@ -15,9 +27,11 @@
<script>
import { ChevronRightIcon } from '@heroicons/vue/20/solid'

import FfButton from '../../../../../ui-components/components/Button.vue'

export default {
name: 'CollapsibleSection',
components: { ChevronRightIcon },
components: { ChevronRightIcon, FfButton },
props: {
autoOpen: {
type: Boolean,
Expand All @@ -30,6 +44,11 @@ export default {
openUp: {
type: Boolean,
default: false
},
// Off when there is nothing to expand: no chevron and no toggle.
collapsible: {
type: Boolean,
default: true
}
},
data () {
Expand Down Expand Up @@ -66,14 +85,30 @@ export default {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--ff-color-text-subtle);

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

&:focus-visible {
outline: 2px solid var(--ff-color-focus);
outline-offset: 2px;
border-radius: 0.25rem;
}

&.is-static {
cursor: default;

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

.ff-collapsible--chevron {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<template>
<collapsible-section class="ff-expert-reasoning">
<template #header>
<LightBulbIcon class="ff-expert-reasoning--glyph" />
<span class="ff-expert-reasoning--label">{{ item.done ? 'Thought' : 'Thinking' }}</span>
</template>
<template #summary="{ expanded }">
<div v-if="preview && !expanded" class="ff-expert-reasoning--preview" :title="preview">{{ preview }}</div>
</template>
<rich-content
class="ff-expert-reasoning--body"
:content="item.text || ''"
:message-uuid="messageUuid"
:answer-uuid="item._uuid"
:should-stream="false"
/>
</collapsible-section>
</template>

<script>
import { LightBulbIcon } from '@heroicons/vue/20/solid'

import CollapsibleSection from './CollapsibleSection.vue'
import RichContent from './resources/RichContent.vue'

// Collapsed whether streaming or done, so the block keeps its height while the reasoning
// arrives. One line under the header shows the latest sentence while streaming and the first
// once done.
export default {
name: 'ReasoningBlock',
components: { CollapsibleSection, LightBulbIcon, RichContent },
props: {
item: {
type: Object,
required: true
},
messageUuid: {
type: String,
required: true
}
},
computed: {
preview () {
if (typeof this.item.text !== 'string') return ''
const plain = this.item.text.replace(/[*_`#>]/g, '')
const sentences = plain.split(/(?<=[.!?])\s+|\n+/).map(s => s.trim()).filter(Boolean)
return (this.item.done ? sentences[0] : sentences[sentences.length - 1]) || ''
}
}
}
</script>

<style scoped lang="scss">
.ff-expert-reasoning {
max-width: 90%;
}

.ff-expert-reasoning--glyph {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}

.ff-expert-reasoning--label {
font-weight: 500;
flex-shrink: 0;
}

.ff-expert-reasoning--preview {
padding-left: 1.5rem;
font-size: 0.8125rem;
color: var(--ff-color-text-subtle);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.ff-expert-reasoning--body {
display: block;
margin-top: 0.25rem;
padding-left: 0.75rem;
border-left: 2px solid var(--ff-color-border-subtle);
color: var(--ff-color-text-subtle);
font-size: 0.875rem;
line-height: 1.5;
}
</style>
Loading
Loading