Files
creator/frontend/src/components/ElementsSidebar.vue
2026-06-07 15:17:50 +02:00

987 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { fetchElements, createElement, chatElement, deleteElement, updateElement, checkElement, styleElement } from '../api.js'
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
openId: { type: String, default: null }, // Element-ID, die geöffnet werden soll
openTick: { type: Number, default: 0 }, // Erhöhung = openId (erneut) öffnen
})
const emit = defineEmits(['close', 'changed'])
const elements = ref([])
const query = ref('')
const creating = ref(false)
const selected = ref(null)
watch(() => props.topic, load, { immediate: true })
async function load() {
selected.value = null
query.value = ''
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Fehler beim Laden der Elemente:', e)
}
openFromProp()
}
// Aus der Übersicht im Hauptbereich angeklicktes Element öffnen
watch(() => props.openTick, openFromProp)
function openFromProp() {
if (!props.openId) return
const el = elements.value.find((e) => e.id === props.openId)
if (el) select(el)
}
// Markdown-Zeichen für Titel und Listen-Vorschau entfernen
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return elements.value
return elements.value.filter(
(el) => el.title.toLowerCase().includes(q) || el.description.toLowerCase().includes(q),
)
})
async function add() {
if (creating.value) return
creating.value = true
try {
const el = await createElement(props.topic, query.value.trim(), props.provider)
elements.value.unshift(el)
query.value = ''
emit('changed')
} catch (e) {
console.error('Fehler beim Erstellen des Elements:', e)
} finally {
creating.value = false
}
}
function select(el) {
selected.value = el
messages.value = []
input.value = ''
resetCheck()
nextTick(() => inputEl.value?.focus())
}
function back() {
selected.value = null
messages.value = []
resetCheck()
}
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) ---
const checking = ref(false)
const statusMsg = ref(null)
function resetCheck() {
checking.value = false
statusMsg.value = null
resetStyle()
}
let checkRun = 0 // laufende Prüfung identifizieren; Abbruch ignoriert ihr Ergebnis
async function runCheck() {
if (!selected.value) return
if (checking.value) { // zweiter Klick = abbrechen
checkRun++
checking.value = false
return
}
const run = ++checkRun
checking.value = true
statusMsg.value = null
try {
const res = await checkElement(selected.value.id, props.provider)
if (run !== checkRun) return // abgebrochen oder neue Prüfung gestartet
const mapped = res.suggestions.map((s) => ({
text: s.text, action: 'hinzufuegen', target: s.target, index: null, content: s.content,
}))
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
else statusMsg.value = 'Keine wichtigen Lücken gefunden.'
} catch (e) {
if (run !== checkRun) return
console.error('Prüfung fehlgeschlagen:', e)
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.'
} finally {
if (run === checkRun) checking.value = false
}
}
// --- Stil-Prüfung: KI schlägt Änderungen vor, Nutzer bestätigt ---
const styleChanges = ref(null) // null = noch nicht geprüft
const styling = ref(false)
const applyingStyle = ref(false)
const ACTION_LABELS = { entfernen: 'Entfernen:', anpassen: 'Anpassen:', hinzufuegen: 'Hinzufügen:' }
let styleRun = 0
function resetStyle() {
styleChanges.value = null
styling.value = false
applyingStyle.value = false
}
async function runStyle() {
if (!selected.value) return
if (styling.value) { // zweiter Klick = abbrechen
styleRun++
styling.value = false
return
}
const run = ++styleRun
styling.value = true
statusMsg.value = null
try {
const res = await styleElement(selected.value.id, props.provider)
if (run !== styleRun) return
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
else statusMsg.value = 'Stil passt bereits.'
} catch (e) {
if (run !== styleRun) return
console.error('Stil-Prüfung fehlgeschlagen:', e)
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.'
} finally {
if (run === styleRun) styling.value = false
}
}
// Vorschläge am Ziel-Ort anzeigen: anpassen/entfernen beim betroffenen Eintrag …
function styleAt(target, index = null) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'hinzufuegen')
}
// … Ergänzungen am Ende der jeweiligen Sektion
function styleAdds(target) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen')
}
function dismissStyleChange(i) {
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
}
async function applyStyleChange(i) {
if (applyingStyle.value || !selected.value) return
const c = styleChanges.value[i]
applyingStyle.value = true
try {
const fields = {
title: selected.value.title,
description: selected.value.description,
examples: [...selected.value.examples],
hints: [...selected.value.hints],
}
if (c.action === 'entfernen') fields[c.target].splice(c.index, 1)
else if (c.action === 'hinzufuegen') {
if (c.target === 'title') fields.title = c.content
else if (c.target === 'description') fields.description += '\n\n' + c.content
else fields[c.target].push(c.content)
} else if (c.target === 'title' || c.target === 'description') fields[c.target] = c.content
else fields[c.target][c.index] = c.content
const updated = await updateElement(selected.value.id, fields)
selected.value = updated
const idx = elements.value.findIndex((e) => e.id === updated.id)
if (idx !== -1) elements.value.splice(idx, 1, updated)
// Rest-Vorschläge behalten; Indizes hinter einer Entfernung rücken auf
const rest = styleChanges.value.filter((_, j) => j !== i)
if (c.action === 'entfernen') {
for (const r of rest) {
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
}
}
styleChanges.value = rest
emit('changed')
} catch (e) {
console.error('Übernehmen fehlgeschlagen:', e)
} finally {
applyingStyle.value = false
}
}
// Inline-Bestätigung wie in TopicSidebar: erster Klick „Sicher?", zweiter löscht
const pendingDelete = ref(null)
let deleteTimer = null
async function confirmDelete(el) {
clearTimeout(deleteTimer)
if (pendingDelete.value === el.id) {
pendingDelete.value = null
await deleteElement(el.id)
elements.value = elements.value.filter((e) => e.id !== el.id)
if (selected.value?.id === el.id) back()
emit('changed')
} else {
pendingDelete.value = el.id
deleteTimer = setTimeout(() => { pendingDelete.value = null }, 3000)
}
}
// --- Chat zum Anpassen des Elements ---
const messages = ref([])
const input = ref('')
const loading = ref(false)
const messagesEl = ref(null)
const inputEl = ref(null)
async function scrollToBottom() {
await nextTick()
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
async function send() {
const text = input.value.trim()
if (!text || loading.value || !selected.value) return
messages.value.push({ role: 'user', content: text })
input.value = ''
loading.value = true
scrollToBottom()
try {
const res = await chatElement(selected.value.id, messages.value, props.provider)
messages.value.push({ role: 'assistant', content: res.reply || '…' })
if (res.element) {
selected.value = res.element
const i = elements.value.findIndex((e) => e.id === res.element.id)
if (i !== -1) elements.value.splice(i, 1, res.element)
emit('changed')
}
} catch {
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
} finally {
loading.value = false
scrollToBottom()
nextTick(() => inputEl.value?.focus())
}
}
</script>
<template>
<aside class="elements-sidebar">
<header class="el-header">
<button v-if="selected" class="el-back" title="Zur Liste" @click="back"></button>
<span class="el-title">{{ selected ? plain(selected.title) : 'Elemente' }}</span>
<button
v-if="selected" class="el-tool" :class="{ busy: checking }"
:title="checking ? 'Prüfung abbrechen' : 'Auf fehlende Infos prüfen'" @click="runCheck"
>🔍</button>
<button
v-if="selected" class="el-tool" :class="{ busy: styling }"
:title="styling ? 'Prüfung abbrechen' : 'Stil prüfen & anpassen'" @click="runStyle"
></button>
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
</header>
<!-- Listen-Modus -->
<template v-if="!selected">
<div class="el-new">
<input
v-model="query"
placeholder="Suchen oder Stichwort…"
:disabled="creating"
@keyup.enter="add"
/>
<button :disabled="creating" title="Element per KI erstellen" @click="add">+</button>
</div>
<div v-if="creating" class="el-creating">KI erstellt Element</div>
<ul class="el-list">
<li v-for="el in filtered" :key="el.id" @click="select(el)">
<div class="el-item-main">
<span class="el-item-title">{{ plain(el.title) }}</span>
<span class="el-item-desc">{{ plain(el.description) }}</span>
</div>
<button
class="el-delete"
:class="{ armed: pendingDelete === el.id }"
title="Element löschen"
@click.stop="confirmDelete(el)"
>{{ pendingDelete === el.id ? 'Sicher?' : '×' }}</button>
</li>
<li v-if="!filtered.length && !creating" class="el-empty">
{{ elements.length ? 'Keine Treffer.' : 'Noch keine Elemente. Stichwort eingeben und + klicken.' }}
</li>
</ul>
</template>
<!-- Detail-Modus -->
<template v-else>
<div class="el-detail">
<div v-if="selected.description" class="el-desc markdown" v-html="renderMarkdown(selected.description)"></div>
<div
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
:key="'sgd' + ci" class="style-sugg"
>
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
<template v-for="(ex, i) in selected.examples" :key="i">
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
<div v-for="[ci, c] in styleAt('examples', i)" :key="'sge' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</template>
<div v-for="[ci, c] in styleAdds('examples')" :key="'sgea' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
<div v-if="selected.hints.length || styleAdds('hints').length" class="el-hints-block">
<h4>Hinweise</h4>
<ul class="el-hints">
<li v-for="(h, i) in selected.hints" :key="i">
<span class="markdown" v-html="renderMarkdown(h)"></span>
<div v-for="[ci, c] in styleAt('hints', i)" :key="'sgh' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</li>
</ul>
<div v-for="[ci, c] in styleAdds('hints')" :key="'sgha' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</div>
<div v-if="checking || styling || statusMsg" class="el-check">
<p v-if="checking" class="check-empty busy-text">Prüft auf fehlende Infos</p>
<p v-if="styling" class="check-empty busy-text">Prüft den Stil</p>
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
</div>
</div>
<div class="el-chat">
<div ref="messagesEl" class="chat-messages">
<p v-if="!messages.length" class="chat-hint">Schreib, was am Element geändert werden soll.</p>
<template v-for="(m, i) in messages" :key="i">
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Passt an</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
placeholder="Element anpassen…"
@keydown.enter.exact.prevent="send"
></textarea>
<button :disabled="!input.trim() || loading" @click="send"></button>
</div>
</div>
</template>
</aside>
</template>
<style scoped>
.elements-sidebar {
width: 320px;
min-width: 320px;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--border);
/* Über dem Guide-Chat (FAB/Panel: z-index 20) */
position: relative;
z-index: 30;
}
.el-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.el-title {
flex: 1;
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-back,
.el-close {
border: none;
background: none;
color: var(--text-faint);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.el-back:hover,
.el-close:hover {
color: var(--text);
}
.el-tool {
border: none;
background: none;
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
padding: 2px 3px;
border-radius: 6px;
filter: grayscale(0.4);
}
.el-tool:hover {
background: var(--panel-soft);
filter: none;
}
.el-tool.busy {
filter: none;
animation: pulse 1.5s ease-in-out infinite;
}
.busy-text {
animation: pulse 1.5s ease-in-out infinite;
}
.el-new {
display: flex;
gap: 6px;
padding: 0.6rem 0.75rem;
}
.el-new input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
background: var(--panel);
color: var(--text);
outline: none;
}
.el-new input:focus {
border-color: var(--accent);
}
.el-new button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1.1rem;
cursor: pointer;
}
.el-new button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.el-creating {
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--warning);
background: var(--warning-soft);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.35; }
}
.el-list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.el-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
.el-list li:hover {
background: var(--panel-soft);
}
.el-item-main {
flex: 1;
min-width: 0;
}
.el-item-title {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-item-desc {
display: block;
font-size: 0.75rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-delete {
border: none;
background: none;
color: var(--danger);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
visibility: hidden;
}
.el-list li:hover .el-delete {
visibility: visible;
}
.el-delete.armed {
visibility: visible;
font-size: 0.7rem;
font-weight: 700;
background: var(--danger);
color: #fff;
border-radius: 4px;
padding: 2px 6px;
}
.el-empty {
cursor: default !important;
color: var(--text-faint);
font-size: 0.8rem;
}
.el-empty:hover {
background: none !important;
}
.el-detail {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
}
.el-desc {
margin: 0 0 0.9rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.el-hints-block {
margin-top: 0.9rem;
}
.el-hints-block h4 {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-entry {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.4rem;
}
.el-entry:last-child {
margin-bottom: 0;
}
.el-hints {
margin: 0;
padding-left: 1.1rem;
}
.el-hints li {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.25rem;
}
/* Hinweis-Text inline neben dem Bullet halten (p ist sonst block) */
.el-hints li > .markdown {
display: inline;
}
.el-hints li > .markdown :deep(p) {
display: inline;
margin: 0;
}
/* --- Markdown im Guide-Stil (Muster: TopicDetail) --- */
.markdown :deep(p) {
margin: 0 0 0.5em;
}
.markdown :deep(p:last-child) {
margin-bottom: 0;
}
.markdown :deep(ul),
.markdown :deep(ol) {
margin: 0.3em 0;
padding-left: 1.2em;
}
.markdown :deep(code) {
background: var(--border);
padding: 1px 4px;
border-radius: 4px;
font-family: "SF Mono", Consolas, monospace;
font-size: 0.85em;
overflow-wrap: anywhere;
}
.markdown :deep(pre) {
background: var(--code-bg, #1e2330);
color: var(--code-fg, #e6e8ee);
padding: 8px 10px;
border-radius: 8px;
/* Schmale Sidebar: Code umbrechen statt horizontal scrollen */
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0.4em 0;
}
.markdown :deep(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.85em;
}
/* --- Stil-Vorschläge inline am Ziel --- */
.style-sugg {
margin: 0.3rem 0 0.6rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--accent);
border-radius: 8px;
background: var(--panel-soft);
}
.style-sugg-text {
font-size: 0.76rem;
line-height: 1.4;
color: var(--text);
}
.style-sugg-text strong {
color: var(--accent);
}
.style-sugg-preview {
margin-top: 0.35rem;
font-size: 0.76rem;
line-height: 1.45;
color: var(--text-muted);
}
.style-sugg-actions {
display: flex;
gap: 6px;
margin-top: 0.45rem;
}
.sugg-ok,
.sugg-no {
padding: 4px 10px;
border-radius: 6px;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
.sugg-ok {
border: none;
background: var(--accent);
color: var(--on-accent);
}
.sugg-no {
border: 1px solid var(--border-strong);
background: none;
color: var(--text-muted);
}
.sugg-no:hover {
border-color: var(--danger);
color: var(--danger);
}
.sugg-ok:disabled,
.sugg-no:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* --- KI-Prüfung --- */
.el-check {
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--border-strong);
}
.check-btn {
width: 100%;
margin-bottom: 0.4rem;
padding: 7px 10px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--panel-soft);
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.12s;
}
.check-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.check-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.check-empty {
margin: 0.6rem 0 0;
font-size: 0.78rem;
color: var(--text-faint);
text-align: center;
}
.sugg-list {
list-style: none;
margin: 0.6rem 0 0;
padding: 0;
}
.sugg-list li {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 0.4rem 0.2rem;
font-size: 0.8rem;
line-height: 1.4;
color: var(--text);
cursor: pointer;
border-radius: 6px;
}
.sugg-list li:hover {
background: var(--panel-soft);
}
.sugg-list input {
margin-top: 2px;
accent-color: var(--accent);
cursor: pointer;
}
.sugg-text em {
color: var(--text-faint);
font-style: normal;
font-size: 0.72rem;
}
.check-btn.busy {
border-style: solid;
border-color: var(--warning);
color: var(--warning);
animation: pulse 1.5s ease-in-out infinite;
}
.sugg-actions {
display: flex;
gap: 6px;
margin-top: 0.5rem;
}
.dismiss-btn {
padding: 7px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: none;
color: var(--text-muted);
font-size: 0.8rem;
cursor: pointer;
}
.dismiss-btn:hover {
border-color: var(--danger);
color: var(--danger);
}
.apply-btn {
flex: 1;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.apply-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Chat unten (Muster: TopicDetail-Chat) */
.el-chat {
display: flex;
flex-direction: column;
flex: 0 0 33%;
min-height: 0;
border-top: 1px solid var(--border);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-hint {
color: var(--text-faint);
font-size: 0.78rem;
text-align: center;
margin-top: 0.5rem;
}
.chat-msg {
max-width: 85%;
padding: 6px 10px;
border-radius: 12px;
font-size: 0.82rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
color: var(--on-accent);
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--panel-soft);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-typing {
color: var(--text-faint);
font-style: italic;
}
.chat-input {
display: flex;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
height: 72px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
background: var(--panel);
color: var(--text);
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>