fix(frontend): deduplicate live workflow activity

This commit is contained in:
User
2026-07-15 11:55:14 +02:00
parent e7a8bc29b0
commit 279b79516c
8 changed files with 169 additions and 183 deletions
+54 -101
View File
@@ -1,112 +1,65 @@
import { useEffect, useReducer, useState } from "react";
import { useLayoutEffect, useRef } from "react";
import { useSessionStore } from "../store/sessionStore";
import { WorkingSpinner } from "./WorkingSpinner";
import { isNearBottom } from "./activityScroll";
/** Last few non-blank lines of a string — a compact "what the model is doing now"
* tail (mirrors ModelActivityPanel's tail-line logic, at a smaller size). Returned
* as an array so each line renders as its own formatted log row. */
function tailLines(s: string, n = 5): string[] {
return s
.split("\n")
.map((l) => l.trimEnd())
.filter((l) => l.trim() !== "")
.slice(-n);
function transcriptLines(transcript: Array<{ text: string }>): string[] {
return transcript.flatMap(({ text }) =>
text
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== ""),
);
}
/** The minimal central view: the user's last input/choice, a live "working" indicator
* (elapsed time + a short tail of the model stream) while the model is busy, and the
* gate's curated messages for the current step. The full verbose stream lives in the
* left Model-activity panel. */
export function CentralStatus({ working, workingLabel }: { working: boolean; workingLabel?: string }) {
const lastUserEntry = useSessionStore((s) => s.lastUserEntry);
const stepMessages = useSessionStore((s) => s.stepMessages);
const transcript = useSessionStore((s) => s.transcript);
/** Compact central projection of the assistant stream while the model is working. */
export function CentralStatus({ working }: { working: boolean }) {
const transcript = useSessionStore((state) => state.transcript);
const rows = transcriptLines(transcript);
const rowCount = rows.length;
const tail = rows.at(-1);
const scrollRef = useRef<HTMLOListElement>(null);
const followRef = useRef(true);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [, tick] = useReducer((x: number) => x + 1, 0);
useLayoutEffect(() => {
const viewport = scrollRef.current;
if (viewport && followRef.current) viewport.scrollTop = viewport.scrollHeight;
}, [rowCount, tail]);
// Start the per-step timer when work begins; tick once a second so the elapsed
// display updates; clear it when work stops.
useEffect(() => {
if (!working) {
setStartedAt(null);
return;
}
setStartedAt((prev) => prev ?? Date.now());
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [working]);
if (!working || rows.length === 0) return null;
if (!working && !lastUserEntry && stepMessages.length === 0) return null;
const elapsed = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
const tailRows = tailLines(transcript.at(-1)?.text ?? "");
return (
<div className="flex flex-col gap-3">
{lastUserEntry && (
<div className="rounded-lg border border-border/70 bg-card px-3 py-2">
<span className="thot-label">
{lastUserEntry.kind === "input" ? "You asked" : "You chose"}
</span>
<p className="mt-0.5 text-sm text-foreground">{lastUserEntry.text}</p>
</div>
)}
{working && (
<div className="flex flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
{/* Pure status indicator — opening the activity panel now lives on the
dedicated arrow toggle in the header, not on this spinner. */}
<span className="grid size-5 shrink-0 place-items-center text-primary">
<WorkingSpinner spinning className="size-3.5" />
<ol
ref={scrollRef}
role="log"
aria-label="Live model activity"
aria-live="polite"
aria-relevant="additions text"
tabIndex={0}
className="max-h-28 overflow-y-auto rounded-lg border border-border/60 bg-muted/40 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);
}}
>
{rows.map((line, index) => {
const isLast = index === rows.length - 1;
return (
<li
key={index}
className={[
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
isLast ? "text-foreground/80" : "text-muted-foreground/55",
].join(" ")}
>
<span
aria-hidden
className={isLast ? "shrink-0 select-none text-primary" : "shrink-0 select-none text-muted-foreground/35"}
>
›
</span>
<span className="shrink-0 font-mono tabular-nums">{elapsed}s</span>
<span>{workingLabel ?? "ThothII is working"}</span>
</div>
{tailRows.length > 0 && (
<ol className="ml-7 flex flex-col gap-0.5 overflow-hidden rounded-lg border border-border/60 bg-muted/40 px-3 py-2">
{tailRows.map((line, i) => {
const isLast = i === tailRows.length - 1;
return (
<li
key={i}
className={[
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
isLast ? "text-foreground/80" : "text-muted-foreground/55",
].join(" ")}
>
<span
aria-hidden
className={[
"shrink-0 select-none",
isLast ? "text-primary" : "text-muted-foreground/35",
].join(" ")}
>
›
</span>
<span className="min-w-0 flex-1 truncate">{line}</span>
</li>
);
})}
</ol>
)}
</div>
)}
{stepMessages.map((m, i) => (
<p
key={i}
className={[
"text-sm",
m.level === "error" ? "text-destructive"
: m.level === "warning" ? "text-amber-600"
: "text-foreground/90",
].join(" ")}
>
{m.text}
</p>
))}
</div>
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words">{line}</span>
</li>
);
})}
</ol>
);
}