From 6e3f4b8d043c83cad9d3cb1412c468b87974d3bc Mon Sep 17 00:00:00 2001
From: gqt <3217233537@qq.com>
Date: Mon, 13 Jul 2026 12:00:01 +0800
Subject: [PATCH] feat: add streaming chat and evidence workspace
---
web/src/app/AppLayout.tsx | 2 +
web/src/app/router.tsx | 6 +-
web/src/features/chat/ChatComposer.tsx | 22 +++++++
web/src/features/chat/ChatPage.tsx | 61 +++++++++++++++++++
web/src/features/chat/EvidencePanel.tsx | 16 +++++
web/src/features/chat/MessageList.tsx | 28 +++++++++
web/src/features/chat/SessionList.tsx | 52 ++++++++++++++++
web/src/features/chat/api.ts | 25 ++++++++
web/src/features/chat/store.ts | 41 +++++++++++++
web/src/features/chat/useChatStream.ts | 39 ++++++++++++
web/src/index.css | 81 +++++++++++++++++++++++++
11 files changed, 370 insertions(+), 3 deletions(-)
create mode 100644 web/src/features/chat/ChatComposer.tsx
create mode 100644 web/src/features/chat/ChatPage.tsx
create mode 100644 web/src/features/chat/EvidencePanel.tsx
create mode 100644 web/src/features/chat/MessageList.tsx
create mode 100644 web/src/features/chat/SessionList.tsx
create mode 100644 web/src/features/chat/api.ts
create mode 100644 web/src/features/chat/store.ts
create mode 100644 web/src/features/chat/useChatStream.ts
diff --git a/web/src/app/AppLayout.tsx b/web/src/app/AppLayout.tsx
index 76f8aad..e018204 100644
--- a/web/src/app/AppLayout.tsx
+++ b/web/src/app/AppLayout.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { Button } from '../components/ui/button'
+import { SessionList } from '../features/chat/SessionList'
import { cn } from '../lib/utils'
function Navigation({ onNavigate }: { onNavigate?: () => void }) {
@@ -22,6 +23,7 @@ function Navigation({ onNavigate }: { onNavigate?: () => void }) {
文档资料库
+
严格知识库模式回答必须引用已索引资料
diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx
index 6e71c75..44ac9fd 100644
--- a/web/src/app/router.tsx
+++ b/web/src/app/router.tsx
@@ -1,7 +1,7 @@
import { Navigate, createBrowserRouter } from 'react-router-dom'
import { AppLayout } from './AppLayout'
-import { PlaceholderPage } from './PlaceholderPage'
+import { ChatPage } from '../features/chat/ChatPage'
import { DocumentsPage } from '../features/documents/DocumentsPage'
export const router = createBrowserRouter([
@@ -10,8 +10,8 @@ export const router = createBrowserRouter([
element:
,
children: [
{ index: true, element:
},
- { path: 'chat', element:
},
- { path: 'chat/:sessionId', element:
},
+ { path: 'chat', element:
},
+ { path: 'chat/:sessionId', element:
},
{ path: 'documents', element:
},
{ path: 'documents/:documentId', element:
},
],
diff --git a/web/src/features/chat/ChatComposer.tsx b/web/src/features/chat/ChatComposer.tsx
new file mode 100644
index 0000000..ba9334c
--- /dev/null
+++ b/web/src/features/chat/ChatComposer.tsx
@@ -0,0 +1,22 @@
+import { SendHorizontal, Square } from 'lucide-react'
+import { useState } from 'react'
+
+import { Button } from '../../components/ui/button'
+
+type Props = { streaming: boolean; onSend: (query: string) => void; onCancel: () => void }
+
+export function ChatComposer({ streaming, onSend, onCancel }: Props) {
+ const [query, setQuery] = useState('')
+ function submit() {
+ const value = query.trim()
+ if (!value || streaming) return
+ setQuery(''); onSend(value)
+ }
+ return (
+
+
+ )
+}
diff --git a/web/src/features/chat/ChatPage.tsx b/web/src/features/chat/ChatPage.tsx
new file mode 100644
index 0000000..05e6647
--- /dev/null
+++ b/web/src/features/chat/ChatPage.tsx
@@ -0,0 +1,61 @@
+import * as Dialog from '@radix-ui/react-dialog'
+import { PanelRightOpen, Radar, Sparkles } from 'lucide-react'
+import { useCallback, useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+
+import { Button } from '../../components/ui/button'
+import type { Message, Source } from '../../lib/types'
+import { announceSessionChange, createSession, listMessages } from './api'
+import { ChatComposer } from './ChatComposer'
+import { EvidencePanel } from './EvidencePanel'
+import { MessageList } from './MessageList'
+import { useChatStreamStore } from './store'
+import { useChatStream } from './useChatStream'
+
+export function ChatPage() {
+ const { sessionId } = useParams()
+ const navigate = useNavigate()
+ const [messages, setMessages] = useState
([])
+ const [evidenceOpen, setEvidenceOpen] = useState(false)
+ const stream = useChatStreamStore()
+
+ const refreshMessages = useCallback(async () => {
+ if (!sessionId) { setMessages([]); return }
+ const page = await listMessages(sessionId)
+ setMessages(page.items); announceSessionChange()
+ }, [sessionId])
+
+ useEffect(() => {
+ if (!sessionId) return
+ void listMessages(sessionId).then((page) => setMessages(page.items))
+ }, [sessionId])
+
+ const { send, cancel } = useChatStream(async () => { await refreshMessages(); useChatStreamStore.getState().reset() })
+
+ async function handleSend(query: string) {
+ let target = sessionId
+ if (!target) {
+ const session = await createSession(); target = session.id; navigate(`/chat/${target}`); announceSessionChange()
+ }
+ await send(target, query)
+ }
+
+ function chooseSource(source: Source, sources: Source[] = stream.sources) {
+ stream.setSources(sources)
+ stream.selectSource(source)
+ setEvidenceOpen(true)
+ }
+ const streaming = stream.phase === 'researching' || stream.phase === 'answering'
+
+ return (
+
+
+ KNOWLEDGE CONVERSATION
{sessionId ? '知识库对话' : '开始新研究'}
+ {stream.activeQuery &&
{stream.phase === 'researching' &&
研究 Agent 正在检索知识库…
}{stream.text &&
{stream.text}
}{stream.error &&
{stream.error}
}
}
+ void handleSend(query)} onCancel={cancel} />
+
+
+ 本轮证据 setEvidenceOpen(false)} />
+
+ )
+}
diff --git a/web/src/features/chat/EvidencePanel.tsx b/web/src/features/chat/EvidencePanel.tsx
new file mode 100644
index 0000000..54581db
--- /dev/null
+++ b/web/src/features/chat/EvidencePanel.tsx
@@ -0,0 +1,16 @@
+import { BookOpenCheck, FileText, X } from 'lucide-react'
+
+import { Button } from '../../components/ui/button'
+import type { Source } from '../../lib/types'
+import { cn } from '../../lib/utils'
+
+type Props = { sources: Source[]; selected: Source | null; onSelect: (source: Source) => void; onClose?: () => void }
+
+export function EvidencePanel({ sources, selected, onSelect, onClose }: Props) {
+ return (
+
+ )
+}
diff --git a/web/src/features/chat/MessageList.tsx b/web/src/features/chat/MessageList.tsx
new file mode 100644
index 0000000..7349617
--- /dev/null
+++ b/web/src/features/chat/MessageList.tsx
@@ -0,0 +1,28 @@
+import { Bot, CircleAlert, UserRound } from 'lucide-react'
+
+import type { Message, Source } from '../../lib/types'
+
+function AnswerContent({ content, sources, onSource }: { content: string; sources: Source[]; onSource: (source: Source, sources: Source[]) => void }) {
+ const lookup = new Map(sources.map((source) => [source.label, source]))
+ return {content.split(/(\[S\d+])/g).map((part, index) => {
+ const label = part.match(/^\[(S\d+)]$/)?.[1]
+ const source = label ? lookup.get(label) : undefined
+ if (!label) return {part}
+ if (!source) return 来源已删除
+ return
+ })}
+}
+
+type Props = { messages: Message[]; onSource: (source: Source, sources: Source[]) => void }
+
+export function MessageList({ messages, onSource }: Props) {
+ if (messages.length === 0) return
STRICT KNOWLEDGE MODE
从有出处的答案开始
系统会先让研究 Agent 检索资料,再由主 Agent 组织回答。
+ return (
+
+ {messages.map((message) =>
+ {message.role === 'user' ? : }
+ {message.role === 'user' ? '你' : '知识库助手'}{message.status === 'failed' ?
回答生成失败,可重新发送问题。
: message.role === 'assistant' ?
:
{message.content}
}
+ )}
+
+ )
+}
diff --git a/web/src/features/chat/SessionList.tsx b/web/src/features/chat/SessionList.tsx
new file mode 100644
index 0000000..6695d74
--- /dev/null
+++ b/web/src/features/chat/SessionList.tsx
@@ -0,0 +1,52 @@
+import { MessageSquarePlus, Trash2 } from 'lucide-react'
+import { useCallback, useEffect, useState } from 'react'
+import { useLocation, useNavigate, useParams } from 'react-router-dom'
+
+import { Button } from '../../components/ui/button'
+import type { Session } from '../../lib/types'
+import { cn } from '../../lib/utils'
+import { createSession, deleteSession, listSessions } from './api'
+
+export function SessionList({ onNavigate }: { onNavigate?: () => void }) {
+ const [sessions, setSessions] = useState([])
+ const navigate = useNavigate()
+ const { sessionId } = useParams()
+ const isChat = useLocation().pathname.startsWith('/chat')
+
+ const refresh = useCallback(async () => {
+ if (!isChat) return
+ const page = await listSessions()
+ setSessions(page.items)
+ }, [isChat])
+
+ useEffect(() => {
+ if (!isChat) return
+ void listSessions().then((page) => setSessions(page.items))
+ const listener = () => void refresh()
+ window.addEventListener('kbqa:sessions-changed', listener)
+ return () => window.removeEventListener('kbqa:sessions-changed', listener)
+ }, [isChat, refresh])
+
+ if (!isChat) return null
+
+ async function addSession() {
+ const session = await createSession()
+ await refresh(); navigate(`/chat/${session.id}`); onNavigate?.()
+ }
+ async function removeSession(id: string) {
+ if (!window.confirm('确定删除这个会话及全部历史吗?')) return
+ await deleteSession(id); await refresh()
+ if (sessionId === id) navigate('/chat')
+ }
+
+ return (
+
+
+ 最近会话
+
+ {sessions.map((session) =>
)}
+ {sessions.length === 0 &&
还没有历史会话
}
+
+
+ )
+}
diff --git a/web/src/features/chat/api.ts b/web/src/features/chat/api.ts
new file mode 100644
index 0000000..79d610b
--- /dev/null
+++ b/web/src/features/chat/api.ts
@@ -0,0 +1,25 @@
+import { apiRequest } from '../../lib/api'
+import type { Message, Page, Session } from '../../lib/types'
+
+export function createSession(title?: string) {
+ return apiRequest('/api/v1/sessions', {
+ method: 'POST',
+ body: JSON.stringify({ title: title || null }),
+ })
+}
+
+export function listSessions() {
+ return apiRequest>('/api/v1/sessions?limit=100')
+}
+
+export function listMessages(sessionId: string) {
+ return apiRequest>(`/api/v1/sessions/${sessionId}/messages?limit=100`)
+}
+
+export function deleteSession(sessionId: string) {
+ return apiRequest(`/api/v1/sessions/${sessionId}`, { method: 'DELETE' })
+}
+
+export function announceSessionChange() {
+ window.dispatchEvent(new Event('kbqa:sessions-changed'))
+}
diff --git a/web/src/features/chat/store.ts b/web/src/features/chat/store.ts
new file mode 100644
index 0000000..a3ea855
--- /dev/null
+++ b/web/src/features/chat/store.ts
@@ -0,0 +1,41 @@
+import { create } from 'zustand'
+
+import type { Source } from '../../lib/types'
+
+type Phase = 'idle' | 'researching' | 'answering' | 'error'
+
+type ChatStreamState = {
+ phase: Phase
+ activeQuery: string
+ text: string
+ sources: Source[]
+ selectedSource: Source | null
+ error: string | null
+ start: (query: string) => void
+ setPhase: (phase: Phase) => void
+ appendText: (content: string) => void
+ setSources: (sources: Source[]) => void
+ selectSource: (source: Source | null) => void
+ fail: (message: string) => void
+ reset: () => void
+}
+
+const initial = {
+ phase: 'idle' as Phase,
+ activeQuery: '',
+ text: '',
+ sources: [] as Source[],
+ selectedSource: null as Source | null,
+ error: null as string | null,
+}
+
+export const useChatStreamStore = create((set) => ({
+ ...initial,
+ start: (query) => set({ ...initial, phase: 'researching', activeQuery: query }),
+ setPhase: (phase) => set({ phase }),
+ appendText: (content) => set((state) => ({ text: state.text + content })),
+ setSources: (sources) => set({ sources, selectedSource: sources[0] ?? null }),
+ selectSource: (selectedSource) => set({ selectedSource }),
+ fail: (error) => set({ phase: 'error', error }),
+ reset: () => set(initial),
+}))
diff --git a/web/src/features/chat/useChatStream.ts b/web/src/features/chat/useChatStream.ts
new file mode 100644
index 0000000..8b4b14c
--- /dev/null
+++ b/web/src/features/chat/useChatStream.ts
@@ -0,0 +1,39 @@
+import { useCallback, useEffect, useRef } from 'react'
+
+import { streamSSE } from '../../lib/sse'
+import { useChatStreamStore } from './store'
+
+export function useChatStream(onDone: () => Promise) {
+ const controllerRef = useRef(null)
+ const store = useChatStreamStore()
+
+ const abort = useCallback(() => controllerRef.current?.abort(), [])
+ const cancel = useCallback(() => {
+ controllerRef.current?.abort()
+ useChatStreamStore.getState().fail('已停止生成,可重新发送问题。')
+ }, [])
+
+ const send = useCallback(async (sessionId: string, query: string) => {
+ controllerRef.current?.abort()
+ const controller = new AbortController()
+ controllerRef.current = controller
+ store.start(query)
+ try {
+ await streamSSE('/api/v1/chat/stream', { session_id: sessionId, query }, (event) => {
+ if (event.event === 'status') store.setPhase(event.data.phase)
+ if (event.event === 'sources') store.setSources(event.data.sources)
+ if (event.event === 'token') store.appendText(event.data.content)
+ if (event.event === 'error') store.fail(event.data.message)
+ }, controller.signal)
+ if (!controller.signal.aborted && useChatStreamStore.getState().phase !== 'error') await onDone()
+ } catch (reason) {
+ if (controller.signal.aborted) return
+ store.fail(reason instanceof Error ? reason.message : '流式回答失败')
+ } finally {
+ if (controllerRef.current === controller) controllerRef.current = null
+ }
+ }, [onDone, store])
+
+ useEffect(() => abort, [abort])
+ return { send, cancel }
+}
diff --git a/web/src/index.css b/web/src/index.css
index bf36a1a..1106289 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -139,6 +139,79 @@ a { color: inherit; text-decoration: none; }
.detail-pagination { padding-top: 9px; display: flex; align-items: center; justify-content: center; gap: 10px; color: #84908b; border-top: 1px solid var(--line); font-size: .65rem; }
.evidence-drawer { position: fixed; inset: 0 0 0 auto; z-index: 51; width: min(390px, 92vw); background: #f0f3f1; box-shadow: -24px 0 80px rgba(0,0,0,.2); animation: drawer-in .25s ease; }
+.session-rail { min-height: 0; margin-top: 14px; display: flex; flex: 1; flex-direction: column; }
+.new-session { width: 100%; justify-content: flex-start; }
+.session-rail .nav-label { margin-top: 18px; }
+.session-list { min-height: 0; display: grid; align-content: start; gap: 4px; overflow-y: auto; scrollbar-width: thin; }
+.session-item { display: grid; grid-template-columns: minmax(0, 1fr) 30px; align-items: center; border-radius: 7px; color: #aebdb7; }
+.session-item > button:first-child { min-width: 0; padding: 8px 7px; color: inherit; border: 0; background: transparent; text-align: left; cursor: pointer; }
+.session-item strong, .session-item span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.session-item strong { font-size: .68rem; font-weight: 650; }
+.session-item span { margin-top: 3px; color: #748b82; font-size: .56rem; }
+.session-item .button { width: 28px; height: 28px; opacity: 0; color: #91a49d; }
+.session-item:hover { background: rgba(255,255,255,.05); }
+.session-item:hover .button { opacity: 1; }
+.session-item--active { color: white; background: var(--forest-2); box-shadow: inset 3px 0 var(--ember); }
+.session-empty { margin: 8px; color: #70867d; font-size: .62rem; }
+
+.chat-workspace { display: grid; grid-template-columns: minmax(500px, 1fr) 330px; min-height: 100vh; }
+.chat-main { min-width: 0; height: 100vh; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; background: rgba(250,250,247,.82); }
+.chat-header { min-height: 78px; padding: 20px clamp(20px, 3vw, 38px); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.62); backdrop-filter: blur(12px); }
+.chat-header h1 { margin: 5px 0 0; font-family: "Songti SC", Georgia, serif; font-size: 1.35rem; font-weight: 600; }
+.evidence-trigger { display: none; }
+.conversation-scroll { min-height: 0; padding: 26px clamp(20px, 4vw, 54px); overflow-y: auto; scroll-behavior: smooth; }
+.chat-empty { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; }
+.chat-empty > div { width: 57px; height: 57px; margin-bottom: 19px; display: grid; place-items: center; color: var(--ember); border: 1px solid #ddc9bd; border-radius: 50%; background: white; box-shadow: 0 16px 45px rgba(39,55,49,.08); }
+.chat-empty h2 { margin: 8px 0 7px; font-family: "Songti SC", Georgia, serif; font-size: 1.45rem; font-weight: 600; }
+.chat-empty > span { max-width: 360px; color: #7e8a85; font-size: .75rem; line-height: 1.7; }
+.message-list, .streaming-turn { display: grid; gap: 21px; }
+.streaming-turn { margin-top: 21px; }
+.message { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 10px; align-items: start; animation: page-enter .25s ease both; }
+.message--user { margin-left: clamp(20px, 15%, 110px); grid-template-columns: minmax(0, 1fr); }
+.message--user .message-body { justify-self: end; max-width: 82%; padding: 10px 13px; border-radius: 12px 12px 3px 12px; background: #e2ebe7; }
+.message--user .message-avatar { display: none; }
+.message-avatar { width: 28px; height: 28px; display: grid; place-items: center; color: white; border-radius: 50%; background: var(--ember); box-shadow: 0 5px 14px rgba(172,86,53,.18); }
+.message-body { min-width: 0; color: #374943; }
+.message-body header { margin: 2px 0 7px; display: flex; align-items: baseline; gap: 8px; }
+.message-body header strong { font-size: .72rem; }
+.message-body header time { color: #929c98; font-size: .58rem; }
+.message-body p { margin: 0; white-space: pre-wrap; font-size: .82rem; line-height: 1.82; }
+.answer-copy { font-family: "Songti SC", Georgia, serif; }
+.citation { display: inline-flex; margin: 0 2px; padding: 1px 5px; color: #a75435; border: 1px solid #dfc1af; border-radius: 4px; background: #fff5ef; cursor: pointer; font-family: "Avenir Next", sans-serif; font-size: .62rem; font-weight: 800; vertical-align: .08em; }
+.citation--deleted { color: #8d9692; border-color: #d9dddb; background: #f0f1ee; cursor: default; }
+.failed-message { display: flex; align-items: center; gap: 7px; color: var(--danger); }
+.research-state { display: flex; align-items: center; gap: 8px; color: #73827c; font-family: "Avenir Next", sans-serif; font-size: .72rem !important; }
+.typing-cursor { display: inline-block; width: 2px; height: 1em; margin-left: 3px; background: var(--ember); animation: blink .8s step-end infinite; vertical-align: -.12em; }
+.pulse { color: var(--ember); animation: pulse 1.2s ease-in-out infinite; }
+.chat-composer { position: relative; margin: 0 clamp(20px, 4vw, 54px) 20px; padding: 10px 10px 24px 13px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 9px; border: 1px solid #cdd7d2; border-radius: 11px; background: white; box-shadow: 0 10px 35px rgba(37,54,47,.08); }
+.chat-composer textarea { width: 100%; min-height: 45px; max-height: 140px; padding: 5px 0; resize: none; color: var(--ink); border: 0; outline: 0; background: transparent; font-size: .8rem; line-height: 1.55; }
+.chat-composer textarea::placeholder { color: #9aa4a0; }
+.chat-composer small { position: absolute; bottom: 7px; left: 13px; color: #a0aaa6; font-size: .54rem; }
+.evidence-desktop { min-width: 0; border-left: 1px solid var(--line); background: #f0f3f1; }
+.evidence-panel { height: 100vh; padding: 22px 17px 16px; display: flex; flex-direction: column; overflow: hidden; }
+.evidence-header { min-height: 47px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: start; gap: 7px; }
+.evidence-header h2 { margin: 5px 0 0; font-family: "Songti SC", Georgia, serif; font-size: 1.15rem; font-weight: 600; }
+.evidence-header > span { margin-top: 7px; color: #82908a; font-size: .62rem; }
+.evidence-empty { flex: 1; display: grid; place-content: center; justify-items: center; gap: 7px; color: #85918c; text-align: center; }
+.evidence-empty svg { margin-bottom: 4px; color: var(--ember); }
+.evidence-empty strong { color: var(--ink); font-family: "Songti SC", Georgia, serif; }
+.evidence-empty span { max-width: 220px; font-size: .68rem; line-height: 1.6; }
+.source-list { max-height: 42%; margin: 16px 0 11px; display: grid; align-content: start; gap: 7px; overflow-y: auto; }
+.source-card { width: 100%; padding: 10px; display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 7px; color: #52625c; border: 1px solid #d8dfdb; border-radius: 8px; background: rgba(255,255,255,.64); cursor: pointer; text-align: left; }
+.source-card > svg { margin-top: 1px; color: var(--ember); }
+.source-card strong, .source-card small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.source-card strong { color: #354a42; font-size: .68rem; }
+.source-card small { margin-top: 4px; color: #89948f; font-size: .58rem; }
+.source-card--active { border-color: #d2a78e; background: white; box-shadow: inset 3px 0 var(--ember), 0 6px 20px rgba(41,58,51,.05); }
+.source-detail { min-height: 0; flex: 1; padding: 14px; overflow-y: auto; color: #e7efeb; border-radius: 10px; background: var(--forest); }
+.source-detail header { display: flex; justify-content: space-between; gap: 10px; }
+.source-detail header strong { font-size: .68rem; }
+.source-detail header span { color: #9fb1aa; font-size: .57rem; }
+.source-detail p { margin: 12px 0 0; white-space: pre-wrap; color: #d8e2de; font-family: "Songti SC", Georgia, serif; font-size: .72rem; line-height: 1.85; }
+
+@keyframes blink { 50% { opacity: 0; } }
+@keyframes pulse { 50% { opacity: .45; transform: scale(.92); } }
+
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes drawer-in { from { transform: translateX(100%); } }
@@ -160,6 +233,9 @@ a { color: inherit; text-decoration: none; }
.documents-workspace { display: block; }
.documents-detail-desktop { display: none; }
.detail-trigger { display: inline-flex; }
+ .chat-workspace { display: block; }
+ .evidence-desktop { display: none; }
+ .evidence-trigger { display: inline-flex; }
}
@media (max-width: 640px) {
@@ -172,6 +248,11 @@ a { color: inherit; text-decoration: none; }
.document-row { grid-template-columns: 40px minmax(0, 1fr); padding-right: 38px; }
.document-row .status { grid-column: 2; justify-self: start; }
.row-actions { grid-column: 2; }
+ .chat-main { height: calc(100vh - 58px); }
+ .chat-header { padding: 14px 16px; }
+ .conversation-scroll { padding: 20px 16px; }
+ .chat-composer { margin: 0 12px 12px; }
+ .message--user { margin-left: 16px; }
}
@media (prefers-reduced-motion: reduce) {