<template>
<div
v-if="msg.thinking || msg.tools?.length || msg.text || !msg.done"
class="msg-assistant"
>
<!-- Thinking block -->
<ThinkingCard
v-if="msg.thinking"
:msg="msg"
/>
<!-- Tool / plan cards -->
<TransitionGroup name="tool-fade" tag="div">
<div v-for="entry in displayItems" :key="entry.key" class="tool-fade-item">
<div v-if="entry.type === 'summary'" class="tool-cards-summary">
<span class="eyebrow">{{ entry.total }} tools</span>
<GnIconButton
size="sm"
:icon="showAllTools ? 'ph ph-caret-up' : 'ph ph-caret-down'"
@click="showAllTools = !showAllTools"
/>
</div>
<ThinkingCard v-else-if="entry.item.kind === 'turn_thinking'" :msg="entry.item" />
<ContentCard
v-else-if="entry.item.kind === 'tool' && entry.item.name === 'content_publish'"
:tool="entry.item"
:default-open="contentToolKey(msg, entry.i) === latestContentToolKey"
:collapse-token="latestContentToolKey"
/>
<details v-else-if="entry.item.kind === 'plan'" class="plan-card">
<summary>
<i class="ph ph-map-trifold"></i>
Plan
<i class="ph ph-caret-down plan-chevron"></i>
</summary>
<div class="plan-body" v-html="renderMarkdown(entry.item.text)" />
</details>
<ToolCard v-else :tool="entry.item" />
</div>
</TransitionGroup>
<!-- Text response -->
<div
v-if="msg.text"
ref="contentEl"
class="msg-assistant-content"
:class="{ 'msg-flash': props.isFlashing }"
v-html="renderedText"
/>
<!-- Planning / waiting indicator -->
<div
v-if="turnActivityLabel"
class="planning-indicator"
:class="{ 'is-executing': activeToolLabel }"
>
<span class="spinner"></span>
<span class="planning-label">{{ turnActivityLabel }}</span>
</div>
<!-- Streaming cursor -->
<span v-else-if="!msg.done && msg.text" class="stream-cursor" />
<!-- Stats + timestamp row + copy + rating -->
<div v-if="msg.done" class="msg-meta-row">
<span v-if="msg.elapsed_seconds != null" class="msg-meta-item">
<i class="ph ph-timer"></i>{{ msg.elapsed_seconds }}s
</span>
<span v-if="msg.tool_call_count" class="msg-meta-item">
<i class="ph ph-wrench"></i>{{ msg.tool_call_count }}
</span>
<span v-if="msg.token_count" class="msg-meta-item">
<i class="ph ph-coins"></i>{{ compactNum(msg.token_count) }}
</span>
<span v-if="msgTime" class="msg-meta-item msg-meta-time">{{ msgTime }}</span>
<GnIconButton
v-if="msg.text"
:icon="copied ? 'ph ph-check' : 'ph ph-copy'"
:label="copied ? 'Copied!' : 'Copy'"
class="msg-copy-btn"
@click="copy(msg.text)"
/>
<GnIconButton
v-if="canRate"
icon="ph ph-thumbs-up"
label="Useful"
class="msg-rate-btn"
:class="{ active: msg.rating === 1 }"
@click="rate(1)"
/>
<GnIconButton
v-if="canRate"
icon="ph ph-thumbs-down"
label="Not useful"
class="msg-rate-btn"
:class="{ active: msg.rating === -1 }"
@click="rate(-1)"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import ThinkingCard from './ThinkingCard.vue'
import ToolCard from './ToolCard.vue'
import ContentCard from './ContentCard.vue'
import { GnIconButton } from 'gnexus-ui-kit/vue'
import { renderMarkdown, attachCopyButtons, attachImageLightbox } from '@/composables/useMarkdown.js'
import { useTimeLabel } from '@/composables/useTime.js'
import { useCopy } from '@/composables/useCopy.js'
import { useChatStore } from '@/stores/chat.js'
const props = defineProps({
msg: { type: Object, required: true },
isFlashing: { type: Boolean, default: false }
})
const contentEl = ref(null)
const chat = useChatStore()
const showAllTools = ref(false)
const renderedText = computed(() => renderMarkdown(props.msg.text))
const tsRef = computed(() => props.msg.done ? props.msg.time : null)
const msgTime = useTimeLabel(tsRef)
const hasMeta = computed(() =>
props.msg.elapsed_seconds != null || props.msg.tool_call_count != null || props.msg.token_count != null
)
const latestContentToolKey = computed(() => {
let latest = null
for (const message of chat.messages) {
if (!message?.tools?.length) continue
message.tools.forEach((tool, index) => {
if (tool.kind === 'tool' && tool.name === 'content_publish') {
latest = contentToolKey(message, index)
}
})
}
return latest
})
const activeToolLabel = computed(() => {
const pending = findPendingTool(props.msg.tools || [])
if (!pending) return ''
return `Running ${toolDisplayName(pending)}...`
})
const turnActivityLabel = computed(() => {
if (props.msg.done) return ''
if (props.msg.statusLabel) return props.msg.statusLabel
if (activeToolLabel.value) return activeToolLabel.value
if (props.msg.text) return ''
if (hasVisibleToolProgress(props.msg.tools || [])) return 'Preparing next action...'
return 'Thinking...'
})
// Only history blocks (id `h_<index>`) are persisted and can be rated.
// Streaming / summary / error placeholders skip the rating UI.
const canRate = computed(() => typeof props.msg.id === 'string' && props.msg.id.startsWith('h_'))
const { copied, copy } = useCopy()
async function rate(value) {
try {
await chat.rateMessage(props.msg, value)
} catch (err) {
console.error('rate failed', err)
}
}
function compactNum(n) {
if (!n) return ''
if (n >= 1000) return (n / 1000).toFixed(1).replace('.0', '') + 'k'
return String(n)
}
function contentToolKey(message, index) {
return `${message.id}:content:${index}`
}
function findPendingTool(items) {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]
if (item.kind === 'tool' && item.pending) return item
if (item.steps?.length) {
const nested = findPendingTool(item.steps)
if (nested) return nested
}
}
return null
}
function hasVisibleToolProgress(items) {
return items.some(item => {
if (item.kind === 'tool' || item.kind === 'plan' || item.kind === 'turn_thinking') return true
return item.steps?.length ? hasVisibleToolProgress(item.steps) : false
})
}
function toolDisplayName(tool) {
if (tool.name === 'filesystem' && tool.args?.action) return `filesystem ${tool.args.action}`
return tool.name
}
function isCountableTool(item) {
return item.kind === 'tool' && item.name !== 'content_publish'
}
const displayItems = computed(() => {
const items = props.msg.tools || []
const toolOnly = items
.map((item, i) => ({ item, i }))
.filter(({ item }) => isCountableTool(item))
if (toolOnly.length === 0 || toolOnly.length < 4) {
return items.map((item, i) => ({ type: 'item', item, i, key: i }))
}
const showAll = showAllTools.value
const visibleSet = new Set(
showAll
? toolOnly.map(({ i }) => i)
: toolOnly.slice(-3).map(({ i }) => i)
)
const result = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (isCountableTool(item) && !visibleSet.has(i)) continue
result.push({ type: 'item', item, i, key: i })
}
result.push({ type: 'summary', total: toolOnly.length, key: 'summary-end' })
return result
})
// Attach copy buttons after render — only when message is done to avoid
// repeated DOM queries on every streaming delta.
watch(
() => props.msg.done,
(done) => {
if (done) nextTick(() => {
attachCopyButtons(contentEl.value)
attachImageLightbox(contentEl.value)
})
},
{ immediate: true }
)
</script>
<style scoped>
.stream-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.msg-rate-btn {
opacity: 0.55;
transition: opacity 0.15s, color 0.15s;
}
.msg-rate-btn:hover { opacity: 1; }
.msg-rate-btn.active {
opacity: 1;
color: var(--accent, #4ec9b0);
}
</style>