design(chat): replace heavy phase dividers with sticky active-status bar; flatten tool groups to match Base44 aesthetic

This commit is contained in:
2026-06-11 11:40:16 -07:00
parent 371ae37cc2
commit 180f0bdc0a

View File

@@ -69,6 +69,7 @@ type TimelineEntry =
name: string; name: string;
status: "running" | "done" | "error"; status: "running" | "done" | "error";
result?: string; result?: string;
args?: Record<string, unknown>;
} }
// A text segment from one round of the assistant's tool loop. // A text segment from one round of the assistant's tool loop.
// Each text SSE event from the server starts a new entry; subsequent // Each text SSE event from the server starts a new entry; subsequent
@@ -617,7 +618,7 @@ function Timeline({ entries }: { entries: TimelineEntry[] }) {
} else if (e.kind === "text") { } else if (e.kind === "text") {
items.push({ kind: "text", text: e.text }); items.push({ kind: "text", text: e.text });
} else if (e.kind === "phase") { } else if (e.kind === "phase") {
items.push({ kind: "phase", phase: e.phase, label: e.label }); // ignore
} else { } else {
const last = items[items.length - 1]; const last = items[items.length - 1];
const category = getFriendlyCategory(e.name); const category = getFriendlyCategory(e.name);
@@ -639,48 +640,16 @@ function Timeline({ entries }: { entries: TimelineEntry[] }) {
if (item.kind === "text") { if (item.kind === "text") {
return <TimelineText key={i} text={item.text} />; return <TimelineText key={i} text={item.text} />;
} }
if (item.kind === "phase") { if (item.kind === "toolGroup") {
return ( return (
<div <TimelineToolGroup
key={i} key={i}
style={{ category={item.category}
padding: "12px 14px", entries={item.entries}
margin: "12px 0 6px", />
borderBottom: "1px solid var(--hairline)",
display: "flex",
alignItems: "center",
gap: 8,
color: "var(--fg)",
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--accent)",
boxShadow: "0 0 10px var(--accent-glow)",
}}
/>
<span
style={{
fontSize: "0.85rem",
fontWeight: 600,
letterSpacing: "-0.01em",
}}
>
{item.label}
</span>
</div>
); );
} }
return ( return null;
<TimelineToolGroup
key={i}
category={item.category}
entries={item.entries}
/>
);
})} })}
</div> </div>
); );
@@ -722,126 +691,102 @@ function TimelineText({ text }: { text: string }) {
} }
function TimelineToolGroup({ function TimelineToolGroup({
category,
entries, entries,
}: { }: {
category: string; category: string;
entries: Array<Extract<TimelineEntry, { kind: "tool" }>>; entries: Array<Extract<TimelineEntry, { kind: "tool" }>>;
}) { }) {
const [expanded, setExpanded] = useState(false);
const count = entries.length;
const allDone = entries.every((e) => e.status === "done");
const hasError = entries.some((e) => e.status === "error");
return ( return (
<div <div
style={{ style={{
margin: "4px 0", display: "flex",
background: "#f0ede8", flexDirection: "column",
borderRadius: 8, gap: 6,
margin: "8px 0",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif", fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
}} }}
> >
<button {entries.map((e, i) => {
onClick={() => setExpanded(!expanded)} const isError = e.status === "error";
style={{ const isDone = e.status === "done";
display: "flex",
alignItems: "center",
width: "100%",
gap: 8,
padding: "6px 12px",
background: "none",
border: "none",
fontSize: "0.75rem",
color: "#6b6560",
cursor: "pointer",
textAlign: "left",
}}
>
<span style={{ width: 14, display: "flex", justifyContent: "center" }}>
{hasError ? (
<span style={{ color: "#ef4444", fontWeight: "bold" }}></span>
) : !allDone ? (
<Loader2
style={{ width: 12, height: 12 }}
className="animate-spin"
/>
) : (
<Wrench style={{ width: 12, height: 12, color: "#2e7d32" }} />
)}
</span>
<span style={{ flex: 1, color: hasError ? "#ef4444" : undefined }}>
{category} {count > 1 ? `(x${count})` : ""}
{hasError ? " ✗" : !allDone ? "..." : " ✓"}
</span>
<span
style={{
transform: expanded ? "rotate(180deg)" : "none",
transition: "transform 0.15s ease",
}}
>
<ChevronDown style={{ width: 12, height: 12, opacity: 0.5 }} />
</span>
</button>
{expanded && ( let argSummary = "";
<div if (e.args) {
style={{ if (e.args.path) argSummary = String(e.args.path);
padding: "0 12px 8px 34px", else if (e.args.command) argSummary = String(e.args.command);
display: "flex", else if (e.args.url)
flexDirection: "column", argSummary = String(e.args.url).replace(/^https?:\/\//, "");
gap: 4, else if (e.args.name) argSummary = String(e.args.name);
}} }
>
{entries.map((e, i) => ( return (
<div <div
key={i} key={i}
style={{
fontSize: "0.75rem",
color: isError ? "#ef4444" : "#52525b",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span style={{ display: "flex", alignItems: "center", width: 14 }}>
{isError ? (
"✗"
) : !isDone ? (
<Loader2
style={{ width: 12, height: 12, color: "#8b5cf6" }}
className="animate-spin"
/>
) : (
<span style={{ color: "#a1a1aa" }}></span>
)}
</span>
<span
style={{ style={{
fontSize: "0.7rem", fontFamily: "var(--font-mono), monospace",
color: "#8c8580", fontWeight: 500,
display: "flex",
alignItems: "center",
gap: 6,
}} }}
> >
<div {friendlyToolName(e.name)}
</span>
{argSummary && (
<span
style={{ style={{
width: 4, background: "#f4f4f5",
height: 4, padding: "2px 6px",
borderRadius: "50%", borderRadius: 4,
background: "#ccc", color: "#71717a",
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}} }}
/> >
<span style={{ fontFamily: "var(--font-mono), monospace" }}> {argSummary}
{friendlyToolName(e.name)}
</span> </span>
{!e.result && e.status === "running" && ( )}
<span className="animate-pulse">...</span> {(() => {
)} const summary = summarizeToolResult(e.result);
{(() => { if (!summary) return null;
// Render a clean, human summary of the outcome — never raw JSON. return (
const summary = summarizeToolResult(e.result); <span
if (!summary) return null; style={{
return ( overflow: "hidden",
<span textOverflow: "ellipsis",
style={{ whiteSpace: "nowrap",
overflow: "hidden", maxWidth: 220,
textOverflow: "ellipsis", color: summary.ok ? "#a1a1aa" : "#ef4444",
whiteSpace: "nowrap", }}
maxWidth: 220, title={summary.label}
color: summary.ok ? "#6b8e6b" : "#c2554d", >
opacity: 0.9, {summary.label}
}} </span>
title={summary.label} );
> })()}
{summary.label} </div>
</span> );
); })}
})()}
</div>
))}
</div>
)}
</div> </div>
); );
} }
@@ -1057,6 +1002,9 @@ export function ChatPanel({
.catch(() => {}); .catch(() => {});
}, [projectId, workspace, status]); }, [projectId, workspace, status]);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [currentPhaseLabel, setCurrentPhaseLabel] = useState<string | 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 messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -1286,6 +1234,7 @@ export function ChatPanel({
const text = raw; const text = raw;
if (!override) setInput(""); if (!override) setInput("");
setSending(true); setSending(true);
setCurrentPhaseLabel("Starting...");
const userMsg: Message = { role: "user", content: text }; const userMsg: Message = { role: "user", content: text };
setMessages((prev) => [...prev, userMsg]); setMessages((prev) => [...prev, userMsg]);
@@ -1374,6 +1323,7 @@ export function ChatPanel({
label?: string; label?: string;
goal?: string; goal?: string;
findings?: string; findings?: string;
args?: Record<string, unknown>;
}; };
try { try {
ev = JSON.parse(line.slice(6)); ev = JSON.parse(line.slice(6));
@@ -1382,20 +1332,8 @@ export function ChatPanel({
} }
if (ev.type === "phase" && ev.phase && ev.label) { if (ev.type === "phase" && ev.phase && ev.label) {
setMessages((prev) => { setCurrentPhaseLabel(ev.label);
const next = [...prev]; // Legacy phase entries in history are ignored
if (msgIndex >= 0 && next[msgIndex]) {
const tl = next[msgIndex].timeline ?? [];
next[msgIndex] = {
...next[msgIndex],
timeline: [
...tl,
{ kind: "phase", phase: ev.phase!, label: ev.label! },
],
};
}
return next;
});
} else if (ev.type === "text" && ev.text) { } else if (ev.type === "text" && ev.text) {
// Each text SSE event = one round of the model's text // Each text SSE event = one round of the model's text
// output. Push a new "text" timeline entry so the // output. Push a new "text" timeline entry so the
@@ -1441,6 +1379,13 @@ export function ChatPanel({
return next; return next;
}); });
} else if (ev.type === "tool_start") { } else if (ev.type === "tool_start") {
// Update bottom status label dynamically based on tool name
const toolLabel =
friendlyToolName(ev.name ?? "")
.replace(/^[^\w\s]+/, "")
.trim() + "...";
setCurrentPhaseLabel(toolLabel);
setMessages((prev) => { setMessages((prev) => {
const next = [...prev]; const next = [...prev];
if (msgIndex >= 0 && next[msgIndex]) { if (msgIndex >= 0 && next[msgIndex]) {
@@ -1449,7 +1394,12 @@ export function ChatPanel({
...next[msgIndex], ...next[msgIndex],
timeline: [ timeline: [
...tl, ...tl,
{ kind: "tool", name: ev.name, status: "running" }, {
kind: "tool",
name: ev.name,
args: ev.args,
status: "running",
},
], ],
}; };
} }
@@ -1595,6 +1545,7 @@ export function ChatPanel({
} finally { } finally {
abortRef.current = null; abortRef.current = null;
setSending(false); setSending(false);
setCurrentPhaseLabel(null);
} }
}, },
[ [
@@ -2052,6 +2003,33 @@ export function ChatPanel({
</div> </div>
)} )}
{/* Action Status Bar anchored above composer */}
{sending && currentPhaseLabel && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 8px 10px",
marginLeft: 4,
}}
>
<Loader2
className="animate-spin"
style={{ width: 14, height: 14, color: "#8b5cf6" }}
/>
<span
style={{
fontSize: "0.75rem",
fontWeight: 500,
color: "#71717a",
}}
>
{currentPhaseLabel}
</span>
</div>
)}
<ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}> <ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}>
{(selectToggle) => ( {(selectToggle) => (
<div <div