diff --git a/frontend/src/components/agent/lab/TickSpine.test.tsx b/frontend/src/components/agent/lab/TickSpine.test.tsx index 155a432aa..af51a649b 100644 --- a/frontend/src/components/agent/lab/TickSpine.test.tsx +++ b/frontend/src/components/agent/lab/TickSpine.test.tsx @@ -210,7 +210,188 @@ describe("a beat is an address", () => { ACTIONS = [deed({ tick: 4, summary: "Deploy bot 'brl_mm'" })]; await render(); - expect(beats()[0].title).toBe("#4 — Deploy bot 'brl_mm'"); + expect(beats()[0].getAttribute("aria-label")).toBe("#4 — Deploy bot 'brl_mm'"); + }); +}); + +describe("hovering a beat opens its card", () => { + const card = () => document.querySelector("[data-beat-card]"); + // jsdom has no PointerEvent; React keys its handlers on the event *type*. + const hover = (el: HTMLElement, clientX = 0, clientY = 0) => + act(() => { + el.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX, clientY })); + }); + // jsdom has no layout; these rectangles can also simulate an animation frame. + const place = (el: HTMLElement, left: number, width = 8, top = 0) => { + el.getBoundingClientRect = () => + ({ left, right: left + width, width, top, bottom: top + 20, height: 20 }) as DOMRect; + }; + const unhover = () => + act(() => { + document + .querySelector('[data-testid="tick-spine"]')! + .dispatchEvent( + new MouseEvent("pointerout", { bubbles: true, relatedTarget: document.body }), + ); + }); + + it("shows the tick's time, journal line and every deed with its error", async () => { + JOURNAL = journal([{ tick: 4, actions: 2, summary: "Fleet healthy, drift 1%" }]); + ACTIONS = [ + deed({ tick: 4, summary: "Deploy bot 'brl_mm'" }), + deed({ tick: 4, summary: "Stop executor abc", ok: false, error: "not found" }), + ]; + await render(); + + expect(card()).toBeNull(); + hover(beats()[0]); + expect(beats()[0].dataset.beatHovered).toBe("true"); + + const c = card()!; + expect(c.dataset.beatCard).toBe("4"); + expect(c.textContent).toContain("2026-08-06 22:04"); + expect(c.textContent).toContain("Action failed"); + expect(c.querySelector("[data-beat-card-summary]")!.textContent).toBe( + "Fleet healthy, drift 1%", + ); + const deeds = [...c.querySelectorAll("[data-beat-card-deed]")].map((d) => d.textContent); + expect(deeds).toEqual(["✓Deploy bot 'brl_mm'", "✗Stop executor abcnot found"]); + + unhover(); + expect(card()).toBeNull(); + expect(beats()[0].dataset.beatHovered).toBeUndefined(); + }); + + it("keeps the nearest beat while the pointer crosses a gap", async () => { + JOURNAL = journal([ + { tick: 1, actions: 0 }, + { tick: 2, actions: 0 }, + ]); + await render(); + + // The visible gap and hover tolerance both use 4px. + place(beats()[0], 100); + place(beats()[1], 112); + const strip = document.querySelector('[data-testid="tick-spine"]')!; + + hover(strip, 109); // nearer beat 1's centre (104) than beat 2's (116) + expect(card()!.dataset.beatCard).toBe("1"); + hover(strip, 111); // past the midpoint + expect(card()!.dataset.beatCard).toBe("2"); + hover(strip, 300); // well past the last beat + expect(card()).toBeNull(); + }); + + it("does not retarget when layout changes underneath a stationary cursor", async () => { + JOURNAL = journal([{ tick: 1, actions: 0 }, { tick: 2, actions: 0 }]); + await render(); + const [first, second] = beats(); + place(first, 100); + place(second, 112); + const strip = container.querySelector('[data-testid="tick-spine"]')!; + + hover(strip, 114); + expect(card()!.dataset.beatCard).toBe("2"); + // Only the visual shapes move during animation, never their hit targets. + place(first.querySelector("[data-beat-visual]")!, 104, 14); + place(second.querySelector("[data-beat-visual]")!, 122, 14); + hover(strip, 114); + expect(card()!.dataset.beatCard).toBe("2"); + + unhover(); + hover(strip, 114); // re-entry uses the same stable boundaries + expect(card()!.dataset.beatCard).toBe("2"); + }); + + it("uses stable boundaries throughout rightward and leftward sweeps", async () => { + JOURNAL = journal([{ tick: 1, actions: 0 }, { tick: 2, actions: 0 }, { tick: 3, actions: 0 }]); + await render(); + beats().forEach((beat, i) => place(beat, 100 + i * 12)); + const strip = container.querySelector('[data-testid="tick-spine"]')!; + const targets = beats().map((beat) => beat.className); + const sweep = [ + [104, "1"], [109, "1"], [111, "2"], [117, "2"], [123, "3"], [128, "3"], + [123, "3"], [121, "2"], [116, "2"], [111, "2"], [109, "1"], [104, "1"], + ] as const; + for (const [x, tick] of sweep) { + hover(strip, x); + expect(card()!.dataset.beatCard).toBe(tick); + expect(beats().map((beat) => beat.className)).toEqual(targets); + } + }); + + it("grows the visual in both dimensions and offsets neighbours within fixed slots", async () => { + JOURNAL = journal([{ tick: 1, actions: 0 }, { tick: 2, actions: 0 }, { tick: 3, actions: 0 }]); + await render(); + const [first, second, third] = beats(); + const visuals = beats().map((beat) => beat.querySelector("[data-beat-visual]")!); + + hover(second); + expect(second.className).toContain("h-5 w-2"); + expect(visuals[1].className).toContain("h-[26px] w-[14px]"); + expect(visuals[0].style.transform).toContain("-3px"); + expect(visuals[2].style.transform).toContain("3px"); + + hover(first); + expect(visuals[1].style.transform).toContain("3px"); + hover(third); + expect(visuals[1].style.transform).toContain("-3px"); + unhover(); + expect(visuals.every((visual) => visual.style.transform.includes("0px"))).toBe(true); + }); + + it("allows changing wrapped rows regardless of horizontal direction", async () => { + JOURNAL = journal([{ tick: 1, actions: 0 }, { tick: 2, actions: 0 }]); + await render(); + const [first, second] = beats(); + place(first, 100); + place(second, 100, 8, 30); + const strip = container.querySelector('[data-testid="tick-spine"]')!; + + hover(strip, 106, 10); + hover(strip, 104, 40); // moving left, but onto the next row + expect(card()!.dataset.beatCard).toBe("2"); + }); + + it("caps a busy tick's deeds and says how many more", async () => { + JOURNAL = journal([{ tick: 1, actions: 9 }]); + ACTIONS = Array.from({ length: 9 }, (_, i) => deed({ tick: 1, summary: `deed ${i}` })); + await render(); + + hover(beats()[0]); + expect(card()!.querySelectorAll("[data-beat-card-deed]")).toHaveLength(6); + expect(card()!.textContent).toContain("+3 more"); + }); + + it("says a pre-log tick is unrecorded rather than empty", async () => { + JOURNAL = journal([{ tick: 1, actions: 0 }]); + await render({ hasActionsLog: false }); + + hover(beats()[0]); + expect(card()!.textContent).toContain("Not logged"); + expect(card()!.textContent).toContain("no action log for this run"); + }); + + it("opens on keyboard focus too", async () => { + JOURNAL = journal([{ tick: 3, actions: 0 }]); + await render(); + + act(() => beats()[0].focus()); + expect(card()!.dataset.beatCard).toBe("3"); + }); + + it("lets pointer input resume after keyboard focus moves elsewhere", async () => { + JOURNAL = journal([{ tick: 1, actions: 0 }, { tick: 2, actions: 0 }]); + await render(); + const [first, second] = beats(); + place(first, 100); + place(second, 112); + + hover(first, 102); + act(() => second.focus()); + expect(card()!.dataset.beatCard).toBe("2"); + hover(first, 104); + expect(card()!.dataset.beatCard).toBe("1"); }); }); diff --git a/frontend/src/components/agent/lab/TickSpine.tsx b/frontend/src/components/agent/lab/TickSpine.tsx index 61af14055..e03d66cee 100644 --- a/frontend/src/components/agent/lab/TickSpine.tsx +++ b/frontend/src/components/agent/lab/TickSpine.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { BEAT_TITLES, @@ -7,8 +8,9 @@ import { beatState, type BeatState, } from "@/components/agent/lab/runs"; +import type { AgentActionRow } from "@/lib/agent-attribution"; import { api } from "@/lib/api"; -import { parseJournal } from "@/lib/parse-agent"; +import { parseJournal, type TickEntry } from "@/lib/parse-agent"; /** * The run's ticks, as its navigation. @@ -26,6 +28,11 @@ import { parseJournal } from "@/lib/parse-agent"; * section tabs rather than a row of its own, so it brings no border or padding * and never wraps — a long session scrolls sideways instead, and opens on its * newest beat, which is the one a reader came for. + * + * Hovering (or focusing) a beat opens a card with what the tick did: its time, + * its state, the journal's line and every deed with its error. The card is + * portalled and fixed-positioned because the strip is a sideways scroller, + * whose `overflow` would clip anything drawn inside it. */ const BEAT_CLASS: Record = { failed: "bg-[var(--color-red)]", @@ -87,6 +94,48 @@ export function TickSpine({ const stripRef = useRef(null); const atEnd = useRef(true); const beatCount = ticks.length; + + // The beat under the pointer (or keyboard focus), with where it is on screen. + const [hovered, setHovered] = useState<{ tick: number; rect: DOMRect } | null>(null); + const showCard = (tick: number, el: HTMLElement) => + setHovered({ tick, rect: el.getBoundingClientRect() }); + const hideCard = () => setHovered(null); + + // Hit targets stay fixed while their visual children animate. In a gap, + // choose the nearest target on the same row, never the moving visual shape. + const trackPointer = (e: React.PointerEvent) => { + if (e.pointerType === "touch") return; + const target = e.target instanceof Element ? e.target.closest("[data-beat]") : null; + let el = target; + if (!el) { + let best = Infinity; + for (const b of e.currentTarget.querySelectorAll("[data-beat]")) { + const r = b.getBoundingClientRect(); + if (e.clientY < r.top - BEAT_GAP || e.clientY > r.bottom + BEAT_GAP) continue; + const d = Math.abs(e.clientX - (r.left + r.width / 2)); + if (d < best) { + best = d; + el = b; + } + } + // Past the last beat's half-gap (the RUN button, the trailing note) is + // not "near a beat". + if (el) { + const r = el.getBoundingClientRect(); + if (e.clientX < r.left - BEAT_GAP || e.clientX > r.right + BEAT_GAP) el = null; + } + } + if (!el) { + if (hovered) hideCard(); + return; + } + const tick = Number(el.dataset.beat); + if (hovered?.tick !== tick) showCard(tick, el); + }; + const hoveredIndex = hovered ? ticks.findIndex((t) => t.tick === hovered.tick) : -1; + const hoveredEntry = hovered + ? ticks.find((t) => t.tick === hovered.tick) ?? null + : null; useEffect(() => { const el = stripRef.current; if (bare && el && atEnd.current) el.scrollLeft = el.scrollWidth; @@ -107,9 +156,13 @@ export function TickSpine({
{ + // The card is pinned to where the beat *was*; a scroll moves it. + hideCard(); const el = e.currentTarget; atEnd.current = el.scrollLeft + el.clientWidth >= el.scrollWidth - 4; @@ -117,12 +170,11 @@ export function TickSpine({ : undefined } className={`flex items-center gap-1 ${ - // The vertical padding is room for the selected beat's ring, which an - // `overflow-x-auto` box would otherwise clip (its overflow-y goes auto - // too); the horizontal is the same room for the first and last beat. + // Reserve room for the visual growth, neighbour offsets and rings so + // neither the scroll width nor wrapping changes when a tick expands. bare - ? "min-w-0 flex-nowrap overflow-x-auto px-1 py-2 [scrollbar-width:thin]" - : "flex-wrap border-b border-[var(--color-border)]/60 px-4 py-2" + ? "h-9 min-w-0 flex-nowrap overflow-x-auto px-1.5 [scrollbar-width:thin]" + : "min-h-9 flex-wrap border-b border-[var(--color-border)]/60 px-4 py-1" }`} > - {ticks.map((entry) => { + {ticks.map((entry, index) => { const deeds = byTick.get(entry.tick) ?? []; const state = beatState({ actions: deeds, @@ -151,20 +203,42 @@ export function TickSpine({ deeds.map((d) => d.summary).join(" · ") || entry.summary || BEAT_TITLES[state]; + const isHovered = hovered?.tick === entry.tick; + const isSelected = selectedTick === entry.tick; + // Split the extra 6px of width equally to either side of the hovered + // tick. Visual neighbours move by 3px to keep their original 4px gap. + const offset = hoveredIndex < 0 || index === hoveredIndex + ? 0 + : index < hoveredIndex ? -3 : 3; return ( ); })} {!hasActionsLog && ( @@ -172,6 +246,140 @@ export function TickSpine({ no action log for this run )} + {hovered && hoveredEntry && ( + + )}
); } + +const STATE_LABEL: Record = { + failed: "Action failed", + ok: "Actions ran", + idle: "No actions", + unlogged: "Not logged", +}; + +const STATE_TEXT: Record = { + failed: "text-[var(--color-red)]", + ok: "text-[var(--color-green)]", + idle: "text-[var(--color-text-muted)]", + unlogged: "text-[var(--color-text-muted)]", +}; + +/** The strip's fixed gap between beats (`gap-1`), in px. */ +const BEAT_GAP = 4; +const CARD_WIDTH = 320; +const CARD_GAP = 6; +const CARD_EDGE = 8; +/** Past this many deeds the card says how many more rather than growing. */ +const MAX_DEEDS = 6; +/** Below this much room under the beat the card opens above it instead. */ +const CARD_MIN_ROOM = 240; + +/** + * One beat's detail, over the page. + * + * `pointer-events-none` so it never takes the hover that keeps it up — the + * beat under it stays the thing you click. + */ +function BeatCard({ + entry, + deeds, + state, + anchor, +}: { + entry: TickEntry; + deeds: AgentActionRow[]; + state: BeatState; + anchor: DOMRect; +}) { + const vw = window.innerWidth; + const vh = window.innerHeight; + const left = Math.max( + CARD_EDGE, + Math.min( + anchor.left + anchor.width / 2 - CARD_WIDTH / 2, + vw - CARD_WIDTH - CARD_EDGE, + ), + ); + const above = vh - anchor.bottom < CARD_MIN_ROOM && anchor.top > vh - anchor.bottom; + const position = above + ? { left, bottom: vh - anchor.top + CARD_GAP } + : { left, top: anchor.bottom + CARD_GAP }; + const shown = deeds.slice(0, MAX_DEEDS); + + return createPortal( +
+
+ #{entry.tick} + + {entry.timestamp} + + + {STATE_LABEL[state]} + +
+ + {entry.summary ? ( +

+ {entry.summary} +

+ ) : ( +

No summary written

+ )} + + {shown.length > 0 && ( +
    + {shown.map((d, i) => ( +
  • + + {d.ok ? "✓" : "✗"} + + + {d.summary} + {!d.ok && d.error && ( + {d.error} + )} + +
  • + ))} + {deeds.length > shown.length && ( +
  • + +{deeds.length - shown.length} more +
  • + )} +
+ )} + + {state === "unlogged" && ( +

{BEAT_TITLES.unlogged}

+ )} + +

+ Click to open this tick +

+
, + document.body, + ); +} diff --git a/frontend/src/components/agent/lab/runs.ts b/frontend/src/components/agent/lab/runs.ts index f0a2d5eb8..086c35e75 100644 --- a/frontend/src/components/agent/lab/runs.ts +++ b/frontend/src/components/agent/lab/runs.ts @@ -236,6 +236,36 @@ export function beatState(input: { return "unlogged"; } +/** + * The ticks either side of `tick` in the journal's order, or `null` at an end. + * + * Neighbours by position, not by `tick ± 1`: a journal can skip a number (a + * tick that crashed before it was written), and stepping must land on a tick + * that has a snapshot rather than on a hole. A tick the journal does not list + * (a hand-typed `?tick=`) gets the nearest one each side. + */ +export function adjacentTicks( + ticks: readonly number[], + tick: number, +): { prev: number | null; next: number | null; index: number } { + const sorted = [...ticks].sort((a, b) => a - b); + const index = sorted.indexOf(tick); + if (index >= 0) { + return { + prev: index > 0 ? sorted[index - 1] : null, + next: index < sorted.length - 1 ? sorted[index + 1] : null, + index, + }; + } + const before = sorted.filter((t) => t < tick); + const after = sorted.filter((t) => t > tick); + return { + prev: before.length ? before[before.length - 1] : null, + next: after.length ? after[0] : null, + index: -1, + }; +} + /** What hovering a beat says, when the beat itself cannot say it. */ export const BEAT_TITLES: Record = { failed: "an action failed on this tick", diff --git a/frontend/src/components/agent/workspace/AgentRunScreen.test.tsx b/frontend/src/components/agent/workspace/AgentRunScreen.test.tsx index 56efc6824..dde9fef44 100644 --- a/frontend/src/components/agent/workspace/AgentRunScreen.test.tsx +++ b/frontend/src/components/agent/workspace/AgentRunScreen.test.tsx @@ -1087,12 +1087,12 @@ describe("the spine and the tabs share one row (ARCH-425)", () => { await click(beat(150)); expect(new URLSearchParams(search()).get("tick")).toBe("150"); - expect(beat(150).className).toContain("ring-2"); - expect(beat(149).className).not.toContain("ring-2"); + expect(beat(150).dataset.beatSelected).toBe("true"); + expect(beat(149).dataset.beatSelected).toBeUndefined(); await click(container.querySelector("[data-spine-overview]")!); expect(new URLSearchParams(search()).get("tick")).toBeNull(); - expect(beat(150).className).not.toContain("ring-2"); + expect(beat(150).dataset.beatSelected).toBeUndefined(); }); it("with no session run, the row holds the tabs alone", async () => { diff --git a/frontend/src/components/agent/workspace/AgentRunScreen.tsx b/frontend/src/components/agent/workspace/AgentRunScreen.tsx index 1f19d3702..2bf3cd005 100644 --- a/frontend/src/components/agent/workspace/AgentRunScreen.tsx +++ b/frontend/src/components/agent/workspace/AgentRunScreen.tsx @@ -1,9 +1,8 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { Activity, X } from "lucide-react"; +import { Activity } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { SnapshotDetail } from "@/components/agent/session/Snapshot"; import { isLoopRun, liveControllerIds } from "@/components/agent/lab/runs"; import { TickSpine } from "@/components/agent/lab/TickSpine"; import { AgentFleet } from "@/components/agent/workspace/AgentFleet"; @@ -12,6 +11,7 @@ import { LoopBar } from "@/components/agent/workspace/LoopBar"; import { NowView } from "@/components/agent/workspace/NowView"; import { PlaybookView } from "@/components/agent/workspace/PlaybookView"; import { RunsBand } from "@/components/agent/workspace/RunsBand"; +import { TickOverlay } from "@/components/agent/workspace/TickOverlay"; import { SECTION_META } from "@/components/agent/workspace/sectionMeta"; import { PANE_SECTIONS, @@ -381,33 +381,17 @@ export function AgentRunScreen({ // Over the screen rather than instead of it — so closing it returns the // reader to the tab and the run they left. In the pane it covers the // pane only: the conversation beside it stays readable. -
-
- - Tick #{url.tick} · session {selectedRun.number} - - -
-
- -
-
+ slug={agent.slug} + sslug={sslug} + sessionNum={selectedRun.number} + tick={url.tick} + onSelectTick={(next) => setParams({ tick: next }, { replace: true })} + onClose={() => setParams({ tick: null })} + /> ); return ( diff --git a/frontend/src/components/agent/workspace/TickOverlay.test.tsx b/frontend/src/components/agent/workspace/TickOverlay.test.tsx new file mode 100644 index 000000000..1f5630ad2 --- /dev/null +++ b/frontend/src/components/agent/workspace/TickOverlay.test.tsx @@ -0,0 +1,159 @@ +/** + * The tick overlay is read forwards: ← and → step through the session's ticks. + * + * Needs a DOM, so this file overrides vitest's default `node` environment. + * + * @vitest-environment jsdom + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { adjacentTicks } from "@/components/agent/lab/runs"; +import { TickOverlay } from "./TickOverlay"; + +vi.mock("@/lib/api", () => ({ + api: { + getSessionJournal: vi.fn(async () => ({ content: JOURNAL })), + getSnapshot: vi.fn(async () => ({ content: "" })), + }, +})); + +vi.mock("@/components/agent/session/Snapshot", () => ({ + SnapshotDetail: ({ tick }: { tick: number }) =>
, +})); + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +let JOURNAL = ""; + +function journal(ticks: number[]): string { + const lines = ticks.map((t) => `- tick#${t} | 2026-08-06 22:00 | actions=0 | held`); + return `# Journal\n\n## Ticks\n\n${lines.join("\n")}\n`; +} + +describe("adjacentTicks", () => { + it("steps by position, skipping a hole in the numbering", () => { + expect(adjacentTicks([1, 2, 4, 5], 2)).toEqual({ prev: 1, next: 4, index: 1 }); + }); + + it("stops at either end", () => { + expect(adjacentTicks([1, 2, 3], 1)).toEqual({ prev: null, next: 2, index: 0 }); + expect(adjacentTicks([1, 2, 3], 3)).toEqual({ prev: 2, next: null, index: 2 }); + }); + + it("finds the nearest neighbours of a tick the journal does not list", () => { + expect(adjacentTicks([1, 2, 5, 6], 4)).toEqual({ prev: 2, next: 5, index: -1 }); + }); + + it("does not depend on the journal's order", () => { + expect(adjacentTicks([3, 1, 2], 2)).toEqual({ prev: 1, next: 3, index: 1 }); + }); +}); + +describe("TickOverlay", () => { + let container: HTMLDivElement; + let root: Root; + let picked: number[]; + let closed: number; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + picked = []; + closed = 0; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + async function render(tick: number) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + await act(async () => { + root.render( + + picked.push(t)} + onClose={() => closed++} + className="" + /> + , + ); + }); + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } + } + + const press = (key: string, init: KeyboardEventInit = {}, target: EventTarget = window) => + act(() => { + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init })); + }); + + it("steps to the neighbouring ticks with the arrow keys", async () => { + JOURNAL = journal([10, 11, 12]); + await render(11); + + press("ArrowLeft"); + press("ArrowRight"); + expect(picked).toEqual([10, 12]); + }); + + it("does nothing past either end", async () => { + JOURNAL = journal([10, 11]); + await render(11); + + press("ArrowRight"); + expect(picked).toEqual([]); + expect( + container.querySelector("[data-tick-next]")!.disabled, + ).toBe(true); + }); + + it("leaves arrows alone while the reader types or holds a modifier", async () => { + JOURNAL = journal([10, 11, 12]); + await render(11); + + const input = document.createElement("input"); + container.appendChild(input); + press("ArrowLeft", {}, input); + press("ArrowLeft", { metaKey: true }); + expect(picked).toEqual([]); + }); + + it("steps with the header buttons and shows where it is in the run", async () => { + JOURNAL = journal([10, 11, 12]); + await render(11); + + expect(container.querySelector("[data-tick-position]")!.textContent).toBe("2/3"); + act(() => container.querySelector("[data-tick-prev]")!.click()); + act(() => container.querySelector("[data-tick-next]")!.click()); + expect(picked).toEqual([10, 12]); + }); + + it("closes from the X", async () => { + JOURNAL = journal([1]); + await render(1); + + act(() => + container.querySelector('[aria-label="Close tick"]')!.click(), + ); + expect(closed).toBe(1); + }); +}); diff --git a/frontend/src/components/agent/workspace/TickOverlay.tsx b/frontend/src/components/agent/workspace/TickOverlay.tsx new file mode 100644 index 000000000..51753de90 --- /dev/null +++ b/frontend/src/components/agent/workspace/TickOverlay.tsx @@ -0,0 +1,143 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; + +import { adjacentTicks } from "@/components/agent/lab/runs"; +import { SnapshotDetail } from "@/components/agent/session/Snapshot"; +import { snapshotQueryOptions } from "@/hooks/useSnapshotBubbles"; +import { api } from "@/lib/api"; +import { parseJournal } from "@/lib/parse-agent"; + +/** True when a key press belongs to whatever has focus, not to the overlay. */ +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +/** + * One tick of a session, over the screen — with the run's other ticks a key + * press away. + * + * ← and → (or the header's arrows) step to the previous and next tick the + * journal recorded, so reviewing a session is reading it forwards rather than + * closing and re-opening beat after beat. The journal is the same + * `["strategy", …, "journal"]` entry the spine reads, so this costs no fetch, + * and both neighbours' snapshots are prefetched so a step lands rendered. + */ +export function TickOverlay({ + slug, + sslug, + sessionNum, + tick, + onSelectTick, + onClose, + className, +}: { + slug: string; + sslug: string; + sessionNum: number; + tick: number; + onSelectTick: (tick: number) => void; + onClose: () => void; + className: string; +}) { + const { data: journalData } = useQuery({ + queryKey: ["strategy", slug, sslug, "session", sessionNum, "journal"], + queryFn: () => api.getSessionJournal(slug, sslug, sessionNum), + enabled: sessionNum > 0, + }); + const journalContent = journalData?.content; + const ticks = useMemo( + () => + journalContent ? parseJournal(journalContent).ticks.map((t) => t.tick) : [], + [journalContent], + ); + const { prev, next, index } = adjacentTicks(ticks, tick); + + const queryClient = useQueryClient(); + useEffect(() => { + for (const t of [prev, next]) { + if (t !== null && t > 0) { + void queryClient.prefetchQuery(snapshotQueryOptions(slug, sslug, sessionNum, t)); + } + } + }, [queryClient, slug, sslug, sessionNum, prev, next]); + + // Kept in a ref so the listener attaches once rather than on every step. + const stepRef = useRef({ prev, next, onSelectTick }); + useEffect(() => { + stepRef.current = { prev, next, onSelectTick }; + }); + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + if (isTypingTarget(e.target)) return; + const { prev: p, next: n, onSelectTick: go } = stepRef.current; + const target = e.key === "ArrowLeft" ? p : e.key === "ArrowRight" ? n : null; + if (target === null) return; + e.preventDefault(); + go(target); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + const stepButton = + "rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)] disabled:pointer-events-none disabled:opacity-30"; + + return ( +
+
+
+ + + + Tick #{tick} · session {sessionNum} + + {index >= 0 && ( + + {index + 1}/{ticks.length} + + )} +
+ +
+ {/* Keyed by tick so each step opens at the top of its snapshot. */} +
+ +
+
+ ); +} diff --git a/frontend/src/components/agent/workspace/workspaceUrl.ts b/frontend/src/components/agent/workspace/workspaceUrl.ts index 6c9b46a63..81e54b72a 100644 --- a/frontend/src/components/agent/workspace/workspaceUrl.ts +++ b/frontend/src/components/agent/workspace/workspaceUrl.ts @@ -145,8 +145,12 @@ export function patchReplaces(patch: WorkspaceUrlPatch): boolean { export interface WorkspaceUrlAdapter { /** What the URL says — {@link parseWorkspace}, memoized. */ url: WorkspaceUrl; - /** Move it. See {@link applyWorkspacePatch}. */ - set: (patch: WorkspaceUrlPatch) => void; + /** + * Move it. See {@link applyWorkspacePatch}. `replace` overrides + * {@link patchReplaces} — stepping tick to tick inside the overlay is one + * reading, so Back still leaves the overlay in one press. + */ + set: (patch: WorkspaceUrlPatch, options?: { replace?: boolean }) => void; } /** Bind the grammar to a host's `useSearchParams`. */ @@ -159,9 +163,9 @@ export function useWorkspaceUrl( ): WorkspaceUrlAdapter { const url = useMemo(() => parseWorkspace(params), [params]); const set = useCallback( - (patch: WorkspaceUrlPatch) => + (patch: WorkspaceUrlPatch, options?: { replace?: boolean }) => setParams(applyWorkspacePatch(params, patch), { - replace: patchReplaces(patch), + replace: options?.replace ?? patchReplaces(patch), }), [params, setParams], ); diff --git a/frontend/src/index.css b/frontend/src/index.css index 8312922c2..e5725d6ae 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -197,6 +197,25 @@ body { transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; } +/* Keep dock magnification above the unlayered theme transition rule, which + overrides Tailwind transition utilities and would make the ticks snap. */ +[data-testid="tick-spine"] [data-beat-visual] { + transition: + transform 220ms cubic-bezier(.22, .8, .25, 1), + width 220ms cubic-bezier(.22, .8, .25, 1), + height 220ms cubic-bezier(.22, .8, .25, 1), + box-shadow 180ms ease, + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease; +} + +@media (prefers-reduced-motion: reduce) { + [data-testid="tick-spine"] [data-beat-visual] { + transition: none; + } +} + /* Scrollbar styling */ ::-webkit-scrollbar { width: 6px;