fix(frontend): show complete model activity from phase one
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ActivityEntry } from "../api/types";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
const TAIL_PARAGRAPHS = 5;
|
||||
export function isNearBottom(
|
||||
el: Pick<HTMLElement, "scrollHeight" | "clientHeight" | "scrollTop">,
|
||||
threshold = 48,
|
||||
): boolean {
|
||||
return el.scrollHeight - el.clientHeight - el.scrollTop <= threshold;
|
||||
}
|
||||
|
||||
export function formatModelActivity(text: string): string {
|
||||
return text
|
||||
@@ -18,57 +24,102 @@ export function formatModelActivity(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Left drawer showing "what's happening": the tail of the model's streamed markdown
|
||||
* output, rendered as markdown (not raw text) so headings/emphasis/lists come through
|
||||
* and paragraphs stay visually separated. Collapsed it shows the last few paragraphs
|
||||
* (refreshing as the stream grows); the expand toggle reveals the full stream. Opened
|
||||
* on demand from the rotating activity icon. */
|
||||
function MarkdownBody({ entry }: { entry: ActivityEntry }) {
|
||||
const tone = entry.kind === "thinking" ? "text-muted-foreground" : "text-foreground";
|
||||
return (
|
||||
<div className={`thot-prose max-w-[74ch] text-sm ${tone}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formatModelActivity(entry.text)}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityBody({ entry }: { entry: ActivityEntry }) {
|
||||
if (entry.kind === "assistant" || entry.kind === "thinking") {
|
||||
return <MarkdownBody entry={entry} />;
|
||||
}
|
||||
|
||||
if (entry.kind === "status") {
|
||||
const tone =
|
||||
entry.level === "error"
|
||||
? "text-destructive"
|
||||
: entry.level === "warning"
|
||||
? "text-amber-600"
|
||||
: "text-foreground";
|
||||
return <p className={`whitespace-pre-wrap break-words text-sm leading-relaxed ${tone}`}>{entry.text}</p>;
|
||||
}
|
||||
|
||||
if (entry.kind === "lifecycle") {
|
||||
return <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted-foreground">{entry.text}</p>;
|
||||
}
|
||||
|
||||
return <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">{entry.text}</p>;
|
||||
}
|
||||
|
||||
function ActivityRow({ entry }: { entry: ActivityEntry }) {
|
||||
return (
|
||||
<article className="border-b border-border/50 py-3 last:border-b-0">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{entry.phase && <span>{entry.phase}</span>}
|
||||
<span>{entry.kind}</span>
|
||||
{entry.status && (
|
||||
<span className={entry.status === "failed" ? "text-destructive" : undefined}>
|
||||
{entry.status}
|
||||
</span>
|
||||
)}
|
||||
{entry.level && (
|
||||
<span
|
||||
className={
|
||||
entry.level === "error"
|
||||
? "text-destructive"
|
||||
: entry.level === "warning"
|
||||
? "text-amber-600"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ActivityBody entry={entry} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** Left drawer showing the complete in-memory model activity timeline. */
|
||||
export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
|
||||
const activityLog = useSessionStore((s) => s.activityLog);
|
||||
const activity = activityLog.filter((entry) => entry.kind === "thinking");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const followRef = useRef(true);
|
||||
|
||||
// Blank lines are markdown's paragraph separator — split on them (not on every "\n",
|
||||
// which would also break single soft-wrapped lines apart) so each block below is a
|
||||
// real paragraph and the tail cut lands on paragraph boundaries.
|
||||
const paragraphs = activity
|
||||
.map((e) => e.text)
|
||||
.join("\n\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p !== "");
|
||||
const shown = expanded ? paragraphs : paragraphs.slice(-TAIL_PARAGRAPHS);
|
||||
const canExpand = paragraphs.length > TAIL_PARAGRAPHS;
|
||||
const source = formatModelActivity(shown.join("\n\n"));
|
||||
useLayoutEffect(() => {
|
||||
const viewport = scrollRef.current;
|
||||
if (viewport && followRef.current) viewport.scrollTop = viewport.scrollHeight;
|
||||
}, [activityLog]);
|
||||
|
||||
return (
|
||||
<aside className="flex w-2/5 shrink-0 flex-col border-r border-border bg-sidebar">
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-3 py-3">
|
||||
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{canExpand && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={expanded ? "Show less" : "Show more"}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-5">
|
||||
{shown.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="activity-scroll"
|
||||
tabIndex={0}
|
||||
aria-label="Model activity timeline"
|
||||
className="flex-1 overflow-y-auto px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40"
|
||||
onScroll={(event) => {
|
||||
followRef.current = isNearBottom(event.currentTarget);
|
||||
}}
|
||||
>
|
||||
{activityLog.length === 0 ? (
|
||||
<p className="py-3 text-sm text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<div className="thot-prose max-w-[74ch]">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{source}</ReactMarkdown>
|
||||
</div>
|
||||
activityLog.map((entry, index) => (
|
||||
<ActivityRow key={`${index}-${entry.toolCallId ?? entry.kind}`} entry={entry} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user