fix(frontend): deduplicate live workflow activity
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user