Restore chat-panel UI improvements

This commit is contained in:
2026-06-15 15:04:13 -07:00
parent a69a2233ab
commit 30581bcf75

View File

@@ -9,7 +9,7 @@ import React, {
type CSSProperties, type CSSProperties,
} from "react"; } from "react";
import Link from "next/link"; import Link from "next/link";
import { ArrowDown, useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { import {
MessageSquare, MessageSquare,
@@ -284,14 +284,11 @@ function summarizeToolResult(result?: string): {
} }
// Plain-text heuristics // Plain-text heuristics
// We explicitly ignore 'error' and 'exception' here because tools like dev_server_logs
// or browser_console legitimately return stack traces when working correctly.
// A raw string with 'error' inside it shouldn't auto-fail the tool execution pill.
const lower = raw.toLowerCase(); const lower = raw.toLowerCase();
if ( if (
/(econnrefused|enoent|permission denied|command not found)/.test( /(econnrefused|enoent|error|failed|traceback|exception|not found|permission denied|cannot)/.test(
lower, lower,
) && !raw.includes("dev_server_logs") && !raw.includes("browser_console") )
) { ) {
return { ok: false, label: `Failed — ${firstLine(raw)}` }; return { ok: false, label: `Failed — ${firstLine(raw)}` };
} }
@@ -520,21 +517,28 @@ const MessageBubble = React.memo(function MessageBubble({
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
borderRadius: "50%",
background: "#f4f4f5", // Zinc-100 instead of black
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
marginRight: 8, marginRight: 8,
flexShrink: 0, flexShrink: 0,
marginTop: 2, marginTop: 2,
border: "1px solid #e4e4e7",
}} }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "#a1a1aa" }}> <span
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/> style={{
<path d="M5 3v4"/> color: "#18181b", // Dark gray instead of white
<path d="M19 17v4"/> fontSize: "0.6rem",
<path d="M3 5h4"/> fontWeight: 700,
<path d="M17 19h4"/> fontFamily: "var(--font-lora),serif",
</svg> fontStyle: "italic",
}}
>
V.
</span>
</div> </div>
)} )}
<div <div
@@ -664,76 +668,79 @@ function Timeline({ entries, isActiveStream }: { entries: TimelineEntry[], isAct
function TimelineThought({ text, isStreaming }: { text: string; isStreaming?: boolean }) { function TimelineThought({ text, isStreaming }: { text: string; isStreaming?: boolean }) {
const [expanded, setExpanded] = React.useState(true); const [expanded, setExpanded] = React.useState(true);
// Auto-collapse when streaming stops
const textLenRef = React.useRef(text.length); const textLenRef = React.useRef(text.length);
React.useEffect(() => {
let t = setTimeout(() => {
if (text.length === textLenRef.current && !isStreaming) {
setExpanded(false);
}
}, 1500);
return () => clearTimeout(t);
}, [text, isStreaming]);
React.useEffect(() => { React.useEffect(() => {
textLenRef.current = text.length; // If not streaming, auto-collapse after a short delay so the user isn't stuck with huge thinking blocks
}, [text]); if (!isStreaming) {
const t = setTimeout(() => setExpanded(false), 500);
return () => clearTimeout(t);
}
}, [isStreaming]);
const proseWrap: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "break-word",
minWidth: 0,
};
return ( return (
<div <div
style={{ style={{
margin: "8px 0", margin: "12px 0",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif", fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}} }}
> >
<button <button
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
style={{ style={{
display: "flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 6,
background: "transparent", background: "#f4f4f5",
border: "none", border: "1px solid #e4e4e7",
padding: 0, borderRadius: "999px",
padding: "4px 10px",
cursor: "pointer", cursor: "pointer",
fontSize: "0.75rem", fontSize: "0.75rem",
color: "#71717a", color: "#52525b",
fontWeight: 500, fontWeight: 500,
transition: "all 0.2s ease",
boxShadow: "0 1px 2px rgba(0,0,0,0.02)",
}} }}
> >
<span <Sparkles
size={14}
className={isStreaming ? "animate-pulse" : ""}
style={{ color: "#8b5cf6" }}
/>
<span className={isStreaming ? "animate-pulse" : ""} style={{ transition: "opacity 0.2s ease" }}>
{isStreaming ? "Thinking..." : "Thought Process"}
</span>
<ChevronRight
size={14}
style={{ style={{
transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 0.15s ease", transition: "transform 0.2s ease",
display: "inline-block",
fontSize: "0.65rem",
color: "#a1a1aa" color: "#a1a1aa"
}} }}
> />
</span>
<span className={isStreaming ? "animate-pulse" : ""} style={{ transition: "opacity 0.2s ease" }}>
Analyzed task
</span>
</button> </button>
{expanded && ( {expanded && (
<div <div
style={{ style={{
marginTop: 8, marginTop: 8,
marginLeft: 6, padding: "10px 14px",
paddingLeft: 12, borderLeft: "2px solid #c4b5fd",
borderLeft: "2px solid #e5e7eb", fontSize: "0.85rem",
fontSize: "0.84rem",
color: "#52525b", color: "#52525b",
lineHeight: 1.6, background: "linear-gradient(to right, #f5f3ff 0%, transparent 100%)",
whiteSpace: "pre-wrap", borderRadius: "0 8px 8px 0",
overflowWrap: "anywhere", ...proseWrap,
wordBreak: "break-word",
}} }}
> >
<span <span
style={proseWrap}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: renderMarkdown(stripRawToolLogs(text)) + (isStreaming ? `<span class="animate-pulse" style="display:inline-block; width:6px; height:13px; background-color:#9ca3af; vertical-align:-1px; margin-left:2px; border-radius:1px;"></span>` : ""), __html: renderMarkdown(stripRawToolLogs(text)) + (isStreaming ? `<span class="animate-pulse" style="display:inline-block; width:6px; height:13px; background-color:#9ca3af; vertical-align:-1px; margin-left:2px; border-radius:1px;"></span>` : ""),
}} }}
@@ -1139,13 +1146,12 @@ export function ChatPanel({
.catch(() => {}); .catch(() => {});
}, [projectId, workspace, status]); }, [projectId, workspace, status]);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [showScrollButton, setShowScrollButton] = useState(false);
const [currentPhaseLabel, setCurrentPhaseLabel] = useState<string | null>( const [currentPhaseLabel, setCurrentPhaseLabel] = useState<string | null>(
null, null,
); );
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
const [mcpToken, setMcpToken] = useState<string | null>(null); const [mcpToken, setMcpToken] = useState<string | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isChatMinimized, setIsChatMinimized] = useState<boolean>(false); const [isChatMinimized, setIsChatMinimized] = useState<boolean>(false);
// Auto-minimize when navigating to dashboard, auto-open when navigating to preview // Auto-minimize when navigating to dashboard, auto-open when navigating to preview
@@ -1629,15 +1635,12 @@ export function ChatPanel({
} }
} catch { } catch {
// 2. If it's a raw string (like a bash crash), scan for fatal keywords // 2. If it's a raw string (like a bash crash), scan for fatal keywords
// We skip this check for log-reading tools since they legitimately contain errors.
const lower = ev.result.toLowerCase(); const lower = ev.result.toLowerCase();
if ( if (
!ev.name?.includes("logs") && lower.includes("error") ||
!ev.name?.includes("console") && lower.includes("failed") ||
(lower.includes("econnrefused") || lower.includes("unexpected") ||
lower.includes("enoent") || lower.includes("not found")
lower.includes("permission denied") ||
lower.includes("command not found"))
) { ) {
isToolErr = true; isToolErr = true;
} }
@@ -1974,6 +1977,11 @@ export function ChatPanel({
{/* Messages */} {/* Messages */}
<div <div
onScroll={(e) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
setShowScrollButton(distanceToBottom > 150);
}}
style={{ style={{
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
@@ -2203,6 +2211,35 @@ export function ChatPanel({
{/* Scroll to bottom button */}
{showScrollButton && (
<button
onClick={scrollToBottom}
style={{
position: "absolute",
bottom: "calc(100% + 12px)",
left: "50%",
transform: "translateX(-50%)",
background: "#ffffff",
border: "1px solid #e4e4e7",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
borderRadius: "50%",
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
zIndex: 50,
color: "#52525b",
transition: "all 0.2s ease",
}}
aria-label="Scroll to bottom"
>
<ArrowDown size={16} />
</button>
)}
<ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}> <ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}>
{(selectToggle) => ( {(selectToggle) => (
<div <div
@@ -2998,36 +3035,3 @@ export function ChatPanel({
); );
} }
function ScrollToBottomButton({ onClick, visible }: { onClick: () => void, visible: boolean }) {
if (!visible) return null;
return (
<button
onClick={onClick}
style={{
position: "absolute",
bottom: "80px",
left: "50%",
transform: "translateX(-50%)",
background: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "999px",
padding: "6px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
cursor: "pointer",
color: "#4b5563",
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s ease"
}}
aria-label="Scroll to bottom"
>
<ArrowDown size={16} />
</button>
);
}