diff --git a/src/web/RunDetail.tsx b/src/web/RunDetail.tsx index 18a6417..8ea575a 100644 --- a/src/web/RunDetail.tsx +++ b/src/web/RunDetail.tsx @@ -1,7 +1,17 @@ -import { useEffect, useState } from "react"; -import { fetchRun, type ApiEvent, type ApiRun, type ApiRunDetail } from "./api.js"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { fetchRun, type ApiEvent, type ApiRun, type ApiRunDetail, type ApiTurn } from "./api.js"; import { StatusBadge } from "./Dashboard.js"; import { useEventStream } from "./useEventStream.js"; +import { + ASSISTANT_LINE_THRESHOLD, + collapsedSummary, + eventDomId, + findErrorEvents, + shouldCollapseTurn, + stepCursor, + turnLineCount, + turnLineThreshold, +} from "./runDetailUtils.js"; type LoadState = | { tag: "loading" } @@ -40,6 +50,7 @@ export function RunDetail({ runId }: { runId: string }) { const { run, turns, events } = state.detail; const errorEvent = events.find((e) => e.eventType === "error"); + const isLive = run.status === "running"; return (
@@ -62,58 +73,192 @@ export function RunDetail({ runId }: { runId: string }) { -
-

Turns

-
    - {turns.map((t) => ( -
  • -
    - - #{t.turnNumber}{" "} - {t.role} - - {t.finalState && ( - - → {t.finalState} - - )} -
    -
    {t.content}
    - {t.toolCalls && } - {t.renderedPrompt && ( -
    - - - Rendered prompt the model saw - -
    -                    {t.renderedPrompt}
    -                  
    -
    - )} -
  • - ))} -
-
+ -
-

Events

-
    - {events.map((e) => ( -
  • + +
+ ); +} + +function TurnsSection({ + turns, + events, + isLive, +}: { + turns: ApiTurn[]; + events: ApiEvent[]; + isLive: boolean; +}) { + const errorEvents = useMemo(() => findErrorEvents(events), [events]); + const sentinelRef = useRef(null); + const programmaticUntil = useRef(0); + const lastY = useRef(typeof window !== "undefined" ? window.scrollY : 0); + const [autoFollow, setAutoFollow] = useState(false); + const [errorCursor, setErrorCursor] = useState(-1); + + useEffect(() => { + const onScroll = () => { + const y = window.scrollY; + if (Date.now() < programmaticUntil.current) { + lastY.current = y; + return; + } + if (autoFollow && y < lastY.current - 4) { + setAutoFollow(false); + } + lastY.current = y; + }; + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, [autoFollow]); + + useEffect(() => { + if (!autoFollow) return; + programmaticUntil.current = Date.now() + 600; + sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [autoFollow, turns.length]); + + const jumpError = (dir: 1 | -1) => { + const next = stepCursor(errorEvents.length, errorCursor, dir); + if (next < 0) return; + setErrorCursor(next); + const id = eventDomId(errorEvents[next].id); + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "center" }); + }; + + return ( +
+
+

Turns

+
+ {errorEvents.length > 0 && ( +
+ + {errorEvents.length === 1 ? "1 error" : `${errorEvents.length} errors`} + + + +
+ )} + {isLive && ( + + )} +
+
+
    + {turns.map((t) => ( + + ))} +
+
+ ); +} + +function TurnCard({ turn }: { turn: ApiTurn }) { + const threshold = turnLineThreshold(turn.role); + const collapsible = shouldCollapseTurn(turn.content, threshold); + const [expanded, setExpanded] = useState(false); + const showCollapsed = collapsible && !expanded; + const summary = showCollapsed ? collapsedSummary(turn.content, threshold) : null; + + return ( +
  • +
    + + #{turn.turnNumber}{" "} + {turn.role} + + {turn.finalState && ( + + → {turn.finalState} + + )} +
    +
    +        {showCollapsed && summary ? summary.head : turn.content}
    +      
    + {collapsible && ( + + )} + {turn.toolCalls && } + {turn.renderedPrompt && ( +
    + + + Rendered prompt the model saw + +
    +            {turn.renderedPrompt}
    +          
    +
    + )} +
  • + ); +} + +function EventsSection({ events }: { events: ApiEvent[] }) { + return ( +
    +

    Events

    +
      + {events.map((e) => { + const isError = /error/i.test(e.eventType); + return ( +
    • {new Date(e.ts).toLocaleTimeString()}{" "} - {e.eventType} + {e.eventType} {e.payload && {e.payload}}
    • - ))} -
    -
    - + ); + })} + + ); } @@ -160,13 +305,14 @@ function safeParse(s: string): unknown { function ToolCalls({ raw }: { raw: string }) { const parsed = safeParse(raw); const pretty = parsed === null ? raw : JSON.stringify(parsed, null, 2); + const longOutput = shouldCollapseTurn(pretty, ASSISTANT_LINE_THRESHOLD); return (
    - Tool calls + Tool calls{longOutput ? ` (${turnLineCount(pretty)} lines)` : ""}
             {pretty}
    diff --git a/src/web/runDetailUtils.test.ts b/src/web/runDetailUtils.test.ts
    new file mode 100644
    index 0000000..353269e
    --- /dev/null
    +++ b/src/web/runDetailUtils.test.ts
    @@ -0,0 +1,122 @@
    +import { describe, expect, it } from "vitest";
    +import type { ApiEvent } from "./api.js";
    +import {
    +  ASSISTANT_LINE_THRESHOLD,
    +  TOOL_LINE_THRESHOLD,
    +  collapsedSummary,
    +  findErrorEvents,
    +  shouldCollapseTurn,
    +  stepCursor,
    +  turnLineCount,
    +  turnLineThreshold,
    +} from "./runDetailUtils.js";
    +
    +function ev(id: number, eventType: string): ApiEvent {
    +  return {
    +    id,
    +    runId: "r",
    +    turnId: null,
    +    eventType,
    +    issueId: null,
    +    payload: null,
    +    ts: "2026-05-03T00:00:00.000Z",
    +  };
    +}
    +
    +describe("turnLineCount", () => {
    +  it("returns 0 for empty content", () => {
    +    expect(turnLineCount("")).toBe(0);
    +  });
    +  it("counts a single line as 1", () => {
    +    expect(turnLineCount("hello")).toBe(1);
    +  });
    +  it("counts trailing newlines", () => {
    +    expect(turnLineCount("a\nb\n")).toBe(3);
    +  });
    +});
    +
    +describe("turnLineThreshold", () => {
    +  it("uses the tool threshold for tool turns", () => {
    +    expect(turnLineThreshold("tool")).toBe(TOOL_LINE_THRESHOLD);
    +  });
    +  it("uses the assistant threshold for assistant turns", () => {
    +    expect(turnLineThreshold("assistant")).toBe(ASSISTANT_LINE_THRESHOLD);
    +  });
    +  it("falls back to the assistant threshold for unknown roles", () => {
    +    expect(turnLineThreshold("user")).toBe(ASSISTANT_LINE_THRESHOLD);
    +  });
    +});
    +
    +describe("shouldCollapseTurn", () => {
    +  it("does not collapse short content", () => {
    +    expect(shouldCollapseTurn("a\nb\nc", ASSISTANT_LINE_THRESHOLD)).toBe(false);
    +  });
    +  it("collapses content above the threshold", () => {
    +    const long = Array.from({ length: ASSISTANT_LINE_THRESHOLD + 1 }, (_, i) => `line ${i}`).join(
    +      "\n",
    +    );
    +    expect(shouldCollapseTurn(long, ASSISTANT_LINE_THRESHOLD)).toBe(true);
    +  });
    +  it("does not collapse exactly at the threshold", () => {
    +    const exact = Array.from({ length: ASSISTANT_LINE_THRESHOLD }, (_, i) => `line ${i}`).join(
    +      "\n",
    +    );
    +    expect(shouldCollapseTurn(exact, ASSISTANT_LINE_THRESHOLD)).toBe(false);
    +  });
    +  it("collapses any multi-line tool content above the tool threshold", () => {
    +    expect(shouldCollapseTurn("a\nb", TOOL_LINE_THRESHOLD)).toBe(true);
    +  });
    +});
    +
    +describe("collapsedSummary", () => {
    +  it("returns the full content when under the threshold", () => {
    +    const out = collapsedSummary("a\nb", ASSISTANT_LINE_THRESHOLD);
    +    expect(out).toEqual({ head: "a\nb", remaining: 0 });
    +  });
    +  it("returns the first N lines and the remaining count", () => {
    +    const long = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n");
    +    const out = collapsedSummary(long, 5);
    +    expect(out.head.split("\n")).toHaveLength(5);
    +    expect(out.head).toBe("line 0\nline 1\nline 2\nline 3\nline 4");
    +    expect(out.remaining).toBe(15);
    +  });
    +  it("collapses tool output to the first line when threshold is 1", () => {
    +    const out = collapsedSummary("first\nsecond\nthird", TOOL_LINE_THRESHOLD);
    +    expect(out).toEqual({ head: "first", remaining: 2 });
    +  });
    +});
    +
    +describe("findErrorEvents", () => {
    +  it("matches eventType containing 'error' case-insensitively", () => {
    +    const events = [
    +      ev(1, "runStarted"),
    +      ev(2, "error"),
    +      ev(3, "rate_limit_error"),
    +      ev(4, "TURN_ERROR"),
    +      ev(5, "runFinished"),
    +    ];
    +    expect(findErrorEvents(events).map((e) => e.id)).toEqual([2, 3, 4]);
    +  });
    +  it("returns empty when none match", () => {
    +    expect(findErrorEvents([ev(1, "tick")])).toEqual([]);
    +  });
    +});
    +
    +describe("stepCursor", () => {
    +  it("returns -1 when there are no items", () => {
    +    expect(stepCursor(0, -1, 1)).toBe(-1);
    +    expect(stepCursor(0, 5, -1)).toBe(-1);
    +  });
    +  it("seeds forward to 0 from the unset cursor", () => {
    +    expect(stepCursor(3, -1, 1)).toBe(0);
    +  });
    +  it("seeds backward to the last index from the unset cursor", () => {
    +    expect(stepCursor(3, -1, -1)).toBe(2);
    +  });
    +  it("wraps forward at the end", () => {
    +    expect(stepCursor(3, 2, 1)).toBe(0);
    +  });
    +  it("wraps backward at the start", () => {
    +    expect(stepCursor(3, 0, -1)).toBe(2);
    +  });
    +});
    diff --git a/src/web/runDetailUtils.ts b/src/web/runDetailUtils.ts
    new file mode 100644
    index 0000000..fe63f56
    --- /dev/null
    +++ b/src/web/runDetailUtils.ts
    @@ -0,0 +1,42 @@
    +import type { ApiEvent } from "./api.js";
    +
    +export const ASSISTANT_LINE_THRESHOLD = 12;
    +export const TOOL_LINE_THRESHOLD = 1;
    +
    +export function turnLineThreshold(role: string): number {
    +  return role === "tool" ? TOOL_LINE_THRESHOLD : ASSISTANT_LINE_THRESHOLD;
    +}
    +
    +export function turnLineCount(content: string): number {
    +  if (content.length === 0) return 0;
    +  return content.split("\n").length;
    +}
    +
    +export function shouldCollapseTurn(content: string, threshold: number): boolean {
    +  return turnLineCount(content) > threshold;
    +}
    +
    +export function collapsedSummary(
    +  content: string,
    +  threshold: number,
    +): { head: string; remaining: number } {
    +  const lines = content.split("\n");
    +  if (lines.length <= threshold) return { head: content, remaining: 0 };
    +  return { head: lines.slice(0, threshold).join("\n"), remaining: lines.length - threshold };
    +}
    +
    +const ERROR_EVENT_RE = /error/i;
    +
    +export function findErrorEvents(events: ReadonlyArray): ApiEvent[] {
    +  return events.filter((e) => ERROR_EVENT_RE.test(e.eventType));
    +}
    +
    +export function stepCursor(total: number, current: number, dir: 1 | -1): number {
    +  if (total <= 0) return -1;
    +  if (current < 0) return dir === 1 ? 0 : total - 1;
    +  return (current + dir + total) % total;
    +}
    +
    +export function eventDomId(eventId: number): string {
    +  return `event-${eventId}`;
    +}