MessageTimeline.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. <script setup lang="ts">
  2. import { computed, nextTick, onMounted, ref, watch } from 'vue'
  3. import AppIcon from '@/components/common/AppIcon.vue'
  4. import EmptyState from '@/components/common/EmptyState.vue'
  5. import InteractionCard from './InteractionCard.vue'
  6. import MarkdownContent from './MarkdownContent.vue'
  7. import ToolTrace from './ToolTrace.vue'
  8. import TurnTrace from './TurnTrace.vue'
  9. import type { UiInteraction, UiLocalFileLink, UiMessage } from './types'
  10. import { buildMessageTimelineRows } from '@/utils/turnTrace'
  11. import { displayContentForMessage } from '@/utils/messageDisplay'
  12. const props = withDefaults(defineProps<{
  13. messages: UiMessage[]
  14. title?: string
  15. subtitle?: string
  16. emptyTitle?: string
  17. emptyDescription?: string
  18. emptyLogo?: boolean
  19. transparentHeader?: boolean
  20. forkSourceTitle?: string
  21. loading?: boolean
  22. loadingOlder?: boolean
  23. hasOlder?: boolean
  24. connected?: boolean
  25. synced?: boolean
  26. showTools?: boolean
  27. showAssistantIdentity?: boolean
  28. interaction?: UiInteraction | null
  29. mentionNames?: string[]
  30. }>(), {
  31. title: '',
  32. subtitle: '',
  33. emptyTitle: '开始一段新对话',
  34. emptyDescription: '从下方输入框发送消息,或选择左侧的历史会话。',
  35. emptyLogo: false,
  36. transparentHeader: false,
  37. forkSourceTitle: '',
  38. loading: false,
  39. loadingOlder: false,
  40. hasOlder: false,
  41. connected: false,
  42. synced: false,
  43. showTools: true,
  44. showAssistantIdentity: true,
  45. interaction: null,
  46. mentionNames: () => [],
  47. })
  48. const emit = defineEmits<{
  49. loadOlder: []
  50. quote: [message: UiMessage]
  51. branch: [message: UiMessage]
  52. preview: [attachment: NonNullable<UiMessage['attachments']>[number]]
  53. previewFile: [file: UiLocalFileLink]
  54. approve: [approved: boolean]
  55. clarify: [text: string]
  56. }>()
  57. const scroller = ref<HTMLElement | null>(null)
  58. const pinnedToBottom = ref(true)
  59. const showJump = ref(false)
  60. const timelineRows = computed(() => buildMessageTimelineRows(props.messages))
  61. function formatTime(value?: string | number | Date) {
  62. if (!value) return ''
  63. const date = value instanceof Date ? value : new Date(value)
  64. if (Number.isNaN(date.getTime())) return ''
  65. return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date)
  66. }
  67. function delegationSummary(metadata?: Record<string, unknown>): string {
  68. const total = Number(metadata?.task_count ?? 0)
  69. const completed = Number(metadata?.completed_count ?? 0)
  70. const failed = Number(metadata?.failed_count ?? 0)
  71. const seconds = Number(metadata?.duration_seconds ?? 0)
  72. const duration = seconds > 0 ? ` · ${seconds < 60 ? `${Math.round(seconds)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`}` : ''
  73. return `${total || completed + failed || 1} 个子任务 · ${completed} 已完成${failed ? ` · ${failed} 失败` : ''}${duration}`
  74. }
  75. function systemSummary(content: string): string {
  76. const model = content.match(/(?:changed to|切换为)\s*([^\s,。\]]+?)(?=\s+via\b|\s*$|\])/i)?.[1]
  77. if (model) return `模型已切换:${model}`
  78. return '系统信息'
  79. }
  80. function isCompactionEvent(message: UiMessage): boolean {
  81. return message.timelineMetadata?.eventKind === 'compaction'
  82. || message.content.trim().toLocaleLowerCase().startsWith('[context compaction')
  83. }
  84. function onScroll() {
  85. const el = scroller.value
  86. if (!el) return
  87. const distance = el.scrollHeight - el.scrollTop - el.clientHeight
  88. pinnedToBottom.value = distance < 100
  89. showJump.value = distance > 240
  90. }
  91. function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
  92. const el = scroller.value
  93. if (!el) return
  94. el.scrollTo({ top: el.scrollHeight, behavior })
  95. }
  96. function scrollToMessage(id: string): boolean {
  97. const root = scroller.value
  98. if (!root) return false
  99. const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(id)}"]`)
  100. if (!message) return false
  101. message.scrollIntoView({ block: 'center', behavior: 'smooth' })
  102. message.classList.remove('message--revealed')
  103. requestAnimationFrame(() => message.classList.add('message--revealed'))
  104. window.setTimeout(() => message.classList.remove('message--revealed'), 1800)
  105. return true
  106. }
  107. function scrollToAnchor(messageId: string, anchorId: string): boolean {
  108. const root = scroller.value
  109. if (!root) return false
  110. const message = root.querySelector<HTMLElement>(`[data-message-id="${CSS.escape(messageId)}"]`)
  111. if (!message) return false
  112. const target = anchorId ? message.querySelector<HTMLElement>(`#${CSS.escape(anchorId)}`) : null
  113. ;(target || message).scrollIntoView({ block: target ? 'start' : 'center', behavior: 'smooth' })
  114. message.classList.remove('message--revealed')
  115. requestAnimationFrame(() => message.classList.add('message--revealed'))
  116. window.setTimeout(() => message.classList.remove('message--revealed'), 1800)
  117. return true
  118. }
  119. async function copyMessage(message: UiMessage) {
  120. await navigator.clipboard.writeText(displayContentForMessage(message.role, message.content))
  121. }
  122. function hasConversationActions(message: UiMessage): boolean {
  123. return message.role === 'user' || (message.role === 'assistant' && Boolean(message.content.trim()))
  124. }
  125. watch(() => props.messages.length, async () => {
  126. if (!pinnedToBottom.value) return
  127. await nextTick()
  128. scrollToBottom('auto')
  129. })
  130. watch(() => props.messages.at(-1)?.content, async () => {
  131. if (!pinnedToBottom.value) return
  132. await nextTick()
  133. scrollToBottom('auto')
  134. })
  135. onMounted(() => nextTick(() => scrollToBottom('auto')))
  136. defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
  137. </script>
  138. <template>
  139. <section class="timeline-frame" :class="{ 'timeline-frame--transparent': transparentHeader }">
  140. <header v-if="title || transparentHeader" class="timeline-header" :class="{ 'timeline-header--transparent': transparentHeader }">
  141. <div v-if="!transparentHeader"><h2>{{ title }}</h2><p v-if="subtitle">{{ subtitle }}</p></div>
  142. <div v-if="!transparentHeader" class="timeline-state" :title="connected ? (synced ? '实时连接与历史均已同步' : '实时连接已就绪,历史同步中') : '实时连接未就绪'">
  143. <span class="status-dot" :class="connected ? (synced ? 'status-dot--online' : 'status-dot--working') : ''" />
  144. {{ connected ? (synced ? '已同步' : '同步中') : '离线' }}
  145. </div>
  146. <slot name="header-actions" />
  147. </header>
  148. <div ref="scroller" class="timeline" @scroll.passive="onScroll">
  149. <div v-if="loading && !messages.length" class="message-skeletons" aria-label="正在加载消息">
  150. <div v-for="n in 4" :key="n" :class="{ user: n % 2 === 0 }"><i /><span /></div>
  151. </div>
  152. <div v-else-if="!messages.length && emptyLogo" class="new-chat-empty">
  153. <img class="new-chat-empty__logo" src="/brand/AppIcon-1024.png" alt="" aria-hidden="true" />
  154. <p>聊点什么</p>
  155. </div>
  156. <EmptyState v-else-if="!messages.length" icon="chat" :title="emptyTitle" :description="emptyDescription" />
  157. <div v-else class="message-stack">
  158. <div v-if="forkSourceTitle" class="fork-divider" role="separator">
  159. <span><AppIcon name="branch" :size="13" />FORK 来源 <strong>{{ forkSourceTitle }}</strong></span>
  160. </div>
  161. <button v-if="hasOlder" class="load-older" type="button" :disabled="loadingOlder" @click="emit('loadOlder')">
  162. {{ loadingOlder ? '正在加载…' : '加载更早消息' }}
  163. </button>
  164. <template v-for="row in timelineRows" :key="row.id">
  165. <TurnTrace v-if="row.kind === 'trace' && showTools" :group="row" />
  166. <template v-else-if="row.kind === 'message'">
  167. <article
  168. v-for="message in [row.message]"
  169. :key="message.id"
  170. :data-message-id="message.id"
  171. class="message"
  172. :class="[`message--${message.role}`, {
  173. 'message--failed': message.status === 'failed',
  174. 'message--tool-only': message.role === 'assistant' && !message.content && !message.reasoning && Boolean(message.tools?.length),
  175. 'message--assistant-internal': message.role === 'assistant' && !message.content.trim(),
  176. 'message--assistant-anonymous': message.role === 'assistant' && !showAssistantIdentity,
  177. }]"
  178. >
  179. <div v-if="message.role !== 'user' && (message.role !== 'assistant' || showAssistantIdentity)" class="message__avatar">{{ (message.author || message.profile || (message.role === 'assistant' ? '夭' : '系')).slice(0, 1).toUpperCase() }}</div>
  180. <div class="message__body">
  181. <template v-if="message.timelineKind === 'delegation-complete'">
  182. <details class="delegation-event">
  183. <summary><AppIcon name="groups" :size="14" /><span><strong>子任务已完成</strong><small>{{ delegationSummary(message.timelineMetadata) }}</small></span></summary>
  184. <MarkdownContent v-if="message.content" :content="message.content" />
  185. </details>
  186. </template>
  187. <template v-else-if="message.timelineKind === 'system' && isCompactionEvent(message)">
  188. <details class="delegation-event compaction-event">
  189. <summary><AppIcon name="archive" :size="14" /><span><strong>上下文已压缩</strong><small>压缩摘要已归档 · 点击查看</small></span></summary>
  190. <MarkdownContent v-if="message.content" :content="message.content" />
  191. </details>
  192. </template>
  193. <template v-else-if="message.timelineKind === 'system'">
  194. <details class="system-event">
  195. <summary><AppIcon name="settings" :size="13" /><span>{{ systemSummary(message.content) }}</span></summary>
  196. <MarkdownContent v-if="message.content" :content="message.content" />
  197. </details>
  198. </template>
  199. <template v-else>
  200. <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
  201. <strong>{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? 'Agent' : '系统') }}</strong>
  202. <time>{{ formatTime(message.createdAt) }}</time>
  203. <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
  204. </div>
  205. <details v-if="message.reasoning" class="message__reasoning">
  206. <summary><AppIcon name="brain" :size="13" />思考过程 · {{ message.reasoning.length }} 字</summary>
  207. <MarkdownContent :content="message.reasoning" />
  208. </details>
  209. <div v-if="message.role === 'user' && message.attachments?.length" class="message__attachments">
  210. <button v-for="attachment in message.attachments" :key="attachment.id" type="button" @click="emit('preview', attachment)">
  211. <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
  212. <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
  213. <strong>{{ attachment.name }}</strong>
  214. </button>
  215. </div>
  216. <div class="message__content">
  217. <MarkdownContent
  218. :content="displayContentForMessage(message.role, message.content)"
  219. :streaming="message.status === 'streaming'"
  220. :legacy-media="message.role === 'assistant'"
  221. :plain="message.role === 'user'"
  222. :mention-names="mentionNames"
  223. :outline-prefix="message.role === 'assistant' ? `outline-${message.id}` : ''"
  224. file-cards
  225. @file-link="(name, url) => emit('previewFile', { name, url })"
  226. />
  227. </div>
  228. <div v-if="message.role !== 'user' && message.attachments?.length" class="message__attachments">
  229. <button v-for="attachment in message.attachments" :key="attachment.id" type="button" @click="emit('preview', attachment)">
  230. <img v-if="attachment.kind === 'image' && attachment.url" :src="attachment.url" :alt="attachment.name" />
  231. <span v-else><AppIcon :name="attachment.kind || 'file'" :size="17" /></span>
  232. <strong>{{ attachment.name }}</strong>
  233. </button>
  234. </div>
  235. <div v-if="showTools && message.tools?.length" class="message__tools">
  236. <ToolTrace v-for="tool in message.tools" :key="tool.id" :tool="tool" />
  237. </div>
  238. <p v-if="message.error" class="message__error"><AppIcon name="alert" :size="13" />{{ message.error }}</p>
  239. <div v-if="hasConversationActions(message)" class="message__actions">
  240. <time v-if="message.role === 'assistant' && !showAssistantIdentity && message.createdAt" class="message__action-time">{{ formatTime(message.createdAt) }}</time>
  241. <button type="button" title="复制" aria-label="复制消息" @click="copyMessage(message)"><AppIcon name="copy" :size="13" /></button>
  242. <button type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
  243. <button v-if="message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
  244. </div>
  245. </template>
  246. </div>
  247. </article>
  248. </template>
  249. </template>
  250. <InteractionCard
  251. v-if="interaction"
  252. :interaction="interaction"
  253. @approve="emit('approve', $event)"
  254. @clarify="emit('clarify', $event)"
  255. />
  256. </div>
  257. </div>
  258. <Transition name="view-fade">
  259. <button v-if="showJump" class="jump-bottom" type="button" @click="scrollToBottom()">
  260. <AppIcon name="chevron-down" :size="15" />回到底部
  261. </button>
  262. </Transition>
  263. </section>
  264. </template>
  265. <style scoped>
  266. .timeline-frame { position: relative; display: flex; min-width: 0; min-height: 0; flex: 1; flex-direction: column; }
  267. .timeline-header { display: flex; z-index: 6; min-height: 62px; align-items: center; gap: 13px; padding: 10px 18px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--canvas) 92%, transparent); backdrop-filter: blur(16px); }
  268. .timeline-header--transparent { position: absolute; top: 0; right: 0; left: 0; min-height: 0; justify-content: flex-end; padding: 11px 14px; border: 0; background: transparent; backdrop-filter: none; pointer-events: none; }
  269. .timeline-header--transparent > * { pointer-events: auto; }
  270. .timeline-header > div:first-child { min-width: 0; flex: 1; }.timeline-header h2 { overflow: hidden; margin: 0; font-size: 14px; font-weight: 630; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }.timeline-header p { overflow: hidden; margin: 3px 0 0; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
  271. .timeline-state { display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 9px; }
  272. .timeline { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; background: var(--conversation-canvas); }
  273. .message-stack { width: min(780px, 100%); min-height: 100%; margin: 0 auto; padding: 24px 24px 36px; }
  274. .timeline-frame--transparent .message-stack { padding-top: 58px; }
  275. .load-older { display: block; margin: 0 auto 18px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 9px; }.load-older:hover { background: var(--surface-soft); color: var(--text-secondary); }
  276. .message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
  277. .message__avatar { display: grid; place-items: center; width: 27px; height: 27px; flex: 0 0 27px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }
  278. .message__body { position: relative; min-width: 0; max-width: min(680px, calc(100% - 37px)); }
  279. .message__meta { display: flex; min-height: 20px; align-items: center; gap: 7px; color: var(--text-muted); font-size: 9px; }.message__meta strong { color: var(--text-primary); font-size: 11px; font-weight: 600; }.message__meta span { color: var(--warning); }
  280. .message__content { color: var(--text-primary); }
  281. .message--user { justify-content: flex-end; margin-top: 27px; }
  282. .message--user .message__body { max-width: min(610px, 76%); padding: 11px 15px; border-radius: 20px 20px 6px 20px; background: #eceff3; }
  283. .dark .message--user .message__body { background: #2d323a; }
  284. .message--user .message__meta { display: none; }
  285. .message--system { justify-content: center; }.message--system .message__avatar, .message--system .message__meta { display: none; }.message--system .message__body { max-width: 82%; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; color: var(--text-muted); text-align: center; }
  286. .delegation-event { min-width: 230px; max-width: 460px; text-align: left; }.delegation-event summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; }.delegation-event summary::-webkit-details-marker { display: none; }.delegation-event summary > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }.delegation-event strong { color: var(--text-secondary); font-size: 11px; font-weight: 650; }.delegation-event small { color: var(--text-muted); font-size: 9px; }.delegation-event :deep(.markdown) { max-height: 260px; margin-top: 9px; overflow: auto; color: var(--text-secondary); font-size: 10px; }
  287. .system-event { max-width: 460px; text-align: left; }.system-event summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; color: var(--text-muted); font-size: 10px; }.system-event summary::-webkit-details-marker { display: none; }.system-event :deep(.markdown) { max-height: 220px; margin-top: 8px; overflow: auto; color: var(--text-secondary); font-size: 10px; }
  288. .message--tool-only { margin-block: 4px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
  289. .message--assistant-internal { margin-bottom: 6px; }.message--assistant-internal .message__reasoning { margin-bottom: 4px; padding-bottom: 4px; }.message--assistant-internal .message__tools { margin-top: 4px; }
  290. .message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
  291. .message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 11px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 9px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }
  292. .fork-divider { display: flex; align-items: center; gap: 12px; margin: 8px 0 28px; color: var(--text-muted); font-size: 10px; }.fork-divider::before, .fork-divider::after { height: 1px; flex: 1; background: var(--line); content: ''; }.fork-divider > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); white-space: nowrap; }.fork-divider strong { max-width: 180px; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }
  293. .message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 10px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
  294. .message--user .message__attachments { margin: 0 0 9px; }.message--user .message__attachments button { width: 100%; max-width: none; min-height: 50px; padding: 7px 9px; border: 0; border-radius: 11px; background: rgba(255,255,255,.48); color: var(--text-primary); }.message--user .message__attachments button > span { width: 28px; height: 28px; background: rgba(255,255,255,.65); }.message--user .message__attachments strong { font-size: 11px; font-weight: 560; }.dark .message--user .message__attachments button { background: rgba(255,255,255,.08); }
  295. .message__tools { margin-top: 9px; }.message__error { display: flex; align-items: center; gap: 5px; margin: 7px 0 0; color: var(--danger); font-size: 9px; }
  296. .message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }
  297. .message--assistant-anonymous .message__actions { align-items: center; }.message__action-time { min-width: 36px; padding: 0 4px 0 3px; color: var(--text-muted); font-size: 9px; font-variant-numeric: tabular-nums; }
  298. .message--failed .message__body { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); }
  299. .message--revealed .message__body { animation: reveal-message 1.8s ease; }
  300. @keyframes reveal-message { 0%, 28% { box-shadow: 0 0 0 5px var(--focus-ring); } 100% { box-shadow: 0 0 0 0 transparent; } }
  301. .jump-bottom { display: flex; position: absolute; z-index: 8; bottom: 8px; left: 50%; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; font-size: 9px; box-shadow: 0 6px 18px rgba(0,0,0,.1); transform: translateX(-50%); }
  302. .new-chat-empty { display: flex; min-height: 100%; align-items: center; justify-content: center; gap: 18px; padding: 12vh 24px; box-sizing: border-box; flex-direction: column; color: var(--text-muted); text-align: center; }
  303. .new-chat-empty__logo { display: block; width: min(180px, 34vw); height: auto; opacity: .18; filter: grayscale(1); }
  304. .new-chat-empty p { margin: 0; color: var(--text-secondary); font-size: 17px; font-weight: 520; letter-spacing: .02em; }
  305. .message-skeletons { width: min(760px, 100%); margin: 0 auto; padding: 35px 24px; }.message-skeletons div { display: flex; gap: 10px; margin: 0 0 25px; }.message-skeletons div.user { justify-content: flex-end; }.message-skeletons i { width: 27px; height: 27px; border-radius: 9px; background: var(--surface-hover); }.message-skeletons span { width: min(480px, 70%); height: 64px; border-radius: 12px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }.message-skeletons .user i { display: none; }.message-skeletons .user span { width: 44%; height: 46px; }
  306. @keyframes shimmer { to { background-position: -200% 0; } }
  307. @media (max-width: 768px) {
  308. .timeline-header { min-height: 54px; padding: 8px 12px; }.timeline-state { font-size: 0; }.timeline-state .status-dot { width: 7px; height: 7px; }
  309. .new-chat-empty { gap: 15px; padding-block: 9vh; }.new-chat-empty__logo { width: min(150px, 42vw); }.new-chat-empty p { font-size: 16px; }
  310. .message-stack { padding: 18px 14px 30px; }.message { margin-bottom: 18px; }.message__body { max-width: calc(100% - 37px); }.message--user .message__body { max-width: 88%; }.message__content :deep(.markdown) { font-size: 13px; }
  311. .message__actions { opacity: .65; }.message__meta time { display: none; }
  312. }
  313. </style>