From 312981b4ce3a62403235c9a49854a0d0bce293b1 Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Tue, 28 Apr 2026 13:07:56 +0300 Subject: [PATCH] feat(chat): quick-action buttons in empty chat state (#363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the blank "Start a Conversation" placeholder with up to 6 playbook buttons (or 4 fallback prompts when the agent has none). Cuts activation energy for new users — Paradigm Life client feedback. Click behavior: - Playbook without `argument_hint` → sends `/` immediately. - Playbook with `argument_hint` → populates input so the user can fill the arg. - Fallback prompts → populate input. PublicChat renders the buttons above the input gated on `userMessageCount === 0` so they coexist with the assistant intro message and disappear after the first user submit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/frontend/src/components/ChatPanel.vue | 44 +++++++--- .../src/components/chat/ChatEmptyState.vue | 84 +++++++++++++++++++ src/frontend/src/components/chat/index.js | 1 + src/frontend/src/views/PublicChat.vue | 35 +++++++- 4 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 src/frontend/src/components/chat/ChatEmptyState.vue diff --git a/src/frontend/src/components/ChatPanel.vue b/src/frontend/src/components/ChatPanel.vue index bdd76c814..3b04f65ee 100644 --- a/src/frontend/src/components/ChatPanel.vue +++ b/src/frontend/src/components/ChatPanel.vue @@ -129,17 +129,11 @@ class="flex-1 px-6" > @@ -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' @@ -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 @@ -708,6 +725,7 @@ watch(() => props.agentStatus, (newStatus) => { if (newStatus === 'running') { loadSessions() checkVoiceAvailability() + loadPlaybooks() } }) @@ -732,6 +750,7 @@ watch(() => props.agentName, () => { closeSSE() if (props.agentStatus === 'running') { loadSessions() + loadPlaybooks() } }) @@ -741,6 +760,7 @@ onMounted(() => { if (props.agentStatus === 'running') { loadSessions() checkVoiceAvailability() + loadPlaybooks() } }) diff --git a/src/frontend/src/components/chat/ChatEmptyState.vue b/src/frontend/src/components/chat/ChatEmptyState.vue new file mode 100644 index 000000000..6a634c1b0 --- /dev/null +++ b/src/frontend/src/components/chat/ChatEmptyState.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/frontend/src/components/chat/index.js b/src/frontend/src/components/chat/index.js index 992ddf980..90cec155d 100644 --- a/src/frontend/src/components/chat/index.js +++ b/src/frontend/src/components/chat/index.js @@ -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' diff --git a/src/frontend/src/views/PublicChat.vue b/src/frontend/src/views/PublicChat.vue index ef84ba036..13858b022 100644 --- a/src/frontend/src/views/PublicChat.vue +++ b/src/frontend/src/views/PublicChat.vue @@ -235,6 +235,17 @@ + + +

{{ chatError }}

@@ -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() @@ -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 @@ -766,6 +797,8 @@ onMounted(async () => { } else { introFetched.value = true // Mark intro as done since we have history } + + loadPlaybooks() } } })