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;
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user