Files
nexus/frontend/src/components/dashboard/ChatPanel.vue
T
developer b7b44494f0
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
fix(shadcn): isolate Nexus CSS vars with --nx- prefix + admin password reset endpoint
2026-06-11 10:06:58 +02:00

292 lines
7.2 KiB
Vue

<script setup lang="ts">
import { ref, nextTick, watch, onUnmounted } from 'vue'
import { Bot, Send, Maximize2 } from '@lucide/vue'
import type { ChatMessage } from '../../composables/useDashboardData'
import { useDashboardData } from '../../composables/useDashboardData'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/Textarea.vue'
import Dialog from '@/components/ui/Dialog.vue'
import DialogHeader from '@/components/ui/DialogHeader.vue'
import DialogTitle from '@/components/ui/DialogTitle.vue'
import ChatMessageList from './ChatMessageList.vue'
const props = defineProps<{
messages: ChatMessage[]
irisBusy: boolean
irisFocus: string
}>()
const { sendChatMessage, busySince } = useDashboardData()
const elapsedSeconds = ref(0)
let elapsedInterval: ReturnType<typeof setInterval> | null = null
function startElapsedTimer(): void {
stopElapsedTimer()
const update = () => {
if (busySince.value > 0) {
elapsedSeconds.value = Math.floor((Date.now() - busySince.value) / 1000)
}
}
update()
elapsedInterval = setInterval(update, 1000)
}
function stopElapsedTimer(): void {
if (elapsedInterval) {
clearInterval(elapsedInterval)
elapsedInterval = null
}
}
watch(() => props.irisBusy, (busy) => {
if (busy) {
startElapsedTimer()
} else {
stopElapsedTimer()
elapsedSeconds.value = 0
}
}, { immediate: true })
onUnmounted(() => {
stopElapsedTimer()
})
const inputText = ref('')
const chatListRef = ref<HTMLElement | null>(null)
const chatModalListRef = ref<HTMLElement | null>(null)
const dialogOpen = ref(false)
function sendMessage(): void {
if (!inputText.value.trim()) return
sendChatMessage(inputText.value)
inputText.value = ''
}
watch(
() => props.messages.length,
async () => {
await nextTick()
const el = dialogOpen.value ? chatModalListRef.value : chatListRef.value
if (el) {
el.scrollTop = el.scrollHeight
}
}
)
</script>
<template>
<!-- Inline Chat Panel -->
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-left">
<Bot :size="16" class="text-[#a78bfa]" />
<h2>Iris Chat</h2>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = true" title="Open larger chat">
<Maximize2 :size="14" />
</Button>
</div>
<!-- Focus Bar -->
<div v-if="irisBusy && irisFocus" class="focus-bar">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
<!-- Messages -->
<div ref="chatListRef" class="chat-messages">
<ChatMessageList
:messages="messages"
:iris-busy="irisBusy"
:elapsed-seconds="elapsedSeconds"
/>
</div>
<!-- Input -->
<div class="chat-input-row">
<Textarea
v-model="inputText"
rows="1"
placeholder="Type a message..."
class="min-h-0 h-9 resize-none text-xs bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385] text-[10px]"
@keyup.enter.exact="sendMessage"
/>
<Button
size="icon"
class="h-8 w-8 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="14" />
</Button>
</div>
</div>
<!-- Expanded Chat Dialog -->
<Dialog :open="dialogOpen" class="sm:max-w-[820px] sm:h-[78vh] p-0 gap-0" @update:open="dialogOpen = $event">
<template #default>
<DialogHeader class="flex-row items-center justify-between px-5 py-4 border-b border-[rgba(255,255,255,0.06)]">
<div class="flex items-center gap-2">
<Bot :size="18" class="text-[#a78bfa]" />
<DialogTitle>Iris Chat</DialogTitle>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = false" aria-label="Close">
<span class="text-lg leading-none">&times;</span>
</Button>
</DialogHeader>
<div v-if="irisBusy && irisFocus" class="focus-bar !px-5">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
<div ref="chatModalListRef" class="flex-1 overflow-y-auto px-5 py-4">
<ChatMessageList
:messages="messages"
:iris-busy="irisBusy"
:elapsed-seconds="elapsedSeconds"
/>
</div>
<div class="flex gap-2 px-4 py-3 border-t border-[rgba(255,255,255,0.06)]">
<Textarea
v-model="inputText"
rows="1"
placeholder="Type a message..."
class="min-h-0 h-10 resize-none text-sm bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385]"
@keyup.enter.exact="sendMessage"
/>
<Button
size="icon"
class="h-10 w-10 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="18" />
</Button>
</div>
</template>
</Dialog>
</template>
<style scoped>
.chat-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 360px;
max-height: 480px;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
overflow: hidden;
}
.chat-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.chat-header h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
}
/* Focus Bar */
.focus-bar {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 16px;
background: rgba(234, 179, 8, 0.04);
border-bottom: 1px solid rgba(234, 179, 8, 0.08);
}
.focus-label {
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #eab308;
}
.focus-text {
font-size: 10px;
color: #7e8799;
line-height: 1.3;
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.chat-messages::-webkit-scrollbar {
width: 5px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
/* Input */
.chat-input-row {
display: flex;
gap: 6px;
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* ── Mobile: compact mode ── */
@media (max-width: 768px) {
.chat-panel {
min-height: 280px;
max-height: 360px;
border-radius: 12px;
}
.chat-header {
padding: 10px 12px;
}
.chat-header h2 {
font-size: 11px;
}
.chat-messages {
padding: 8px 12px;
}
.chat-input-row {
padding: 8px 10px;
gap: 4px;
}
.focus-bar {
padding: 6px 12px;
}
.focus-label {
font-size: 7px;
}
.focus-text {
font-size: 9px;
}
}
</style>