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
44 changes: 32 additions & 12 deletions src/frontend/src/components/ChatPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,11 @@
class="flex-1 px-6"
>
<template #empty>
<div class="text-center py-12">
<div class="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Start a Conversation</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm max-w-md mx-auto">
Chat with your agent using a simple interface. All activity is tracked in the Dashboard timeline.
</p>
</div>
<ChatEmptyState
:playbooks="playbooks"
subheading="Pick a quick action below or type your own message. All activity is tracked in the Dashboard timeline."
@select="onEmptyStateSelect"
/>
</template>
</ChatMessages>

Expand Down Expand Up @@ -177,7 +171,7 @@
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import axios from 'axios'
import { useAuthStore } from '../stores/auth'
import { ChatMessages, ChatInput } from './chat'
import { ChatMessages, ChatInput, ChatEmptyState } from './chat'
import VoiceOverlay from './chat/VoiceOverlay.vue'
import ModelSelector from './ModelSelector.vue'
import { getStatusFromStreamEvent, MIN_LABEL_DISPLAY_MS, HEARTBEAT_TIMEOUT_MS } from '../utils/execution-status'
Expand Down Expand Up @@ -276,6 +270,29 @@ const focusChatInput = () => {
// Model selection
const selectedModel = ref(localStorage.getItem('trinity_chat_model') || '')

// Playbooks (for empty-state quick actions)
const playbooks = ref([])
const loadPlaybooks = async () => {
if (!props.agentName || props.agentStatus !== 'running') return
try {
const response = await axios.get(
`/api/agents/${props.agentName}/playbooks`,
{ headers: authStore.authHeader }
)
playbooks.value = response.data.skills || []
} catch {
playbooks.value = []
}
}
const onEmptyStateSelect = ({ text, sendImmediately }) => {
if (sendImmediately) {
sendMessage(text)
} else {
message.value = text
nextTick(() => chatInputRef.value?.focus())
}
}

// SSE state (THINK-001)
let heartbeatTimer = null
let labelTimer = null
Expand Down Expand Up @@ -708,6 +725,7 @@ watch(() => props.agentStatus, (newStatus) => {
if (newStatus === 'running') {
loadSessions()
checkVoiceAvailability()
loadPlaybooks()
}
})

Expand All @@ -732,6 +750,7 @@ watch(() => props.agentName, () => {
closeSSE()
if (props.agentStatus === 'running') {
loadSessions()
loadPlaybooks()
}
})

Expand All @@ -741,6 +760,7 @@ onMounted(() => {
if (props.agentStatus === 'running') {
loadSessions()
checkVoiceAvailability()
loadPlaybooks()
}
})

Expand Down
84 changes: 84 additions & 0 deletions src/frontend/src/components/chat/ChatEmptyState.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<template>
<div class="text-center py-8 px-4">
<div v-if="showIcon" class="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<h3 v-if="heading" class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ heading }}</h3>
<p v-if="subheading" class="text-gray-500 dark:text-gray-400 text-sm max-w-md mx-auto mb-6">
{{ subheading }}
</p>

<div v-if="suggestions.length > 0" class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-w-2xl mx-auto">
<button
v-for="(item, idx) in suggestions"
:key="idx"
type="button"
@click="$emit('select', { text: item.text, sendImmediately: item.sendImmediately })"
class="text-left px-4 py-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors group"
>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ item.label }}
</div>
<div v-if="item.hint" class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{{ item.hint }}
</div>
</button>
</div>
</div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
playbooks: {
type: Array,
default: () => []
},
heading: {
type: String,
default: 'Start a Conversation'
},
subheading: {
type: String,
default: 'Pick a quick action below or type your own message.'
},
maxButtons: {
type: Number,
default: 6
},
showIcon: {
type: Boolean,
default: true
}
})

defineEmits(['select'])

const FALLBACK_PROMPTS = [
{ label: 'What can you help me with?', text: 'What can you help me with?', sendImmediately: false },
{ label: 'How do I get started?', text: 'How do I get started?', sendImmediately: false },
{ label: 'What data sources do you have access to?', text: 'What data sources do you have access to?', sendImmediately: false },
{ label: 'Show me an example of what you can do', text: 'Show me an example of what you can do', sendImmediately: false }
]

const suggestions = computed(() => {
const usable = (props.playbooks || []).filter(p => p && p.user_invocable !== false)

if (usable.length === 0) {
return FALLBACK_PROMPTS
}

return usable.slice(0, props.maxButtons).map(p => {
const hasArg = !!p.argument_hint
return {
label: p.description || `/${p.name}`,
hint: hasArg ? `/${p.name} ${p.argument_hint}` : `/${p.name}`,
text: hasArg ? `/${p.name} ` : `/${p.name}`,
sendImmediately: !hasArg
}
})
})
</script>
1 change: 1 addition & 0 deletions src/frontend/src/components/chat/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { default as ChatBubble } from './ChatBubble.vue'
export { default as ChatLoadingIndicator } from './ChatLoadingIndicator.vue'
export { default as ChatInput } from './ChatInput.vue'
export { default as ChatMessages } from './ChatMessages.vue'
export { default as ChatEmptyState } from './ChatEmptyState.vue'
35 changes: 34 additions & 1 deletion src/frontend/src/views/PublicChat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,17 @@
</template>
</ChatMessages>

<!-- Quick-action buttons (#363) — render alongside intro until first user message -->
<ChatEmptyState
v-if="showQuickActions"
:playbooks="playbooks"
:show-icon="false"
heading=""
subheading=""
@select="onEmptyStateSelect"
class="mb-2"
/>

<!-- Error message -->
<div v-if="chatError" class="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400">{{ chatError }}</p>
Expand All @@ -257,7 +268,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { ChatMessages, ChatInput } from '../components/chat'
import { ChatMessages, ChatInput, ChatEmptyState } from '../components/chat'
import { getStatusFromStreamEvent, MIN_LABEL_DISPLAY_MS, HEARTBEAT_TIMEOUT_MS } from '../utils/execution-status'

const route = useRoute()
Expand Down Expand Up @@ -300,6 +311,26 @@ const introLoading = ref(false)
const introError = ref(null)
const introFetched = ref(false)

// Playbooks (for empty-state quick actions)
const playbooks = ref([])
const userMessageCount = computed(() => messages.value.filter(m => m.role === 'user').length)
const showQuickActions = computed(() => userMessageCount.value === 0 && !introLoading.value)
const loadPlaybooks = async () => {
try {
const response = await axios.get(`/api/public/playbooks/${token.value}`)
playbooks.value = response.data.skills || []
} catch {
playbooks.value = []
}
}
const onEmptyStateSelect = ({ text, sendImmediately }) => {
if (sendImmediately) {
sendMessage(text)
} else {
message.value = text
}
}

// Load link info
const loadLinkInfo = async () => {
loading.value = true
Expand Down Expand Up @@ -766,6 +797,8 @@ onMounted(async () => {
} else {
introFetched.value = true // Mark intro as done since we have history
}

loadPlaybooks()
}
}
})
Expand Down
Loading