feat(chat): slash command palette, action button containers, app detection fix

- Slash commands (/code, /nostr, /design, /search) now appear in the
  prompt palette with descriptions, auto-send on select
- Chat message hover actions wrapped in a proper glass container with
  backdrop blur, divider between actions and feedback thumbs
- Palette has 8px side margins, no scroll limit
- Fix app extraction: queries mentioning known app names (e.g. "start9")
  now surface the Apps tab even without explicit app/nostr query patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 05:15:15 +00:00
co-authored by Claude Opus 4.6
parent 84ccdc7048
commit f346992924
5 changed files with 171 additions and 78 deletions
+16 -2
View File
@@ -199,10 +199,10 @@ const images = ref<ImageAttachment[]>([])
const paletteRef = ref<InstanceType<typeof PromptPalette> | null>(null)
// Prompt palette: opens when text starts with "/" (but not "/search " or "/code" etc.)
// Prompt palette: opens when text starts with "/" and has no space yet
const isPaletteMode = computed(() => {
const t = text.value.trimStart()
return t === '/' || (t.startsWith('/') && !t.includes(' ') && !t.startsWith('/search') && !t.startsWith('/code') && !t.startsWith('/nostr') && !t.startsWith('/exit'))
return t === '/' || (t.startsWith('/') && !t.includes(' '))
})
const paletteQuery = computed(() => {
@@ -211,6 +211,20 @@ const paletteQuery = computed(() => {
})
function handlePaletteSelect(templateText: string) {
// Slash commands — send immediately (except /search which needs query input)
if (templateText.startsWith('/') && !templateText.includes('{{')) {
text.value = templateText
if (templateText.trimEnd() === '/search') {
// /search needs a query — set text and let user type
nextTick(() => {
autoResize()
textareaRef.value?.focus()
})
return
}
nextTick(() => send())
return
}
text.value = templateText
nextTick(() => {
autoResize()
@@ -9,59 +9,61 @@
<!-- Action buttons (hover on desktop, always visible on touch) -->
<div
v-if="!isEditing"
class="absolute -top-3 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 z-10 flex gap-1"
class="absolute -top-4 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-200 z-10"
:class="isUser ? 'right-1' : 'left-1'"
>
<button
v-if="isUser"
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
title="Edit message"
@click.stop="startEditing"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
v-if="!isUser"
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
title="Regenerate response"
@click.stop="$emit('regenerate')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
</button>
<button
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
title="Reply"
@click.stop="$emit('reply', message.id, message.content)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</button>
<button
v-if="!isUser"
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center text-white/60 hover:text-white/90"
title="Branch from here"
@click.stop="$emit('branch', message.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5z" clip-rule="evenodd" /></svg>
</button>
<!-- Feedback thumbs -->
<button
v-if="!isUser"
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center transition-colors"
:class="message.feedback === 'up' ? 'text-green-400' : 'text-white/60 hover:text-green-400/80'"
title="Good response"
@click.stop="toggleFeedback('up')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" /></svg>
</button>
<button
v-if="!isUser"
class="glass-button-sm !h-7 !min-h-0 !w-7 !min-w-0 !px-0 flex items-center justify-center transition-colors"
:class="message.feedback === 'down' ? 'text-red-400' : 'text-white/60 hover:text-red-400/80'"
title="Poor response"
@click.stop="toggleFeedback('down')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.106-1.79l-.05-.025A4 4 0 0011.057 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" /></svg>
</button>
<div class="flex items-center gap-0.5 px-1 py-0.5 rounded-lg bg-black/60 backdrop-blur-md border border-white/10 shadow-lg">
<button
v-if="isUser"
class="w-7 h-7 flex items-center justify-center rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Edit message"
@click.stop="startEditing"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
</button>
<button
v-if="!isUser"
class="w-7 h-7 flex items-center justify-center rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Regenerate response"
@click.stop="$emit('regenerate')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
</button>
<button
class="w-7 h-7 flex items-center justify-center rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Reply"
@click.stop="$emit('reply', message.id, message.content)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</button>
<button
v-if="!isUser"
class="w-7 h-7 flex items-center justify-center rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-colors"
title="Branch from here"
@click.stop="$emit('branch', message.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5z" clip-rule="evenodd" /></svg>
</button>
<div v-if="!isUser" class="w-px h-4 bg-white/10 mx-0.5" />
<button
v-if="!isUser"
class="w-7 h-7 flex items-center justify-center rounded-md transition-colors"
:class="message.feedback === 'up' ? 'text-green-400' : 'text-white/50 hover:text-green-400/80 hover:bg-white/10'"
title="Good response"
@click.stop="toggleFeedback('up')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" /></svg>
</button>
<button
v-if="!isUser"
class="w-7 h-7 flex items-center justify-center rounded-md transition-colors"
:class="message.feedback === 'down' ? 'text-red-400' : 'text-white/50 hover:text-red-400/80 hover:bg-white/10'"
title="Poor response"
@click.stop="toggleFeedback('down')"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M18 9.5a1.5 1.5 0 11-3 0v-6a1.5 1.5 0 013 0v6zM14 9.667v-5.43a2 2 0 00-1.106-1.79l-.05-.025A4 4 0 0011.057 2H5.64a2 2 0 00-1.962 1.608l-1.2 6A2 2 0 004.44 12H8v4a2 2 0 002 2 1 1 0 001-1v-.667a4 4 0 01.8-2.4l1.4-1.866a4 4 0 00.8-2.4z" /></svg>
</button>
</div>
</div>
<div
@@ -158,7 +158,7 @@ defineEmits<{
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel } = useAI()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
const codeContext = useCodeContext()
import { usePersonaStore } from '@/stores/personas'
@@ -325,6 +325,16 @@ async function handleSend(text: string, images: ImageAttachment[] = []) {
return
}
if (trimmed === '/design') {
enterDesignSystemMode()
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/design' })
chatStore.addMessage(convId, { role: 'assistant', content: 'Design system viewer opened. Browse colors, typography, spacing, and components.' })
}
return
}
if (trimmed === '/code exit' || trimmed === '/exit') {
if (codeContext.isCodeMode.value) {
codeContext.exitCodeMode()
@@ -1,5 +1,5 @@
<template>
<div v-if="isOpen" class="absolute bottom-full left-0 right-0 mb-1 z-20">
<div v-if="isOpen" class="absolute bottom-full left-2 right-2 mb-1 z-20">
<!-- Variable fill form -->
<div
v-if="selectedTemplate && variables.length > 0"
@@ -32,27 +32,52 @@
</button>
</div>
<!-- Template list -->
<!-- Command + template list -->
<div
v-else
class="glass-card max-h-[240px] overflow-y-auto scrollbar-hide animate-scale-in"
class="glass-card animate-scale-in"
>
<div class="p-2 border-b border-white/5">
<p class="text-[10px] text-white/30 px-2">Templates</p>
<!-- Commands section -->
<div v-if="filteredCommands.length > 0">
<div class="p-2 border-b border-white/5">
<p class="text-[10px] text-white/30 px-2">Commands</p>
</div>
<div
v-for="(cmd, i) in filteredCommands"
:key="cmd.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="i === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectCommand(cmd)"
@mouseenter="highlightIndex = i"
>
<div class="flex items-center gap-2">
<span class="text-[11px] font-mono text-accent/70">{{ cmd.slash }}</span>
<p class="text-sm text-white/80">{{ cmd.title }}</p>
</div>
<p v-if="cmd.preview" class="text-[11px] text-white/40 mt-0.5 truncate">{{ cmd.preview }}</p>
</div>
</div>
<div
v-for="(t, i) in filteredTemplates"
:key="t.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="i === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectTemplate(t)"
@mouseenter="highlightIndex = i"
>
<p class="text-sm text-white/80">{{ t.title }}</p>
<p v-if="t.preview" class="text-[11px] text-white/40 mt-0.5 truncate">{{ t.preview }}</p>
<!-- Templates section -->
<div v-if="filteredUserTemplates.length > 0">
<div class="p-2 border-b border-white/5">
<p class="text-[10px] text-white/30 px-2">Templates</p>
</div>
<div
v-for="(t, i) in filteredUserTemplates"
:key="t.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="(filteredCommands.length + i) === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectTemplate(t)"
@mouseenter="highlightIndex = filteredCommands.length + i"
>
<p class="text-sm text-white/80">{{ t.title }}</p>
<p v-if="t.preview" class="text-[11px] text-white/40 mt-0.5 truncate">{{ t.preview }}</p>
</div>
</div>
<div v-if="filteredTemplates.length === 0" class="px-3 py-4 text-center">
<p class="text-xs text-white/30">No matching templates</p>
<div v-if="filteredCommands.length === 0 && filteredUserTemplates.length === 0" class="px-3 py-4 text-center">
<p class="text-xs text-white/30">No matching commands</p>
</div>
</div>
</div>
@@ -62,6 +87,20 @@
import { ref, computed, watch } from 'vue'
import { usePromptTemplateStore, extractVariables, type PromptTemplate } from '@/stores/promptTemplates'
interface PaletteCommand {
id: string
slash: string
title: string
preview: string
}
const BUILT_IN_COMMANDS: PaletteCommand[] = [
{ id: 'cmd-code', slash: '/code', title: 'Code', preview: 'Open project browser and code editor' },
{ id: 'cmd-nostr', slash: '/nostr', title: 'Nostr', preview: 'Browse the Nostr network feed' },
{ id: 'cmd-design', slash: '/design', title: 'Design System', preview: 'Open the design system viewer' },
{ id: 'cmd-search', slash: '/search ', title: 'Search', preview: 'Search your content library' },
]
const props = defineProps<{
query: string
isOpen: boolean
@@ -77,7 +116,17 @@ const highlightIndex = ref(0)
const selectedTemplate = ref<PromptTemplate | null>(null)
const variableValues = ref<Record<string, string>>({})
const filteredTemplates = computed(() => {
const filteredCommands = computed(() => {
const q = props.query.toLowerCase()
if (!q) return BUILT_IN_COMMANDS
return BUILT_IN_COMMANDS.filter(c =>
c.slash.toLowerCase().includes('/' + q) ||
c.title.toLowerCase().includes(q) ||
c.preview.toLowerCase().includes(q)
)
})
const filteredUserTemplates = computed(() => {
const q = props.query.toLowerCase()
if (!q) return templateStore.sortedTemplates
return templateStore.sortedTemplates.filter(t =>
@@ -85,6 +134,8 @@ const filteredTemplates = computed(() => {
)
})
const allItemsCount = computed(() => filteredCommands.value.length + filteredUserTemplates.value.length)
const variables = computed(() => {
if (!selectedTemplate.value) return []
return extractVariables(selectedTemplate.value.content)
@@ -103,10 +154,14 @@ watch(() => props.isOpen, (open) => {
}
})
function selectCommand(cmd: PaletteCommand) {
// Commands like /code, /nostr send the slash text directly
emit('select', cmd.slash)
}
function selectTemplate(t: PromptTemplate) {
const vars = extractVariables(t.content)
if (vars.length === 0) {
// No variables — insert directly
emit('select', t.content)
return
}
@@ -135,12 +190,17 @@ function navigateUp() {
}
function navigateDown() {
if (highlightIndex.value < filteredTemplates.value.length - 1) highlightIndex.value++
if (highlightIndex.value < allItemsCount.value - 1) highlightIndex.value++
}
function selectHighlighted() {
const t = filteredTemplates.value[highlightIndex.value]
if (t) selectTemplate(t)
const idx = highlightIndex.value
if (idx < filteredCommands.value.length) {
selectCommand(filteredCommands.value[idx])
} else {
const t = filteredUserTemplates.value[idx - filteredCommands.value.length]
if (t) selectTemplate(t)
}
}
defineExpose({
@@ -1218,8 +1218,15 @@ export function extractApps(text: string, userQuery: string): AppEntry[] {
}
}
// Surface apps if: app/nostr query with 1+, or 2+ apps detected in any context
const isAppContext = isAppQuery(userQuery) || isNostrQuery(userQuery)
// Check if query itself mentions a known app name
const queryLower = userQuery.toLowerCase()
const queryMatchesApp = APP_DATABASE.some(app =>
[app.name.toLowerCase(), ...app.keywords.map(k => k.toLowerCase())]
.some(kw => kw.length >= 3 && queryLower.includes(kw))
)
// Surface apps if: app/nostr/known-app query with 1+, or 2+ apps detected in any context
const isAppContext = isAppQuery(userQuery) || isNostrQuery(userQuery) || queryMatchesApp
if (isAppContext && matched.length >= 1) return matched
if (matched.length >= 2) return matched
return []