fix(frontend): deduplicate live workflow activity
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { http, HttpResponse, delay } from "msw";
|
import { http, HttpResponse, delay } from "msw";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -45,7 +45,7 @@ test("New session starts prewarm without delaying composer focus", async () => {
|
|||||||
expect(composer).toHaveClass("thot-awaiting-input");
|
expect(composer).toHaveClass("thot-awaiting-input");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows the question and creation timer before POST /sessions resolves, then opens SSE", async () => {
|
test("records the prompt without central duplication, then opens the live log", async () => {
|
||||||
let releaseCreate!: () => void;
|
let releaseCreate!: () => void;
|
||||||
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
|
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
|
||||||
server.use(
|
server.use(
|
||||||
@@ -60,20 +60,20 @@ test("shows the question and creation timer before POST /sessions resolves, then
|
|||||||
await userEvent.type(composer, "How many patients?");
|
await userEvent.type(composer, "How many patients?");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||||
|
|
||||||
expect(await screen.findByText("How many patients?", { selector: "p" })).toBeInTheDocument();
|
expect(screen.queryByText("How many patients?", { selector: "p" })).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Creating session…")).toBeInTheDocument();
|
expect(screen.queryByText("Creating session…")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText(/\d+s/)).toBeInTheDocument();
|
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
|
||||||
expect(useSessionStore.getState().activityLog[0]).toEqual({
|
expect(useSessionStore.getState().activityLog[0]).toEqual({
|
||||||
kind: "prompt",
|
kind: "prompt",
|
||||||
phase: "F1",
|
phase: "F1",
|
||||||
text: "How many patients?",
|
text: "How many patients?",
|
||||||
});
|
});
|
||||||
expect(FakeEventSource.instances).toHaveLength(0);
|
|
||||||
|
|
||||||
releaseCreate();
|
releaseCreate();
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
expect(screen.getByText("How many patients?", { selector: "p" })).toBeInTheDocument();
|
act(() => FakeEventSource.instances[0].emit({ type: "text_delta", text: "Inspecting cohort" }));
|
||||||
expect(screen.getByText("Analyzing question…")).toBeInTheDocument();
|
expect(await screen.findByRole("log", { name: "Live model activity" }))
|
||||||
|
.toHaveTextContent("Inspecting cohort");
|
||||||
|
expect(screen.queryByText("Analyzing question…")).not.toBeInTheDocument();
|
||||||
expect(useSessionStore.getState().currentPhase).toBe("F1");
|
expect(useSessionStore.getState().currentPhase).toBe("F1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -697,15 +697,13 @@ test("closing and reopening Model activity preserves the complete activity log",
|
|||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.setPhase("F4");
|
store.setPhase("F4");
|
||||||
store.setLastUserEntry({ kind: "input", text: "Inspect patient cohort" });
|
store.setLastUserEntry({ kind: "input", text: "Inspect patient cohort" });
|
||||||
store.applyEvent({
|
|
||||||
type: "activity_event",
|
|
||||||
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" },
|
|
||||||
});
|
|
||||||
store.applyEvent({
|
store.applyEvent({
|
||||||
type: "activity_event",
|
type: "activity_event",
|
||||||
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" },
|
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" },
|
||||||
});
|
});
|
||||||
store.applyEvent({ type: "text_delta", text: "Cohort ready" });
|
store.applyEvent({ type: "text_delta", text: "Cohort ready" });
|
||||||
|
store.applyEvent({ type: "activity_delta", text: "Inspecting **cohort**" });
|
||||||
|
store.applyEvent({ type: "info", level: "info", text: "Cohort status" });
|
||||||
});
|
});
|
||||||
const beforeClose = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
const beforeClose = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
||||||
|
|
||||||
@@ -713,7 +711,9 @@ test("closing and reopening Model activity preserves the complete activity log",
|
|||||||
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
|
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
|
||||||
const panel = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
const panel = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
||||||
expect(panel).not.toBeNull();
|
expect(panel).not.toBeNull();
|
||||||
expect(within(panel!).getByText("Inspect patient cohort")).toBeInTheDocument();
|
expect(within(panel!).getByText("cohort").tagName).toBe("STRONG");
|
||||||
|
expect(within(panel!).getByText("Cohort status")).toBeInTheDocument();
|
||||||
|
expect(within(panel!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
|
||||||
expect(within(panel!).queryByText("bash")).not.toBeInTheDocument();
|
expect(within(panel!).queryByText("bash")).not.toBeInTheDocument();
|
||||||
expect(within(panel!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
expect(within(panel!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
||||||
|
|
||||||
@@ -724,7 +724,9 @@ test("closing and reopening Model activity preserves the complete activity log",
|
|||||||
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
||||||
const reopened = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
const reopened = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
||||||
expect(reopened).not.toBeNull();
|
expect(reopened).not.toBeNull();
|
||||||
expect(within(reopened!).getByText("Inspect patient cohort")).toBeInTheDocument();
|
expect(within(reopened!).getByText("cohort").tagName).toBe("STRONG");
|
||||||
|
expect(within(reopened!).getByText("Cohort status")).toBeInTheDocument();
|
||||||
|
expect(within(reopened!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
|
||||||
expect(within(reopened!).queryByText("bash")).not.toBeInTheDocument();
|
expect(within(reopened!).queryByText("bash")).not.toBeInTheDocument();
|
||||||
expect(within(reopened!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
expect(within(reopened!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
||||||
expect(useSessionStore.getState().activityLog).toEqual(beforeClose);
|
expect(useSessionStore.getState().activityLog).toEqual(beforeClose);
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export function AppShell() {
|
|||||||
// The harness "holds the ball" whenever a session is live, no widget is waiting on
|
// The harness "holds the ball" whenever a session is live, no widget is waiting on
|
||||||
// the human, AND the Pi turn is still in flight (agentActive). Without the last
|
// the human, AND the Pi turn is still in flight (agentActive). Without the last
|
||||||
// condition the final workflow step — which ends with no follow-up gate — would
|
// condition the final workflow step — which ends with no follow-up gate — would
|
||||||
// leave the spinner on forever.
|
// leave the working state on forever.
|
||||||
const pendingWidget = useSessionStore((s) => s.pendingWidget);
|
const pendingWidget = useSessionStore((s) => s.pendingWidget);
|
||||||
const resetSession = useSessionStore((s) => s.resetSession);
|
const resetSession = useSessionStore((s) => s.resetSession);
|
||||||
const recordLifecycle = useSessionStore((s) => s.recordLifecycle);
|
const recordLifecycle = useSessionStore((s) => s.recordLifecycle);
|
||||||
@@ -289,7 +289,7 @@ export function AppShell() {
|
|||||||
const agentActive = useSessionStore((s) => s.agentActive);
|
const agentActive = useSessionStore((s) => s.agentActive);
|
||||||
const sessionViewOpen = Boolean(activeSessionId) || creatingSession;
|
const sessionViewOpen = Boolean(activeSessionId) || creatingSession;
|
||||||
const working = sessionViewOpen && !pendingWidget && agentActive;
|
const working = sessionViewOpen && !pendingWidget && agentActive;
|
||||||
// Processing time counts only while the harness works, not while a finalized
|
// The workflow bar runs only while the harness works, not while a finalized
|
||||||
// session sits idle or a gate awaits the reviewer (pendingWidget).
|
// session sits idle or a gate awaits the reviewer (pendingWidget).
|
||||||
const running = working && !finalized;
|
const running = working && !finalized;
|
||||||
|
|
||||||
@@ -336,7 +336,7 @@ export function AppShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function finishSessionCreation(id: string) {
|
function finishSessionCreation(id: string) {
|
||||||
// React batches these updates, preserving the provisional view and timer
|
// React batches these updates, preserving the provisional session view
|
||||||
// while useSessionStream opens the durable session's SSE channel.
|
// while useSessionStream opens the durable session's SSE channel.
|
||||||
selectActiveSession(id);
|
selectActiveSession(id);
|
||||||
setCreatingSession(false);
|
setCreatingSession(false);
|
||||||
@@ -406,10 +406,7 @@ export function AppShell() {
|
|||||||
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
||||||
{sessionViewOpen ? (
|
{sessionViewOpen ? (
|
||||||
<>
|
<>
|
||||||
<CentralStatus
|
<CentralStatus working={working} />
|
||||||
working={working}
|
|
||||||
workingLabel={creatingSession ? "Creating session…" : "Analyzing question…"}
|
|
||||||
/>
|
|
||||||
{activeSessionId && <WidgetHost sessionId={activeSessionId} />}
|
{activeSessionId && <WidgetHost sessionId={activeSessionId} />}
|
||||||
{finalized && !agentActive && (
|
{finalized && !agentActive && (
|
||||||
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
|
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
|
||||||
|
|||||||
@@ -1,43 +1,85 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { beforeEach } from "vitest";
|
import { beforeEach } from "vitest";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
import { CentralStatus } from "./CentralStatus";
|
import { CentralStatus } from "./CentralStatus";
|
||||||
|
|
||||||
beforeEach(() => useSessionStore.getState().resetSession());
|
beforeEach(() => useSessionStore.getState().resetSession());
|
||||||
|
|
||||||
test("the working spinner is a pure status indicator (no activity toggle on it)", () => {
|
function setScrollGeometry(
|
||||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: "reading the schema" });
|
element: HTMLElement,
|
||||||
|
values: { scrollHeight: number; clientHeight: number; scrollTop: number },
|
||||||
|
) {
|
||||||
|
Object.defineProperties(element, {
|
||||||
|
scrollHeight: { configurable: true, value: values.scrollHeight },
|
||||||
|
clientHeight: { configurable: true, value: values.clientHeight },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: values.scrollTop },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renders only the chronological assistant transcript log while working", () => {
|
||||||
|
useSessionStore.setState({
|
||||||
|
lastUserEntry: { kind: "input", text: "how many patients?" },
|
||||||
|
stepMessages: [{ level: "warning", text: "Searching the schema…" }],
|
||||||
|
transcript: [
|
||||||
|
{ role: "assistant", text: "First line\n\nSecond line" },
|
||||||
|
{ role: "assistant", text: "Third line" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
render(<CentralStatus working />);
|
render(<CentralStatus working />);
|
||||||
// The spinner no longer opens the panel — that moved to the header arrow toggle.
|
|
||||||
expect(screen.queryByRole("button", { name: /model activity/i })).not.toBeInTheDocument();
|
const log = screen.getByRole("log", { name: "Live model activity" });
|
||||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
expect(log).toHaveTextContent("First line");
|
||||||
|
expect(log).toHaveTextContent("Second line");
|
||||||
|
expect(log).toHaveTextContent("Third line");
|
||||||
|
expect(log).toHaveClass("max-h-28", "overflow-y-auto");
|
||||||
|
expect(screen.queryByText("how many patients?")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Searching the schema…")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders nothing when not working and there is no user entry or step messages", () => {
|
test("wraps live log lines instead of truncating them", () => {
|
||||||
const { container } = render(<CentralStatus working={false} />);
|
useSessionStore.setState({
|
||||||
|
transcript: [{ role: "assistant", text: "a_very_long_model_activity_line" }],
|
||||||
|
});
|
||||||
|
render(<CentralStatus working />);
|
||||||
|
expect(screen.getByText("a_very_long_model_activity_line")).toHaveClass(
|
||||||
|
"whitespace-pre-wrap",
|
||||||
|
"break-words",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("a_very_long_model_activity_line")).not.toHaveClass("truncate");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ working: false, transcript: [{ role: "assistant" as const, text: "hidden" }] },
|
||||||
|
{ working: true, transcript: [] },
|
||||||
|
{ working: true, transcript: [{ role: "assistant" as const, text: " \n " }] },
|
||||||
|
])("renders nothing without active non-blank transcript content", ({ working, transcript }) => {
|
||||||
|
useSessionStore.setState({ transcript });
|
||||||
|
const { container } = render(<CentralStatus working={working} />);
|
||||||
expect(container).toBeEmptyDOMElement();
|
expect(container).toBeEmptyDOMElement();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("echoes the last user entry and the step messages", () => {
|
test("bottom-follows appended transcript text only while near the bottom", () => {
|
||||||
const st = useSessionStore.getState();
|
useSessionStore.setState({ transcript: [{ role: "assistant", text: "First" }] });
|
||||||
st.setLastUserEntry({ kind: "input", text: "how many patients?" });
|
|
||||||
st.applyEvent({ type: "info", level: "info", text: "Searching the schema…" });
|
|
||||||
render(<CentralStatus working={false} />);
|
|
||||||
expect(screen.getByText("how many patients?")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Searching the schema…")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows a working indicator with elapsed time and the model-stream tail while working", () => {
|
|
||||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: "Reading the schema for dim_patient" });
|
|
||||||
render(<CentralStatus working />);
|
render(<CentralStatus working />);
|
||||||
// elapsed time (e.g. "0s")
|
const log = screen.getByRole("log", { name: "Live model activity" });
|
||||||
expect(screen.getByText(/\d+s/)).toBeInTheDocument();
|
setScrollGeometry(log, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
|
||||||
// a short tail of the model's current output
|
|
||||||
expect(screen.getByText(/Reading the schema for dim_patient/)).toBeInTheDocument();
|
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: " second" }));
|
||||||
|
|
||||||
|
expect(log.scrollTop).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no working indicator when not working, even with a transcript", () => {
|
test("preserves manual live-log scroll position away from the bottom", () => {
|
||||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: "background narration" });
|
useSessionStore.setState({ transcript: [{ role: "assistant", text: "First" }] });
|
||||||
render(<CentralStatus working={false} />);
|
render(<CentralStatus working />);
|
||||||
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
|
const log = screen.getByRole("log", { name: "Live model activity" });
|
||||||
|
setScrollGeometry(log, { scrollHeight: 400, clientHeight: 100, scrollTop: 100 });
|
||||||
|
fireEvent.scroll(log);
|
||||||
|
|
||||||
|
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: " second" }));
|
||||||
|
|
||||||
|
expect(log.scrollTop).toBe(100);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,76 +1,50 @@
|
|||||||
import { useEffect, useReducer, useState } from "react";
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
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"
|
function transcriptLines(transcript: Array<{ text: string }>): string[] {
|
||||||
* tail (mirrors ModelActivityPanel's tail-line logic, at a smaller size). Returned
|
return transcript.flatMap(({ text }) =>
|
||||||
* as an array so each line renders as its own formatted log row. */
|
text
|
||||||
function tailLines(s: string, n = 5): string[] {
|
|
||||||
return s
|
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((l) => l.trimEnd())
|
.map((line) => line.trimEnd())
|
||||||
.filter((l) => l.trim() !== "")
|
.filter((line) => line.trim() !== ""),
|
||||||
.slice(-n);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The minimal central view: the user's last input/choice, a live "working" indicator
|
/** Compact central projection of the assistant stream while the model is working. */
|
||||||
* (elapsed time + a short tail of the model stream) while the model is busy, and the
|
export function CentralStatus({ working }: { working: boolean }) {
|
||||||
* gate's curated messages for the current step. The full verbose stream lives in the
|
const transcript = useSessionStore((state) => state.transcript);
|
||||||
* left Model-activity panel. */
|
const rows = transcriptLines(transcript);
|
||||||
export function CentralStatus({ working, workingLabel }: { working: boolean; workingLabel?: string }) {
|
const rowCount = rows.length;
|
||||||
const lastUserEntry = useSessionStore((s) => s.lastUserEntry);
|
const tail = rows.at(-1);
|
||||||
const stepMessages = useSessionStore((s) => s.stepMessages);
|
const scrollRef = useRef<HTMLOListElement>(null);
|
||||||
const transcript = useSessionStore((s) => s.transcript);
|
const followRef = useRef(true);
|
||||||
|
|
||||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
useLayoutEffect(() => {
|
||||||
const [, tick] = useReducer((x: number) => x + 1, 0);
|
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
|
if (!working || rows.length === 0) return null;
|
||||||
// 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 && !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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<ol
|
||||||
{lastUserEntry && (
|
ref={scrollRef}
|
||||||
<div className="rounded-lg border border-border/70 bg-card px-3 py-2">
|
role="log"
|
||||||
<span className="thot-label">
|
aria-label="Live model activity"
|
||||||
{lastUserEntry.kind === "input" ? "You asked" : "You chose"}
|
aria-live="polite"
|
||||||
</span>
|
aria-relevant="additions text"
|
||||||
<p className="mt-0.5 text-sm text-foreground">{lastUserEntry.text}</p>
|
tabIndex={0}
|
||||||
</div>
|
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);
|
||||||
{working && (
|
}}
|
||||||
<div className="flex flex-col gap-1.5">
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
{rows.map((line, index) => {
|
||||||
{/* Pure status indicator — opening the activity panel now lives on the
|
const isLast = index === rows.length - 1;
|
||||||
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" />
|
|
||||||
</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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={i}
|
key={index}
|
||||||
className={[
|
className={[
|
||||||
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
|
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
|
||||||
isLast ? "text-foreground/80" : "text-muted-foreground/55",
|
isLast ? "text-foreground/80" : "text-muted-foreground/55",
|
||||||
@@ -78,35 +52,14 @@ export function CentralStatus({ working, workingLabel }: { working: boolean; wor
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={[
|
className={isLast ? "shrink-0 select-none text-primary" : "shrink-0 select-none text-muted-foreground/35"}
|
||||||
"shrink-0 select-none",
|
|
||||||
isLast ? "text-primary" : "text-muted-foreground/35",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
>
|
||||||
›
|
›
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate">{line}</span>
|
<span className="min-w-0 flex-1 whitespace-pre-wrap break-words">{line}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { beforeEach, vi } from "vitest";
|
import { beforeEach, vi } from "vitest";
|
||||||
import type { ActivityEntry, ActivityKind } from "../api/types";
|
import type { ActivityEntry, ActivityKind } from "../api/types";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
|
import { isNearBottom } from "./activityScroll";
|
||||||
import * as activityPanelModule from "./ModelActivityPanel";
|
import * as activityPanelModule from "./ModelActivityPanel";
|
||||||
import { isNearBottom, ModelActivityPanel } from "./ModelActivityPanel";
|
import { ModelActivityPanel } from "./ModelActivityPanel";
|
||||||
|
|
||||||
beforeEach(() => useSessionStore.getState().resetSession());
|
beforeEach(() => useSessionStore.getState().resetSession());
|
||||||
|
|
||||||
test("renders only prompt, thinking, status, and gate from a mixed F1 sequence", () => {
|
test("renders only thinking and status from a mixed F1 sequence", () => {
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.setPhase("F1");
|
store.setPhase("F1");
|
||||||
store.setLastUserEntry({ kind: "input", text: "How many patients?" });
|
store.setLastUserEntry({ kind: "input", text: "How many patients?" });
|
||||||
@@ -17,10 +18,6 @@ test("renders only prompt, thinking, status, and gate from a mixed F1 sequence",
|
|||||||
type: "activity_event",
|
type: "activity_event",
|
||||||
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" },
|
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" },
|
||||||
});
|
});
|
||||||
store.applyEvent({
|
|
||||||
type: "activity_event",
|
|
||||||
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" },
|
|
||||||
});
|
|
||||||
store.applyEvent({ type: "text_delta", text: "Let me run another bash search." });
|
store.applyEvent({ type: "text_delta", text: "Let me run another bash search." });
|
||||||
store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." });
|
store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." });
|
||||||
store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" });
|
store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" });
|
||||||
@@ -31,14 +28,12 @@ test("renders only prompt, thinking, status, and gate from a mixed F1 sequence",
|
|||||||
|
|
||||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||||
|
|
||||||
expect(screen.getAllByRole("article")).toHaveLength(4);
|
expect(screen.getAllByRole("article")).toHaveLength(2);
|
||||||
expect(screen.getByText("How many patients?")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("schema").tagName).toBe("STRONG");
|
expect(screen.getByText("schema").tagName).toBe("STRONG");
|
||||||
expect(screen.getByText("Retrying schema lookup")).toBeInTheDocument();
|
expect(screen.getByText("Retrying schema lookup")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Confirm cohort")).toBeInTheDocument();
|
expect(screen.queryByText("How many patients?")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Agent started")).not.toBeInTheDocument();
|
expect(screen.queryByText("Confirm cohort")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("bash")).not.toBeInTheDocument();
|
expect(screen.queryByText("bash")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("completed")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Let me run another bash search.")).not.toBeInTheDocument();
|
expect(screen.queryByText("Let me run another bash search.")).not.toBeInTheDocument();
|
||||||
expect(useSessionStore.getState().transcript).toEqual([
|
expect(useSessionStore.getState().transcript).toEqual([
|
||||||
{ role: "assistant", text: "Let me run another bash search." },
|
{ role: "assistant", text: "Let me run another bash search." },
|
||||||
@@ -52,10 +47,10 @@ test("uses an explicit default-deny activity-kind allowlist", () => {
|
|||||||
expect(predicate).toBeTypeOf("function");
|
expect(predicate).toBeTypeOf("function");
|
||||||
if (!predicate) return;
|
if (!predicate) return;
|
||||||
|
|
||||||
for (const kind of ["prompt", "thinking", "status", "gate"] satisfies ActivityKind[]) {
|
for (const kind of ["thinking", "status"] satisfies ActivityKind[]) {
|
||||||
expect(predicate({ kind, phase: "F1", text: kind })).toBe(true);
|
expect(predicate({ kind, phase: "F1", text: kind })).toBe(true);
|
||||||
}
|
}
|
||||||
for (const kind of ["assistant", "tool", "lifecycle"] satisfies ActivityKind[]) {
|
for (const kind of ["prompt", "gate", "assistant", "tool", "lifecycle"] satisfies ActivityKind[]) {
|
||||||
expect(predicate({ kind, phase: "F1", text: kind })).toBe(false);
|
expect(predicate({ kind, phase: "F1", text: kind })).toBe(false);
|
||||||
}
|
}
|
||||||
expect(predicate({ kind: "future-kind" as ActivityKind, phase: "F1", text: "future" })).toBe(false);
|
expect(predicate({ kind: "future-kind" as ActivityKind, phase: "F1", text: "future" })).toBe(false);
|
||||||
@@ -64,6 +59,8 @@ test("uses an explicit default-deny activity-kind allowlist", () => {
|
|||||||
test("shows the empty state when the raw log contains only hidden entries", () => {
|
test("shows the empty state when the raw log contains only hidden entries", () => {
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
activityLog: [
|
activityLog: [
|
||||||
|
{ kind: "prompt", phase: "F1", text: "How many patients?" },
|
||||||
|
{ kind: "gate", phase: "F1", text: "Confirm cohort" },
|
||||||
{ kind: "assistant", phase: "F1", text: "Let me try another command." },
|
{ kind: "assistant", phase: "F1", text: "Let me try another command." },
|
||||||
{ kind: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" },
|
{ kind: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" },
|
||||||
{ kind: "lifecycle", phase: "F1", text: "Turn end" },
|
{ kind: "lifecycle", phase: "F1", text: "Turn end" },
|
||||||
@@ -122,7 +119,7 @@ test("renders thinking markdown without assistant narration", () => {
|
|||||||
test("close calls onClose without mutating the activity log", async () => {
|
test("close calls onClose without mutating the activity log", async () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const activityLog: ActivityEntry[] = [
|
const activityLog: ActivityEntry[] = [
|
||||||
{ kind: "prompt", phase: "F1", text: "Keep this row" },
|
{ kind: "status", phase: "F1", text: "Keep this row" },
|
||||||
];
|
];
|
||||||
useSessionStore.setState({ activityLog });
|
useSessionStore.setState({ activityLog });
|
||||||
render(<ModelActivityPanel onClose={onClose} />);
|
render(<ModelActivityPanel onClose={onClose} />);
|
||||||
@@ -151,9 +148,9 @@ function setScrollGeometry(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("follows appended activity while the viewport is near the bottom", () => {
|
test("follows appended visible activity while near the bottom", () => {
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
activityLog: [{ kind: "prompt", phase: "F1", text: "First" }],
|
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
|
||||||
});
|
});
|
||||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||||
const viewport = screen.getByTestId("activity-scroll");
|
const viewport = screen.getByTestId("activity-scroll");
|
||||||
@@ -165,9 +162,9 @@ test("follows appended activity while the viewport is near the bottom", () => {
|
|||||||
expect(viewport).toHaveAttribute("tabindex", "0");
|
expect(viewport).toHaveAttribute("tabindex", "0");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves manual scroll position when appended activity arrives", () => {
|
test("preserves manual scroll position when visible activity arrives", () => {
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
activityLog: [{ kind: "prompt", phase: "F1", text: "First" }],
|
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
|
||||||
});
|
});
|
||||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||||
const viewport = screen.getByTestId("activity-scroll");
|
const viewport = screen.getByTestId("activity-scroll");
|
||||||
@@ -179,18 +176,15 @@ test("preserves manual scroll position when appended activity arrives", () => {
|
|||||||
expect(viewport.scrollTop).toBe(100);
|
expect(viewport.scrollTop).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not bottom-follow when only a hidden event arrives", () => {
|
test("does not bottom-follow when only a hidden prompt arrives", () => {
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
activityLog: [{ kind: "prompt", phase: "F1", text: "Visible prompt" }],
|
activityLog: [{ kind: "status", phase: "F1", text: "Visible status" }],
|
||||||
});
|
});
|
||||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||||
const viewport = screen.getByTestId("activity-scroll");
|
const viewport = screen.getByTestId("activity-scroll");
|
||||||
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
|
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
|
||||||
|
|
||||||
act(() => useSessionStore.getState().applyEvent({
|
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Hidden prompt" }));
|
||||||
type: "activity_event",
|
|
||||||
activity: { kind: "tool", toolCallId: "tool-2", toolName: "bash", status: "running" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(viewport.scrollTop).toBe(300);
|
expect(viewport.scrollTop).toBe(300);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,25 +5,17 @@ import remarkGfm from "remark-gfm";
|
|||||||
import type { ActivityEntry, ActivityKind } from "../api/types";
|
import type { ActivityEntry, ActivityKind } from "../api/types";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
|
import { isNearBottom } from "./activityScroll";
|
||||||
|
|
||||||
const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet<ActivityKind> = new Set([
|
const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet<ActivityKind> = new Set([
|
||||||
"prompt",
|
|
||||||
"thinking",
|
"thinking",
|
||||||
"status",
|
"status",
|
||||||
"gate",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isVisibleModelActivity(entry: ActivityEntry): boolean {
|
export function isVisibleModelActivity(entry: ActivityEntry): boolean {
|
||||||
return VISIBLE_MODEL_ACTIVITY_KINDS.has(entry.kind);
|
return VISIBLE_MODEL_ACTIVITY_KINDS.has(entry.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export function formatModelActivity(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/\r\n?/g, "\n")
|
.replace(/\r\n?/g, "\n")
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function isNearBottom(
|
||||||
|
el: Pick<HTMLElement, "scrollHeight" | "clientHeight" | "scrollTop">,
|
||||||
|
threshold = 48,
|
||||||
|
): boolean {
|
||||||
|
return el.scrollHeight - el.clientHeight - el.scrollTop <= threshold;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user