Chat UI/UX improvement
This commit is contained in:
parent
9baed309d3
commit
1ccff68520
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { DiffCard } from "./diff-viewer";
|
||||
|
||||
/* ─── Diff synthesis from edit tool args ─── */
|
||||
@ -39,11 +40,7 @@ export type ChainPart =
|
||||
output?: Record<string, unknown>;
|
||||
errorText?: string;
|
||||
}
|
||||
| {
|
||||
kind: "status";
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
;
|
||||
|
||||
/* ─── Media / file type helpers ─── */
|
||||
|
||||
@ -498,6 +495,10 @@ type VisualItem =
|
||||
type: "media-group";
|
||||
mediaKind: "image" | "video" | "pdf" | "audio";
|
||||
items: Array<{ path: string; tool: ToolPart }>;
|
||||
}
|
||||
| {
|
||||
type: "fetch-group";
|
||||
items: ToolPart[];
|
||||
};
|
||||
|
||||
function groupToolSteps(tools: ToolPart[]): VisualItem[] {
|
||||
@ -534,6 +535,26 @@ function groupToolSteps(tools: ToolPart[]): VisualItem[] {
|
||||
items: group,
|
||||
});
|
||||
i = j;
|
||||
} else if (kind === "fetch") {
|
||||
// Group consecutive fetch tools into a single compact card
|
||||
const group: ToolPart[] = [tool];
|
||||
let j = i + 1;
|
||||
while (j < tools.length) {
|
||||
const next = tools[j];
|
||||
const nextKind = classifyTool(next.toolName, next.args);
|
||||
if (nextKind === "fetch") {
|
||||
group.push(next);
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (group.length > 1) {
|
||||
result.push({ type: "fetch-group", items: group });
|
||||
} else {
|
||||
result.push({ type: "tool", tool });
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
result.push({ type: "tool", tool });
|
||||
i++;
|
||||
@ -550,8 +571,7 @@ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isS
|
||||
const isActive = parts.some(
|
||||
(p) =>
|
||||
(p.kind === "reasoning" && p.isStreaming) ||
|
||||
(p.kind === "tool" && p.status === "running") ||
|
||||
(p.kind === "status" && p.isActive),
|
||||
(p.kind === "tool" && p.status === "running"),
|
||||
);
|
||||
|
||||
/* ─── Live elapsed-time tracking ─── */
|
||||
@ -595,10 +615,6 @@ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isS
|
||||
}
|
||||
}, [isStreaming, parts.length]);
|
||||
|
||||
const statusParts = parts.filter(
|
||||
(p): p is Extract<ChainPart, { kind: "status" }> =>
|
||||
p.kind === "status",
|
||||
);
|
||||
const reasoningText = parts
|
||||
.filter(
|
||||
(p): p is Extract<ChainPart, { kind: "reasoning" }> =>
|
||||
@ -615,16 +631,10 @@ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isS
|
||||
);
|
||||
const visualItems = groupToolSteps(tools);
|
||||
|
||||
// Derive a more descriptive header from status parts
|
||||
const activeStatus = statusParts.find((s) => s.isActive);
|
||||
const headerLabel = isActive
|
||||
? activeStatus
|
||||
? elapsed > 0
|
||||
? `${activeStatus.label} ${formatDuration(elapsed)}`
|
||||
: activeStatus.label
|
||||
: elapsed > 0
|
||||
? `Thinking... ${formatDuration(elapsed)}`
|
||||
: "Thinking..."
|
||||
? elapsed > 0
|
||||
? `Thinking for ${formatDuration(elapsed)}`
|
||||
: "Thinking..."
|
||||
: elapsed > 0
|
||||
? `Thought for ${formatDuration(elapsed)}`
|
||||
: "Thought";
|
||||
@ -642,12 +652,6 @@ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isS
|
||||
<span className="font-medium">
|
||||
{headerLabel}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full animate-pulse flex-shrink-0"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
/>
|
||||
)}
|
||||
<ChevronIcon
|
||||
className={`w-3.5 h-3.5 ml-1 flex-shrink-0 transition-transform duration-200 ${
|
||||
isOpen ? "" : "-rotate-90"
|
||||
@ -656,83 +660,112 @@ export function ChainOfThought({ parts, isStreaming }: { parts: ChainPart[]; isS
|
||||
</button>
|
||||
|
||||
{/* Collapsible content */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||
style={{
|
||||
gridTemplateRows: isOpen ? "1fr" : "0fr",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative pt-2 pb-1">
|
||||
{/* Timeline connector line */}
|
||||
<div
|
||||
className="absolute w-px"
|
||||
style={{
|
||||
left: 9,
|
||||
top: 16,
|
||||
bottom: 8,
|
||||
background: "var(--color-border)",
|
||||
}}
|
||||
/>
|
||||
{statusParts.map((sp, idx) => (
|
||||
<StatusStep
|
||||
key={`status-${idx}`}
|
||||
label={sp.label}
|
||||
isActive={sp.isActive}
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative pt-2 pb-1">
|
||||
{/* Timeline connector line */}
|
||||
<div
|
||||
className="absolute w-px"
|
||||
style={{
|
||||
left: 9,
|
||||
top: 16,
|
||||
bottom: 8,
|
||||
background: "var(--color-border)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{reasoningText && (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
<AnimatePresence initial={false}>
|
||||
{reasoningText && (
|
||||
<motion.div
|
||||
key="reasoning"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="flex items-start gap-2.5 py-1.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--color-text-muted)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
}}
|
||||
>
|
||||
<path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7z" />
|
||||
<path d="M10 21h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<ReasoningBlock
|
||||
text={reasoningText}
|
||||
isStreaming={
|
||||
isReasoningStreaming
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{visualItems.map((item, idx) => {
|
||||
if (item.type === "media-group") {
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--color-text-muted)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7z" />
|
||||
<path d="M10 21h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<ReasoningBlock
|
||||
text={reasoningText}
|
||||
isStreaming={
|
||||
isReasoningStreaming
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
{visualItems.map((item, idx) => {
|
||||
if (item.type === "media-group") {
|
||||
return (
|
||||
<motion.div
|
||||
key={`media-${idx}`}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
>
|
||||
<MediaGroup
|
||||
mediaKind={item.mediaKind}
|
||||
items={item.items}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (item.type === "fetch-group") {
|
||||
return (
|
||||
<motion.div
|
||||
key={`fetch-${idx}`}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
>
|
||||
<FetchGroup items={item.items} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MediaGroup
|
||||
key={idx}
|
||||
mediaKind={item.mediaKind}
|
||||
items={item.items}
|
||||
/>
|
||||
<motion.div
|
||||
key={item.tool.toolCallId}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
>
|
||||
<ToolStep
|
||||
{...item.tool}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ToolStep
|
||||
key={item.tool.toolCallId}
|
||||
{...item.tool}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -746,17 +779,10 @@ function ReasoningBlock({
|
||||
text: string;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLong = text.length > 400;
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
className={`text-[13px] whitespace-pre-wrap leading-relaxed ${
|
||||
!expanded && isLong
|
||||
? "max-h-24 overflow-hidden"
|
||||
: ""
|
||||
}`}
|
||||
className="text-[13px] whitespace-pre-wrap leading-relaxed"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
{text}
|
||||
@ -769,36 +795,23 @@ function ReasoningBlock({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isLong && !expanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-[12px] hover:underline mt-1 cursor-pointer"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Status step (lifecycle / compaction indicators) ─── */
|
||||
/* ─── Fetch group (consecutive web fetches in one compact card) ─── */
|
||||
|
||||
function FetchGroup({ items }: { items: ToolPart[] }) {
|
||||
const anyRunning = items.some((t) => t.status === "running");
|
||||
const doneCount = items.filter((t) => t.status === "done").length;
|
||||
|
||||
function StatusStep({
|
||||
label,
|
||||
isActive,
|
||||
}: {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 py-1.5">
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<div
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 flex items-center justify-center rounded-full"
|
||||
className="relative z-10 flex-shrink-0 w-5 h-5 mt-0.5 flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
{isActive ? (
|
||||
{anyRunning ? (
|
||||
<span
|
||||
className="w-4 h-4 border-[1.5px] rounded-full animate-spin"
|
||||
style={{
|
||||
@ -807,34 +820,129 @@ function StatusStep({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--color-success, var(--color-accent))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<StepIcon kind="fetch" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-[13px] leading-snug"
|
||||
style={{
|
||||
color: isActive
|
||||
? "var(--color-text)"
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[13px] leading-snug mb-1.5 flex items-center justify-between"
|
||||
style={{
|
||||
color: anyRunning
|
||||
? "var(--color-text)"
|
||||
: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{anyRunning
|
||||
? `Fetching ${items.length} sources...`
|
||||
: `Fetched ${items.length} sources`}
|
||||
</span>
|
||||
{!anyRunning && (
|
||||
<span
|
||||
className="text-[11px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{doneCount} {doneCount === 1 ? "result" : "results"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.5)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{items.map((tool, i) => {
|
||||
const { domain, url } = getFetchDomainAndUrl(tool.args, tool.output);
|
||||
return (
|
||||
<a
|
||||
key={tool.toolCallId}
|
||||
href={url ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 text-[12px] no-underline"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
cursor: url ? "pointer" : "default",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--color-surface-hover)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
|
||||
onClick={url ? undefined : (e) => e.preventDefault()}
|
||||
>
|
||||
{domain ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={faviconUrl(domain)}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-sm flex-shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm flex-shrink-0"
|
||||
style={{ background: "var(--color-surface-hover)" }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="flex-1 min-w-0 truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{domain?.replace(/^www\./, "") ?? "Loading..."}
|
||||
</span>
|
||||
{tool.status === "running" ? (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full animate-pulse flex-shrink-0"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
/>
|
||||
) : url ? (
|
||||
<span
|
||||
className="text-[11px] truncate max-w-[45%] text-right"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract domain and full URL from fetch tool args/output */
|
||||
function getFetchDomainAndUrl(
|
||||
args?: Record<string, unknown>,
|
||||
output?: Record<string, unknown>,
|
||||
): { domain: string | null; url: string | null } {
|
||||
for (const key of ["url", "targetUrl", "path", "src"]) {
|
||||
const v = args?.[key];
|
||||
if (typeof v === "string" && v.startsWith("http")) {
|
||||
try {
|
||||
return { domain: new URL(v).hostname, url: v };
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of ["url", "finalUrl", "targetUrl"]) {
|
||||
const v = output?.[key];
|
||||
if (typeof v === "string" && v.startsWith("http")) {
|
||||
try {
|
||||
return { domain: new URL(v).hostname, url: v };
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
return { domain: null, url: null };
|
||||
}
|
||||
|
||||
/* ─── Media group (images, videos, PDFs, audio) ─── */
|
||||
|
||||
function MediaGroup({
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { UIMessage } from "ai";
|
||||
import { memo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@ -97,8 +99,8 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
|
||||
text: string;
|
||||
state?: string;
|
||||
};
|
||||
// Detect status reasoning blocks emitted by lifecycle/compaction events.
|
||||
// These have short, specific labels — render as status indicators instead.
|
||||
// Skip lifecycle/compaction status labels — they add noise
|
||||
// (e.g. "Preparing response...", "Optimizing session context...")
|
||||
const statusLabels = [
|
||||
"Preparing response...",
|
||||
"Optimizing session context...",
|
||||
@ -106,13 +108,7 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] {
|
||||
const isStatus = statusLabels.some((l) =>
|
||||
rp.text.startsWith(l),
|
||||
);
|
||||
if (isStatus) {
|
||||
chain.push({
|
||||
kind: "status",
|
||||
label: rp.text.split("\n")[0],
|
||||
isActive: rp.state === "streaming",
|
||||
});
|
||||
} else {
|
||||
if (!isStatus) {
|
||||
chain.push({
|
||||
kind: "reasoning",
|
||||
text: rp.text,
|
||||
@ -513,7 +509,7 @@ const mdComponents: Components = {
|
||||
|
||||
/* ─── Chat message ─── */
|
||||
|
||||
export function ChatMessage({ message, isStreaming }: { message: UIMessage; isStreaming?: boolean }) {
|
||||
export const ChatMessage = memo(function ChatMessage({ message, isStreaming }: { message: UIMessage; isStreaming?: boolean }) {
|
||||
const isUser = message.role === "user";
|
||||
const segments = groupParts(message.parts);
|
||||
|
||||
@ -533,7 +529,7 @@ export function ChatMessage({ message, isStreaming }: { message: UIMessage; isSt
|
||||
return (
|
||||
<div className="flex justify-end py-2">
|
||||
<div
|
||||
className="font-bookerly max-w-[80%] rounded-2xl rounded-br-sm px-4 py-2.5 text-[17px] leading-9"
|
||||
className="font-bookerly max-w-[80%] rounded-2xl rounded-br-sm px-4 py-2.5 text-sm leading-6"
|
||||
style={{
|
||||
background: "var(--color-user-bubble)",
|
||||
color: "var(--color-user-bubble-text)",
|
||||
@ -564,9 +560,15 @@ export function ChatMessage({ message, isStreaming }: { message: UIMessage; isSt
|
||||
);
|
||||
}
|
||||
|
||||
// Find the last text segment index for streaming optimization
|
||||
const lastTextIdx = isStreaming
|
||||
? segments.reduce((acc, s, i) => (s.type === "text" ? i : acc), -1)
|
||||
: -1;
|
||||
|
||||
// Assistant: free-flowing text, left-aligned, NO bubble
|
||||
return (
|
||||
<div className="py-3 space-y-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === "text") {
|
||||
// Detect agent error messages
|
||||
@ -623,10 +625,32 @@ export function ChatMessage({ message, isStreaming }: { message: UIMessage; isSt
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// During streaming, render the active text as plain text
|
||||
// to avoid expensive ReactMarkdown re-parses on every token.
|
||||
// Switch to full markdown once streaming ends.
|
||||
if (index === lastTextIdx) {
|
||||
return (
|
||||
<motion.div
|
||||
key={`text-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="chat-prose font-bookerly text-sm whitespace-pre-wrap"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{segment.text}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="chat-prose font-bookerly text-[17px]"
|
||||
<motion.div
|
||||
key={`text-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="chat-prose font-bookerly text-sm"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
@ -635,33 +659,48 @@ export function ChatMessage({ message, isStreaming }: { message: UIMessage; isSt
|
||||
>
|
||||
{segment.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (segment.type === "report-artifact") {
|
||||
return (
|
||||
<ReportCard
|
||||
key={index}
|
||||
config={segment.config}
|
||||
/>
|
||||
<motion.div
|
||||
key={`report-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<ReportCard config={segment.config} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (segment.type === "diff-artifact") {
|
||||
return (
|
||||
<DiffCard
|
||||
key={index}
|
||||
diff={segment.diff}
|
||||
/>
|
||||
<motion.div
|
||||
key={`diff-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<DiffCard diff={segment.diff} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChainOfThought
|
||||
key={index}
|
||||
parts={segment.parts}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
<motion.div
|
||||
key={`chain-${index}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<ChainOfThought
|
||||
parts={segment.parts}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -526,10 +526,39 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
status === "submitted" ||
|
||||
isReconnecting;
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
// Auto-scroll to bottom on new messages, but only when the user
|
||||
// is already near the bottom. If the user scrolls up during
|
||||
// streaming, we stop auto-scrolling until they return to the
|
||||
// bottom (or a new user message is sent).
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const userScrolledAwayRef = useRef(false);
|
||||
const scrollRafRef = useRef(0);
|
||||
|
||||
// Detect when the user scrolls away from the bottom.
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) {return;}
|
||||
|
||||
const onScroll = () => {
|
||||
const distanceFromBottom =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
// Threshold: if within 80px of the bottom, consider "at bottom"
|
||||
userScrolledAwayRef.current = distanceFromBottom > 80;
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll effect — skips when user has scrolled away.
|
||||
useEffect(() => {
|
||||
if (userScrolledAwayRef.current) {return;}
|
||||
if (scrollRafRef.current) {return;}
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = 0;
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
@ -907,6 +936,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
isFirstFileMessageRef.current = false;
|
||||
}
|
||||
|
||||
// Reset scroll lock so we auto-scroll to the new user message
|
||||
userScrolledAwayRef.current = false;
|
||||
void sendMessage({ text: messageText });
|
||||
},
|
||||
[
|
||||
@ -1120,13 +1151,16 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
// ── Render ──
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
<div className="flex flex-col min-h-full">
|
||||
{/* Header — sticky glass bar */}
|
||||
<header
|
||||
className={`${compact ? "px-3 py-2" : "px-6 py-3"} border-b flex items-center justify-between flex-shrink-0`}
|
||||
className={`${compact ? "px-3 py-2" : "px-6 py-3"} flex items-center justify-between sticky top-0 z-20 backdrop-blur-md`}
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
background: "var(--color-bg-glass)",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
@ -1172,7 +1206,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{compact && (
|
||||
<button
|
||||
type="button"
|
||||
@ -1198,30 +1232,16 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStop()}
|
||||
className={`${compact ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs"} rounded-full font-medium`}
|
||||
style={{
|
||||
background:
|
||||
"var(--color-surface-hover)",
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* File-scoped session tabs (compact mode) */}
|
||||
{compact && fileContext && fileSessions.length > 0 && (
|
||||
<div
|
||||
className="px-2 py-1.5 border-b flex gap-1 overflow-x-auto flex-shrink-0"
|
||||
className="px-2 py-1.5 border-b flex gap-1 overflow-x-auto sticky top-[41px] z-20 backdrop-blur-md"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-bg-glass)",
|
||||
}}
|
||||
>
|
||||
{fileSessions.slice(0, 10).map((s) => (
|
||||
@ -1231,7 +1251,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
onClick={() =>
|
||||
handleSessionSelect(s.id)
|
||||
}
|
||||
className="px-2.5 py-1 text-[10px] rounded-full whitespace-nowrap flex-shrink-0 font-medium"
|
||||
className="px-2.5 py-1 text-[10px] rounded-full whitespace-nowrap shrink-0 font-medium"
|
||||
style={{
|
||||
background:
|
||||
s.id === currentSessionId
|
||||
@ -1257,10 +1277,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto ${compact ? "px-3" : "px-6"}`}
|
||||
className={`flex-1 ${compact ? "px-3" : "px-6"}`}
|
||||
>
|
||||
{loadingSession ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center justify-center h-full min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full animate-spin mx-auto mb-3"
|
||||
@ -1282,7 +1302,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</div>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center justify-center h-full min-h-[60vh]">
|
||||
<div className="text-center max-w-md px-4">
|
||||
{compact ? (
|
||||
<p
|
||||
@ -1319,7 +1339,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`${compact ? "" : "max-w-3xl mx-auto"} py-3`}
|
||||
className={`${compact ? "" : "max-w-2xl mx-auto"} py-3`}
|
||||
>
|
||||
{messages.map((message, i) => (
|
||||
<ChatMessage
|
||||
@ -1336,7 +1356,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
{/* Transport-level error display */}
|
||||
{error && (
|
||||
<div
|
||||
className="px-3 py-2 border-t flex-shrink-0 flex items-center gap-2"
|
||||
className="px-3 py-2 flex items-center gap-2 sticky bottom-[72px] z-10"
|
||||
style={{
|
||||
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
|
||||
borderColor: `color-mix(in srgb, var(--color-error) 18%, transparent)`,
|
||||
@ -1352,7 +1372,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0"
|
||||
className="shrink-0"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line
|
||||
@ -1372,20 +1392,21 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input — Dench-style rounded area with toolbar */}
|
||||
{/* Input — sticky glass bar at bottom */}
|
||||
<div
|
||||
className={`${compact ? "px-3 py-2" : "px-6 py-4"} flex-shrink-0`}
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
className={`${compact ? "px-3 py-2" : "px-6 pb-5 pt-0"} sticky bottom-0 z-20 backdrop-blur-md`}
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<div
|
||||
className={compact ? "" : "max-w-3xl mx-auto"}
|
||||
className={compact ? "" : "max-w-[720px] mx-auto"}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden"
|
||||
className="rounded-3xl overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
"var(--color-chat-input-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 0 32px rgba(0,0,0,0.07)",
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types.includes("application/x-file-mention")) {
|
||||
@ -1476,55 +1497,71 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
disabled={
|
||||
(editorEmpty &&
|
||||
attachedFiles.length ===
|
||||
0) ||
|
||||
isStreaming ||
|
||||
loadingSession ||
|
||||
startingNewSession
|
||||
}
|
||||
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
style={{
|
||||
background:
|
||||
!editorEmpty ||
|
||||
attachedFiles.length >
|
||||
0
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-border-strong)",
|
||||
color: "white",
|
||||
}}
|
||||
{/* Send / Stop button */}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStop()}
|
||||
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center`}
|
||||
style={{
|
||||
background: "var(--color-text)",
|
||||
color: "var(--color-bg)",
|
||||
}}
|
||||
title="Stop generating"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<div
|
||||
className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
)}
|
||||
<svg
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 10 10"
|
||||
fill="currentColor"
|
||||
>
|
||||
<rect width="10" height="10" rx="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
disabled={
|
||||
(editorEmpty &&
|
||||
attachedFiles.length ===
|
||||
0) ||
|
||||
loadingSession ||
|
||||
startingNewSession
|
||||
}
|
||||
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
style={{
|
||||
background:
|
||||
!editorEmpty ||
|
||||
attachedFiles.length >
|
||||
0
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-border-strong)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File picker modal */}
|
||||
<FilePickerModal
|
||||
|
||||
@ -212,6 +212,7 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder ?? "Ask anything...",
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
FileMentionNode,
|
||||
createChatFileMentionSuggestion(),
|
||||
@ -361,6 +362,11 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
padding: ${compact ? "10px 12px" : "14px 16px"};
|
||||
font-size: ${compact ? "12px" : "14px"};
|
||||
line-height: 1.5;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.chat-editor-content[contenteditable="false"] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chat-editor-content p {
|
||||
margin: 0;
|
||||
|
||||
@ -6,9 +6,9 @@
|
||||
|
||||
:root {
|
||||
/* Background / Surface */
|
||||
--color-bg: #f5f5f0;
|
||||
--color-bg: #f5f5f4;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-hover: #f0efeb;
|
||||
--color-surface-hover: #f5f4f1;
|
||||
--color-surface-raised: #ffffff;
|
||||
|
||||
/* Borders */
|
||||
@ -21,14 +21,14 @@
|
||||
--color-text-muted: #8a8a82;
|
||||
|
||||
/* Accent (blue) */
|
||||
--color-accent: #2563eb;
|
||||
--color-accent: rgba(37, 99, 235, 0.9);
|
||||
--color-accent-hover: #1d4ed8;
|
||||
--color-accent-light: rgba(37, 99, 235, 0.08);
|
||||
|
||||
/* Chat */
|
||||
--color-user-bubble: #e9e5dd;
|
||||
--color-user-bubble: #eae8e4;
|
||||
--color-user-bubble-text: #1c1c1a;
|
||||
--color-chat-input-bg: #eeeee8;
|
||||
--color-chat-input-bg: rgba(255, 255, 255, 0.8);
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #16a34a;
|
||||
@ -39,6 +39,7 @@
|
||||
/* Glassmorphism */
|
||||
--color-glass: rgba(255, 255, 255, 0.72);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.85);
|
||||
--color-bg-glass: rgba(245, 245, 244, 0.8);
|
||||
|
||||
/* Object type chips */
|
||||
--color-chip-object: rgba(37, 99, 235, 0.08);
|
||||
@ -62,7 +63,7 @@
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.10);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -100,6 +101,7 @@
|
||||
/* Glassmorphism */
|
||||
--color-glass: rgba(22, 22, 21, 0.72);
|
||||
--color-glass-border: rgba(255, 255, 255, 0.06);
|
||||
--color-bg-glass: rgba(12, 12, 11, 0.8);
|
||||
|
||||
/* Object type chips */
|
||||
--color-chip-object: rgba(59, 130, 246, 0.12);
|
||||
@ -112,18 +114,18 @@
|
||||
--color-chip-report-text: #4ade80;
|
||||
|
||||
/* Diff viewer */
|
||||
--diff-add-bg: rgba(34, 197, 94, 0.10);
|
||||
--diff-add-bg: rgba(34, 197, 94, 0.1);
|
||||
--diff-add-text: #4ade80;
|
||||
--diff-add-badge: #22c55e;
|
||||
--diff-del-bg: rgba(239, 68, 68, 0.10);
|
||||
--diff-del-bg: rgba(239, 68, 68, 0.1);
|
||||
--diff-del-text: #f87171;
|
||||
--diff-del-badge: #ef4444;
|
||||
|
||||
/* Shadow */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.20);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.30);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.40);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.50);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@ -176,7 +178,9 @@ body {
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Font utilities */
|
||||
@ -203,7 +207,8 @@ textarea,
|
||||
button,
|
||||
a,
|
||||
[role="button"] {
|
||||
transition-property: background-color, border-color, color, box-shadow, opacity;
|
||||
transition-property:
|
||||
background-color, border-color, color, box-shadow, opacity;
|
||||
transition-duration: 0.15s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
@ -318,8 +323,8 @@ a,
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.workspace-prose li>ul,
|
||||
.workspace-prose li>ol {
|
||||
.workspace-prose li > ul,
|
||||
.workspace-prose li > ol {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@ -480,7 +485,12 @@ a,
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"] {
|
||||
.editor-content-area
|
||||
.tiptap
|
||||
ul[data-type="taskList"]
|
||||
li
|
||||
label
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
@ -490,12 +500,22 @@ a,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked {
|
||||
.editor-content-area
|
||||
.tiptap
|
||||
ul[data-type="taskList"]
|
||||
li
|
||||
label
|
||||
input[type="checkbox"]:checked {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked::after {
|
||||
.editor-content-area
|
||||
.tiptap
|
||||
ul[data-type="taskList"]
|
||||
li
|
||||
label
|
||||
input[type="checkbox"]:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
@ -507,7 +527,7 @@ a,
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.editor-content-area .tiptap ul[data-type="taskList"] li>div {
|
||||
.editor-content-area .tiptap ul[data-type="taskList"] li > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@ -845,11 +865,11 @@ a,
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.chat-prose>*:first-child {
|
||||
.chat-prose > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-prose>*:last-child {
|
||||
.chat-prose > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@ -928,12 +948,12 @@ a,
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.chat-prose li>p {
|
||||
.chat-prose li > p {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.chat-prose li>ul,
|
||||
.chat-prose li>ol {
|
||||
.chat-prose li > ul,
|
||||
.chat-prose li > ol {
|
||||
margin-top: 0.2em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user