Chat UI/UX improvement

This commit is contained in:
Mark 2026-02-14 14:07:53 -08:00
parent 9baed309d3
commit 1ccff68520
5 changed files with 493 additions and 283 deletions

View File

@ -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({

View File

@ -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>
);
}
});

View File

@ -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

View File

@ -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;

View File

@ -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;
}