fix(frontend): restore readable model activity timeline
This commit is contained in:
@@ -711,11 +711,12 @@ 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(panel).toHaveTextContent("Inspect patient cohort");
|
||||||
|
expect(panel).toHaveTextContent("Inspecting cohort");
|
||||||
expect(within(panel!).getByText("cohort").tagName).toBe("STRONG");
|
expect(within(panel!).getByText("cohort").tagName).toBe("STRONG");
|
||||||
expect(within(panel!).getByText("Cohort status")).toBeInTheDocument();
|
expect(within(panel!).getByText("Cohort ready")).toBeInTheDocument();
|
||||||
expect(within(panel!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
|
expect(within(panel!).queryByText("Cohort status")).not.toBeInTheDocument();
|
||||||
expect(within(panel!).queryByText("bash")).not.toBeInTheDocument();
|
expect(within(panel!).queryByText("bash")).not.toBeInTheDocument();
|
||||||
expect(within(panel!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /close model activity/i }));
|
await userEvent.click(screen.getByRole("button", { name: /close model activity/i }));
|
||||||
expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument();
|
||||||
@@ -724,11 +725,12 @@ 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(reopened).toHaveTextContent("Inspect patient cohort");
|
||||||
|
expect(reopened).toHaveTextContent("Inspecting cohort");
|
||||||
expect(within(reopened!).getByText("cohort").tagName).toBe("STRONG");
|
expect(within(reopened!).getByText("cohort").tagName).toBe("STRONG");
|
||||||
expect(within(reopened!).getByText("Cohort status")).toBeInTheDocument();
|
expect(within(reopened!).getByText("Cohort ready")).toBeInTheDocument();
|
||||||
expect(within(reopened!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
|
expect(within(reopened!).queryByText("Cohort status")).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(useSessionStore.getState().activityLog).toEqual(beforeClose);
|
expect(useSessionStore.getState().activityLog).toEqual(beforeClose);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,17 @@ import { ModelActivityPanel } from "./ModelActivityPanel";
|
|||||||
|
|
||||||
beforeEach(() => useSessionStore.getState().resetSession());
|
beforeEach(() => useSessionStore.getState().resetSession());
|
||||||
|
|
||||||
test("renders only thinking and status from a mixed F1 sequence", () => {
|
test("renders question, reasoning, and response in chronological order", () => {
|
||||||
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**?" });
|
||||||
store.applyEvent({ type: "system_event", event: "agent_start" });
|
store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" });
|
||||||
|
store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." });
|
||||||
|
store.applyEvent({ type: "text_delta", text: "I found *42* patients." });
|
||||||
store.applyEvent({
|
store.applyEvent({
|
||||||
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: "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" });
|
|
||||||
store.applyEvent({
|
store.applyEvent({
|
||||||
type: "ui_request",
|
type: "ui_request",
|
||||||
ui_request: { id: "gate-1", widget: "select", phase: "F1_review", title: "Confirm cohort" },
|
ui_request: { id: "gate-1", widget: "select", phase: "F1_review", title: "Confirm cohort" },
|
||||||
@@ -28,16 +27,17 @@ test("renders only thinking and status from a mixed F1 sequence", () => {
|
|||||||
|
|
||||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
render(<ModelActivityPanel onClose={vi.fn()} />);
|
||||||
|
|
||||||
expect(screen.getAllByRole("article")).toHaveLength(2);
|
const rows = screen.getAllByRole("article");
|
||||||
expect(screen.getByText("schema").tagName).toBe("STRONG");
|
expect(rows).toHaveLength(3);
|
||||||
expect(screen.getByText("Retrying schema lookup")).toBeInTheDocument();
|
expect(within(rows[0]).getByText("Question")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("How many patients?")).not.toBeInTheDocument();
|
expect(within(rows[0]).getByText("patients").tagName).toBe("STRONG");
|
||||||
|
expect(within(rows[1]).getByText("Reasoning")).toBeInTheDocument();
|
||||||
|
expect(within(rows[1]).getByText("schema").tagName).toBe("STRONG");
|
||||||
|
expect(within(rows[2]).getByText("Response")).toBeInTheDocument();
|
||||||
|
expect(within(rows[2]).getByText("42").tagName).toBe("EM");
|
||||||
|
expect(screen.queryByText("Retrying schema lookup")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Confirm cohort")).not.toBeInTheDocument();
|
expect(screen.queryByText("Confirm cohort")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("bash")).not.toBeInTheDocument();
|
expect(screen.queryByText("bash")).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." },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses an explicit default-deny activity-kind allowlist", () => {
|
test("uses an explicit default-deny activity-kind allowlist", () => {
|
||||||
@@ -47,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 ["thinking", "status"] satisfies ActivityKind[]) {
|
for (const kind of ["prompt", "thinking", "assistant"] satisfies ActivityKind[]) {
|
||||||
expect(predicate({ kind, phase: "F1", text: kind })).toBe(true);
|
expect(predicate({ kind, phase: "F1", text: kind })).toBe(true);
|
||||||
}
|
}
|
||||||
for (const kind of ["prompt", "gate", "assistant", "tool", "lifecycle"] satisfies ActivityKind[]) {
|
for (const kind of ["status", "gate", "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);
|
||||||
@@ -59,9 +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: "status", phase: "F1", text: "Status" },
|
||||||
{ kind: "gate", phase: "F1", text: "Confirm cohort" },
|
{ 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: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" },
|
||||||
{ kind: "lifecycle", phase: "F1", text: "Turn end" },
|
{ kind: "lifecycle", phase: "F1", text: "Turn end" },
|
||||||
],
|
],
|
||||||
@@ -75,7 +74,7 @@ test("shows the empty state when the raw log contains only hidden entries", () =
|
|||||||
|
|
||||||
test("keeps all timeline entries visible without an expansion control", () => {
|
test("keeps all timeline entries visible without an expansion control", () => {
|
||||||
const activityLog: ActivityEntry[] = Array.from({ length: 8 }, (_, index) => ({
|
const activityLog: ActivityEntry[] = Array.from({ length: 8 }, (_, index) => ({
|
||||||
kind: "status",
|
kind: "thinking",
|
||||||
phase: `F${index + 1}`,
|
phase: `F${index + 1}`,
|
||||||
text: `timeline-${index + 1}`,
|
text: `timeline-${index + 1}`,
|
||||||
}));
|
}));
|
||||||
@@ -87,39 +86,10 @@ test("keeps all timeline entries visible without an expansion control", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /show (more|less)/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /show (more|less)/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("labels a warning in text instead of relying on its semantic color", () => {
|
|
||||||
useSessionStore.setState({
|
|
||||||
activityLog: [
|
|
||||||
{ kind: "status", phase: "F3", text: "Retrying retrieval", level: "warning" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
|
||||||
|
|
||||||
const row = screen.getByText("Retrying retrieval").closest("article");
|
|
||||||
expect(row).not.toBeNull();
|
|
||||||
expect(within(row!).getByText("warning")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders thinking markdown without assistant narration", () => {
|
|
||||||
useSessionStore.setState({
|
|
||||||
activityLog: [
|
|
||||||
{ kind: "thinking", phase: "F2", text: "Considering **history**." },
|
|
||||||
{ kind: "assistant", phase: "F2", text: "Use *current* records." },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ModelActivityPanel onClose={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("history").tagName).toBe("STRONG");
|
|
||||||
expect(screen.queryByText("current")).not.toBeInTheDocument();
|
|
||||||
expect(screen.getAllByRole("article")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
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: "status", phase: "F1", text: "Keep this row" },
|
{ kind: "thinking", phase: "F1", text: "Keep this row" },
|
||||||
];
|
];
|
||||||
useSessionStore.setState({ activityLog });
|
useSessionStore.setState({ activityLog });
|
||||||
render(<ModelActivityPanel onClose={onClose} />);
|
render(<ModelActivityPanel onClose={onClose} />);
|
||||||
@@ -148,7 +118,7 @@ function setScrollGeometry(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("follows appended visible activity while near the bottom", () => {
|
test("follows appended visible response while near the bottom", () => {
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
|
activityLog: [{ kind: "thinking", phase: "F1", text: "First" }],
|
||||||
});
|
});
|
||||||
@@ -156,10 +126,8 @@ test("follows appended visible activity while near the bottom", () => {
|
|||||||
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({ type: "info", text: "Second" }));
|
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" }));
|
||||||
|
|
||||||
expect(viewport.scrollTop).toBe(400);
|
expect(viewport.scrollTop).toBe(400);
|
||||||
expect(viewport).toHaveAttribute("tabindex", "0");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves manual scroll position when visible activity arrives", () => {
|
test("preserves manual scroll position when visible activity arrives", () => {
|
||||||
@@ -171,20 +139,19 @@ test("preserves manual scroll position when visible activity arrives", () => {
|
|||||||
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 100 });
|
setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 100 });
|
||||||
fireEvent.scroll(viewport);
|
fireEvent.scroll(viewport);
|
||||||
|
|
||||||
act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Second" }));
|
act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" }));
|
||||||
|
|
||||||
expect(viewport.scrollTop).toBe(100);
|
expect(viewport.scrollTop).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not bottom-follow when only a hidden prompt arrives", () => {
|
test("does not bottom-follow when only a hidden status arrives", () => {
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
activityLog: [{ kind: "status", phase: "F1", text: "Visible status" }],
|
activityLog: [{ kind: "thinking", phase: "F1", text: "Visible reasoning" }],
|
||||||
});
|
});
|
||||||
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().setLastUserEntry({ kind: "input", text: "Hidden prompt" }));
|
act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Hidden status" }));
|
||||||
|
|
||||||
expect(viewport.scrollTop).toBe(300);
|
expect(viewport.scrollTop).toBe(300);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,10 +8,17 @@ import { Button } from "../components/ui/button";
|
|||||||
import { isNearBottom } from "./activityScroll";
|
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",
|
"assistant",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const ACTIVITY_LABELS: Readonly<Partial<Record<ActivityKind, string>>> = {
|
||||||
|
prompt: "Question",
|
||||||
|
thinking: "Reasoning",
|
||||||
|
assistant: "Response",
|
||||||
|
};
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -36,54 +43,14 @@ function MarkdownBody({ entry }: { entry: ActivityEntry }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
function ActivityRow({ entry }: { entry: ActivityEntry }) {
|
||||||
return (
|
return (
|
||||||
<article className="border-b border-border/50 py-3 last:border-b-0">
|
<article className="border-b border-border/50 py-2 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">
|
<div className="mb-1 flex flex-wrap items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||||
{entry.phase && <span>{entry.phase}</span>}
|
<span className="text-foreground/80">{ACTIVITY_LABELS[entry.kind]}</span>
|
||||||
<span>{entry.kind}</span>
|
{entry.phase && <span aria-label={`Phase ${entry.phase}`}>{entry.phase}</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>
|
</div>
|
||||||
<ActivityBody entry={entry} />
|
<MarkdownBody entry={entry} />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,7 +71,7 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex w-2/5 shrink-0 flex-col border-r border-border bg-sidebar">
|
<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">
|
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
|
||||||
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
|
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
|
||||||
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
<Button size="icon-sm" variant="ghost" aria-label="Close model activity" onClick={onClose}>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
|
|||||||
Reference in New Issue
Block a user