design(chat): replace heavy phase dividers with sticky active-status bar; flatten tool groups to match Base44 aesthetic
This commit is contained in:
@@ -69,6 +69,7 @@ type TimelineEntry =
|
||||
name: string;
|
||||
status: "running" | "done" | "error";
|
||||
result?: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
// A text segment from one round of the assistant's tool loop.
|
||||
// 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") {
|
||||
items.push({ kind: "text", text: e.text });
|
||||
} else if (e.kind === "phase") {
|
||||
items.push({ kind: "phase", phase: e.phase, label: e.label });
|
||||
// ignore
|
||||
} else {
|
||||
const last = items[items.length - 1];
|
||||
const category = getFriendlyCategory(e.name);
|
||||
@@ -639,48 +640,16 @@ function Timeline({ entries }: { entries: TimelineEntry[] }) {
|
||||
if (item.kind === "text") {
|
||||
return <TimelineText key={i} text={item.text} />;
|
||||
}
|
||||
if (item.kind === "phase") {
|
||||
if (item.kind === "toolGroup") {
|
||||
return (
|
||||
<div
|
||||
<TimelineToolGroup
|
||||
key={i}
|
||||
style={{
|
||||
padding: "12px 14px",
|
||||
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>
|
||||
category={item.category}
|
||||
entries={item.entries}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TimelineToolGroup
|
||||
key={i}
|
||||
category={item.category}
|
||||
entries={item.entries}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -722,126 +691,102 @@ function TimelineText({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
function TimelineToolGroup({
|
||||
category,
|
||||
entries,
|
||||
}: {
|
||||
category: string;
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
margin: "4px 0",
|
||||
background: "#f0ede8",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
margin: "8px 0",
|
||||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
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>
|
||||
{entries.map((e, i) => {
|
||||
const isError = e.status === "error";
|
||||
const isDone = e.status === "done";
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0 12px 8px 34px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{entries.map((e, i) => (
|
||||
<div
|
||||
key={i}
|
||||
let argSummary = "";
|
||||
if (e.args) {
|
||||
if (e.args.path) argSummary = String(e.args.path);
|
||||
else if (e.args.command) argSummary = String(e.args.command);
|
||||
else if (e.args.url)
|
||||
argSummary = String(e.args.url).replace(/^https?:\/\//, "");
|
||||
else if (e.args.name) argSummary = String(e.args.name);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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={{
|
||||
fontSize: "0.7rem",
|
||||
color: "#8c8580",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: "var(--font-mono), monospace",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{friendlyToolName(e.name)}
|
||||
</span>
|
||||
{argSummary && (
|
||||
<span
|
||||
style={{
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: "50%",
|
||||
background: "#ccc",
|
||||
background: "#f4f4f5",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
color: "#71717a",
|
||||
maxWidth: 200,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontFamily: "var(--font-mono), monospace" }}>
|
||||
{friendlyToolName(e.name)}
|
||||
>
|
||||
{argSummary}
|
||||
</span>
|
||||
{!e.result && e.status === "running" && (
|
||||
<span className="animate-pulse">...</span>
|
||||
)}
|
||||
{(() => {
|
||||
// Render a clean, human summary of the outcome — never raw JSON.
|
||||
const summary = summarizeToolResult(e.result);
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 220,
|
||||
color: summary.ok ? "#6b8e6b" : "#c2554d",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
title={summary.label}
|
||||
>
|
||||
— {summary.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{(() => {
|
||||
const summary = summarizeToolResult(e.result);
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 220,
|
||||
color: summary.ok ? "#a1a1aa" : "#ef4444",
|
||||
}}
|
||||
title={summary.label}
|
||||
>
|
||||
— {summary.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1057,6 +1002,9 @@ export function ChatPanel({
|
||||
.catch(() => {});
|
||||
}, [projectId, workspace, status]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [currentPhaseLabel, setCurrentPhaseLabel] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [showThreads, setShowThreads] = useState(false);
|
||||
const [mcpToken, setMcpToken] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -1286,6 +1234,7 @@ export function ChatPanel({
|
||||
const text = raw;
|
||||
if (!override) setInput("");
|
||||
setSending(true);
|
||||
setCurrentPhaseLabel("Starting...");
|
||||
|
||||
const userMsg: Message = { role: "user", content: text };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
@@ -1374,6 +1323,7 @@ export function ChatPanel({
|
||||
label?: string;
|
||||
goal?: string;
|
||||
findings?: string;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
ev = JSON.parse(line.slice(6));
|
||||
@@ -1382,20 +1332,8 @@ export function ChatPanel({
|
||||
}
|
||||
|
||||
if (ev.type === "phase" && ev.phase && ev.label) {
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
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;
|
||||
});
|
||||
setCurrentPhaseLabel(ev.label);
|
||||
// Legacy phase entries in history are ignored
|
||||
} else if (ev.type === "text" && ev.text) {
|
||||
// Each text SSE event = one round of the model's text
|
||||
// output. Push a new "text" timeline entry so the
|
||||
@@ -1441,6 +1379,13 @@ export function ChatPanel({
|
||||
return next;
|
||||
});
|
||||
} 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) => {
|
||||
const next = [...prev];
|
||||
if (msgIndex >= 0 && next[msgIndex]) {
|
||||
@@ -1449,7 +1394,12 @@ export function ChatPanel({
|
||||
...next[msgIndex],
|
||||
timeline: [
|
||||
...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 {
|
||||
abortRef.current = null;
|
||||
setSending(false);
|
||||
setCurrentPhaseLabel(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -2052,6 +2003,33 @@ export function ChatPanel({
|
||||
</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}>
|
||||
{(selectToggle) => (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user