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 { http, HttpResponse, delay } from "msw";
|
||||
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");
|
||||
});
|
||||
|
||||
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;
|
||||
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
|
||||
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.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(await screen.findByText("How many patients?", { selector: "p" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Creating session…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/\d+s/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("How many patients?", { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Creating session…")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
|
||||
expect(useSessionStore.getState().activityLog[0]).toEqual({
|
||||
kind: "prompt",
|
||||
phase: "F1",
|
||||
text: "How many patients?",
|
||||
});
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
|
||||
releaseCreate();
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||
expect(screen.getByText("How many patients?", { selector: "p" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Analyzing question…")).toBeInTheDocument();
|
||||
act(() => FakeEventSource.instances[0].emit({ type: "text_delta", text: "Inspecting cohort" }));
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -697,15 +697,13 @@ test("closing and reopening Model activity preserves the complete activity log",
|
||||
const store = useSessionStore.getState();
|
||||
store.setPhase("F4");
|
||||
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({
|
||||
type: "activity_event",
|
||||
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" },
|
||||
});
|
||||
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 }));
|
||||
|
||||
@@ -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 }));
|
||||
const panel = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
||||
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("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 }));
|
||||
const reopened = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
||||
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("Cohort ready")).not.toBeInTheDocument();
|
||||
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 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
|
||||
// leave the spinner on forever.
|
||||
// leave the working state on forever.
|
||||
const pendingWidget = useSessionStore((s) => s.pendingWidget);
|
||||
const resetSession = useSessionStore((s) => s.resetSession);
|
||||
const recordLifecycle = useSessionStore((s) => s.recordLifecycle);
|
||||
@@ -289,7 +289,7 @@ export function AppShell() {
|
||||
const agentActive = useSessionStore((s) => s.agentActive);
|
||||
const sessionViewOpen = Boolean(activeSessionId) || creatingSession;
|
||||
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).
|
||||
const running = working && !finalized;
|
||||
|
||||
@@ -336,7 +336,7 @@ export function AppShell() {
|
||||
}
|
||||
|
||||
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.
|
||||
selectActiveSession(id);
|
||||
setCreatingSession(false);
|
||||
@@ -406,10 +406,7 @@ export function AppShell() {
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6">
|
||||
{sessionViewOpen ? (
|
||||
<>
|
||||
<CentralStatus
|
||||
working={working}
|
||||
workingLabel={creatingSession ? "Creating session…" : "Analyzing question…"}
|
||||
/>
|
||||
<CentralStatus working={working} />
|
||||
{activeSessionId && <WidgetHost sessionId={activeSessionId} />}
|
||||
{finalized && !agentActive && (
|
||||
<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 { useSessionStore } from "../store/sessionStore";
|
||||
import { CentralStatus } from "./CentralStatus";
|
||||
|
||||
beforeEach(() => useSessionStore.getState().resetSession());
|
||||
|
||||
test("the working spinner is a pure status indicator (no activity toggle on it)", () => {
|
||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: "reading the schema" });
|
||||
function setScrollGeometry(
|
||||
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 />);
|
||||
// The spinner no longer opens the panel — that moved to the header arrow toggle.
|
||||
expect(screen.queryByRole("button", { name: /model activity/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
|
||||
const log = screen.getByRole("log", { name: "Live model activity" });
|
||||
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", () => {
|
||||
const { container } = render(<CentralStatus working={false} />);
|
||||
test("wraps live log lines instead of truncating them", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
test("echoes the last user entry and the step messages", () => {
|
||||
const st = useSessionStore.getState();
|
||||
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" });
|
||||
test("bottom-follows appended transcript text only while near the bottom", () => {
|
||||
useSessionStore.setState({ transcript: [{ role: "assistant", text: "First" }] });
|
||||
render(<CentralStatus working />);
|
||||
// elapsed time (e.g. "0s")
|
||||
expect(screen.getByText(/\d+s/)).toBeInTheDocument();
|
||||
// a short tail of the model's current output
|
||||
expect(screen.getByText(/Reading the schema for dim_patient/)).toBeInTheDocument();
|
||||
const log = screen.getByRole("log", { name: "Live model activity" });
|
||||
setScrollGeometry(log, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
|
||||
|
||||
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", () => {
|
||||
useSessionStore.getState().applyEvent({ type: "text_delta", text: "background narration" });
|
||||
render(<CentralStatus working={false} />);
|
||||
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
|
||||
test("preserves manual live-log scroll position away from the bottom", () => {
|
||||
useSessionStore.setState({ transcript: [{ role: "assistant", text: "First" }] });
|
||||
render(<CentralStatus working />);
|
||||
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 { 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
|
||||
function transcriptLines(transcript: Array<{ text: string }>): string[] {
|
||||
return transcript.flatMap(({ text }) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())
|
||||
.filter((l) => l.trim() !== "")
|
||||
.slice(-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 && !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 ?? "");
|
||||
if (!working || rows.length === 0) return null;
|
||||
|
||||
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" />
|
||||
</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;
|
||||
<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={i}
|
||||
key={index}
|
||||
className={[
|
||||
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
|
||||
isLast ? "text-foreground/80" : "text-muted-foreground/55",
|
||||
@@ -78,35 +52,14 @@ export function CentralStatus({ working, workingLabel }: { working: boolean; wor
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={[
|
||||
"shrink-0 select-none",
|
||||
isLast ? "text-primary" : "text-muted-foreground/35",
|
||||
].join(" ")}
|
||||
className={isLast ? "shrink-0 select-none text-primary" : "shrink-0 select-none text-muted-foreground/35"}
|
||||
>
|
||||
›
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</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 type { ActivityEntry, ActivityKind } from "../api/types";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { isNearBottom } from "./activityScroll";
|
||||
import * as activityPanelModule from "./ModelActivityPanel";
|
||||
import { isNearBottom, ModelActivityPanel } from "./ModelActivityPanel";
|
||||
import { ModelActivityPanel } from "./ModelActivityPanel";
|
||||
|
||||
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();
|
||||
store.setPhase("F1");
|
||||
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",
|
||||
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: "activity_delta", text: "Inspecting **schema**." });
|
||||
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()} />);
|
||||
|
||||
expect(screen.getAllByRole("article")).toHaveLength(4);
|
||||
expect(screen.getByText("How many patients?")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("article")).toHaveLength(2);
|
||||
expect(screen.getByText("schema").tagName).toBe("STRONG");
|
||||
expect(screen.getByText("Retrying schema lookup")).toBeInTheDocument();
|
||||
expect(screen.getByText("Confirm cohort")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Agent started")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("How many patients?")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Confirm cohort")).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(useSessionStore.getState().transcript).toEqual([
|
||||
{ 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");
|
||||
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);
|
||||
}
|
||||
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: "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", () => {
|
||||
useSessionStore.setState({
|
||||
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: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" },
|
||||
{ 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 () => {
|
||||
const onClose = vi.fn();
|
||||
const activityLog: ActivityEntry[] = [
|
||||
{ kind: "prompt", phase: "F1", text: "Keep this row" },
|
||||
{ kind: "status", phase: "F1", text: "Keep this row" },
|
||||
];
|
||||
useSessionStore.setState({ activityLog });
|
||||
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({
|
||||
activityLog: [{ kind: "prompt", phase: "F1", text: "First" }],
|
||||
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
|
||||
});
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
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");
|
||||
});
|
||||
|
||||
test("preserves manual scroll position when appended activity arrives", () => {
|
||||
test("preserves manual scroll position when visible activity arrives", () => {
|
||||
useSessionStore.setState({
|
||||
activityLog: [{ kind: "prompt", phase: "F1", text: "First" }],
|
||||
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
|
||||
});
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
const viewport = screen.getByTestId("activity-scroll");
|
||||
@@ -179,18 +176,15 @@ test("preserves manual scroll position when appended activity arrives", () => {
|
||||
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({
|
||||
activityLog: [{ kind: "prompt", phase: "F1", text: "Visible prompt" }],
|
||||
activityLog: [{ kind: "status", phase: "F1", text: "Visible status" }],
|
||||
});
|
||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||
const viewport = screen.getByTestId("activity-scroll");
|
||||
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 });
|
||||
|
||||
act(() => useSessionStore.getState().applyEvent({
|
||||
type: "activity_event",
|
||||
activity: { kind: "tool", toolCallId: "tool-2", toolName: "bash", status: "running" },
|
||||
}));
|
||||
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Hidden prompt" }));
|
||||
|
||||
expect(viewport.scrollTop).toBe(300);
|
||||
});
|
||||
|
||||
@@ -5,25 +5,17 @@ import remarkGfm from "remark-gfm";
|
||||
import type { ActivityEntry, ActivityKind } from "../api/types";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { isNearBottom } from "./activityScroll";
|
||||
|
||||
const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet<ActivityKind> = new Set([
|
||||
"prompt",
|
||||
"thinking",
|
||||
"status",
|
||||
"gate",
|
||||
]);
|
||||
|
||||
export function isVisibleModelActivity(entry: ActivityEntry): boolean {
|
||||
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 {
|
||||
return text
|
||||
.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