diff --git a/apps/web/app/components/chain-of-thought.tsx b/apps/web/app/components/chain-of-thought.tsx index 746086fb4d8..10007cd0739 100644 --- a/apps/web/app/components/chain-of-thought.tsx +++ b/apps/web/app/components/chain-of-thought.tsx @@ -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; 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 => - p.kind === "status", - ); const reasoningText = parts .filter( (p): p is Extract => @@ -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 {headerLabel} - {isActive && ( - - )} {/* Collapsible content */} -
-
-
- {/* Timeline connector line */} -
- {statusParts.map((sp, idx) => ( - + {isOpen && ( + +
+ {/* Timeline connector line */} +
- ))} - {reasoningText && ( -
-
+ {reasoningText && ( + - - - - -
-
- -
-
- )} - {visualItems.map((item, idx) => { - if (item.type === "media-group") { + + + + +
+
+ +
+ + )} + {visualItems.map((item, idx) => { + if (item.type === "media-group") { + return ( + + + + ); + } + if (item.type === "fetch-group") { + return ( + + + + ); + } return ( - + + + ); - } - return ( - - ); - })} -
-
-
+ })} + +
+ + )} +
); } @@ -746,17 +779,10 @@ function ReasoningBlock({ text: string; isStreaming: boolean; }) { - const [expanded, setExpanded] = useState(false); - const isLong = text.length > 400; - return (
{text} @@ -769,36 +795,23 @@ function ReasoningBlock({ /> )}
- {isLong && !expanded && ( - - )}
); } -/* ─── 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 ( -
+
- {isActive ? ( + {anyRunning ? ( ) : ( - - - + )}
- - {label} - +
+
+ + {anyRunning + ? `Fetching ${items.length} sources...` + : `Fetched ${items.length} sources`} + + {!anyRunning && ( + + {doneCount} {doneCount === 1 ? "result" : "results"} + + )} +
+
); } +/** Extract domain and full URL from fetch tool args/output */ +function getFetchDomainAndUrl( + args?: Record, + output?: Record, +): { 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({ diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index 8f93e73e50c..09c25867ca1 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -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 (
(s.type === "text" ? i : acc), -1) + : -1; + // Assistant: free-flowing text, left-aligned, NO bubble return (
+ {segments.map((segment, index) => { if (segment.type === "text") { // Detect agent error messages @@ -623,10 +625,32 @@ export function ChatMessage({ message, isStreaming }: { message: UIMessage; isSt
); } + + // 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 ( + + {segment.text} + + ); + } + return ( -
{segment.text} -
+ ); } if (segment.type === "report-artifact") { return ( - + + + ); } if (segment.type === "diff-artifact") { return ( - + + + ); } return ( - + + + ); })} +
); -} +}); diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index 2066b8e4512..509e1eb454f 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -526,10 +526,39 @@ export const ChatPanel = forwardRef( 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(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( 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( // ── Render ── return ( -
- {/* Header */} +
+
+ {/* Header — sticky glass bar */}
@@ -1172,7 +1206,7 @@ export const ChatPanel = forwardRef( )}
-
+
{compact && ( )} - {isStreaming && ( - - )}
{/* File-scoped session tabs (compact mode) */} {compact && fileContext && fileSessions.length > 0 && (
{fileSessions.slice(0, 10).map((s) => ( @@ -1231,7 +1251,7 @@ export const ChatPanel = forwardRef( 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( {/* Messages */}
{loadingSession ? ( -
+
(
) : messages.length === 0 ? ( -
+
{compact ? (

(

) : (
{messages.map((message, i) => ( ( {/* Transport-level error display */} {error && (
( strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" - className="flex-shrink-0" + className="shrink-0" > (
)} - {/* Input — Dench-style rounded area with toolbar */} + {/* Input — sticky glass bar at bottom */}
{ if (e.dataTransfer?.types.includes("application/x-file-mention")) { @@ -1476,55 +1497,71 @@ export const ChatPanel = forwardRef(
- {/* Send button */} - + ) : ( + + )}
+
{/* File picker modal */} ( }), Placeholder.configure({ placeholder: placeholder ?? "Ask anything...", + showOnlyWhenEditable: false, }), FileMentionNode, createChatFileMentionSuggestion(), @@ -361,6 +362,11 @@ export const ChatEditor = forwardRef( 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; diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 1cbc3c0e7aa..80392133772 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; }