30dfbce426
Per the design rule "italic is for emphasis, not for design", removed
every chrome `font-style: italic` declaration across the frontend.
42 declarations gone across 24 files: empty-state placeholder copy,
loading messages, "no results" hints, ghost text, voice/role labels,
field placeholder text, brand wordmark, et al.
Markdown content emphasis is unaffected — `<em>` and `<i>` tags from
`*emphasis*` markup still render italic via browser default styling
(prose.css doesn't override em behavior). User-typed emphasis in
notes, journal entries, and chat messages keeps its italic.
Specific spots that lost the decorative italic:
- KnowledgeView .filter-label, .empty-narrator
- NoteEditorView .ef-label, link-suggest related
- ChatPanel .empty-msg, .empty-greeting, .role-label
- ChatMessage .role-assistant .role-label (the "Fable" voice tag —
was italic per the doc's Illuminated Transcript spec, but per the
new typography rule the speaker tag stays regular and lets the
border-left + glow do the bubble framing on their own)
- AppHeader .brand-text ("Fabled" wordmark)
- editor-shared.css .title-input::placeholder
- ProjectView .project-title-input::placeholder
- HomeView .urgency-loading
- WorkspaceTaskPanel .empty-group
- WeatherCard .weather-unavailable
- SharedWithMeView .empty-msg
- DiffView empty-state spans
- ToolCallCard .tool-event-more
- SettingsView 7 spots (.you-label, .geo-pending, hint text, etc.)
- + several other empty-state / hint text spots
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
953 lines
31 KiB
Vue
953 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
|
import { useChatStore } from '@/stores/chat'
|
|
import ChatMessage from '@/components/ChatMessage.vue'
|
|
import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
|
|
import ChatInputBar from '@/components/ChatInputBar.vue'
|
|
import ToolCallCard from '@/components/ToolCallCard.vue'
|
|
import type { Message, ToolCallRecord } from '@/types/chat'
|
|
import { Mic, ChevronDown, X } from 'lucide-vue-next'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
variant: 'full' | 'widget'
|
|
/** Workspace: pins RAG scope and workspace_project_id */
|
|
projectId?: number
|
|
/** Briefing: hides scope chip, hides note picker */
|
|
briefingMode?: boolean
|
|
/** Hides input bar — for read-only historical views */
|
|
readOnly?: boolean
|
|
placeholder?: string
|
|
autoFocus?: boolean
|
|
}>(), {
|
|
briefingMode: false,
|
|
readOnly: false,
|
|
autoFocus: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
/** Widget only: emitted when a new conversation is created */
|
|
'conversation-started': [convId: number]
|
|
}>()
|
|
|
|
const store = useChatStore()
|
|
|
|
// ── Scroll ────────────────────────────────────────────────────────────────────
|
|
const messagesEl = ref<HTMLElement | null>(null)
|
|
|
|
function scrollToBottom() {
|
|
nextTick(() => {
|
|
if (messagesEl.value) {
|
|
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
|
}
|
|
})
|
|
}
|
|
|
|
watch(() => store.streamingContent, () => scrollToBottom())
|
|
watch(() => store.currentConversation?.messages.length, () => scrollToBottom())
|
|
|
|
// Notify CalendarView when an event is created/updated/deleted via tool
|
|
const _calendarToolNames = new Set(['create_event', 'update_event', 'delete_event'])
|
|
const _seenCalendarToolIdx = new Set<number>()
|
|
watch(() => store.streamingToolCalls, (calls) => {
|
|
calls.forEach((tc, i) => {
|
|
if (!_seenCalendarToolIdx.has(i) && _calendarToolNames.has(tc.function) && tc.status === 'success') {
|
|
_seenCalendarToolIdx.add(i)
|
|
document.dispatchEvent(new CustomEvent('fable:calendar-changed'))
|
|
}
|
|
})
|
|
}, { deep: true })
|
|
watch(() => store.streaming, (s) => { if (!s) _seenCalendarToolIdx.clear() })
|
|
|
|
// ── Note context (full variant — included / suggested / auto-injected notes) ──
|
|
const includedNoteIds = ref<Set<number>>(new Set())
|
|
const includedNotes = ref<{ id: number; title: string }[]>([])
|
|
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([])
|
|
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([])
|
|
const excludedNoteIds = ref<number[]>([])
|
|
|
|
watch(
|
|
() => store.lastContextMeta,
|
|
(meta) => {
|
|
if (!meta || props.variant !== 'full') return
|
|
const alreadyIncluded = includedNoteIds.value
|
|
const alreadyAutoInjected = new Set(autoInjectedNotes.value.map((n) => n.id))
|
|
const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id))
|
|
|
|
for (const note of meta.auto_injected_notes ?? []) {
|
|
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
|
autoInjectedNotes.value.push(note)
|
|
alreadyAutoInjected.add(note.id)
|
|
}
|
|
}
|
|
for (const note of meta.auto_notes ?? []) {
|
|
if (note.auto_injected) {
|
|
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
|
|
autoInjectedNotes.value.push(note)
|
|
alreadyAutoInjected.add(note.id)
|
|
}
|
|
} else {
|
|
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id) && !alreadyAutoInjected.has(note.id)) {
|
|
suggestedNotes.value.push(note)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
function includeNote(note: { id: number; title: string }) {
|
|
if (includedNoteIds.value.has(note.id)) return
|
|
includedNoteIds.value = new Set([...includedNoteIds.value, note.id])
|
|
includedNotes.value.push(note)
|
|
suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id)
|
|
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== note.id)
|
|
}
|
|
|
|
function excludeAutoNote(noteId: number) {
|
|
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== noteId)
|
|
if (!excludedNoteIds.value.includes(noteId)) {
|
|
excludedNoteIds.value = [...excludedNoteIds.value, noteId]
|
|
}
|
|
}
|
|
|
|
function removeIncludedNote(noteId: number) {
|
|
includedNoteIds.value = new Set([...includedNoteIds.value].filter((id) => id !== noteId))
|
|
const removed = includedNotes.value.find((n) => n.id === noteId)
|
|
includedNotes.value = includedNotes.value.filter((n) => n.id !== noteId)
|
|
if (removed && !suggestedNotes.value.some((n) => n.id === noteId)) {
|
|
suggestedNotes.value.push(removed)
|
|
}
|
|
}
|
|
|
|
const contextCount = computed(
|
|
() => autoInjectedNotes.value.length + suggestedNotes.value.length + includedNotes.value.length
|
|
)
|
|
|
|
const hasContextData = computed(
|
|
() => props.variant === 'full' && !props.briefingMode && !props.projectId && contextCount.value > 0
|
|
)
|
|
|
|
// ── Narrow-viewport sidebar toggle ────────────────────────────────────────────
|
|
const NARROW_BREAKPOINT = 1200
|
|
const isNarrow = ref(typeof window !== 'undefined' && window.innerWidth < NARROW_BREAKPOINT)
|
|
const sidebarOpen = ref(false)
|
|
|
|
function onResize() {
|
|
isNarrow.value = window.innerWidth < NARROW_BREAKPOINT
|
|
}
|
|
onMounted(() => window.addEventListener('resize', onResize))
|
|
onUnmounted(() => window.removeEventListener('resize', onResize))
|
|
|
|
function toggleContextSidebar() {
|
|
sidebarOpen.value = !sidebarOpen.value
|
|
}
|
|
|
|
const hasContextSidebar = computed(() => hasContextData.value && sidebarOpen.value)
|
|
|
|
// ── Collapsible sections (per-conversation, localStorage) ─────────────────────
|
|
type SectionKey = 'auto' | 'suggested' | 'included'
|
|
const collapsedSections = ref<Set<SectionKey>>(new Set())
|
|
|
|
function storageKey(): string | null {
|
|
const id = store.currentConversation?.id
|
|
return id ? `fa_chat_ctx_collapsed_${id}` : null
|
|
}
|
|
|
|
function loadCollapsed() {
|
|
const key = storageKey()
|
|
if (!key) { collapsedSections.value = new Set(); return }
|
|
try {
|
|
const raw = localStorage.getItem(key)
|
|
if (!raw) { collapsedSections.value = new Set(); return }
|
|
const arr = JSON.parse(raw) as SectionKey[]
|
|
collapsedSections.value = new Set(arr)
|
|
} catch {
|
|
collapsedSections.value = new Set()
|
|
}
|
|
}
|
|
|
|
function saveCollapsed() {
|
|
const key = storageKey()
|
|
if (!key) return
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify([...collapsedSections.value]))
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
function toggleSection(key: SectionKey) {
|
|
const next = new Set(collapsedSections.value)
|
|
if (next.has(key)) next.delete(key)
|
|
else next.add(key)
|
|
collapsedSections.value = next
|
|
saveCollapsed()
|
|
}
|
|
|
|
watch(() => store.currentConversation?.id, () => loadCollapsed(), { immediate: true })
|
|
|
|
// ── Empty-state (full variant, fresh/empty conversation) ─────────────────────
|
|
const showEmptyState = computed(
|
|
() =>
|
|
props.variant === 'full'
|
|
&& !props.briefingMode
|
|
&& !props.projectId
|
|
&& !store.streaming
|
|
&& (store.currentConversation?.messages.length ?? 0) === 0
|
|
)
|
|
|
|
const recentConversations = computed(() => {
|
|
const currentId = store.currentConversation?.id
|
|
return store.conversations
|
|
.filter((c) => c.id !== currentId && c.message_count > 0)
|
|
.slice(0, 5)
|
|
})
|
|
|
|
function startVoiceMode() {
|
|
window.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
|
}
|
|
|
|
// ── Send ──────────────────────────────────────────────────────────────────────
|
|
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null)
|
|
|
|
async function onSubmit(payload: { content: string; contextNoteId?: number }) {
|
|
if (props.variant === 'widget') {
|
|
await widgetSend(payload)
|
|
return
|
|
}
|
|
if (!store.currentConversation) return
|
|
await store.sendMessage(
|
|
payload.content,
|
|
payload.contextNoteId,
|
|
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
|
|
false,
|
|
undefined,
|
|
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
|
|
props.projectId ?? store.ragProjectId,
|
|
props.projectId ?? undefined,
|
|
)
|
|
scrollToBottom()
|
|
nextTick(() => inputBarRef.value?.focus())
|
|
}
|
|
|
|
// ── Widget state ──────────────────────────────────────────────────────────────
|
|
const widgetConvId = ref<number | null>(null)
|
|
const widgetQuery = ref('')
|
|
const widgetDone = ref(false)
|
|
const widgetFinalContent = ref('')
|
|
const widgetFinalToolCalls = ref<ToolCallRecord[]>([])
|
|
|
|
const isConversational = computed(
|
|
() => widgetDone.value && widgetFinalToolCalls.value.length === 0
|
|
)
|
|
|
|
function clearWidget() {
|
|
widgetConvId.value = null
|
|
widgetDone.value = false
|
|
widgetQuery.value = ''
|
|
widgetFinalContent.value = ''
|
|
widgetFinalToolCalls.value = []
|
|
}
|
|
|
|
async function widgetSend(payload: { content: string; contextNoteId?: number }) {
|
|
widgetConvId.value = null
|
|
widgetDone.value = false
|
|
widgetFinalContent.value = ''
|
|
widgetFinalToolCalls.value = []
|
|
widgetQuery.value = payload.content
|
|
|
|
const conv = await store.createConversation()
|
|
await store.fetchConversation(conv.id)
|
|
widgetConvId.value = conv.id
|
|
emit('conversation-started', conv.id)
|
|
|
|
await store.sendMessage(payload.content, payload.contextNoteId, undefined, false)
|
|
|
|
const msgs = store.currentConversation?.messages ?? []
|
|
const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === 'assistant')
|
|
widgetFinalContent.value = lastAssistant?.content ?? ''
|
|
widgetFinalToolCalls.value = (lastAssistant?.tool_calls ?? []) as ToolCallRecord[]
|
|
widgetDone.value = true
|
|
}
|
|
|
|
// ── Save as note ──────────────────────────────────────────────────────────────
|
|
async function handleSaveAsNote(messageId: number) {
|
|
try {
|
|
await store.saveMessageAsNote(messageId)
|
|
const { useToastStore } = await import('@/stores/toast')
|
|
useToastStore().show('Saved as note')
|
|
} catch {
|
|
const { useToastStore } = await import('@/stores/toast')
|
|
useToastStore().show('Failed to save as note', 'error')
|
|
}
|
|
}
|
|
|
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
|
onMounted(() => {
|
|
if (props.autoFocus) nextTick(() => inputBarRef.value?.focus())
|
|
})
|
|
|
|
// ── Exposed ───────────────────────────────────────────────────────────────────
|
|
function focus() {
|
|
inputBarRef.value?.focus()
|
|
}
|
|
|
|
function prefill(text: string) {
|
|
inputBarRef.value?.prefill(text)
|
|
}
|
|
|
|
async function send(text: string) {
|
|
await onSubmit({ content: text })
|
|
}
|
|
|
|
// ── Briefing slot separator helper ────────────────────────────────────────────
|
|
function hasEarlierBriefingSlot(index: number): boolean {
|
|
const msgs = store.currentConversation?.messages ?? []
|
|
for (let i = 0; i < index; i++) {
|
|
const m = msgs[i]
|
|
const meta = m?.metadata as Record<string, unknown> | null | undefined
|
|
if (
|
|
m?.role === 'assistant' &&
|
|
meta?.briefing_slot != null &&
|
|
!meta?.briefing_intermediate
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSidebar, hasContextData })
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ═══════════════════════════════ FULL VARIANT ══════════════════════════════ -->
|
|
<template v-if="variant === 'full'">
|
|
<div class="chat-full">
|
|
<!-- Message list -->
|
|
<div ref="messagesEl" class="messages-container">
|
|
<div class="messages-inner">
|
|
<template
|
|
v-for="(msg, index) in store.currentConversation?.messages ?? []"
|
|
:key="msg.id"
|
|
>
|
|
<!-- Briefing slot separator: before any non-first slot message (skip intermediate tool-call rows) -->
|
|
<div
|
|
v-if="briefingMode && msg.role === 'assistant' && msg.metadata?.briefing_slot && !msg.metadata?.briefing_intermediate && hasEarlierBriefingSlot(index)"
|
|
class="briefing-slot-separator"
|
|
></div>
|
|
<ChatMessage
|
|
:message="msg"
|
|
@save-as-note="handleSaveAsNote"
|
|
/>
|
|
</template>
|
|
<!-- Streaming bubble -->
|
|
<ChatStreamingBubble v-if="store.streaming" />
|
|
<!-- Queued messages -->
|
|
<template v-if="store.queuedMessages.length">
|
|
<div
|
|
v-for="(q, i) in store.queuedMessages"
|
|
:key="`queued-${i}`"
|
|
class="chat-message role-user queued-message"
|
|
>
|
|
<div class="message-bubble queued-bubble">
|
|
<div class="queued-badge">Queued</div>
|
|
<div class="message-content">{{ q.content }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="queued-clear-row">
|
|
<button class="queued-clear-btn" @click="store.clearQueue()">
|
|
Cancel {{ store.queuedMessages.length }} queued
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<div v-if="showEmptyState" class="chat-empty-state">
|
|
<h2 class="empty-greeting">What's on your mind?</h2>
|
|
<div v-if="recentConversations.length" class="empty-recent">
|
|
<div class="empty-section-label">Jump back in</div>
|
|
<router-link
|
|
v-for="conv in recentConversations"
|
|
:key="conv.id"
|
|
:to="`/chat/${conv.id}`"
|
|
class="empty-recent-item"
|
|
>
|
|
<span class="empty-recent-title">{{ conv.title || 'Untitled conversation' }}</span>
|
|
<span class="empty-recent-count">{{ conv.message_count }} msg</span>
|
|
</router-link>
|
|
</div>
|
|
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
|
|
<Mic class="voice-icon" :size="16" />
|
|
<span>Start in voice mode</span>
|
|
</button>
|
|
</div>
|
|
<p
|
|
v-else-if="!store.currentConversation?.messages.length && !store.streaming"
|
|
class="empty-msg"
|
|
>Start a conversation.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Context sidebar (full, non-briefing, non-workspace) -->
|
|
<aside v-if="hasContextSidebar" class="context-sidebar">
|
|
<template v-if="autoInjectedNotes.length">
|
|
<button
|
|
class="context-sidebar-header"
|
|
:class="{ collapsed: collapsedSections.has('auto') }"
|
|
@click="toggleSection('auto')"
|
|
>
|
|
<ChevronDown class="chev" :size="16" />
|
|
<span>Auto-included</span>
|
|
<span class="section-count">{{ autoInjectedNotes.length }}</span>
|
|
</button>
|
|
<div v-show="!collapsedSections.has('auto')">
|
|
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
|
|
<span class="auto-pill" title="Auto-included by relevance">AUTO</span>
|
|
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
|
<span
|
|
v-if="note.score != null"
|
|
class="context-note-score"
|
|
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
|
>{{ Math.round(note.score * 100) }}%</span>
|
|
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context"><X :size="16" /></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-if="suggestedNotes.length">
|
|
<button
|
|
class="context-sidebar-header"
|
|
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
|
|
@click="toggleSection('suggested')"
|
|
>
|
|
<ChevronDown class="chev" :size="16" />
|
|
<span>Suggested</span>
|
|
<span class="section-count">{{ suggestedNotes.length }}</span>
|
|
</button>
|
|
<div v-show="!collapsedSections.has('suggested')">
|
|
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
|
|
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
|
<span
|
|
v-if="note.score != null"
|
|
class="context-note-score"
|
|
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
|
>{{ Math.round(note.score * 100) }}%</span>
|
|
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-if="includedNotes.length">
|
|
<button
|
|
class="context-sidebar-header"
|
|
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
|
|
@click="toggleSection('included')"
|
|
>
|
|
<ChevronDown class="chev" :size="16" />
|
|
<span>In Context</span>
|
|
<span class="section-count">{{ includedNotes.length }}</span>
|
|
</button>
|
|
<div v-show="!collapsedSections.has('included')">
|
|
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
|
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
|
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context"><X :size="16" /></button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</aside>
|
|
|
|
<!-- Input area (hidden when readOnly) -->
|
|
<div v-if="!readOnly" class="input-wrapper">
|
|
<!-- Unified input bar -->
|
|
<ChatInputBar
|
|
ref="inputBarRef"
|
|
:placeholder="placeholder"
|
|
:briefing-mode="briefingMode"
|
|
@submit="onSubmit"
|
|
@abort="store.cancelGeneration()"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ══════════════════════════════ WIDGET VARIANT ══════════════════════════════ -->
|
|
<template v-else>
|
|
<ChatInputBar
|
|
ref="inputBarRef"
|
|
pill
|
|
:placeholder="placeholder ?? 'Start a new chat… (Enter to send)'"
|
|
:briefing-mode="false"
|
|
@submit="onSubmit"
|
|
@abort="store.cancelGeneration()"
|
|
/>
|
|
|
|
<!-- Inline response area -->
|
|
<div v-if="widgetConvId" class="widget-response">
|
|
<div class="widget-query">{{ widgetQuery }}</div>
|
|
|
|
<!-- Tool calls -->
|
|
<div v-if="store.streaming && store.streamingToolCalls.length" class="widget-tool-calls">
|
|
<ToolCallCard v-for="(tc, i) in store.streamingToolCalls" :key="i" :tool-call="tc" />
|
|
</div>
|
|
<div v-else-if="widgetDone && widgetFinalToolCalls.length" class="widget-tool-calls">
|
|
<ToolCallCard v-for="(tc, i) in widgetFinalToolCalls" :key="i" :tool-call="tc" />
|
|
</div>
|
|
|
|
<!-- Streaming / final text -->
|
|
<div v-if="store.streaming" class="widget-text streaming">
|
|
<div v-if="store.streamingStatus && !store.streamingContent" class="widget-status">
|
|
<span class="widget-status-dot"></span>{{ store.streamingStatus }}
|
|
</div>
|
|
<span v-else-if="store.streamingContent">{{ store.streamingContent }}</span>
|
|
<span v-else class="thinking-dots">...</span>
|
|
</div>
|
|
<div v-else-if="widgetDone && widgetFinalContent" class="widget-text">
|
|
{{ widgetFinalContent }}
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="widget-actions" :class="{ conversational: isConversational }">
|
|
<router-link
|
|
:to="`/chat/${widgetConvId}`"
|
|
class="btn-open-chat"
|
|
:class="{ prominent: isConversational }"
|
|
>{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}</router-link>
|
|
<button class="btn-clear-response" @click="clearWidget">Clear</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ── Full variant layout ──
|
|
* Single grid owns the reading column + context sidebar + input bar so
|
|
* messages and input bar share one centered reading track while the
|
|
* context sidebar spans the full height from header to bottom of view.
|
|
* Reading column is always centered (3-column grid: 1fr | content | 1fr).
|
|
* The context sidebar overlays the right gutter so it never shifts layout.
|
|
*/
|
|
.chat-full {
|
|
position: relative;
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns:
|
|
1fr
|
|
minmax(0, var(--chat-reading-width))
|
|
1fr;
|
|
grid-template-rows: minmax(0, 1fr) auto;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.messages-container {
|
|
grid-column: 2;
|
|
grid-row: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem 1rem 0.75rem;
|
|
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
|
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
|
}
|
|
.messages-inner {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
/* Briefing slot separator */
|
|
.briefing-slot-separator {
|
|
height: 1px;
|
|
margin: 1.25rem 0 0.75rem;
|
|
background: var(--color-border, #333);
|
|
opacity: 0.35;
|
|
}
|
|
|
|
/* Context sidebar — overlays right gutter, never shifts reading column */
|
|
.context-sidebar {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
width: var(--chat-context-sidebar-width);
|
|
border-left: 1px solid var(--color-border);
|
|
padding: 0.75rem 0.5rem;
|
|
overflow-y: auto;
|
|
background: var(--color-bg-secondary);
|
|
font-size: 0.78rem;
|
|
z-index: 2;
|
|
}
|
|
.context-sidebar-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
width: 100%;
|
|
background: none;
|
|
border: none;
|
|
padding: 0.15rem 0.1rem;
|
|
font-family: inherit;
|
|
font-size: 0.65rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-muted);
|
|
margin-bottom: 0.35rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.context-sidebar-header:hover { color: var(--color-text); }
|
|
.context-sidebar-header .chev {
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.context-sidebar-header.collapsed .chev {
|
|
transform: rotate(-90deg);
|
|
}
|
|
.context-sidebar-header .section-count {
|
|
margin-left: auto;
|
|
color: var(--color-text-muted);
|
|
font-weight: 500;
|
|
background: var(--color-bg);
|
|
padding: 0.05rem 0.3rem;
|
|
border-radius: 8px;
|
|
}
|
|
.context-sidebar-header-gap { margin-top: 0.75rem; }
|
|
.context-note {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
margin-bottom: 0.25rem;
|
|
padding: 0.2rem 0.35rem;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.context-note-auto {
|
|
background: color-mix(in srgb, var(--color-text-muted) 8%, transparent);
|
|
opacity: 0.85;
|
|
}
|
|
.context-note-included {
|
|
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
border-left: 2px solid var(--color-primary);
|
|
}
|
|
.auto-pill {
|
|
font-size: 0.55rem;
|
|
font-weight: 500;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.05rem 0.3rem;
|
|
border-radius: 4px;
|
|
background: var(--color-bg);
|
|
color: var(--color-text-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
.context-note-name {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--color-text);
|
|
text-decoration: none;
|
|
font-size: 0.78rem;
|
|
}
|
|
.context-note-name:hover { color: var(--color-primary); }
|
|
.context-note-score {
|
|
font-size: 0.65rem;
|
|
padding: 0.05rem 0.25rem;
|
|
border-radius: 4px;
|
|
background: var(--color-bg);
|
|
}
|
|
.score-high { color: #22c55e; }
|
|
.score-medium { color: #f59e0b; }
|
|
.score-low { color: var(--color-text-muted); }
|
|
.context-note-remove, .context-note-add {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
line-height: 1;
|
|
padding: 0 0.1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.context-note-remove:hover { color: #ef4444; }
|
|
.context-note-add:hover { color: var(--color-primary); }
|
|
|
|
/* Input wrapper — lives in the same reading column as messages */
|
|
.input-wrapper {
|
|
grid-column: 2;
|
|
grid-row: 2;
|
|
padding: 0.5rem 1rem 0.75rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
/* Queued messages */
|
|
.queued-message { opacity: 0.45; }
|
|
.queued-bubble {
|
|
background: var(--color-bg-secondary);
|
|
border: 1px dashed var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
.queued-badge {
|
|
font-size: 0.65rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-muted);
|
|
font-weight: 500;
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
.queued-clear-row {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 0.25rem 0;
|
|
}
|
|
.queued-clear-btn {
|
|
background: none;
|
|
border: none;
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.empty-msg {
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.9rem;
|
|
text-align: center;
|
|
padding: 2rem 1rem;
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
}
|
|
|
|
.chat-empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 1.5rem;
|
|
padding: 3rem 1rem 2rem;
|
|
max-width: 520px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.empty-greeting {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-weight: 400;
|
|
font-size: 1.75rem;
|
|
color: var(--color-text-secondary);
|
|
text-align: center;
|
|
margin: 0;
|
|
}
|
|
|
|
.empty-section-label {
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--color-text-muted);
|
|
margin-bottom: 0.5rem;
|
|
padding-left: 0.25rem;
|
|
}
|
|
|
|
.empty-recent {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.empty-recent-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 0.9rem;
|
|
border-radius: var(--radius-md, 10px);
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-bg-elevated, rgba(255, 255, 255, 0.02));
|
|
color: var(--color-text);
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
|
}
|
|
|
|
.empty-recent-item + .empty-recent-item {
|
|
margin-top: 0.4rem;
|
|
}
|
|
|
|
.empty-recent-item:hover {
|
|
border-color: var(--color-primary);
|
|
background: rgba(91, 74, 138, 0.05);
|
|
transform: translateX(2px);
|
|
}
|
|
|
|
.empty-recent-title {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.empty-recent-count {
|
|
font-size: 0.7rem;
|
|
color: var(--color-text-muted);
|
|
font-variant-numeric: tabular-nums;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.empty-voice-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.6rem;
|
|
padding: 0.75rem 1.2rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--color-border);
|
|
background: transparent;
|
|
color: var(--color-text);
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
align-self: center;
|
|
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
|
}
|
|
|
|
.empty-voice-btn:hover {
|
|
border-color: var(--color-primary);
|
|
background: linear-gradient(135deg, #5B4A8A, #3F3560);
|
|
color: #fff;
|
|
box-shadow: 0 4px 16px rgba(91, 74, 138, 0.35);
|
|
}
|
|
|
|
.empty-voice-btn .voice-icon {
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
/* ── Widget variant ── */
|
|
.widget-response {
|
|
margin-top: 0.75rem;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 0.75rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.widget-query {
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.widget-text {
|
|
font-size: 0.9rem;
|
|
line-height: 1.55;
|
|
color: var(--color-text);
|
|
}
|
|
.widget-text.streaming { color: var(--color-text-muted); }
|
|
.thinking-dots { color: var(--color-text-muted); letter-spacing: 0.2em; }
|
|
.widget-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.widget-status-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-primary);
|
|
animation: blink 1s infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
@keyframes blink { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
|
|
.widget-tool-calls { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
.widget-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding-top: 0.25rem;
|
|
border-top: 1px solid var(--color-border);
|
|
flex-wrap: wrap;
|
|
}
|
|
.btn-open-chat {
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
text-decoration: none;
|
|
}
|
|
.btn-open-chat:hover { color: var(--color-primary); }
|
|
.btn-open-chat.prominent {
|
|
font-size: 0.9rem;
|
|
color: var(--color-primary);
|
|
font-weight: 500;
|
|
}
|
|
.btn-clear-response {
|
|
background: none;
|
|
border: none;
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
margin-left: auto;
|
|
}
|
|
.btn-clear-response:hover { color: var(--color-text); }
|
|
|
|
/* Streaming bubble styles — applied via :deep to ChatStreamingBubble children */
|
|
:deep(.streaming-bubble) {
|
|
max-width: 85%;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 16px 16px 16px 4px;
|
|
background: var(--color-bg-card);
|
|
border-left: 2px solid var(--color-primary);
|
|
box-shadow: 0 1px 4px var(--color-shadow);
|
|
}
|
|
:deep(.streaming-tool-calls) { margin-bottom: 0.5rem; }
|
|
:deep(.streaming-status-line) {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
:deep(.streaming-status-dot) {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-primary);
|
|
animation: blink 1s infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
:deep(.thinking-block) {
|
|
margin-bottom: 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
}
|
|
:deep(.thinking-summary) {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
list-style: none;
|
|
background: var(--color-bg-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
:deep(.thinking-summary::-webkit-details-marker) { display: none; }
|
|
:deep(.thinking-text) {
|
|
margin: 0;
|
|
padding: 0.5rem;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-secondary);
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
background: var(--color-bg-secondary);
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
:deep(.typing-indicator) {
|
|
display: inline-block;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-primary);
|
|
animation: blink 1s infinite;
|
|
margin-left: 4px;
|
|
vertical-align: middle;
|
|
}
|
|
</style>
|