From ddd4c518ce18e2a782197355b96763b44d8d4d9a Mon Sep 17 00:00:00 2001 From: cardosofede Date: Sat, 3 Oct 2026 13:32:49 +0700 Subject: [PATCH 1/5] feat(agents): hover card on tick beats, arrow keys between tick snapshots - TickSpine: hovering or focusing a beat opens a portalled card with the tick's time, state, journal line and every deed (errors in red), capped at 6 with '+N more'. Replaces the native title tooltip. - TickOverlay: the tick view gets prev/next buttons, an N/M counter and ArrowLeft/ArrowRight navigation (ignored while typing or with modifiers). Steps replace history so Back still closes the overlay in one press, and neighbouring snapshots are prefetched. - WorkspaceUrlAdapter.set takes an optional { replace } override. --- .../components/agent/lab/TickSpine.test.tsx | 68 +++++++- .../src/components/agent/lab/TickSpine.tsx | 160 +++++++++++++++++- frontend/src/components/agent/lab/runs.ts | 30 ++++ .../agent/workspace/AgentRunScreen.tsx | 36 ++-- .../agent/workspace/TickOverlay.test.tsx | 159 +++++++++++++++++ .../agent/workspace/TickOverlay.tsx | 143 ++++++++++++++++ .../agent/workspace/workspaceUrl.ts | 12 +- 7 files changed, 574 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/agent/workspace/TickOverlay.test.tsx create mode 100644 frontend/src/components/agent/workspace/TickOverlay.tsx diff --git a/frontend/src/components/agent/lab/TickSpine.test.tsx b/frontend/src/components/agent/lab/TickSpine.test.tsx index 155a432aa..74ffe206b 100644 --- a/frontend/src/components/agent/lab/TickSpine.test.tsx +++ b/frontend/src/components/agent/lab/TickSpine.test.tsx @@ -210,7 +210,73 @@ 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]"); + const hover = (el: HTMLElement) => + act(() => { + el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + }); + const unhover = (el: HTMLElement) => + act(() => { + el.dispatchEvent( + new MouseEvent("mouseout", { 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]); + + 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(beats()[0]); + expect(card()).toBeNull(); + }); + + 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"); }); }); diff --git a/frontend/src/components/agent/lab/TickSpine.tsx b/frontend/src/components/agent/lab/TickSpine.tsx index 61af14055..12e86f52e 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,15 @@ 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); + 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; @@ -110,6 +126,8 @@ export function TickSpine({ onScroll={ bare ? (e) => { + // 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; @@ -157,8 +175,12 @@ export function TickSpine({ type="button" data-beat={entry.tick} data-beat-state={state} - title={`#${entry.tick} — ${title}`} + aria-label={`#${entry.tick} — ${title}`} onClick={() => onSelectTick(entry.tick)} + onMouseEnter={(e) => showCard(entry.tick, e.currentTarget)} + onMouseLeave={hideCard} + onFocus={(e) => showCard(entry.tick, e.currentTarget)} + onBlur={hideCard} className={`h-5 w-2 shrink-0 rounded-sm transition-all hover:scale-y-110 ${BEAT_CLASS[state]} ${ selectedTick === entry.tick ? "ring-2 ring-[var(--color-primary)] ring-offset-1 ring-offset-[var(--color-bg)]" @@ -172,6 +194,138 @@ 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)]", +}; + +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.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], ); From efe3f273078351913ae866254ced70a87c2f1b25 Mon Sep 17 00:00:00 2001 From: cardosofede Date: Sat, 3 Oct 2026 14:18:07 +0700 Subject: [PATCH 2/5] feat(agents): hovered tick beat grows so it is harder to slip off The beat under the pointer (or focus) scales to 2x width and 1.2x height, covering the gaps beside it. A transform rather than a width change, so neighbours do not reflow under the cursor. --- frontend/src/components/agent/lab/TickSpine.test.tsx | 2 ++ frontend/src/components/agent/lab/TickSpine.tsx | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/agent/lab/TickSpine.test.tsx b/frontend/src/components/agent/lab/TickSpine.test.tsx index 74ffe206b..2a3502607 100644 --- a/frontend/src/components/agent/lab/TickSpine.test.tsx +++ b/frontend/src/components/agent/lab/TickSpine.test.tsx @@ -237,6 +237,7 @@ describe("hovering a beat opens its card", () => { expect(card()).toBeNull(); hover(beats()[0]); + expect(beats()[0].dataset.beatHovered).toBe("true"); const c = card()!; expect(c.dataset.beatCard).toBe("4"); @@ -250,6 +251,7 @@ describe("hovering a beat opens its card", () => { unhover(beats()[0]); expect(card()).toBeNull(); + expect(beats()[0].dataset.beatHovered).toBeUndefined(); }); it("caps a busy tick's deeds and says how many more", async () => { diff --git a/frontend/src/components/agent/lab/TickSpine.tsx b/frontend/src/components/agent/lab/TickSpine.tsx index 12e86f52e..dd39aa0f0 100644 --- a/frontend/src/components/agent/lab/TickSpine.tsx +++ b/frontend/src/components/agent/lab/TickSpine.tsx @@ -181,7 +181,14 @@ export function TickSpine({ onMouseLeave={hideCard} onFocus={(e) => showCard(entry.tick, e.currentTarget)} onBlur={hideCard} - className={`h-5 w-2 shrink-0 rounded-sm transition-all hover:scale-y-110 ${BEAT_CLASS[state]} ${ + data-beat-hovered={hovered?.tick === entry.tick || undefined} + // The beat under the pointer (or focus) grows to twice its width, + // which covers the gaps beside it: a wider target is harder to slip + // off onto a neighbour. A transform, not a width, so nothing + // reflows and the neighbours stay where the pointer expects them. + className={`relative h-5 w-2 shrink-0 rounded-sm transition-transform duration-100 ${BEAT_CLASS[state]} ${ + hovered?.tick === entry.tick ? "z-10 scale-x-200 scale-y-120" : "" + } ${ selectedTick === entry.tick ? "ring-2 ring-[var(--color-primary)] ring-offset-1 ring-offset-[var(--color-bg)]" : "" From ff0b4ca437369b1f4a2afb334ec9c7f722346aaf Mon Sep 17 00:00:00 2001 From: cardosofede Date: Sat, 3 Oct 2026 14:34:28 +0700 Subject: [PATCH 3/5] feat(agents): contour on the hovered tick beat, more room between beats Each beat is now a fixed 16px button with the bar drawn inside it, so the targets sit edge to edge while the bars get an 8px gap. Hovering grows only the inner bar and gives it a 2px contour (the selected beat keeps its primary ring), without reflowing the neighbours. --- .../src/components/agent/lab/TickSpine.tsx | 47 ++++++++++++------- .../agent/workspace/AgentRunScreen.test.tsx | 6 +-- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/agent/lab/TickSpine.tsx b/frontend/src/components/agent/lab/TickSpine.tsx index dd39aa0f0..92ba7f33f 100644 --- a/frontend/src/components/agent/lab/TickSpine.tsx +++ b/frontend/src/components/agent/lab/TickSpine.tsx @@ -134,13 +134,13 @@ 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. + className={`flex items-center ${ + // Each beat's button is taller than its bar, which is the room the + // hovered or selected bar's ring needs — an `overflow-x-auto` box would + // otherwise clip it (its overflow-y goes auto too). 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" + ? "min-w-0 flex-nowrap overflow-x-auto py-0.5 [scrollbar-width:thin]" + : "flex-wrap border-b border-[var(--color-border)]/60 px-4 py-1" }`} > ); })} {!hasActionsLog && ( 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 () => { From 2cdada2f1fea05dffa3558420defc6dad284dc1e Mon Sep 17 00:00:00 2001 From: cardosofede Date: Sat, 3 Oct 2026 16:36:56 +0700 Subject: [PATCH 4/5] feat(agents): dock-style magnification on the tick strip The hovered beat's real width and height animate (8x20 -> 14x26, 200ms ease-out), so its neighbours slide over and the 8px gap stays fixed, instead of a fixed slot with the bar growing inside it. Hover is tracked on the strip: a pointer in a gap keeps the nearest beat, so sliding along the row never flickers. Respects prefers-reduced-motion. --- .../components/agent/lab/TickSpine.test.tsx | 41 +++++++-- .../src/components/agent/lab/TickSpine.tsx | 85 +++++++++++++------ 2 files changed, 91 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/agent/lab/TickSpine.test.tsx b/frontend/src/components/agent/lab/TickSpine.test.tsx index 2a3502607..7e8f5eff3 100644 --- a/frontend/src/components/agent/lab/TickSpine.test.tsx +++ b/frontend/src/components/agent/lab/TickSpine.test.tsx @@ -216,15 +216,18 @@ describe("a beat is an address", () => { describe("hovering a beat opens its card", () => { const card = () => document.querySelector("[data-beat-card]"); - const hover = (el: HTMLElement) => + // jsdom has no PointerEvent; React keys its handlers on the event *type*. + const hover = (el: HTMLElement, clientX = 0) => act(() => { - el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + el.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX })); }); - const unhover = (el: HTMLElement) => + const unhover = () => act(() => { - el.dispatchEvent( - new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }), - ); + 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 () => { @@ -249,11 +252,35 @@ describe("hovering a beat opens its card", () => { const deeds = [...c.querySelectorAll("[data-beat-card-deed]")].map((d) => d.textContent); expect(deeds).toEqual(["✓Deploy bot 'brl_mm'", "✗Stop executor abcnot found"]); - unhover(beats()[0]); + 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(); + + // jsdom lays nothing out: place the two beats by hand, 8px apart. + const place = (el: HTMLElement, left: number) => { + el.getBoundingClientRect = () => + ({ left, right: left + 8, width: 8, top: 0, bottom: 20, height: 20 }) as DOMRect; + }; + place(beats()[0], 100); + place(beats()[1], 116); + const strip = document.querySelector('[data-testid="tick-spine"]')!; + + hover(strip, 109); // in the gap, nearer beat 1's centre (104) than beat 2's (120) + expect(card()!.dataset.beatCard).toBe("1"); + hover(strip, 113); // past the midpoint + expect(card()!.dataset.beatCard).toBe("2"); + hover(strip, 300); // well past the last beat + expect(card()).toBeNull(); + }); + 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}` })); diff --git a/frontend/src/components/agent/lab/TickSpine.tsx b/frontend/src/components/agent/lab/TickSpine.tsx index 92ba7f33f..7e82e6a57 100644 --- a/frontend/src/components/agent/lab/TickSpine.tsx +++ b/frontend/src/components/agent/lab/TickSpine.tsx @@ -100,6 +100,39 @@ export function TickSpine({ const showCard = (tick: number, el: HTMLElement) => setHovered({ tick, rect: el.getBoundingClientRect() }); const hideCard = () => setHovered(null); + + // Hover is tracked on the strip, not per beat: a pointer in the gap between + // two beats keeps the nearest one rather than dropping the hover, so sliding + // along the row never flickers through "nothing". Only pointer input moves + // it — the reflow a growing beat causes must not feed back into the choice. + 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(); + 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 hoveredEntry = hovered ? ticks.find((t) => t.tick === hovered.tick) ?? null : null; @@ -123,6 +156,8 @@ export function TickSpine({
{ @@ -134,13 +169,13 @@ export function TickSpine({ } : undefined } - className={`flex items-center ${ - // Each beat's button is taller than its bar, which is the room the - // hovered or selected bar's ring needs — an `overflow-x-auto` box would - // otherwise clip it (its overflow-y goes auto too). + className={`flex items-center gap-2 ${ + // The fixed height is room for a hovered beat and its ring, which an + // `overflow-x-auto` box would otherwise clip (its overflow-y goes auto + // too); the horizontal padding is the same room for the end beats. bare - ? "min-w-0 flex-nowrap overflow-x-auto py-0.5 [scrollbar-width:thin]" - : "flex-wrap border-b border-[var(--color-border)]/60 px-4 py-1" + ? "h-9 min-w-0 flex-nowrap overflow-x-auto px-1 [scrollbar-width:thin]" + : "min-h-9 flex-wrap border-b border-[var(--color-border)]/60 px-4 py-1" }`} > + // Dock-style: the hovered beat's real width and height animate, so + // its neighbours slide over to make room and the gap between beats + // stays fixed — a `scale()` would overlap them instead. Box-shadow + // rings are not transformed, so the contour stays a clean 2px. + className={`shrink-0 rounded-sm transition-[width,height] duration-200 ease-[cubic-bezier(.22,.8,.25,1)] motion-reduce:transition-none ${BEAT_CLASS[state]} ${ + isHovered ? "h-[26px] w-[14px]" : "h-5 w-2" + } ${ + isSelected + ? "ring-2 ring-[var(--color-primary)] ring-offset-1 ring-offset-[var(--color-bg)]" + : isHovered + ? "ring-2 ring-[var(--color-text)] ring-offset-1 ring-offset-[var(--color-bg)]" + : "" + }`} + /> ); })} {!hasActionsLog && ( @@ -242,6 +269,8 @@ const STATE_TEXT: Record = { unlogged: "text-[var(--color-text-muted)]", }; +/** The strip's fixed gap between beats (`gap-2`), in px. */ +const BEAT_GAP = 8; const CARD_WIDTH = 320; const CARD_GAP = 6; const CARD_EDGE = 8; From af3ec33943db0cc73aceb7ac5e8aabe8bd5060d9 Mon Sep 17 00:00:00 2001 From: Wojak Date: Sat, 3 Oct 2026 17:17:49 +0700 Subject: [PATCH 5/5] fix tick hovering --- .../components/agent/lab/TickSpine.test.tsx | 106 ++++++++++++++++-- .../src/components/agent/lab/TickSpine.tsx | 59 +++++----- frontend/src/index.css | 19 ++++ 3 files changed, 148 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/agent/lab/TickSpine.test.tsx b/frontend/src/components/agent/lab/TickSpine.test.tsx index 7e8f5eff3..af51a649b 100644 --- a/frontend/src/components/agent/lab/TickSpine.test.tsx +++ b/frontend/src/components/agent/lab/TickSpine.test.tsx @@ -217,10 +217,15 @@ describe("a beat is an address", () => { 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) => + const hover = (el: HTMLElement, clientX = 0, clientY = 0) => act(() => { - el.dispatchEvent(new MouseEvent("pointermove", { bubbles: true, clientX })); + 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 @@ -264,23 +269,90 @@ describe("hovering a beat opens its card", () => { ]); await render(); - // jsdom lays nothing out: place the two beats by hand, 8px apart. - const place = (el: HTMLElement, left: number) => { - el.getBoundingClientRect = () => - ({ left, right: left + 8, width: 8, top: 0, bottom: 20, height: 20 }) as DOMRect; - }; + // The visible gap and hover tolerance both use 4px. place(beats()[0], 100); - place(beats()[1], 116); + place(beats()[1], 112); const strip = document.querySelector('[data-testid="tick-spine"]')!; - hover(strip, 109); // in the gap, nearer beat 1's centre (104) than beat 2's (120) + hover(strip, 109); // nearer beat 1's centre (104) than beat 2's (116) expect(card()!.dataset.beatCard).toBe("1"); - hover(strip, 113); // past the midpoint + 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}` })); @@ -307,6 +379,20 @@ describe("hovering a beat opens its card", () => { 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"); + }); }); describe("drawn bare, beside the tabs (ARCH-425)", () => { diff --git a/frontend/src/components/agent/lab/TickSpine.tsx b/frontend/src/components/agent/lab/TickSpine.tsx index 7e82e6a57..e03d66cee 100644 --- a/frontend/src/components/agent/lab/TickSpine.tsx +++ b/frontend/src/components/agent/lab/TickSpine.tsx @@ -101,10 +101,8 @@ export function TickSpine({ setHovered({ tick, rect: el.getBoundingClientRect() }); const hideCard = () => setHovered(null); - // Hover is tracked on the strip, not per beat: a pointer in the gap between - // two beats keeps the nearest one rather than dropping the hover, so sliding - // along the row never flickers through "nothing". Only pointer input moves - // it — the reflow a growing beat causes must not feed back into the choice. + // 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; @@ -113,6 +111,7 @@ export function TickSpine({ 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; @@ -133,6 +132,7 @@ export function TickSpine({ 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; @@ -169,12 +169,11 @@ export function TickSpine({ } : undefined } - className={`flex items-center gap-2 ${ - // The fixed height is room for a hovered beat and its ring, which an - // `overflow-x-auto` box would otherwise clip (its overflow-y goes auto - // too); the horizontal padding is the same room for the end beats. + className={`flex items-center gap-1 ${ + // Reserve room for the visual growth, neighbour offsets and rings so + // neither the scroll width nor wrapping changes when a tick expands. bare - ? "h-9 min-w-0 flex-nowrap overflow-x-auto px-1 [scrollbar-width:thin]" + ? "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" }`} > @@ -190,7 +189,7 @@ export function TickSpine({ > Run - {ticks.map((entry) => { + {ticks.map((entry, index) => { const deeds = byTick.get(entry.tick) ?? []; const state = beatState({ actions: deeds, @@ -206,6 +205,11 @@ export function TickSpine({ 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 && ( @@ -269,8 +276,8 @@ const STATE_TEXT: Record = { unlogged: "text-[var(--color-text-muted)]", }; -/** The strip's fixed gap between beats (`gap-2`), in px. */ -const BEAT_GAP = 8; +/** 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; 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;