diff --git a/docs/2026-03-27-flow-replay-viewer.md b/docs/2026-03-27-flow-replay-viewer.md new file mode 100644 index 00000000..528a4310 --- /dev/null +++ b/docs/2026-03-27-flow-replay-viewer.md @@ -0,0 +1,741 @@ +# Flow Replay Viewer + +This document specifies how the external flow replay viewer should present saved +run bundles from `acpx`. + +It covers: + +- graph semantics +- layout rules +- replay controls +- panel structure +- ACP conversation rendering + +It does not change the run-bundle storage model. The viewer derives its display +semantics from the stored flow definition, trace, projections, and bundled +session data. + +## Information density + +The viewer exists to inspect dense technical state. + +That means the default visual style should prefer: + +- small typography +- tight spacing +- strong grouping +- less duplicated metadata + +The viewer should not feel like a marketing dashboard or a spacious card UI. + +### Typography + +The default font size should be small enough to keep substantial technical +context visible at once. + +Requirements: + +- compact base text +- compact labels +- compact metadata chips +- no oversized headers inside the main viewing surface + +The viewer should optimize for scanability over visual decoration. + +### Metadata budget + +There is too much state available to show all at once. + +The default view should show only the metadata needed to answer: + +- what run is this? +- where am I in replay? +- what node is selected? +- what happened here? + +Everything else should be: + +- collapsed +- secondary +- or moved into a detail view + +## Viewer chrome + +The viewer should not have a large changing top navbar. + +The left sidebar already establishes: + +- run selection +- app identity +- navigation + +So the main viewing surface should avoid a second heavy navigation layer. + +### Top-level chrome rules + +- no large persistent top navbar +- no step-dependent global header that changes while replay advances +- replay controls should live at the bottom edge of the graph surface, not in a + separate app bar +- run outcome should live with the replay controls, not in a stacked summary + card above the graph + +If any top chrome remains, it should be minimal and stable. + +### Delete the current redundant chrome + +The following current surfaces should be removed rather than restyled: + +- the large top navbar/header strip +- the duplicated run name, status, node, and source pills in that strip +- the separate run-outcome card when it duplicates the player state +- duplicated current-step metadata chips such as attempt, started-at, or run + duration when that same information is already available in the player +- the large selected-step summary card above the graph when it only repeats the + current replay position +- the long row of visited-node pills beneath the player +- the graph header legend pills such as `COMPLETED`, `SELECTED`, `QUEUED`, and + `PROBLEM` +- redundant section titles when the surrounding layout already makes the section + obvious + +The viewer should not present the same state in multiple stacked boxes. It +should keep one canonical place for: + +- current replay position +- run outcome +- selected node +- ACP conversation + +It should not repeat the same replay-step metadata both above and below the +scrubber. + +### Playback stability + +While replay is playing: + +- the main chrome should not jump +- the header should not change size +- layout should not reflow because the selected step changes + +Only the replay-specific surfaces should update: + +- scrubber +- current-step indicator +- graph overlay +- inspector content + +## Purpose + +The viewer must make two things legible at the same time: + +- the **flow definition** +- the **run that happened on top of that definition** + +The graph should stay the full definition. The viewer must not collapse the +default graph into only the executed path. + +The execution should instead appear as a strong overlay on top of the full +definition. + +## Primary graph model + +The primary graph is the full `FlowDefinitionSnapshot`. + +The graph answers: + +- where the run can start +- where it can branch +- which nodes are actions, ACP steps, compute steps, and checkpoints +- which nodes are terminal in the definition + +The run overlay answers: + +- which nodes were visited +- in what order +- which attempt is currently selected in replay +- where the run actually stopped or completed + +## Derived graph semantics + +These semantics should be derived in the viewer and should not be persisted as +additional fields in the run bundle. + +### Start node + +The definition start node is: + +- `flow.startAt` + +It must be rendered explicitly as the entry point. + +### Terminal nodes + +A definition-terminal node is any node with no outgoing edges. + +That should be inferred by: + +- collecting all `edge.from` values +- marking nodes that never appear as `edge.from` + +Definition-terminal nodes must be visually distinct from normal nodes. + +### Decision nodes + +A definition-decision node is any node with more than one outgoing target. + +That includes: + +- switch edges with multiple cases +- multiple direct edges from the same source, if the flow representation ever + permits that + +Decision nodes must be visually distinct from ordinary action or ACP steps. + +### Loop and back edges + +A back edge is any edge that moves against the main top-to-bottom direction of +the graph. + +That should be inferred after ranking nodes. + +Back edges must not be routed through the middle of the graph. They should be +sent out to side rails when possible. + +## Run semantics + +The viewer must not confuse: + +- replay position +- run outcome + +Replay position means: + +- which recorded attempt the scrubber is currently pointing at + +Run outcome means: + +- `completed` +- `failed` +- `timed_out` +- `waiting` +- `running` + +The current replay position must never imply successful completion. + +The run outcome should be derived from `run.status`, `run.error`, +`run.currentNode`, `live`, and the recorded steps. + +## Graph presentation + +### Required visual distinctions + +The graph must clearly distinguish: + +- start node +- definition-terminal node +- decision node +- ACP node +- action node +- compute node +- checkpoint node +- visited node +- selected replay attempt +- actual run stop/completion point + +The current graph's small `nodeType` text tag is not sufficient. + +### Node labeling + +Each node should show: + +- primary label: a human-readable name +- secondary label: the raw node id only if useful + +The viewer should not use raw internal ids as the only or dominant label. + +Short human labels may be derived by: + +- using a node summary if present +- otherwise prettifying the node id + +### Terminal rendering + +Definition-terminal nodes should be visually obvious even before any replay is +considered. + +Run-terminal state should be rendered separately: + +- if the run completed, failed, timed out, or stopped at a specific node, that + should be shown as an overlay attached to the actual reached node +- the lowest node in the graph must not be treated as the end state unless the + run actually ended there + +### Edge labeling + +Branch labels must not appear as long raw route ids in floating pills in the +middle of the graph. + +Edge labels should be: + +- short +- human-readable +- attached near the branching source, not floating in arbitrary mid-edge + positions + +When labels would overlap or create noise, the viewer should prefer: + +- abbreviated labels +- hover or selected-edge disclosure +- branch labels rendered near the source node + +Raw route ids such as `comment_and_escalate_to_human` should not be shown +directly as edge labels in the default view. + +## Layout rules + +The graph should be laid out primarily top-to-bottom. + +The layout engine must do more than simple breadth-first ranking. + +The viewer should not rely on hand-tuned explicit coordinates for real flows. +The graph should derive a readable layout automatically from the definition and +its inferred semantics. + +### Goals + +- start near the top +- definition-terminal nodes biased toward the bottom +- sibling branches grouped cleanly +- fewer edge crossings +- back edges routed away from the central reading path + +### Rules + +1. Rank nodes by distance from the start node. +2. Bias definition-terminal nodes toward the final rank. +3. Keep sibling branches horizontally grouped. +4. Route back edges on outer rails instead of through the center. +5. Avoid placing label-heavy branches directly over one another. + +If the automatic layout cannot satisfy these rules well enough, the viewer +should add post-processing rather than accepting a tangled graph. + +### Preferred layout engine + +The target implementation should use a real layered graph layout engine rather +than continuing to grow a custom heuristic ranker. + +Preferred direction: + +- `ELK layered` from Eclipse Layout Kernel + +Acceptable transitional direction: + +- `dagre` + +But the long-term target is `ELK layered`, not a permanent in-house layout +algorithm. + +### Why ELK layered + +The replay viewer needs more than node ranking. It needs: + +- crossing reduction +- branch grouping +- top-to-bottom layered flow layout +- port-aware edge routing +- bend points for orthogonal or near-orthogonal edges +- separation of forward edges from back edges + +That is a real graph-layout problem. It should be handled by a graph-layout +engine instead of a growing pile of viewer-specific heuristics. + +### Required graph-to-layout pipeline + +The viewer should derive semantic structure first, then hand a layout graph to +the engine. + +Required derived semantics: + +- start node +- definition-terminal nodes +- decision nodes +- pre-terminal chains +- back edges / loop edges + +The layout pipeline should then: + +1. build a directed graph from the flow definition +2. separate back edges from the forward layered graph for layout purposes +3. insert dummy routing points for long edges that span multiple ranks when + needed +4. assign layered layout constraints +5. route back edges on outer rails instead of through the central reading path +6. return node positions and routed edge geometry + +The viewer should then render that returned geometry in React Flow. + +### Required layout constraints + +The layout engine should be configured so that: + +- the graph direction is top-to-bottom +- definition-terminal nodes sink to the bottom ranks +- pre-terminal chains stay near terminal ranks +- sibling branches stay grouped +- decision nodes sit above their branch fan-out +- long forward edges do not cut straight through unrelated branches +- back edges are visibly distinct and routed outside the main flow + +### Rendering contract + +React Flow should be treated as the renderer, not the layout engine. + +That means: + +- node positions should come from the layout result +- edge bend points or routed segments should come from the layout result when + available +- the viewer should not depend on default edge generation from rough node + placement if the result causes avoidable crossings + +### Long-term architecture + +The durable architecture is: + +- semantic graph inference +- ELK layered layout +- routed edges from the layout result +- run replay as an overlay on top of that static semantic map + +This is the preferred "once and for all" direction for making large flow +definitions readable without hand-authoring coordinates. + +## Replay controls + +The transport should behave like a media player. + +### Required controls + +- play +- pause +- previous +- next +- jump to start +- jump to latest recorded attempt +- draggable scrubber +- compact icon buttons in the transport surface when space is tight +- footer placement at the bottom of the graph card + +### Camera modes + +The graph should support two viewing modes: + +- `follow` +- `overview` + +`follow` should be the default. + +`follow` means: + +- the camera tracks the currently active node +- the camera transition eases from node to node +- switching steps should not cause a hard jump + +`overview` means: + +- the full definition graph is visible +- the camera stops auto-following step changes +- switching into overview should ease into a fit-to-view state + +The viewer should not require persisted coordinates or hand-authored camera +positions for this behavior. + +### Replay timeline + +The scrubber represents: + +- attempt index within the recorded run + +It should not represent: + +- success percentage +- completion percentage + +The timeline should show: + +- `Attempt N of M` +- current node +- real run outcome separately + +It should not show multiple secondary status boxes that repeat the same replay +state in different wordings. + +### Continuous playback model + +The replay viewer should keep the stored run data discrete while making playback +feel continuous. + +That means: + +- the run bundle remains step-based and attempt-based +- the viewer adds a transient continuous playhead +- pausing or releasing the scrubber snaps back to the nearest discrete attempt + +The viewer should not add fractional or interpolated playback state to the +stored run bundle. + +### Canonical vs transient state + +The viewer should keep two layers of state: + +1. canonical discrete replay state +2. transient playback state + +Canonical discrete replay state answers: + +- which attempt is selected +- which ACP slice is selected +- which node is selected when the viewer is paused + +Transient playback state answers: + +- where the playhead currently is while playing +- where the scrubber is while dragging +- how far the current ACP message reveal has progressed + +The discrete state remains the source of truth when replay is paused. + +### Time model + +Continuous playback should be time-based, not percentage-based. + +The viewer should derive playback from: + +- attempt start time +- attempt finish time +- run start time +- run finish time when available + +Within a step, local playback progress should be derived from elapsed time +between the step start and finish. + +If a step has no meaningful duration, the viewer may use a small synthetic +minimum playback duration for presentation only. + +That synthetic duration must remain viewer-local and must not be written back to +the bundle. + +### ACP message reveal + +When replay is actively playing, ACP text should reveal progressively rather +than appearing only at step boundaries. + +Required behavior: + +- user turns should appear as full turns, not type character by character +- user-turn appearance should still ease in smoothly instead of popping +- assistant text should reveal progressively +- tool calls and tool results may appear once the playhead reaches the relevant + message threshold +- raw payload disclosures should stay closed by default during playback + +When replay is paused or scrubbing ends: + +- the ACP pane should snap to the nearest discrete step +- the selected step should render in its full discrete state +- the viewer should not remain stuck in a half-revealed message state + +### Graph overlay during playback + +The full definition graph should remain structurally discrete. + +Continuous playback should affect only the overlay: + +- edge highlight progression +- node emphasis +- selected-attempt indicator +- current-position glow or stroke + +The viewer should not invent intermediate nodes or intermediate graph topology. + +### Scrubber behavior + +The scrubber should behave like a media player seek bar. + +While dragging: + +- the viewer may preview a continuous playhead position +- the viewer should not immediately commit a new discrete selection on every + pointer movement + +When the drag ends: + +- snap to the nearest discrete attempt +- commit that attempt as the selected step +- render the canonical paused state for that attempt + +### Paused state + +When replay is paused: + +- the graph overlay should reflect a single discrete attempt +- the inspector should reflect a single discrete ACP slice +- the playhead should not imply continuous progression + +The paused viewer should always answer: + +- which attempt is selected right now +- which node that attempt belongs to +- what the exact stored state of that attempt was + +### Implementation boundary + +This behavior belongs in the viewer playback model and view-model only. + +It should not require: + +- changes to the run bundle schema +- new persistence fields for interpolation +- synthetic progress values stored in run projections + +## Layout shell + +The viewer should fit within the viewport. + +The outer shell should not grow beyond the screen height. + +Scrolling should happen inside sections, not on the page root. + +### Required shell + +- full-height left sidebar for run selection +- player area for replay controls and run outcome +- central graph pane +- side or lower pane for attempt/session inspection + +### Sidebar + +The run list should behave like a real left sidebar, similar to a chat or file +picker. + +Requirements: + +- full-height from top to bottom +- collapsible +- compact one-line rows +- not large card tiles +- not stretched vertically to fill space + +The selected run should remain obvious, but the list should not dominate the +screen during normal viewing. + +## Inspector panels + +The ACP session should be the default panel and the primary reading surface. + +The viewer should not dump raw JSON into the main reading path by default. + +### Default tabs + +- ACP session +- selected attempt details +- raw events + +The ACP session tab should be selected by default on load and after run +switching. + +The session panel should not feel secondary to attempt metadata. It should be +the main readable explanation of what happened at the selected point in replay. + +The viewer should prefer the ACP session pane over extra attempt-summary boxes. +If space is tight, remove duplicate attempt summary surfaces before shrinking +the session view. + +### ACP session rendering + +The default ACP session view should read like a conversation: + +- user messages +- agent messages +- tool calls +- tool results + +Tool noise should be collapsed or summarized by default. + +The user should not have to read large raw payloads unless they intentionally +expand them. + +Human-readable session text must not be truncated with ellipses in the default +conversation rendering. + +Readable conversation text should: + +- wrap +- remain selectable +- remain fully visible within the scrollable session panel + +Only clearly secondary metadata may be truncated. + +### Required behavior + +- show human-readable user and agent text blocks by default +- summarize tool calls in one line +- summarize tool results in one line +- collapse raw payloads behind disclosure controls +- keep the selected ACP slice highlighted +- avoid truncating readable message text +- give the ACP session pane more visual priority than attempt metadata + +Raw JSON is still important, but it belongs behind expansion controls or in a +raw-events view. + +## Run browser behavior + +The run list should not be part of the primary reading path while a run is +being inspected. + +That means: + +- the sidebar can stay collapsed +- selecting a run should not force the main graph or session panes to reflow in + a disruptive way +- the run picker should feel like a browser sidebar, not a giant dashboard card + +## What the viewer should answer quickly + +At a glance, the viewer should answer: + +- where does this flow start? +- what are the possible end states? +- what type of step is this node? +- which path did this run actually take? +- where did it stop? +- what ACP conversation corresponds to the selected step? + +If the viewer cannot answer those questions quickly, the presentation is wrong +even if the underlying data is correct. + +## Implementation guidance + +The cleanest implementation split is: + +- storage stays unchanged +- graph semantics are derived in the viewer view-model +- layout improvements happen in the viewer graph builder +- session readability improvements happen in the viewer inspector components + +That means the likely implementation sites are: + +- viewer view-model for start/terminal/decision inference +- graph layout builder for ranking and untangling +- node and edge renderers for semantics and labeling +- inspector components for ACP rendering + +## Non-goals + +- changing the run-bundle schema just to support presentation +- storing precomputed terminal-node flags in the bundle +- replacing the full definition graph with only the executed path in the default + view diff --git a/examples/flows/README.md b/examples/flows/README.md index 2026c949..f596e5c9 100644 --- a/examples/flows/README.md +++ b/examples/flows/README.md @@ -7,7 +7,7 @@ They range from small primitives to one larger end-to-end example. - `echo.flow.ts`: one ACP step that returns a JSON reply - `branch.flow.ts`: ACP classification followed by a deterministic branch into either `continue` or `checkpoint` - `pr-triage/pr-triage.flow.ts`: a larger single-PR workflow example with a colocated written spec in `pr-triage/README.md` -- `replay-viewer/`: a browser app that visualizes saved flow run bundles with React Flow, a recent-runs picker, and ACP session inspection +- `replay-viewer/`: a browser app that visualizes saved flow run bundles with React Flow, a recent-runs picker, ACP session inspection, and a dedicated viewer spec in `docs/2026-03-27-flow-replay-viewer.md` - `shell.flow.ts`: one native runtime-owned shell action that returns structured JSON - `workdir.flow.ts`: native workspace prep followed by an ACP step that runs inside that isolated cwd - `two-turn.flow.ts`: two ACP prompts in the same implicit main session diff --git a/examples/flows/pr-triage/README.md b/examples/flows/pr-triage/README.md index d3886418..99d757be 100644 --- a/examples/flows/pr-triage/README.md +++ b/examples/flows/pr-triage/README.md @@ -172,7 +172,7 @@ These are the current operational timeout assumptions in the single-file executa - nested local `codex review` inside `collect_review_state`: 30 minutes - `review_loop`: 90 minutes - `collect_ci_state`: 15 minutes -- `fix_ci_failures`: 30 minutes +- `fix_ci_failures`: 60 minutes - `check_final_conflicts`: 20 minutes - `resolve_final_conflicts`: 30 minutes - `post_close_pr`: 15 minutes diff --git a/examples/flows/pr-triage/pr-triage.flow.ts b/examples/flows/pr-triage/pr-triage.flow.ts index 31562bd7..c5387515 100644 --- a/examples/flows/pr-triage/pr-triage.flow.ts +++ b/examples/flows/pr-triage/pr-triage.flow.ts @@ -154,7 +154,7 @@ const flow = { nodeType: "acp", session: MAIN_SESSION, cwd: ({ outputs }) => prepared(outputs).workdir, - timeoutMs: 30 * 60_000, + timeoutMs: 60 * 60_000, async prompt({ outputs }) { return promptFixCiFailures(prepared(outputs), outputs); }, @@ -366,7 +366,6 @@ const flow = { switch: { on: "$.route", cases: { - collect_ci_state: "collect_ci_state", check_final_conflicts: "check_final_conflicts", comment_and_escalate_to_human: "comment_and_escalate_to_human", }, @@ -1043,19 +1042,21 @@ function promptFixCiFailures(pr, outputs) { "Stay on the autonomous CI lane for this single PR.", `Target PR: ${prRef(pr)}`, `The CI mechanics have already been collected by the flow runtime in ${ciStatePath}.`, - "Read that local JSON file and the checked-out repo state instead of rerunning broad CI discovery yourself.", + "Start from that local JSON file and the checked-out repo state, then own the CI lane yourself until it reaches a stable green outcome or a real blocker forces escalation.", `Use the local branch ${pr.localBranch}. If you need to push, use remote ${pr.pushRemote} branch ${pr.pushRef}.`, "If any relevant GitHub Actions workflow run is approval-blocked, approve it immediately yourself with `gh api -X POST repos/{owner}/{repo}/actions/runs/{run_id}/approve` before making any escalation decision.", "Treat a workflow run as approval-blocked when its state clearly shows `action_required`, including cases where that appears in the conclusion rather than the status.", - "After you approve a blocked workflow run, route back to `collect_ci_state` so the flow runtime can re-check CI on the updated state.", - "If related failures remain and you can fix them, fix them directly in the repo, run focused checks when feasible, rerun the earlier targeted validation, commit and push the branch yourself, and then route back to `collect_ci_state` so the flow runtime can re-check CI.", + "Do not bounce back to `collect_ci_state` just to wait for CI. If a relevant workflow run is queued or in progress, monitor it yourself with `gh run watch`, `gh pr checks --watch`, or direct `gh api` polling until it reaches a terminal state.", + "If you approve a blocked workflow run successfully, keep monitoring inside this same step until the rerun finishes green, surfaces a real related failure, or hits a real platform/permission blocker.", + "If related failures remain and you can fix them, fix them directly in the repo, run focused checks when feasible, rerun the earlier targeted validation, commit and push the branch yourself, rerun or monitor CI yourself, and stay in this same step until the updated CI reaches a terminal state.", + "Only return from this step once CI is actually green/unrelated, or once you have a real reason that a human must take over.", `Latest validation summary: ${validation?.summary ?? "none"}.`, "If CI is green or the remaining failures are clearly unrelated, route to `check_final_conflicts` so the final conflict gate can run before the human handoff.", "Only route to `comment_and_escalate_to_human` for workflow approval if you actually tried to approve the blocked run and could not clear it because of a real permission or platform failure.", ...exactJsonResponse([ "Return exactly one JSON object with this shape:", "{", - ' "route": "collect_ci_state" | "check_final_conflicts" | "comment_and_escalate_to_human",', + ' "route": "check_final_conflicts" | "comment_and_escalate_to_human",', ' "ci_status": "related_failures_remain" | "green_or_unrelated" | "approval_blocked",', ' "summary": "short explanation",', ' "related_failures": ["brief failure"],', diff --git a/examples/flows/replay-viewer/README.md b/examples/flows/replay-viewer/README.md index 58eb0a2e..39a65334 100644 --- a/examples/flows/replay-viewer/README.md +++ b/examples/flows/replay-viewer/README.md @@ -2,6 +2,9 @@ This example app visualizes one saved flow run bundle at a time. +For the viewer semantics and UX/layout rules, see +[docs/2026-03-27-flow-replay-viewer.md](../../../docs/2026-03-27-flow-replay-viewer.md). + It is separate from the `acpx` CLI surface on purpose: - `acpx` writes replayable run bundles under `~/.acpx/flows/runs/` @@ -45,6 +48,9 @@ bundle outside that default directory. - the ACP conversation slice for the selected ACP step - the raw bundled ACP event slice for that step +The full flow definition remains the main graph. The run is shown as an overlay +on that graph rather than replacing it with an execution-only path. + ## Included sample The bundled sample under `public/sample-run/` comes from a real run of diff --git a/examples/flows/replay-viewer/src/app.tsx b/examples/flows/replay-viewer/src/app.tsx index d20b0cee..a69beab3 100644 --- a/examples/flows/replay-viewer/src/app.tsx +++ b/examples/flows/replay-viewer/src/app.tsx @@ -1,166 +1,138 @@ import { Background, Controls, ReactFlow, type Node } from "@xyflow/react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { FlowNodeCard } from "./components/flow-node-card"; import { InspectorPanel } from "./components/inspector-panel"; +import { RoutedFlowEdge } from "./components/routed-flow-edge"; import { RunBrowser } from "./components/run-browser"; import { StepTimeline } from "./components/step-timeline"; -import { - createRecentRunBundleReader, - createDirectoryBundleReader, - createSampleBundleReader, - isDirectoryPickerSupported, - listRecentRuns, -} from "./lib/bundle-reader"; -import { loadRunBundle } from "./lib/load-bundle"; +import { REPLAY_FIT_VIEW_OPTIONS, useGraphCamera } from "./hooks/use-graph-camera"; +import { useGraphLayout } from "./hooks/use-graph-layout"; +import { PLAYBACK_SPEED_OPTIONS, usePlaybackController } from "./hooks/use-playback-controller"; +import { useRunBundleLoader } from "./hooks/use-run-bundle-loader"; +import { isDirectoryPickerSupported } from "./lib/bundle-reader"; import { buildGraph, - deriveRunOutcomeView, - formatDuration, + humanizeIdentifier, + listSessionViews, + playbackSelectionMs, selectAttemptView, } from "./lib/view-model"; -import type { LoadedRunBundle, RunBundleSummary } from "./types"; +import type { LoadedRunBundle } from "./types"; const nodeTypes = { flowNode: FlowNodeCard, }; +const edgeTypes = { + routedFlow: RoutedFlowEdge, +}; + export function App() { - const [bundle, setBundle] = useState(null); - const [recentRuns, setRecentRuns] = useState([]); - const [activeRunId, setActiveRunId] = useState(null); - const [selectedStepIndex, setSelectedStepIndex] = useState(0); + const { + bundle, + recentRuns, + activeRunId, + loadingState, + errorMessage, + bootstrap, + refreshRuns, + loadSample, + loadLocalBundle, + loadRecentRun, + } = useRunBundleLoader(); + const playback = usePlaybackController(bundle); + const graphLayout = useGraphLayout(bundle); const [activeTab, setActiveTab] = useState<"attempt" | "session" | "events">("session"); const [runsCollapsed, setRunsCollapsed] = useState(true); - const [loadingState, setLoadingState] = useState< - "bootstrap" | "runs" | "sample" | "local" | "run" | null - >("bootstrap"); - const [errorMessage, setErrorMessage] = useState(null); - const [playing, setPlaying] = useState(false); + const [activeSessionId, setActiveSessionId] = useState(null); + const [viewMode, setViewMode] = useState<"follow" | "overview">("follow"); useEffect(() => { void bootstrap(); - }, []); + }, [bootstrap]); useEffect(() => { - if (!bundle || !playing) { - return undefined; - } - if (selectedStepIndex >= bundle.steps.length - 1) { - setPlaying(false); - return undefined; - } - const intervalId = window.setInterval(() => { - setSelectedStepIndex((current) => { - if (!bundle || current >= bundle.steps.length - 1) { - setPlaying(false); - return current; - } - return current + 1; - }); - }, 950); - return () => window.clearInterval(intervalId); - }, [bundle, playing, selectedStepIndex]); - - const graph = bundle ? buildGraph(bundle, selectedStepIndex) : { nodes: [], edges: [] }; - const selectedAttempt = bundle ? selectAttemptView(bundle, selectedStepIndex) : null; - - async function bootstrap(): Promise { - setLoadingState("bootstrap"); - setErrorMessage(null); - setPlaying(false); - - const runs = await refreshRuns(); - if (runs && runs.length > 0) { - await loadRecentRun(runs[0]); - return; - } - await loadSample(); - } + setActiveTab("session"); + setViewMode("follow"); + }, [bundle?.run.runId]); - async function refreshRuns(): Promise { - setLoadingState("runs"); - try { - const runs = await listRecentRuns(); - if (runs) { - setRecentRuns(runs); - } - return runs; - } finally { - setLoadingState(null); - } - } - - async function loadSample(): Promise { - setLoadingState("sample"); - setErrorMessage(null); - setPlaying(false); - - try { - const loaded = await loadRunBundle(createSampleBundleReader()); - setBundle(loaded); - setActiveRunId(null); - setSelectedStepIndex(defaultSelectedStepIndex(loaded)); - setActiveTab("session"); - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : String(error)); - } finally { - setLoadingState(null); + const graph = bundle + ? buildGraph(bundle, playback.effectiveStepIndex, playback.playbackPreview, graphLayout) + : { nodes: [], edges: [] }; + const graphLayoutKey = useMemo( + () => graph.nodes.map((node) => `${node.id}:${node.position.x}:${node.position.y}`).join("|"), + [graph.nodes], + ); + const selectedAttempt = useMemo( + () => (bundle ? selectAttemptView(bundle, playback.effectiveStepIndex) : null), + [bundle, playback.effectiveStepIndex], + ); + const sessionItems = useMemo( + () => (bundle && selectedAttempt ? listSessionViews(bundle, selectedAttempt) : []), + [bundle, selectedAttempt], + ); + const currentStep = bundle?.steps[playback.effectiveStepIndex] ?? null; + const currentDuration = currentStep + ? `${playback.effectiveStepIndex + 1} / ${bundle?.steps.length ?? 0} · ${currentStep.nodeType} · ${playback.playbackPreview ? playbackProgressLabel(playback.playbackPreview.stepProgress) : deriveStepDurationLabel(currentStep)}` + : "n/a"; + const sessionRevealProgress = + playback.playbackPreview && selectedAttempt?.step.attemptId === currentStep?.attemptId + ? playback.playbackPreview.stepProgress + : null; + const currentNodeId = currentStep?.nodeId ?? graph.nodes[0]?.id ?? null; + const currentNodePosition = useMemo(() => { + if (!currentNodeId) { + return null; } - } - - async function loadLocalBundle(): Promise { - setLoadingState("local"); - setErrorMessage(null); - setPlaying(false); + const node = graph.nodes.find((candidate) => candidate.id === currentNodeId); + return node ? { x: node.position.x, y: node.position.y } : null; + }, [currentNodeId, graphLayoutKey]); + const playbackValue = + playback.playbackPreview?.playheadMs ?? + (playback.playbackTimeline + ? playbackSelectionMs( + playback.playbackTimeline, + playback.selectedStepIndex, + bundle?.steps.length ?? 0, + ) + : 0) ?? + 0; - try { - const reader = await createDirectoryBundleReader(); - const loaded = await loadRunBundle(reader); - setBundle(loaded); - setActiveRunId(null); - setSelectedStepIndex(defaultSelectedStepIndex(loaded)); - setActiveTab("session"); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - return; - } - setErrorMessage(error instanceof Error ? error.message : String(error)); - } finally { - setLoadingState(null); - } - } + const { setFlowInstance } = useGraphCamera({ + runId: bundle?.run.runId, + layoutKey: graphLayoutKey, + currentNodeId, + currentNodePosition, + viewMode, + }); - async function loadRecentRun(run: RunBundleSummary): Promise { - setLoadingState("run"); - setErrorMessage(null); - setPlaying(false); - - try { - const loaded = await loadRunBundle(createRecentRunBundleReader(run)); - setBundle(loaded); - setActiveRunId(run.runId); - setSelectedStepIndex(defaultSelectedStepIndex(loaded)); - setActiveTab("session"); - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : String(error)); - } finally { - setLoadingState(null); - } - } + useEffect(() => { + const defaultSessionId = + selectedAttempt?.sessionSourceStep?.trace?.conversation?.sessionId ?? + selectedAttempt?.sessionSourceStep?.trace?.sessionId ?? + sessionItems[0]?.id ?? + null; + setActiveSessionId(defaultSessionId); + }, [ + selectedAttempt?.step.attemptId, + selectedAttempt?.sessionSourceStep?.attemptId, + sessionItems[0]?.id, + ]); function selectNode(nodeId: string): void { if (!bundle) { return; } - const visibleSteps = bundle.steps.slice(0, selectedStepIndex + 1); + playback.clearPlayback(); + const visibleSteps = bundle.steps.slice(0, playback.effectiveStepIndex + 1); const visibleIndex = visibleSteps.map((step) => step.nodeId).lastIndexOf(nodeId); if (visibleIndex >= 0) { - setSelectedStepIndex(visibleIndex); + playback.selectStep(visibleIndex); return; } const firstIndex = bundle.steps.findIndex((step) => step.nodeId === nodeId); if (firstIndex >= 0) { - setSelectedStepIndex(firstIndex); + playback.selectStep(firstIndex); } } @@ -190,106 +162,84 @@ export function App() { />
-
-
-
-
acpx flow replay
-

Trace Viewer

-
-
- {bundle ? ( -
- {bundle.run.flowName} - - {bundle.run.status} - - - {bundle.steps[selectedStepIndex]?.nodeId ?? bundle.live?.currentNode ?? "n/a"} - - {bundle.sourceLabel} -
- ) : null} -
- {errorMessage ?
{errorMessage}
: null}
{bundle ? ( - (() => { - const runOutcome = deriveRunOutcomeView(bundle); - - return ( - <> -
- { - setPlaying(false); - setSelectedStepIndex(index); - }} - onPlay={() => { - if (selectedStepIndex >= bundle.steps.length - 1) { - setSelectedStepIndex(0); - } - setPlaying(true); - }} - onPause={() => setPlaying(false)} - onReset={() => { - setPlaying(false); - setSelectedStepIndex(0); - }} - onJumpToEnd={() => { - setPlaying(false); - setSelectedStepIndex(Math.max(bundle.steps.length - 1, 0)); - }} - runStartedAt={bundle.run.startedAt} - runDurationLabel={formatDuration( - (bundle.run.finishedAt ? Date.parse(bundle.run.finishedAt) : Date.now()) - - Date.parse(bundle.run.startedAt), - )} - /> -
- -
-
-
-
Graph replay
-

{bundle.flow.name}

-
-
- completed - selected - queued - problem -
-
-
- selectNode(node.id)} - minZoom={0.28} - maxZoom={1.35} - proOptions={{ hideAttribution: true }} - > - - - -
-
- - ); - })() +
+
+ selectNode(node.id)} + minZoom={REPLAY_FIT_VIEW_OPTIONS.minZoom} + maxZoom={1.35} + fitViewOptions={REPLAY_FIT_VIEW_OPTIONS} + proOptions={{ hideAttribution: true }} + > + { + setViewMode("overview"); + }} + /> + + +
+
+ setViewMode("follow")} + > + + Follow + + setViewMode("overview")} + > + + Overview + +
+
+
+ +
) : (

Load a run bundle

@@ -303,8 +253,12 @@ export function App() {
@@ -312,6 +266,58 @@ export function App() { ); } -function defaultSelectedStepIndex(bundle: LoadedRunBundle): number { - return Math.max(bundle.steps.length - 1, 0); +function deriveStepDurationLabel(step: LoadedRunBundle["steps"][number]): string { + return `${Math.max(0, Date.parse(step.finishedAt) - Date.parse(step.startedAt))} ms`; +} + +function playbackProgressLabel(progress: number): string { + return `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`; +} + +function ModeButton({ + children, + label, + active, + onClick, +}: { + children: ReactNode; + label: string; + active: boolean; + onClick(): void; +}) { + return ( + + ); +} + +function FollowIcon() { + return ( + + ); +} + +function OverviewIcon() { + return ( + + ); } diff --git a/examples/flows/replay-viewer/src/components/flow-node-card.tsx b/examples/flows/replay-viewer/src/components/flow-node-card.tsx index d656a050..99d88e42 100644 --- a/examples/flows/replay-viewer/src/components/flow-node-card.tsx +++ b/examples/flows/replay-viewer/src/components/flow-node-card.tsx @@ -9,24 +9,89 @@ type FlowNodeCardProps = { export function FlowNodeCard({ data, selected = false }: FlowNodeCardProps) { return (
- + {typeof data.playbackProgress === "number" ? ( + ); } @@ -49,3 +114,17 @@ function labelForStatus(status: ViewerNodeData["status"]): string { return status; } } + +function labelForNodeType(nodeType: ViewerNodeData["nodeType"]): string { + switch (nodeType) { + case "acp": + return "ACP"; + case "action": + return "Action"; + case "checkpoint": + return "Checkpoint"; + case "compute": + default: + return "Compute"; + } +} diff --git a/examples/flows/replay-viewer/src/components/inspector-panel.tsx b/examples/flows/replay-viewer/src/components/inspector-panel.tsx index a277dda8..08b46faa 100644 --- a/examples/flows/replay-viewer/src/components/inspector-panel.tsx +++ b/examples/flows/replay-viewer/src/components/inspector-panel.tsx @@ -1,285 +1,62 @@ -import type { ReactNode } from "react"; -import { formatDate, formatDuration, formatJson } from "../lib/view-model"; -import type { SelectedAttemptView } from "../lib/view-model"; +import type { SelectedAttemptView, SessionListItemView } from "../lib/view-model"; +import { AttemptTab } from "./inspector/attempt-tab"; +import { EventsTab } from "./inspector/events-tab"; +import { SessionTab } from "./inspector/session-tab"; type InspectorPanelProps = { selectedAttempt: SelectedAttemptView | null; + sessionItems: SessionListItemView[]; + activeSessionId: string | null; + sessionRevealProgress: number | null; activeTab: "attempt" | "session" | "events"; onTabChange(tab: "attempt" | "session" | "events"): void; + onSessionChange(sessionId: string): void; }; -export function InspectorPanel({ selectedAttempt, activeTab, onTabChange }: InspectorPanelProps) { +export function InspectorPanel({ + selectedAttempt, + sessionItems, + activeSessionId, + sessionRevealProgress, + activeTab, + onTabChange, + onSessionChange, +}: InspectorPanelProps) { if (!selectedAttempt) { return ( ); } - const { step } = selectedAttempt; - return ( ); } -function AttemptTab({ selectedAttempt }: { selectedAttempt: SelectedAttemptView }) { - const { step } = selectedAttempt; - - return ( -
-
- {formatJson(step.output)} -
- - {step.promptText ? ( - - {step.promptText} - - ) : null} - - {step.rawText ? ( - - {step.rawText} - - ) : null} - - {step.trace?.action ? ( - - {formatJson(step.trace.action)} - - ) : null} - - {step.error ? ( -
- {step.error} -
- ) : null} -
- ); -} - -function SessionTab({ selectedAttempt }: { selectedAttempt: SelectedAttemptView }) { - const { step, sessionRecord, sessionSlice, sessionSourceStep, sessionFromFallback } = - selectedAttempt; - - if (!sessionRecord) { - return ( -
-
-
This step did not use an ACP session.
-
-
- ); - } - - return ( -
-
- {sessionFromFallback && sessionSourceStep ? ( -
- Showing the latest visible ACP conversation from{" "} - {sessionSourceStep.nodeId} because {step.nodeId} does not - carry its own session slice. -
- ) : null} -
-
-
Name
-
{step.session?.name ?? sessionRecord.name ?? "n/a"}
-
-
-
Session id
-
{step.trace?.conversation?.sessionId ?? step.trace?.sessionId ?? "n/a"}
-
-
-
cwd
-
{sessionRecord.cwd ?? step.agent?.cwd ?? "n/a"}
-
-
-
Agent command
-
{sessionRecord.agentCommand ?? step.agent?.agentCommand ?? "n/a"}
-
-
-
- -
-
- {sessionSlice.map((message) => ( -
-
- - {message.title} - - #{message.index} -
- {message.textBlocks.length > 0 ? ( -
- {message.textBlocks.map((text, index) => ( -

{text}

- ))} -
- ) : ( -
No visible text content.
- )} - - {message.toolUses.length > 0 ? ( - -
- {message.toolUses.map((toolUse) => ( -
-
- {toolUse.name} - {toolUse.id} -
-

{toolUse.summary}

-
- Raw tool call - {formatJson(toolUse.raw)} -
-
- ))} -
-
- ) : null} - - {message.toolResults.length > 0 ? ( - -
- {message.toolResults.map((toolResult) => ( -
-
- {toolResult.toolName} - {toolResult.status} -
-

{toolResult.preview}

-
- Raw tool result - {formatJson(toolResult.raw)} -
-
- ))} -
-
- ) : null} - - {message.hiddenPayloads.length > 0 ? ( - -
- {message.hiddenPayloads.map((payload, index) => ( -
-
- {payload.label} -
- {formatJson(payload.raw)} -
- ))} -
-
- ) : null} -
- ))} -
-
-
- ); -} - -function EventsTab({ selectedAttempt }: { selectedAttempt: SelectedAttemptView }) { - return ( -
-
-
- {selectedAttempt.traceEvents.map((event) => ( -
-
- {event.seq} - {event.scope} - {event.type} -
-
- Show payload - {formatJson(event.payload)} -
-
- ))} - {selectedAttempt.traceEvents.length === 0 ? ( -
No trace events were captured for this attempt.
- ) : null} -
-
- -
-
- {selectedAttempt.rawEventSlice.map((event) => ( -
-
- {event.seq} - {event.direction} -
-
- Show event payload - {formatJson(event.message)} -
-
- ))} - {selectedAttempt.rawEventSlice.length === 0 ? ( -
This attempt has no bundled ACP event slice.
- ) : null} -
-
-
- ); -} - function TabButton({ tab, activeTab, @@ -299,33 +76,3 @@ function TabButton({ ); } - -function Section({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - -function DisclosureSection({ - title, - children, - compact = false, -}: { - title: string; - children: ReactNode; - compact?: boolean; -}) { - return ( -
- {title} -
{children}
-
- ); -} - -function CodeBlock({ children }: { children: string }) { - return
{children}
; -} diff --git a/examples/flows/replay-viewer/src/components/inspector/attempt-tab.tsx b/examples/flows/replay-viewer/src/components/inspector/attempt-tab.tsx new file mode 100644 index 00000000..419ee284 --- /dev/null +++ b/examples/flows/replay-viewer/src/components/inspector/attempt-tab.tsx @@ -0,0 +1,42 @@ +import { formatDate, formatDuration, formatJson } from "../../lib/view-model"; +import type { SelectedAttemptView } from "../../lib/view-model"; +import { CodeBlock, DisclosureSection, Section } from "./common"; + +export function AttemptTab({ selectedAttempt }: { selectedAttempt: SelectedAttemptView }) { + const { step } = selectedAttempt; + + return ( +
+
+ {formatJson(step.output)} +
+ + {step.promptText ? ( + + {step.promptText} + + ) : null} + + {step.rawText ? ( + + {step.rawText} + + ) : null} + + {step.trace?.action ? ( + + {formatJson(step.trace.action)} + + ) : null} + + {step.error ? ( +
+ {step.error} +
+ ) : null} +
+ ); +} diff --git a/examples/flows/replay-viewer/src/components/inspector/common.tsx b/examples/flows/replay-viewer/src/components/inspector/common.tsx new file mode 100644 index 00000000..c219ae20 --- /dev/null +++ b/examples/flows/replay-viewer/src/components/inspector/common.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; + +export function Section({ + title, + subtitle, + fill = false, + children, +}: { + title: string; + subtitle?: string; + fill?: boolean; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle ?
{subtitle}
: null} +
+ {children} +
+ ); +} + +export function DisclosureSection({ + title, + children, + compact = false, +}: { + title: string; + children: ReactNode; + compact?: boolean; +}) { + return ( +
+ {title} +
{children}
+
+ ); +} + +export function CodeBlock({ children }: { children: string }) { + return
{children}
; +} diff --git a/examples/flows/replay-viewer/src/components/inspector/conversation-message.tsx b/examples/flows/replay-viewer/src/components/inspector/conversation-message.tsx new file mode 100644 index 00000000..3debc4b6 --- /dev/null +++ b/examples/flows/replay-viewer/src/components/inspector/conversation-message.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import { formatJson } from "../../lib/view-model"; +import type { SelectedAttemptView } from "../../lib/view-model"; +import { CodeBlock, DisclosureSection } from "./common"; + +export function ConversationMessage({ + message, + animate, +}: { + message: SelectedAttemptView["sessionSlice"][number]; + animate: boolean; +}) { + const [entered, setEntered] = useState(!animate); + + useEffect(() => { + if (!animate) { + setEntered(true); + return; + } + + setEntered(false); + const frameId = window.requestAnimationFrame(() => { + setEntered(true); + }); + return () => window.cancelAnimationFrame(frameId); + }, [animate, message.index, message.role]); + + return ( +
+ {message.textBlocks.length > 0 ? ( +
+ {message.textBlocks.map((text, index) => ( +

{text}

+ ))} +
+ ) : ( +
No visible text content.
+ )} + + {message.toolUses.length > 0 ? ( + +
+ {message.toolUses.map((toolUse) => ( +
+
+ {toolUse.name} + {toolUse.id} +
+

{toolUse.summary}

+
+ Raw tool call + {formatJson(toolUse.raw)} +
+
+ ))} +
+
+ ) : null} + + {message.toolResults.length > 0 ? ( + +
+ {message.toolResults.map((toolResult) => ( +
+
+ {toolResult.toolName} + {toolResult.status} +
+

{toolResult.preview}

+
+ Raw tool result + {formatJson(toolResult.raw)} +
+
+ ))} +
+
+ ) : null} + + {message.hiddenPayloads.length > 0 ? ( + +
+ {message.hiddenPayloads.map((payload, index) => ( +
+
+ {payload.label} +
+ {formatJson(payload.raw)} +
+ ))} +
+
+ ) : null} +
+ ); +} diff --git a/examples/flows/replay-viewer/src/components/inspector/events-tab.tsx b/examples/flows/replay-viewer/src/components/inspector/events-tab.tsx new file mode 100644 index 00000000..761e8c0b --- /dev/null +++ b/examples/flows/replay-viewer/src/components/inspector/events-tab.tsx @@ -0,0 +1,50 @@ +import { formatJson } from "../../lib/view-model"; +import type { SelectedAttemptView } from "../../lib/view-model"; +import { CodeBlock, Section } from "./common"; + +export function EventsTab({ selectedAttempt }: { selectedAttempt: SelectedAttemptView }) { + return ( +
+
+
+ {selectedAttempt.traceEvents.map((event) => ( +
+
+ {event.seq} + {event.scope} + {event.type} +
+
+ Show payload + {formatJson(event.payload)} +
+
+ ))} + {selectedAttempt.traceEvents.length === 0 ? ( +
No trace events were captured for this attempt.
+ ) : null} +
+
+ +
+
+ {selectedAttempt.rawEventSlice.map((event) => ( +
+
+ {event.seq} + {event.direction} +
+
+ Show event payload + {formatJson(event.message)} +
+
+ ))} + {selectedAttempt.rawEventSlice.length === 0 ? ( +
This attempt has no bundled ACP event slice.
+ ) : null} +
+
+
+ ); +} diff --git a/examples/flows/replay-viewer/src/components/inspector/session-tab.tsx b/examples/flows/replay-viewer/src/components/inspector/session-tab.tsx new file mode 100644 index 00000000..4634ef34 --- /dev/null +++ b/examples/flows/replay-viewer/src/components/inspector/session-tab.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react"; +import { revealConversationTranscript } from "../../lib/view-model"; +import type { SelectedAttemptView, SessionListItemView } from "../../lib/view-model"; +import { ConversationMessage } from "./conversation-message"; + +export function SessionTab({ + selectedAttempt, + sessionItems, + activeSessionId, + sessionRevealProgress, + onSessionChange, +}: { + selectedAttempt: SelectedAttemptView; + sessionItems: SessionListItemView[]; + activeSessionId: string | null; + sessionRevealProgress: number | null; + onSessionChange(sessionId: string): void; +}) { + const activeSession = + sessionItems.find((session) => session.id === activeSessionId) ?? sessionItems[0] ?? null; + const sessionEndRef = useRef(null); + + const renderedSessionSlice = + activeSession?.isStreamingSource && typeof sessionRevealProgress === "number" + ? revealConversationTranscript(activeSession.sessionSlice, sessionRevealProgress) + : (activeSession?.sessionSlice ?? []); + const animateConversation = + activeSession?.isStreamingSource && typeof sessionRevealProgress === "number"; + + useEffect(() => { + if (!activeSession || typeof sessionRevealProgress !== "number") { + return; + } + sessionEndRef.current?.scrollIntoView({ block: "end" }); + }, [activeSession, renderedSessionSlice, sessionRevealProgress]); + + if (!activeSession) { + return ( +
+
This step did not use an ACP session.
+
+ ); + } + + return ( +
+ {sessionItems.length > 1 ? ( +
+ {sessionItems.map((session) => ( + + ))} +
+ ) : null} + +
+ {renderedSessionSlice.map((message) => ( + + ))} + +
+ ); +} diff --git a/examples/flows/replay-viewer/src/components/routed-flow-edge.tsx b/examples/flows/replay-viewer/src/components/routed-flow-edge.tsx new file mode 100644 index 00000000..8b7e6349 --- /dev/null +++ b/examples/flows/replay-viewer/src/components/routed-flow-edge.tsx @@ -0,0 +1,48 @@ +import { BaseEdge, type EdgeProps } from "@xyflow/react"; +import type { ViewerEdgeData } from "../lib/view-model.js"; + +export function RoutedFlowEdge({ + id, + data, + markerEnd, + style, + sourceX, + sourceY, + targetX, + targetY, +}: EdgeProps) { + const points = dataPoints(data, sourceX, sourceY, targetX, targetY); + const path = buildPolylinePath(points); + + return ; +} + +function dataPoints( + data: unknown, + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, +) { + const viewerData = data as ViewerEdgeData | undefined; + if (viewerData?.points && viewerData.points.length >= 2) { + return viewerData.points; + } + return [ + { x: sourceX, y: sourceY }, + { x: targetX, y: targetY }, + ]; +} + +function buildPolylinePath(points: Array<{ x: number; y: number }>): string { + const [first, ...rest] = points; + if (!first) { + return ""; + } + + let path = `M ${first.x} ${first.y}`; + for (const point of rest) { + path += ` L ${point.x} ${point.y}`; + } + return path; +} diff --git a/examples/flows/replay-viewer/src/components/step-timeline.tsx b/examples/flows/replay-viewer/src/components/step-timeline.tsx index fd236c4e..1d5775ac 100644 --- a/examples/flows/replay-viewer/src/components/step-timeline.tsx +++ b/examples/flows/replay-viewer/src/components/step-timeline.tsx @@ -1,32 +1,46 @@ -import { formatDate, formatDuration, type RunOutcomeView } from "../lib/view-model"; +import type { ReactNode } from "react"; import type { FlowStepRecord } from "../types"; type StepTimelineProps = { steps: FlowStepRecord[]; selectedIndex: number; + playbackValue: number; + playbackMax: number; + playbackRate: number; + playbackSpeedOptions: readonly number[]; + currentNodeLabel: string; + currentMeta: string; playing: boolean; - runOutcome: RunOutcomeView; - runStartedAt: string; - runDurationLabel: string; onSelect(index: number): void; onPlay(): void; onPause(): void; onReset(): void; onJumpToEnd(): void; + onSeekStart(): void; + onSeek(value: number): void; + onSeekCommit(value: number): void; + onPlaybackRateChange(playbackRate: number): void; }; export function StepTimeline({ steps, selectedIndex, + playbackValue, + playbackMax, + playbackRate, + playbackSpeedOptions, + currentNodeLabel, + currentMeta, playing, - runOutcome, - runStartedAt, - runDurationLabel, onSelect, onPlay, onPause, onReset, onJumpToEnd, + onSeekStart, + onSeek, + onSeekCommit, + onPlaybackRateChange, }: StepTimelineProps) { if (steps.length === 0) { return ( @@ -36,130 +50,180 @@ export function StepTimeline({ ); } - const currentStep = steps[selectedIndex] ?? steps[0]; - const currentDuration = - currentStep != null - ? formatDuration(Date.parse(currentStep.finishedAt) - Date.parse(currentStep.startedAt)) - : "n/a"; - return (
-
-
-
Run outcome
-
{runOutcome.headline}
-
{runOutcome.detail}
-
-
- - {runOutcome.status} - - {runOutcome.nodeId ? {runOutcome.nodeId} : null} - {runOutcome.attemptId ? ( - {runOutcome.attemptId} - ) : null} -
-
-
-
-
Replay position
-
{currentStep?.nodeId ?? "n/a"}
-
- Step {selectedIndex + 1} of {steps.length} • {currentStep?.nodeType ?? "n/a"} •{" "} - {currentStep?.outcome ?? "n/a"} • {currentDuration} -
-
- - Attempt - {currentStep?.attemptId ?? "n/a"} - - - Started - {formatDate(runStartedAt)} - - - Run - {runDurationLabel} - -
-
-
- - - - - -
-
-
- - Attempt {selectedIndex + 1} of {steps.length} - - {playing ? "playing" : "paused"} - {steps.at(-1)?.nodeId ?? "latest"} -
onSelect(Number(event.target.value))} + value={Math.min(playbackValue, Math.max(playbackMax, 0))} + onPointerDown={onSeekStart} + onChange={(event) => onSeek(Number(event.target.value))} + onPointerUp={(event) => onSeekCommit(Number((event.target as HTMLInputElement).value))} + onKeyUp={(event) => onSeekCommit(Number((event.target as HTMLInputElement).value))} + onBlur={(event) => onSeekCommit(Number((event.target as HTMLInputElement).value))} aria-label={`Replay position step ${selectedIndex + 1} of ${steps.length}`} />
-
-
- {currentStep?.nodeId ?? "n/a"} - {currentStep?.attemptId ?? "n/a"} +
+
+
{currentNodeLabel}
+
{currentMeta}
-
- {formatDate(currentStep?.startedAt)} - - {currentStep?.session?.handle ? `session ${currentStep.session.handle}` : "no session"} - +
+ + + + onSelect(Math.max(selectedIndex - 1, 0))} + disabled={selectedIndex === 0} + > + + + + {playing ? : } + + onSelect(Math.min(selectedIndex + 1, steps.length - 1))} + disabled={selectedIndex >= steps.length - 1} + > + + + + + +
+
+
+ {playbackSpeedOptions.map((option) => ( + onPlaybackRateChange(option)} + > + {formatPlaybackRate(option)} + + ))} +
-
-
- {steps.map((step, index) => { - const active = index === selectedIndex; - const completed = index < selectedIndex; - return ( - - ); - })}
); } + +function formatPlaybackRate(playbackRate: number): string { + return Number.isInteger(playbackRate) ? `${playbackRate}x` : `${playbackRate.toFixed(1)}x`; +} + +function IconButton({ + children, + label, + onClick, + disabled = false, + primary = false, +}: { + children: ReactNode; + label: string; + onClick(): void; + disabled?: boolean; + primary?: boolean; +}) { + return ( + + ); +} + +function SpeedButton({ + children, + label, + active, + onClick, +}: { + children: ReactNode; + label: string; + active: boolean; + onClick(): void; +}) { + return ( + + ); +} + +function FirstIcon() { + return ( + + ); +} + +function PreviousIcon() { + return ( + + ); +} + +function PlayIcon() { + return ( + + ); +} + +function PauseIcon() { + return ( + + ); +} + +function NextIcon() { + return ( + + ); +} + +function LastIcon() { + return ( + + ); +} diff --git a/examples/flows/replay-viewer/src/hooks/use-graph-camera.ts b/examples/flows/replay-viewer/src/hooks/use-graph-camera.ts new file mode 100644 index 00000000..71abeead --- /dev/null +++ b/examples/flows/replay-viewer/src/hooks/use-graph-camera.ts @@ -0,0 +1,99 @@ +import type { ReactFlowInstance } from "@xyflow/react"; +import { useEffect, useRef, useState } from "react"; + +type UseGraphCameraOptions = { + runId: string | undefined; + layoutKey: string; + currentNodeId: string | null; + currentNodePosition: { x: number; y: number } | null; + viewMode: "follow" | "overview"; +}; + +export const REPLAY_FIT_VIEW_OPTIONS = { + padding: 0.56, + minZoom: 0.08, + maxZoom: 0.92, + duration: 360, + ease: easeOutCubic, +} as const; + +export function useGraphCamera({ + runId, + layoutKey, + currentNodeId, + currentNodePosition, + viewMode, +}: UseGraphCameraOptions) { + const [flowInstance, setFlowInstance] = useState(null); + const lastFollowTargetRef = useRef(null); + + useEffect(() => { + if (!flowInstance?.viewportInitialized || !runId || viewMode !== "overview") { + return; + } + lastFollowTargetRef.current = null; + + let cancelled = false; + const frameId = window.requestAnimationFrame(() => { + if (cancelled) { + return; + } + void flowInstance.fitView(REPLAY_FIT_VIEW_OPTIONS); + }); + + return () => { + cancelled = true; + window.cancelAnimationFrame(frameId); + }; + }, [flowInstance, layoutKey, runId, viewMode]); + + useEffect(() => { + if ( + !flowInstance?.viewportInitialized || + !runId || + viewMode !== "follow" || + !currentNodeId || + !currentNodePosition + ) { + return; + } + const followTargetKey = `${runId}:${layoutKey}:${currentNodeId}`; + if (lastFollowTargetRef.current === followTargetKey) { + return; + } + lastFollowTargetRef.current = followTargetKey; + + let cancelled = false; + const frameId = window.requestAnimationFrame(() => { + if (cancelled) { + return; + } + + const internalNode = flowInstance.getInternalNode(currentNodeId); + const width = internalNode?.measured?.width ?? internalNode?.width ?? 284; + const height = internalNode?.measured?.height ?? internalNode?.height ?? 134; + const centerX = currentNodePosition.x + width / 2; + const centerY = currentNodePosition.y + height / 2 + 72; + + void flowInstance.setCenter(centerX, centerY, { + zoom: 0.84, + duration: 320, + ease: easeOutCubic, + }); + }); + + return () => { + cancelled = true; + window.cancelAnimationFrame(frameId); + }; + }, [currentNodeId, currentNodePosition, flowInstance, layoutKey, runId, viewMode]); + + return { + flowInstance, + setFlowInstance, + }; +} + +function easeOutCubic(value: number): number { + return 1 - Math.pow(1 - value, 3); +} diff --git a/examples/flows/replay-viewer/src/hooks/use-graph-layout.ts b/examples/flows/replay-viewer/src/hooks/use-graph-layout.ts new file mode 100644 index 00000000..185b85aa --- /dev/null +++ b/examples/flows/replay-viewer/src/hooks/use-graph-layout.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { buildGraphLayout } from "../lib/view-model.js"; +import type { ViewerGraphLayout } from "../lib/view-model.js"; +import type { LoadedRunBundle } from "../types"; + +export function useGraphLayout(bundle: LoadedRunBundle | null) { + const [layout, setLayout] = useState(null); + + useEffect(() => { + let cancelled = false; + + if (!bundle) { + setLayout(null); + return; + } + + setLayout(null); + + void buildGraphLayout(bundle.flow).then((nextLayout) => { + if (cancelled) { + return; + } + setLayout(nextLayout); + }); + + return () => { + cancelled = true; + }; + }, [bundle?.run.runId]); + + return layout; +} diff --git a/examples/flows/replay-viewer/src/hooks/use-playback-controller.ts b/examples/flows/replay-viewer/src/hooks/use-playback-controller.ts new file mode 100644 index 00000000..bdfeda6e --- /dev/null +++ b/examples/flows/replay-viewer/src/hooks/use-playback-controller.ts @@ -0,0 +1,204 @@ +import { useEffect, useMemo, useState } from "react"; +import { + buildPlaybackTimeline, + derivePlaybackPreview, + playbackSelectionMs, +} from "../lib/view-model.js"; +import type { PlaybackTimeline } from "../lib/view-model.js"; +import type { LoadedRunBundle } from "../types"; + +type PlaybackMode = "playing" | "seeking" | null; +export const PLAYBACK_SPEED_OPTIONS = [1, 2, 5, 10] as const; +const DEFAULT_PLAYBACK_RATE = 1; + +export function usePlaybackController(bundle: LoadedRunBundle | null) { + const [selectedStepIndex, setSelectedStepIndex] = useState(0); + const [playbackMode, setPlaybackMode] = useState(null); + const [playheadMs, setPlayheadMs] = useState(null); + const [playbackRate, setPlaybackRate] = useState(DEFAULT_PLAYBACK_RATE); + + useEffect(() => { + setSelectedStepIndex(defaultSelectedStepIndex(bundle)); + setPlaybackMode(null); + setPlayheadMs(null); + }, [bundle?.run.runId]); + + const playbackTimeline = useMemo(() => (bundle ? buildPlaybackTimeline(bundle) : null), [bundle]); + const playbackPreview = useMemo( + () => + playbackTimeline && playheadMs != null + ? derivePlaybackPreview(playbackTimeline, playheadMs) + : null, + [playbackTimeline, playheadMs], + ); + + useEffect(() => { + if (playbackMode !== "playing" || !playbackTimeline || playheadMs == null) { + return undefined; + } + if (playbackTimeline.segments.length === 0) { + return undefined; + } + let frameId = 0; + let lastTimestamp: number | null = null; + + const tick = (timestamp: number) => { + if (lastTimestamp == null) { + lastTimestamp = timestamp; + } + const deltaMs = timestamp - lastTimestamp; + lastTimestamp = timestamp; + + setPlayheadMs((current) => { + if (current == null) { + return current; + } + return Math.min( + advancePlaybackPlayhead(current, deltaMs, playbackRate, playbackTimeline.totalDurationMs), + playbackTimeline.totalDurationMs, + ); + }); + frameId = window.requestAnimationFrame(tick); + }; + + frameId = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frameId); + }, [playbackMode, playbackRate, playbackTimeline, playheadMs]); + + useEffect(() => { + if ( + playbackMode === "playing" && + playbackTimeline && + playbackPreview && + playbackPreview.playheadMs >= playbackTimeline.totalDurationMs + ) { + setSelectedStepIndex(Math.max(bundle?.steps.length ?? 1, 1) - 1); + setPlaybackMode(null); + setPlayheadMs(null); + } + }, [bundle?.steps.length, playbackMode, playbackPreview, playbackTimeline]); + + const effectiveStepIndex = playbackPreview?.activeStepIndex ?? selectedStepIndex; + + function clearPlayback(): void { + setPlaybackMode(null); + setPlayheadMs(null); + } + + function selectStep(index: number): void { + clearPlayback(); + setSelectedStepIndex(index); + } + + function play(): void { + if (!playbackTimeline) { + return; + } + const resumeMs = resolvePlaybackResumeMs( + playbackTimeline, + playheadMs, + selectedStepIndex, + bundle?.steps.length ?? 0, + ); + setPlayheadMs(resumeMs); + setPlaybackMode("playing"); + } + + function pause(): void { + if (!playbackPreview) { + clearPlayback(); + return; + } + setSelectedStepIndex(playbackPreview.nearestStepIndex); + clearPlayback(); + } + + function reset(): void { + clearPlayback(); + setSelectedStepIndex(0); + } + + function jumpToEnd(): void { + clearPlayback(); + setSelectedStepIndex(Math.max((bundle?.steps.length ?? 1) - 1, 0)); + } + + function startSeek(): void { + setPlaybackMode("seeking"); + setPlayheadMs( + playbackPreview?.playheadMs ?? + (playbackTimeline + ? playbackSelectionMs(playbackTimeline, selectedStepIndex, bundle?.steps.length ?? 0) + : 0), + ); + } + + function seek(value: number): void { + setPlayheadMs(value); + } + + function commitSeek(value: number): void { + if (!playbackTimeline) { + return; + } + const preview = derivePlaybackPreview(playbackTimeline, value); + setSelectedStepIndex(preview?.nearestStepIndex ?? selectedStepIndex); + clearPlayback(); + } + + return { + selectedStepIndex, + effectiveStepIndex, + playbackMode, + playbackRate, + playbackTimeline, + playbackPreview, + isPlaying: playbackMode === "playing", + setPlaybackRate, + clearPlayback, + selectStep, + play, + pause, + reset, + jumpToEnd, + startSeek, + seek, + commitSeek, + }; +} + +export function resolvePlaybackResumeMs( + timeline: PlaybackTimeline, + playheadMs: number | null, + selectedStepIndex: number, + stepCount: number, +): number { + if (playheadMs != null) { + return playheadMs; + } + const isTerminalSelection = + selectedStepIndex >= Math.max(stepCount - 1, 0) && timeline.segments.length > 0; + if (isTerminalSelection) { + return 0; + } + return playbackSelectionMs(timeline, selectedStepIndex, stepCount); +} + +export function advancePlaybackPlayhead( + currentMs: number, + deltaMs: number, + playbackRate: number, + totalDurationMs: number, +): number { + const safeDeltaMs = Math.max(0, deltaMs); + const safeRate = PLAYBACK_SPEED_OPTIONS.includes( + playbackRate as (typeof PLAYBACK_SPEED_OPTIONS)[number], + ) + ? playbackRate + : DEFAULT_PLAYBACK_RATE; + return Math.min(currentMs + safeDeltaMs * safeRate, totalDurationMs); +} + +function defaultSelectedStepIndex(bundle: LoadedRunBundle | null): number { + return bundle ? Math.max(bundle.steps.length - 1, 0) : 0; +} diff --git a/examples/flows/replay-viewer/src/hooks/use-run-bundle-loader.ts b/examples/flows/replay-viewer/src/hooks/use-run-bundle-loader.ts new file mode 100644 index 00000000..1f25e8f0 --- /dev/null +++ b/examples/flows/replay-viewer/src/hooks/use-run-bundle-loader.ts @@ -0,0 +1,123 @@ +import { useCallback, useState } from "react"; +import { + createDirectoryBundleReader, + createRecentRunBundleReader, + createSampleBundleReader, + listRecentRuns, +} from "../lib/bundle-reader"; +import { loadRunBundle } from "../lib/load-bundle"; +import { readRequestedRunIdFromWindow, syncRequestedRunId } from "../lib/run-url"; +import type { LoadedRunBundle, RunBundleSummary } from "../types"; + +export type RunBundleLoadingState = "bootstrap" | "runs" | "sample" | "local" | "run" | null; + +export function useRunBundleLoader() { + const [bundle, setBundle] = useState(null); + const [recentRuns, setRecentRuns] = useState([]); + const [activeRunId, setActiveRunId] = useState(null); + const [loadingState, setLoadingState] = useState("bootstrap"); + const [errorMessage, setErrorMessage] = useState(null); + + const refreshRuns = useCallback(async (): Promise => { + setLoadingState("runs"); + try { + const runs = await listRecentRuns(); + if (runs) { + setRecentRuns(runs); + } + return runs; + } finally { + setLoadingState(null); + } + }, []); + + const loadSample = useCallback(async (): Promise => { + setLoadingState("sample"); + setErrorMessage(null); + + try { + const loaded = await loadRunBundle(createSampleBundleReader()); + setBundle(loaded); + setActiveRunId(null); + syncRequestedRunId(null); + return loaded; + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : String(error)); + return null; + } finally { + setLoadingState(null); + } + }, []); + + const loadLocalBundle = useCallback(async (): Promise => { + setLoadingState("local"); + setErrorMessage(null); + + try { + const reader = await createDirectoryBundleReader(); + const loaded = await loadRunBundle(reader); + setBundle(loaded); + setActiveRunId(null); + syncRequestedRunId(null); + return loaded; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return null; + } + setErrorMessage(error instanceof Error ? error.message : String(error)); + return null; + } finally { + setLoadingState(null); + } + }, []); + + const loadRecentRun = useCallback( + async (run: RunBundleSummary): Promise => { + setLoadingState("run"); + setErrorMessage(null); + + try { + const loaded = await loadRunBundle(createRecentRunBundleReader(run)); + setBundle(loaded); + setActiveRunId(run.runId); + syncRequestedRunId(run.runId); + return loaded; + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : String(error)); + return null; + } finally { + setLoadingState(null); + } + }, + [], + ); + + const bootstrap = useCallback(async (): Promise => { + setLoadingState("bootstrap"); + setErrorMessage(null); + + const runs = await refreshRuns(); + const requestedRunId = readRequestedRunIdFromWindow(); + if (runs && runs.length > 0) { + const requestedRun = requestedRunId + ? (runs.find((candidate) => candidate.runId === requestedRunId) ?? null) + : null; + await loadRecentRun(requestedRun ?? runs[0]); + return; + } + await loadSample(); + }, [loadRecentRun, loadSample, refreshRuns]); + + return { + bundle, + recentRuns, + activeRunId, + loadingState, + errorMessage, + bootstrap, + refreshRuns, + loadSample, + loadLocalBundle, + loadRecentRun, + }; +} diff --git a/examples/flows/replay-viewer/src/lib/run-url.ts b/examples/flows/replay-viewer/src/lib/run-url.ts new file mode 100644 index 00000000..173a4681 --- /dev/null +++ b/examples/flows/replay-viewer/src/lib/run-url.ts @@ -0,0 +1,46 @@ +const RUN_QUERY_PARAM = "run"; +const RUN_PATH_PREFIX = "/run/"; + +export function readRequestedRunId(search: string, pathname: string = "/"): string | null { + const pathRunId = readRequestedRunIdFromPath(pathname); + if (pathRunId) { + return pathRunId; + } + + const queryRunId = new URLSearchParams(search).get(RUN_QUERY_PARAM)?.trim() ?? ""; + return queryRunId.length > 0 ? queryRunId : null; +} + +export function buildRunLocation(currentUrl: string, runId: string | null): string { + const url = new URL(currentUrl, "http://localhost"); + url.pathname = runId ? `${RUN_PATH_PREFIX}${encodeURIComponent(runId)}` : "/"; + url.searchParams.delete(RUN_QUERY_PARAM); + const next = `${url.pathname}${url.search}${url.hash}`; + return next.length > 0 ? next : "/"; +} + +export function readRequestedRunIdFromWindow(): string | null { + if (typeof window === "undefined") { + return null; + } + return readRequestedRunId(window.location.search, window.location.pathname); +} + +export function syncRequestedRunId(runId: string | null): void { + if (typeof window === "undefined") { + return; + } + + const nextLocation = buildRunLocation(window.location.href, runId); + window.history.replaceState(window.history.state, "", nextLocation); +} + +function readRequestedRunIdFromPath(pathname: string): string | null { + if (!pathname.startsWith(RUN_PATH_PREFIX)) { + return null; + } + + const rawRunId = pathname.slice(RUN_PATH_PREFIX.length).split("/").filter(Boolean)[0] ?? ""; + const runId = decodeURIComponent(rawRunId).trim(); + return runId.length > 0 ? runId : null; +} diff --git a/examples/flows/replay-viewer/src/lib/view-model-conversation.ts b/examples/flows/replay-viewer/src/lib/view-model-conversation.ts new file mode 100644 index 00000000..8df6a7fb --- /dev/null +++ b/examples/flows/replay-viewer/src/lib/view-model-conversation.ts @@ -0,0 +1,554 @@ +import type { + FlowBundledSessionEvent, + FlowStepRecord, + LoadedRunBundle, + SessionRecord, +} from "../types"; +import type { SelectedAttemptView, SessionListItemView } from "./view-model-types"; + +export function selectAttemptView( + bundle: LoadedRunBundle, + selectedStepIndex: number, +): SelectedAttemptView | null { + const step = bundle.steps[selectedStepIndex]; + + if (!step) { + return null; + } + + const sessionSourceStep = resolveSessionSourceStep(bundle.steps, selectedStepIndex); + const sessionId = + sessionSourceStep?.trace?.conversation?.sessionId ?? sessionSourceStep?.trace?.sessionId; + const session = sessionId ? (bundle.sessions[sessionId] ?? null) : null; + const sessionRecord = session?.record ?? null; + const sessionEvents = session?.events ?? []; + const conversation = sessionSourceStep?.trace?.conversation; + const sessionSlice = createSessionSlice( + sessionRecord, + conversation?.messageStart, + conversation?.messageEnd, + ); + const rawEventSlice = createRawEventSlice( + sessionEvents, + conversation?.eventStartSeq, + conversation?.eventEndSeq, + ); + const traceEvents = bundle.trace.filter((event) => event.attemptId === step.attemptId); + + return { + step, + sessionSourceStep, + sessionFromFallback: + sessionSourceStep != null && sessionSourceStep.attemptId !== step.attemptId, + sessionRecord, + sessionEvents, + sessionSlice, + rawEventSlice, + traceEvents, + }; +} + +export function listSessionViews( + bundle: LoadedRunBundle, + selectedAttempt: SelectedAttemptView | null, +): SessionListItemView[] { + if (!selectedAttempt?.sessionRecord) { + return []; + } + const streamingSessionId = + selectedAttempt?.sessionSourceStep?.trace?.conversation?.sessionId ?? + selectedAttempt?.sessionSourceStep?.trace?.sessionId ?? + null; + const conversation = selectedAttempt?.sessionSourceStep?.trace?.conversation; + + return Object.values(bundle.sessions) + .slice() + .toSorted((left, right) => + (left.record.name ?? left.binding.name ?? left.id).localeCompare( + right.record.name ?? right.binding.name ?? right.id, + ), + ) + .map((session) => ({ + id: session.id, + label: session.record.name ?? session.binding.name ?? session.id, + sessionRecord: session.record, + sessionSlice: createSessionSlice( + session.record, + session.id === streamingSessionId ? conversation?.messageStart : undefined, + session.id === streamingSessionId ? conversation?.messageEnd : undefined, + ), + isStreamingSource: session.id === streamingSessionId, + })); +} + +export function revealConversationSlice( + sessionSlice: SelectedAttemptView["sessionSlice"], + progress: number, +): SelectedAttemptView["sessionSlice"] { + const clampedProgress = clamp01(progress); + if (clampedProgress >= 1) { + return sessionSlice; + } + const revealed: SelectedAttemptView["sessionSlice"] = []; + const totalWeight = countStreamedConversationChars(sessionSlice); + + if (totalWeight <= 0) { + return sessionSlice.filter(isRevealableMessage); + } + + let consumedWeight = 0; + + for (let index = 0; index < sessionSlice.length; index += 1) { + const message = sessionSlice[index]; + if (!message) { + break; + } + + if (!isRevealableMessage(message)) { + continue; + } + + const messageWeight = messageRevealWeight(message); + const start = consumedWeight / totalWeight; + + if (messageWeight <= 0) { + if (clampedProgress >= start) { + revealed.push(message); + continue; + } + break; + } + + const end = (consumedWeight + messageWeight) / totalWeight; + if (clampedProgress >= end) { + revealed.push(message); + consumedWeight += messageWeight; + continue; + } + + if (clampedProgress < start) { + break; + } + + const charCount = messageWeight; + const localProgress = clamp01( + (clampedProgress - start) / Math.max(end - start, Number.EPSILON), + ); + const partialTextBlocks = + charCount > 0 + ? revealTextBlocks(message.textBlocks, Math.max(1, Math.round(charCount * localProgress))) + : []; + + if (partialTextBlocks.length > 0 || (message.textBlocks.length === 0 && localProgress >= 1)) { + revealed.push({ + ...message, + textBlocks: partialTextBlocks, + toolUses: [], + toolResults: [], + hiddenPayloads: [], + }); + } + break; + } + + return revealed; +} + +export function revealConversationTranscript( + sessionSlice: SelectedAttemptView["sessionSlice"], + progress: number, +): SelectedAttemptView["sessionSlice"] { + const highlightedIndexes = sessionSlice + .map((message, index) => (message.highlighted ? index : -1)) + .filter((index) => index >= 0); + + if (highlightedIndexes.length === 0) { + return sessionSlice; + } + + const firstHighlightedIndex = highlightedIndexes[0]!; + const lastHighlightedIndex = highlightedIndexes.at(-1)!; + const visiblePrefix = sessionSlice.slice(0, firstHighlightedIndex); + const highlightedSlice = sessionSlice.slice(firstHighlightedIndex, lastHighlightedIndex + 1); + const visibleHighlightedSlice = revealConversationSlice(highlightedSlice, progress); + + return [...visiblePrefix, ...visibleHighlightedSlice]; +} + +export function countStreamedConversationChars( + sessionSlice: SelectedAttemptView["sessionSlice"], +): number { + return sessionSlice.reduce((sum, message) => sum + messageRevealWeight(message), 0); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function clamp01(value: number): number { + return clamp(value, 0, 1); +} + +function resolveSessionSourceStep( + steps: FlowStepRecord[], + selectedStepIndex: number, +): FlowStepRecord | null { + const direct = steps[selectedStepIndex]; + if (direct?.trace?.conversation) { + return direct; + } + + for (let index = selectedStepIndex - 1; index >= 0; index -= 1) { + const candidate = steps[index]; + if (candidate?.trace?.conversation || candidate?.session) { + return candidate; + } + } + + if (direct?.session) { + return direct; + } + + return null; +} + +function createSessionSlice( + sessionRecord: SessionRecord | null, + start: number | undefined, + end: number | undefined, +): SelectedAttemptView["sessionSlice"] { + const messages = Array.isArray(sessionRecord?.messages) ? sessionRecord.messages : []; + return messages.map((message, index) => { + const role = detectMessageRole(message); + const contentView = describeMessage(message, role); + return { + index, + role, + title: role === "agent" ? "Agent" : role === "user" ? "User" : "Message", + highlighted: + typeof start === "number" && typeof end === "number" && index >= start && index <= end, + textBlocks: contentView.textBlocks, + toolUses: contentView.toolUses, + toolResults: contentView.toolResults, + hiddenPayloads: contentView.hiddenPayloads, + }; + }); +} + +function isRevealableMessage(message: SelectedAttemptView["sessionSlice"][number]): boolean { + return ( + message.textBlocks.length > 0 || + message.toolUses.length > 0 || + message.toolResults.length > 0 || + message.hiddenPayloads.length > 0 + ); +} + +function messageRevealWeight(message: SelectedAttemptView["sessionSlice"][number]): number { + if (message.role !== "agent") { + return 0; + } + return message.textBlocks.reduce((sum, block) => sum + block.length, 0); +} + +function revealTextBlocks(textBlocks: string[], charBudget: number): string[] { + const revealed: string[] = []; + let remainingChars = Math.max(0, charBudget); + + for (const block of textBlocks) { + if (remainingChars <= 0) { + break; + } + const take = Math.min(block.length, remainingChars); + revealed.push(block.slice(0, take)); + remainingChars -= take; + if (take < block.length) { + break; + } + } + + return revealed.filter((value) => value.length > 0); +} + +function createRawEventSlice( + events: FlowBundledSessionEvent[], + startSeq: number | undefined, + endSeq: number | undefined, +): FlowBundledSessionEvent[] { + if (typeof startSeq !== "number" || typeof endSeq !== "number") { + return []; + } + return events.filter((event) => event.seq >= startSeq && event.seq <= endSeq); +} + +function detectMessageRole(message: unknown): "user" | "agent" | "unknown" { + if (message && typeof message === "object") { + if ("User" in message) { + return "user"; + } + if ("Agent" in message) { + return "agent"; + } + } + return "unknown"; +} + +function describeMessage( + message: unknown, + role: "user" | "agent" | "unknown", +): Pick< + SelectedAttemptView["sessionSlice"][number], + "textBlocks" | "toolUses" | "toolResults" | "hiddenPayloads" +> { + if (!message || typeof message !== "object") { + return { + textBlocks: [String(message ?? "")].filter(Boolean), + toolUses: [], + toolResults: [], + hiddenPayloads: [], + }; + } + + if (role === "user") { + const user = (message as { User?: { content?: unknown } }).User; + return describeStructuredMessage(user?.content, undefined); + } + + if (role === "agent") { + const agent = ( + message as { + Agent?: { + content?: unknown; + tool_results?: unknown; + }; + } + ).Agent; + return describeStructuredMessage(agent?.content, agent?.tool_results); + } + + return { + textBlocks: [], + toolUses: [], + toolResults: [], + hiddenPayloads: [{ label: "Raw message", raw: message }], + }; +} + +function describeStructuredMessage( + content: unknown, + toolResults: unknown, +): Pick< + SelectedAttemptView["sessionSlice"][number], + "textBlocks" | "toolUses" | "toolResults" | "hiddenPayloads" +> { + const textBlocks: string[] = []; + const toolUses: SelectedAttemptView["sessionSlice"][number]["toolUses"] = []; + const hiddenPayloads: SelectedAttemptView["sessionSlice"][number]["hiddenPayloads"] = []; + + if (Array.isArray(content)) { + for (const [index, part] of content.entries()) { + if (!part || typeof part !== "object") { + const text = String(part ?? "").trim(); + if (text) { + textBlocks.push(text); + } + continue; + } + + if ("Text" in part && typeof (part as { Text?: unknown }).Text === "string") { + const text = (part as { Text: string }).Text.trim(); + if (text) { + textBlocks.push(text); + } + continue; + } + + if ("ToolUse" in part) { + const toolUse = (part as { ToolUse?: Record }).ToolUse; + if (toolUse && typeof toolUse === "object") { + toolUses.push({ + id: String(toolUse.id ?? `tool-use-${index}`), + name: typeof toolUse.name === "string" ? toolUse.name : "Tool call", + summary: summarizeToolUse(toolUse), + raw: toolUse, + }); + continue; + } + } + + hiddenPayloads.push({ + label: `Structured content ${index + 1}`, + raw: part, + }); + } + } else if (content != null) { + hiddenPayloads.push({ + label: "Structured content", + raw: content, + }); + } + + return { + textBlocks, + toolUses, + toolResults: describeToolResults(toolResults), + hiddenPayloads, + }; +} + +function describeToolResults( + toolResults: unknown, +): SelectedAttemptView["sessionSlice"][number]["toolResults"] { + if (!toolResults || typeof toolResults !== "object") { + return []; + } + + return Object.entries(toolResults as Record).map(([id, entry]) => { + const result = entry as { + tool_name?: unknown; + is_error?: unknown; + output?: Record; + content?: unknown; + }; + + const toolName = + typeof result.tool_name === "string" && result.tool_name.trim().length > 0 + ? result.tool_name + : "Tool result"; + const preview = summarizeToolResult(result); + const status = + typeof result.output?.status === "string" + ? result.output.status + : result.is_error + ? "error" + : "completed"; + + return { + id, + toolName, + status, + preview, + isError: Boolean(result.is_error), + raw: result, + }; + }); +} + +function summarizeToolUse(toolUse: Record): string { + const parsed = + parsePossiblyEncodedJson(toolUse.input) ?? parsePossiblyEncodedJson(toolUse.raw_input); + const parsedCommand = findFirstParsedCommand(parsed); + if (parsedCommand) { + return parsedCommand; + } + const command = findShellCommand(parsed); + if (command) { + return command; + } + return "Structured input hidden by default"; +} + +function summarizeToolResult(result: { + output?: Record; + content?: unknown; +}): string { + const output = result.output ?? {}; + const preferredText = [ + typeof output.formatted_output === "string" ? output.formatted_output : null, + typeof output.aggregated_output === "string" ? output.aggregated_output : null, + typeof output.stderr === "string" && output.stderr.trim().length > 0 ? output.stderr : null, + typeof output.stdout === "string" && output.stdout.trim().length > 0 ? output.stdout : null, + extractTextFromToolContent(result.content), + ].find((value): value is string => Boolean(value && value.trim().length > 0)); + + if (!preferredText) { + return "Structured result hidden by default"; + } + + const normalized = preferredText.replace(/\s+/g, " ").trim(); + return normalized.length > 180 ? `${normalized.slice(0, 177)}…` : normalized; +} + +function parsePossiblyEncodedJson(value: unknown): Record | null { + if (!value) { + return null; + } + if (typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + return null; +} + +function findFirstParsedCommand(payload: Record | null): string | null { + const parsedCmd = payload?.parsed_cmd; + if (!Array.isArray(parsedCmd) || parsedCmd.length === 0) { + return null; + } + const first = parsedCmd[0] as Record | undefined; + if (!first || typeof first !== "object") { + return null; + } + const name = typeof first.name === "string" ? first.name : null; + const cmd = typeof first.cmd === "string" ? first.cmd : null; + if (name && cmd) { + return `${name}: ${truncate(cmd, 96)}`; + } + if (cmd) { + return truncate(cmd, 96); + } + return name; +} + +function findShellCommand(payload: Record | null): string | null { + const command = payload?.command; + if (!Array.isArray(command) || command.length === 0) { + return null; + } + return truncate( + command.map((part) => (typeof part === "string" ? part : JSON.stringify(part))).join(" "), + 96, + ); +} + +function extractTextFromToolContent(content: unknown): string | null { + if (!content) { + return null; + } + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + const text = content + .map((entry) => + entry && typeof entry === "object" && "Text" in entry + ? (entry as { Text?: unknown }).Text + : null, + ) + .filter((entry): entry is string => typeof entry === "string") + .join("\n"); + return text || null; + } + if (typeof content === "object" && "Text" in content) { + const text = (content as { Text?: unknown }).Text; + return typeof text === "string" ? text : null; + } + return null; +} + +function truncate(value: string, maxLength: number): string { + const normalized = value.trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1)}…`; +} diff --git a/examples/flows/replay-viewer/src/lib/view-model-format.ts b/examples/flows/replay-viewer/src/lib/view-model-format.ts new file mode 100644 index 00000000..e1a45cf0 --- /dev/null +++ b/examples/flows/replay-viewer/src/lib/view-model-format.ts @@ -0,0 +1,44 @@ +export function humanizeIdentifier(value: string): string { + const normalized = value + .replace(/[_-]+/g, " ") + .replace(/\bpr\b/gi, "PR") + .replace(/\bci\b/gi, "CI") + .replace(/\bacp\b/gi, "ACP") + .trim(); + + if (!normalized) { + return value; + } + + return normalized.replace(/\b\w/g, (match) => match.toUpperCase()); +} + +export function formatDuration(durationMs: number | undefined): string { + if (durationMs == null || Number.isNaN(durationMs)) { + return "n/a"; + } + if (durationMs < 1_000) { + return `${durationMs} ms`; + } + const seconds = durationMs / 1_000; + if (seconds < 60) { + return `${seconds.toFixed(1)} s`; + } + const minutes = Math.floor(seconds / 60); + const remainder = Math.round(seconds % 60); + return `${minutes}m ${remainder}s`; +} + +export function formatDate(iso: string | undefined): string { + if (!iso) { + return "n/a"; + } + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }).format(new Date(iso)); +} + +export function formatJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} diff --git a/examples/flows/replay-viewer/src/lib/view-model-graph.ts b/examples/flows/replay-viewer/src/lib/view-model-graph.ts new file mode 100644 index 00000000..371d4445 --- /dev/null +++ b/examples/flows/replay-viewer/src/lib/view-model-graph.ts @@ -0,0 +1,893 @@ +import { Position, type Edge, type Node } from "@xyflow/react"; +import type { + ELK as ElkEngine, + ElkExtendedEdge, + ElkNode, + ElkPoint, +} from "elkjs/lib/elk.bundled.js"; +import type { + FlowDefinitionSnapshot, + FlowNodeOutcome, + FlowStepRecord, + LoadedRunBundle, +} from "../types"; +import { formatDuration, humanizeIdentifier } from "./view-model-format.js"; +import type { + PlaybackPreview, + RunOutcomeView, + ViewerEdgeData, + ViewerGraphLayout, + ViewerNodeData, + ViewerNodeStatus, +} from "./view-model-types"; + +type ExpandedFlowEdge = { + source: string; + target: string; + edgeId: string; +}; + +type NodeSemantics = { + startNodeId: string; + terminalNodeIds: Set; + decisionNodeIds: Set; + outgoingTargets: Map; + outgoingLabels: Map; +}; + +const ELK_NODE_WIDTH = 264; +const ELK_NODE_BASE_HEIGHT = 132; +const ELK_BRANCH_ROW_HEIGHT = 26; +let elkPromise: Promise | null = null; + +export function buildGraph( + bundle: LoadedRunBundle, + selectedStepIndex: number, + playback: PlaybackPreview | null = null, + layout: ViewerGraphLayout | null = null, +): { + nodes: Node[]; + edges: Edge[]; +} { + const orderedNodeIds = layoutNodeIds(bundle.flow, bundle.steps); + const selectedStep = bundle.steps[selectedStepIndex] ?? null; + const visibleSteps = bundle.steps.slice(0, Math.max(selectedStepIndex + 1, 0)); + const actualTransitions = new Set(); + const semantics = inferNodeSemantics(bundle.flow); + const expandedEdges = expandFlowEdges(bundle.flow); + const provisionalLevels = computeShortestLevels(bundle.flow, expandedEdges, orderedNodeIds); + const backEdgeIds = findBackEdgeIds(expandedEdges, provisionalLevels); + const levelByNode = computeLevels( + bundle.flow, + orderedNodeIds, + expandedEdges, + backEdgeIds, + semantics.terminalNodeIds, + ); + const runOutcome = deriveRunOutcomeView(bundle); + const fallbackRankOrder = orderNodesWithinRanks( + orderedNodeIds, + expandedEdges, + levelByNode, + backEdgeIds, + ); + + for (let index = 1; index < visibleSteps.length; index += 1) { + actualTransitions.add(`${visibleSteps[index - 1]?.nodeId}->${visibleSteps[index]?.nodeId}`); + } + + const graphNodes = orderedNodeIds.map((nodeId) => { + const nodeType = bundle.flow.nodes[nodeId]?.nodeType ?? "compute"; + const attemptsForNode = bundle.steps.filter((step) => step.nodeId === nodeId); + const visibleAttempt = findLatestVisibleAttempt(visibleSteps, nodeId); + const status = deriveNodeStatus(nodeId, visibleAttempt, selectedStep); + const fallbackPosition = deriveFallbackNodePosition(nodeId, levelByNode, fallbackRankOrder); + const layoutPosition = layout?.nodePositions[nodeId]; + const x = layoutPosition?.x ?? fallbackPosition.x; + const y = layoutPosition?.y ?? fallbackPosition.y; + const isStart = nodeId === semantics.startNodeId; + const isTerminal = semantics.terminalNodeIds.has(nodeId); + const isDecision = semantics.decisionNodeIds.has(nodeId); + const branchCount = semantics.outgoingTargets.get(nodeId)?.length ?? 0; + const branchLabels = semantics.outgoingLabels.get(nodeId) ?? []; + + return { + id: nodeId, + type: "flowNode", + data: { + nodeId, + title: humanizeIdentifier(nodeId), + subtitle: nodeId, + nodeType, + status, + attempts: attemptsForNode.length, + latestAttemptId: visibleAttempt?.attemptId, + durationLabel: visibleAttempt + ? formatDuration( + Date.parse(visibleAttempt.finishedAt) - Date.parse(visibleAttempt.startedAt), + ) + : undefined, + isStart, + isTerminal, + isDecision, + branchCount, + branchLabels, + isRunOutcomeNode: runOutcome.nodeId === nodeId, + runOutcomeLabel: + runOutcome.nodeId === nodeId && runOutcome.isTerminal ? runOutcome.shortLabel : undefined, + playbackProgress: + playback && selectedStep?.nodeId === nodeId ? clamp01(playback.stepProgress) : undefined, + }, + position: { x, y }, + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + draggable: false, + selectable: true, + } satisfies Node; + }); + + const graphEdges = expandedEdges.map((edge) => { + const isTraversed = actualTransitions.has(`${edge.source}->${edge.target}`); + const isSelected = Boolean( + selectedStep != null && + visibleSteps.at(-2)?.nodeId === edge.source && + selectedStep.nodeId === edge.target, + ); + const isBackEdge = backEdgeIds.has(edge.edgeId); + const stroke = isSelected + ? "var(--edge-active)" + : isTraversed + ? "var(--edge-complete)" + : "var(--edge-pending)"; + const routedPoints = layout?.edgeRoutes[edge.edgeId]?.points; + + return { + id: edge.edgeId, + source: edge.source, + target: edge.target, + type: "routedFlow", + animated: isSelected, + data: { + points: routedPoints, + isBackEdge, + }, + style: { + stroke, + strokeWidth: isSelected || isTraversed ? 2.4 : 1.2, + opacity: isTraversed || isSelected ? 1 : 0.72, + strokeDasharray: isBackEdge ? "6 5" : undefined, + }, + markerEnd: { + type: "arrowclosed", + color: stroke, + }, + zIndex: isBackEdge ? 0 : 1, + } satisfies Edge; + }); + + return { + nodes: graphNodes, + edges: graphEdges, + }; +} + +export function deriveRunOutcomeView(bundle: LoadedRunBundle): RunOutcomeView { + const lastStep = bundle.steps.at(-1) ?? null; + const activeNodeId = + bundle.run.currentNode ?? bundle.live?.currentNode ?? lastStep?.nodeId ?? null; + const activeNodeLabel = activeNodeId ? humanizeIdentifier(activeNodeId) : null; + const activeAttemptId = + bundle.run.currentAttemptId ?? bundle.live?.currentAttemptId ?? lastStep?.attemptId ?? null; + const errorText = + typeof bundle.run.error === "string" && bundle.run.error.trim().length > 0 + ? bundle.run.error.trim() + : null; + const waitingOn = + typeof bundle.run.waitingOn === "string" && bundle.run.waitingOn.trim().length > 0 + ? bundle.run.waitingOn.trim() + : null; + + switch (bundle.run.status) { + case "completed": + return { + status: bundle.run.status, + headline: "Run completed", + detail: activeNodeLabel + ? `The final recorded step completed at ${activeNodeLabel}.` + : "The flow reached a completed terminal state.", + shortLabel: "completed", + accent: "ok", + nodeId: activeNodeId, + attemptId: activeAttemptId, + isTerminal: true, + }; + case "running": + return { + status: bundle.run.status, + headline: activeNodeLabel ? `Running at ${activeNodeLabel}` : "Run is still active", + detail: + bundle.run.statusDetail?.trim() || + "The run is still in progress. Replay position shows recorded attempts only.", + shortLabel: "running", + accent: "active", + nodeId: activeNodeId, + attemptId: activeAttemptId, + isTerminal: false, + }; + case "waiting": + return { + status: bundle.run.status, + headline: waitingOn + ? `Waiting at ${waitingOn}` + : activeNodeLabel + ? `Waiting at ${activeNodeLabel}` + : "Run is waiting", + detail: + bundle.run.statusDetail?.trim() || + "The run paused at a checkpoint or external wait state.", + shortLabel: "waiting", + accent: "active", + nodeId: activeNodeId, + attemptId: activeAttemptId, + isTerminal: false, + }; + case "timed_out": + return { + status: bundle.run.status, + headline: activeNodeLabel ? `Timed out at ${activeNodeLabel}` : "Run timed out", + detail: errorText || "The run stopped because a node exceeded its timeout budget.", + shortLabel: "timed out", + accent: "timed_out", + nodeId: activeNodeId, + attemptId: activeAttemptId, + isTerminal: true, + }; + case "failed": + default: + return { + status: bundle.run.status, + headline: activeNodeLabel ? `Stopped at ${activeNodeLabel}` : "Run failed", + detail: + errorText || + "The run exited early because a node failed before reaching a completed terminal state.", + shortLabel: "stopped", + accent: "failed", + nodeId: activeNodeId, + attemptId: activeAttemptId, + isTerminal: true, + }; + } +} + +export async function buildGraphLayout( + flow: FlowDefinitionSnapshot, +): Promise { + const orderedNodeIds = layoutNodeIds(flow, []); + const semantics = inferNodeSemantics(flow); + const expandedEdges = expandFlowEdges(flow); + const shortestLevels = computeShortestLevels(flow, expandedEdges, orderedNodeIds); + const backEdgeIds = findBackEdgeIds(expandedEdges, shortestLevels); + const elkGraph: ElkNode = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.edgeRouting": "ORTHOGONAL", + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.layered.considerModelOrder.strategy": "PREFER_NODES", + "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + "elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED", + "elk.layered.unnecessaryBendpoints": "true", + "elk.padding": "[top=48,left=72,bottom=72,right=72]", + "elk.spacing.nodeNode": "56", + "elk.layered.spacing.nodeNodeBetweenLayers": "96", + "elk.spacing.edgeNode": "42", + "elk.spacing.edgeEdge": "24", + }, + children: orderedNodeIds.map((nodeId, index) => { + const branchLabels = semantics.outgoingLabels.get(nodeId) ?? []; + const layoutOptions: Record = { + "elk.priority": `${1000 - index}`, + }; + if (semantics.terminalNodeIds.has(nodeId)) { + layoutOptions["elk.layered.layering.layerConstraint"] = "LAST"; + } else if (nodeId === flow.startAt) { + layoutOptions["elk.layered.layering.layerConstraint"] = "FIRST"; + } + return { + id: nodeId, + width: ELK_NODE_WIDTH, + height: estimateElkNodeHeight(branchLabels.length), + layoutOptions, + } satisfies ElkNode; + }), + edges: expandedEdges.map((edge, index) => { + const layoutOptions: Record = backEdgeIds.has(edge.edgeId) + ? { + "elk.layered.priority.direction": "1", + } + : { + "elk.priority": `${1000 - index}`, + }; + return { + id: edge.edgeId, + sources: [edge.source], + targets: [edge.target], + layoutOptions, + }; + }) satisfies ElkExtendedEdge[], + }; + + try { + const elk = await getElk(); + const layout = await elk.layout(elkGraph); + const nodePositions: ViewerGraphLayout["nodePositions"] = {}; + const edgeRoutes: ViewerGraphLayout["edgeRoutes"] = {}; + + for (const child of layout.children ?? []) { + nodePositions[child.id] = { + x: child.x ?? 0, + y: child.y ?? 0, + }; + } + + for (const edge of layout.edges ?? []) { + const points = extractElkEdgePoints(edge); + if (points.length === 0) { + continue; + } + edgeRoutes[edge.id] = { + points, + isBackEdge: backEdgeIds.has(edge.id), + }; + } + + return { + nodePositions, + edgeRoutes, + }; + } catch { + return null; + } +} + +async function getElk(): Promise { + if (!elkPromise) { + elkPromise = import("elkjs/lib/elk.bundled.js").then(({ default: Elk }) => new Elk()); + } + return elkPromise; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function clamp01(value: number): number { + return clamp(value, 0, 1); +} + +function humanizeBranchLabel(value: string): string { + const mapped = ( + { + close_pr: "close", + comment_and_escalate_to_human: "human", + bug_or_feature: "classify", + judge_initial_conflicts: "assess", + resolve_initial_conflicts: "resolve", + reproduce_bug_and_test_fix: "bug path", + test_feature_directly: "feature path", + judge_refactor: "refactor", + collect_review_state: "review", + do_superficial_refactor: "refactor", + collect_ci_state: "ci", + check_final_conflicts: "final conflicts", + judge_final_conflicts: "assess", + resolve_final_conflicts: "resolve", + post_close_pr: "post close", + post_escalation_comment: "post comment", + } as Record + )[value]; + + return mapped ?? humanizeIdentifier(value).toLowerCase(); +} + +function deriveNodeStatus( + nodeId: string, + visibleAttempt: FlowStepRecord | undefined, + selectedStep: FlowStepRecord | null, +): ViewerNodeStatus { + if (selectedStep?.nodeId === nodeId) { + return "active"; + } + if (!visibleAttempt) { + return "queued"; + } + return mapOutcomeToStatus(visibleAttempt.outcome); +} + +function mapOutcomeToStatus(outcome: FlowNodeOutcome): ViewerNodeStatus { + switch (outcome) { + case "ok": + return "completed"; + case "timed_out": + return "timed_out"; + case "failed": + return "failed"; + case "cancelled": + return "cancelled"; + default: + return "queued"; + } +} + +function findLatestVisibleAttempt( + steps: FlowStepRecord[], + nodeId: string, +): FlowStepRecord | undefined { + const matching = steps.filter((step) => step.nodeId === nodeId); + return matching.at(-1); +} + +function deriveFallbackNodePosition( + nodeId: string, + levelByNode: Map, + rankOrder: Map, +): { x: number; y: number } { + const level = levelByNode.get(nodeId) ?? 0; + const laneNodes = rankOrder.get(level) ?? []; + const column = laneNodes.indexOf(nodeId); + const laneWidth = 332; + return { + x: (column - (laneNodes.length - 1) / 2) * laneWidth, + y: level * 236, + }; +} + +function estimateElkNodeHeight(branchLabelCount: number): number { + const branchRows = branchLabelCount > 0 ? Math.ceil(Math.min(branchLabelCount, 4) / 3) : 0; + return ELK_NODE_BASE_HEIGHT + branchRows * ELK_BRANCH_ROW_HEIGHT; +} + +function extractElkEdgePoints(edge: ElkExtendedEdge): ElkPoint[] { + const points: ElkPoint[] = []; + + for (const section of edge.sections ?? []) { + if (points.length === 0) { + points.push(section.startPoint); + } + for (const bendPoint of section.bendPoints ?? []) { + points.push(bendPoint); + } + points.push(section.endPoint); + } + + return dedupeConsecutivePoints(points); +} + +function dedupeConsecutivePoints(points: ElkPoint[]): ElkPoint[] { + const deduped: ElkPoint[] = []; + for (const point of points) { + const previous = deduped.at(-1); + if (previous && previous.x === point.x && previous.y === point.y) { + continue; + } + deduped.push(point); + } + return deduped; +} + +function expandFlowEdges(flow: FlowDefinitionSnapshot): ExpandedFlowEdge[] { + return flow.edges.flatMap((edge, index) => { + if ("to" in edge) { + return [ + { + source: edge.from, + target: edge.to, + edgeId: `${edge.from}->${edge.to}-${index}-0`, + }, + ]; + } + + return Object.values(edge.switch.cases).map((target, branchIndex) => ({ + source: edge.from, + target, + edgeId: `${edge.from}->${target}-${index}-${branchIndex}`, + })); + }); +} + +function inferNodeSemantics(flow: FlowDefinitionSnapshot): NodeSemantics { + const outgoingTargets = new Map(); + const outgoingLabels = new Map(); + + for (const edge of flow.edges) { + const targets = "to" in edge ? [edge.to] : Object.values(edge.switch.cases); + outgoingTargets.set(edge.from, [...(outgoingTargets.get(edge.from) ?? []), ...targets]); + if ("switch" in edge) { + outgoingLabels.set(edge.from, [ + ...(outgoingLabels.get(edge.from) ?? []), + ...Object.keys(edge.switch.cases).map((caseKey) => humanizeBranchLabel(caseKey)), + ]); + } + } + + const terminalNodeIds = new Set(); + const decisionNodeIds = new Set(); + + for (const nodeId of Object.keys(flow.nodes)) { + const targets = outgoingTargets.get(nodeId) ?? []; + if (targets.length === 0) { + terminalNodeIds.add(nodeId); + } + if (new Set(targets).size > 1) { + decisionNodeIds.add(nodeId); + } + } + + return { + startNodeId: flow.startAt, + terminalNodeIds, + decisionNodeIds, + outgoingTargets, + outgoingLabels, + }; +} + +function layoutNodeIds(flow: FlowDefinitionSnapshot, steps: FlowStepRecord[]): string[] { + const stepOrder = Array.from(new Set(steps.map((step) => step.nodeId))); + const queue = [flow.startAt]; + const visited = new Set(); + const ordered: string[] = []; + + while (queue.length > 0) { + const nodeId = queue.shift(); + if (!nodeId || visited.has(nodeId)) { + continue; + } + visited.add(nodeId); + ordered.push(nodeId); + + for (const edge of flow.edges) { + if (edge.from !== nodeId) { + continue; + } + if ("to" in edge) { + queue.push(edge.to); + continue; + } + for (const target of Object.values(edge.switch.cases)) { + queue.push(target); + } + } + } + + for (const nodeId of stepOrder) { + if (!visited.has(nodeId)) { + ordered.push(nodeId); + visited.add(nodeId); + } + } + + for (const nodeId of Object.keys(flow.nodes).toSorted()) { + if (!visited.has(nodeId)) { + ordered.push(nodeId); + } + } + + return ordered; +} + +function computeLevels( + flow: FlowDefinitionSnapshot, + orderedNodeIds: string[], + expandedEdges: ExpandedFlowEdge[], + backEdgeIds: Set, + terminalNodeIds: Set, +): Map { + const forwardEdges = expandedEdges.filter((edge) => !backEdgeIds.has(edge.edgeId)); + const topologicalOrder = computeTopologicalOrder(orderedNodeIds, forwardEdges); + const longestFromStart = computeLongestLevels(flow.startAt, topologicalOrder, forwardEdges); + const tailDepths = computeTailDepths(orderedNodeIds, forwardEdges, terminalNodeIds); + const levelByNode = new Map(); + let fallbackLevel = Math.max(...longestFromStart.values(), 0); + + for (const nodeId of orderedNodeIds) { + const baseLevel = longestFromStart.get(nodeId); + if (baseLevel == null) { + fallbackLevel += 1; + levelByNode.set(nodeId, fallbackLevel); + continue; + } + levelByNode.set(nodeId, baseLevel); + } + + const maxLevel = Math.max(...levelByNode.values(), 0); + + for (const nodeId of orderedNodeIds) { + const tailDepth = tailDepths.get(nodeId); + if (tailDepth == null) { + continue; + } + const currentLevel = levelByNode.get(nodeId) ?? 0; + levelByNode.set(nodeId, Math.max(currentLevel, maxLevel - tailDepth)); + } + + return levelByNode; +} + +function computeTopologicalOrder( + orderedNodeIds: string[], + forwardEdges: ExpandedFlowEdge[], +): string[] { + const indegree = new Map(); + const outgoing = new Map(); + + for (const nodeId of orderedNodeIds) { + indegree.set(nodeId, 0); + outgoing.set(nodeId, []); + } + + for (const edge of forwardEdges) { + indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1); + outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]); + } + + const queue = orderedNodeIds.filter((nodeId) => (indegree.get(nodeId) ?? 0) === 0); + const visited = new Set(); + const order: string[] = []; + + while (queue.length > 0) { + const nodeId = queue.shift()!; + if (visited.has(nodeId)) { + continue; + } + visited.add(nodeId); + order.push(nodeId); + + for (const target of outgoing.get(nodeId) ?? []) { + const nextDegree = (indegree.get(target) ?? 0) - 1; + indegree.set(target, nextDegree); + if (nextDegree === 0) { + queue.push(target); + } + } + } + + for (const nodeId of orderedNodeIds) { + if (!visited.has(nodeId)) { + order.push(nodeId); + } + } + + return order; +} + +function computeLongestLevels( + startNodeId: string, + topologicalOrder: string[], + forwardEdges: ExpandedFlowEdge[], +): Map { + const levels = new Map(); + const outgoing = new Map(); + levels.set(startNodeId, 0); + + for (const edge of forwardEdges) { + outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]); + } + + for (const nodeId of topologicalOrder) { + const fromLevel = levels.get(nodeId); + if (fromLevel == null) { + continue; + } + + for (const target of outgoing.get(nodeId) ?? []) { + levels.set(target, Math.max(levels.get(target) ?? -1, fromLevel + 1)); + } + } + + return levels; +} + +function computeTailDepths( + orderedNodeIds: string[], + forwardEdges: ExpandedFlowEdge[], + terminalNodeIds: Set, +): Map { + const outgoing = new Map(); + const memo = new Map(); + + for (const edge of forwardEdges) { + outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge.target]); + } + + function visit(nodeId: string): number | null { + if (memo.has(nodeId)) { + return memo.get(nodeId)!; + } + if (terminalNodeIds.has(nodeId)) { + memo.set(nodeId, 0); + return 0; + } + const targets = outgoing.get(nodeId) ?? []; + if (targets.length !== 1) { + memo.set(nodeId, null); + return null; + } + const childDepth = visit(targets[0]!); + const depth = childDepth == null ? null : childDepth + 1; + memo.set(nodeId, depth); + return depth; + } + + for (const nodeId of orderedNodeIds) { + visit(nodeId); + } + + return new Map( + Array.from(memo.entries()).filter((entry): entry is [string, number] => entry[1] != null), + ); +} + +function computeShortestLevels( + flow: FlowDefinitionSnapshot, + expandedEdges: ExpandedFlowEdge[], + orderedNodeIds: string[], +): Map { + const levels = new Map(); + levels.set(flow.startAt, 0); + + for (const nodeId of orderedNodeIds) { + const sourceLevel = levels.get(nodeId); + if (sourceLevel == null) { + continue; + } + + for (const edge of expandedEdges) { + if (edge.source !== nodeId) { + continue; + } + const nextLevel = sourceLevel + 1; + const current = levels.get(edge.target); + if (current == null || nextLevel < current) { + levels.set(edge.target, nextLevel); + } + } + } + + return levels; +} + +function findBackEdgeIds( + expandedEdges: ExpandedFlowEdge[], + shortestLevels: Map, +): Set { + const backEdgeIds = new Set(); + + for (const edge of expandedEdges) { + const sourceLevel = shortestLevels.get(edge.source); + const targetLevel = shortestLevels.get(edge.target); + if (sourceLevel == null || targetLevel == null) { + continue; + } + if (targetLevel <= sourceLevel) { + backEdgeIds.add(edge.edgeId); + } + } + + return backEdgeIds; +} + +function orderNodesWithinRanks( + orderedNodeIds: string[], + expandedEdges: ExpandedFlowEdge[], + levelByNode: Map, + backEdgeIds: Set, +): Map { + const forwardEdges = expandedEdges.filter((edge) => !backEdgeIds.has(edge.edgeId)); + const ranks = new Map(); + const orderIndex = new Map(orderedNodeIds.map((nodeId, index) => [nodeId, index])); + + for (const nodeId of orderedNodeIds) { + const level = levelByNode.get(nodeId) ?? 0; + const existing = ranks.get(level) ?? []; + existing.push(nodeId); + ranks.set(level, existing); + } + + const maxLevel = Math.max(...ranks.keys()); + let currentOrder = buildRankOrderIndex(ranks); + + for (let pass = 0; pass < 6; pass += 1) { + for (let level = 1; level <= maxLevel; level += 1) { + const nodes = ranks.get(level) ?? []; + sortRankByNeighborBarycenter(nodes, { + direction: "down", + forwardEdges, + levelByNode, + currentOrder, + fallbackOrder: orderIndex, + }); + currentOrder = buildRankOrderIndex(ranks); + } + + for (let level = maxLevel - 1; level >= 0; level -= 1) { + const nodes = ranks.get(level) ?? []; + sortRankByNeighborBarycenter(nodes, { + direction: "up", + forwardEdges, + levelByNode, + currentOrder, + fallbackOrder: orderIndex, + }); + currentOrder = buildRankOrderIndex(ranks); + } + } + + return ranks; +} + +function buildRankOrderIndex(ranks: Map): Map { + const order = new Map(); + for (const [, nodes] of ranks) { + nodes.forEach((nodeId, index) => { + order.set(nodeId, index); + }); + } + return order; +} + +function sortRankByNeighborBarycenter( + nodes: string[], + options: { + direction: "down" | "up"; + forwardEdges: ExpandedFlowEdge[]; + levelByNode: Map; + currentOrder: Map; + fallbackOrder: Map; + }, +): void { + nodes.sort((left, right) => { + const leftScore = computeNeighborBarycenter(left, options); + const rightScore = computeNeighborBarycenter(right, options); + if (leftScore !== rightScore) { + return leftScore - rightScore; + } + return (options.fallbackOrder.get(left) ?? 0) - (options.fallbackOrder.get(right) ?? 0); + }); +} + +function computeNeighborBarycenter( + nodeId: string, + options: { + direction: "down" | "up"; + forwardEdges: ExpandedFlowEdge[]; + levelByNode: Map; + currentOrder: Map; + }, +): number { + const nodeLevel = options.levelByNode.get(nodeId) ?? 0; + const neighbors = + options.direction === "down" + ? options.forwardEdges + .filter( + (edge) => + edge.target === nodeId && (options.levelByNode.get(edge.source) ?? 0) < nodeLevel, + ) + .map((edge) => options.currentOrder.get(edge.source)) + : options.forwardEdges + .filter( + (edge) => + edge.source === nodeId && (options.levelByNode.get(edge.target) ?? 0) > nodeLevel, + ) + .map((edge) => options.currentOrder.get(edge.target)); + + const orderedNeighbors = neighbors.filter((value): value is number => typeof value === "number"); + if (orderedNeighbors.length === 0) { + return Number.MAX_SAFE_INTEGER; + } + + return ( + orderedNeighbors.reduce((sum, value) => sum + value, 0) / Math.max(orderedNeighbors.length, 1) + ); +} diff --git a/examples/flows/replay-viewer/src/lib/view-model-playback.ts b/examples/flows/replay-viewer/src/lib/view-model-playback.ts new file mode 100644 index 00000000..20e7433f --- /dev/null +++ b/examples/flows/replay-viewer/src/lib/view-model-playback.ts @@ -0,0 +1,123 @@ +import type { LoadedRunBundle } from "../types"; +import { countStreamedConversationChars, selectAttemptView } from "./view-model-conversation.js"; +import type { PlaybackPreview, PlaybackSegment, PlaybackTimeline } from "./view-model-types"; + +export function buildPlaybackTimeline(bundle: LoadedRunBundle): PlaybackTimeline { + let cursorMs = 0; + + const segments = bundle.steps.map((step, stepIndex) => { + const durationMs = estimatePlaybackDuration(bundle, stepIndex); + const segment = { + stepIndex, + nodeId: step.nodeId, + nodeType: step.nodeType, + startMs: cursorMs, + endMs: cursorMs + durationMs, + durationMs, + } satisfies PlaybackSegment; + cursorMs += durationMs; + return segment; + }); + + return { + segments, + totalDurationMs: Math.max(cursorMs, 0), + }; +} + +export function derivePlaybackPreview( + timeline: PlaybackTimeline, + playheadMs: number, +): PlaybackPreview | null { + if (timeline.segments.length === 0) { + return null; + } + + const clampedPlayhead = clamp(playheadMs, 0, timeline.totalDurationMs); + const lastSegment = timeline.segments.at(-1)!; + const activeSegment = + timeline.segments.find((segment) => clampedPlayhead < segment.endMs) ?? lastSegment; + const durationMs = Math.max(activeSegment.durationMs, 1); + const localProgress = + activeSegment === lastSegment && clampedPlayhead >= timeline.totalDurationMs + ? 1 + : clamp01((clampedPlayhead - activeSegment.startMs) / durationMs); + + return { + playheadMs: clampedPlayhead, + activeStepIndex: activeSegment.stepIndex, + nearestStepIndex: findNearestStepIndex(timeline, clampedPlayhead), + stepProgress: localProgress, + stepStartMs: activeSegment.startMs, + stepEndMs: activeSegment.endMs, + totalDurationMs: timeline.totalDurationMs, + }; +} + +export function playbackAnchorMs(timeline: PlaybackTimeline, stepIndex: number): number { + const segment = timeline.segments[clamp(stepIndex, 0, Math.max(timeline.segments.length - 1, 0))]; + return segment?.startMs ?? 0; +} + +export function playbackSelectionMs( + timeline: PlaybackTimeline, + stepIndex: number, + stepCount: number, +): number { + const isTerminalSelection = + stepIndex >= Math.max(stepCount - 1, 0) && timeline.segments.length > 0; + if (isTerminalSelection) { + return timeline.totalDurationMs; + } + return playbackAnchorMs(timeline, stepIndex); +} + +function estimatePlaybackDuration(bundle: LoadedRunBundle, stepIndex: number): number { + const step = bundle.steps[stepIndex]; + if (!step) { + return 800; + } + + const actualDurationMs = Math.max(0, Date.parse(step.finishedAt) - Date.parse(step.startedAt)); + const actualScaledMs = actualDurationMs > 0 ? Math.round(actualDurationMs / 8) : 0; + + if (step.nodeType === "acp") { + const selected = selectAttemptView(bundle, stepIndex); + const isDirectSession = selected?.sessionSourceStep?.attemptId === step.attemptId; + const visibleChars = isDirectSession + ? countStreamedConversationChars(selected.sessionSlice) + : [step.promptText, step.rawText].reduce( + (sum, value) => sum + (typeof value === "string" ? value.length : 0), + 0, + ); + const revealDurationMs = 420 + visibleChars * 3; + return clamp(Math.max(actualScaledMs, revealDurationMs), 700, 3_800); + } + + const minimumMs = step.nodeType === "action" ? 850 : step.nodeType === "checkpoint" ? 650 : 700; + const maximumMs = step.nodeType === "action" ? 3_000 : 2_400; + return clamp(Math.max(actualScaledMs, minimumMs), minimumMs, maximumMs); +} + +function findNearestStepIndex(timeline: PlaybackTimeline, playheadMs: number): number { + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const segment of timeline.segments) { + const distance = Math.abs(segment.startMs - playheadMs); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = segment.stepIndex; + } + } + + return bestIndex; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function clamp01(value: number): number { + return clamp(value, 0, 1); +} diff --git a/examples/flows/replay-viewer/src/lib/view-model-types.ts b/examples/flows/replay-viewer/src/lib/view-model-types.ts new file mode 100644 index 00000000..89e7af90 --- /dev/null +++ b/examples/flows/replay-viewer/src/lib/view-model-types.ts @@ -0,0 +1,133 @@ +import type { + FlowBundledSessionEvent, + FlowRunState, + FlowStepRecord, + FlowTraceEvent, + SessionRecord, +} from "../types"; + +export type ViewerNodeStatus = + | "queued" + | "active" + | "completed" + | "failed" + | "timed_out" + | "cancelled"; + +export type ViewerNodeData = { + nodeId: string; + title: string; + subtitle: string; + nodeType: FlowStepRecord["nodeType"]; + status: ViewerNodeStatus; + attempts: number; + latestAttemptId?: string; + durationLabel?: string; + isStart: boolean; + isTerminal: boolean; + isDecision: boolean; + branchCount: number; + branchLabels: string[]; + isRunOutcomeNode: boolean; + runOutcomeLabel?: string; + playbackProgress?: number; +}; + +export type ViewerPoint = { + x: number; + y: number; +}; + +export type ViewerEdgeData = { + points?: ViewerPoint[]; + isBackEdge: boolean; +}; + +export type ViewerGraphLayout = { + nodePositions: Record; + edgeRoutes: Record< + string, + { + points: ViewerPoint[]; + isBackEdge: boolean; + } + >; +}; + +export type PlaybackSegment = { + stepIndex: number; + nodeId: string; + nodeType: FlowStepRecord["nodeType"]; + startMs: number; + endMs: number; + durationMs: number; +}; + +export type PlaybackTimeline = { + segments: PlaybackSegment[]; + totalDurationMs: number; +}; + +export type PlaybackPreview = { + playheadMs: number; + activeStepIndex: number; + nearestStepIndex: number; + stepProgress: number; + stepStartMs: number; + stepEndMs: number; + totalDurationMs: number; +}; + +export type SelectedAttemptView = { + step: FlowStepRecord; + sessionSourceStep: FlowStepRecord | null; + sessionFromFallback: boolean; + sessionRecord: SessionRecord | null; + sessionEvents: FlowBundledSessionEvent[]; + sessionSlice: Array<{ + index: number; + role: "user" | "agent" | "unknown"; + title: string; + highlighted: boolean; + textBlocks: string[]; + toolUses: Array<{ + id: string; + name: string; + summary: string; + raw: unknown; + }>; + toolResults: Array<{ + id: string; + toolName: string; + status: string; + preview: string; + isError: boolean; + raw: unknown; + }>; + hiddenPayloads: Array<{ + label: string; + raw: unknown; + }>; + }>; + rawEventSlice: FlowBundledSessionEvent[]; + traceEvents: FlowTraceEvent[]; +}; + +export type SessionListItemView = { + id: string; + label: string; + sessionRecord: SessionRecord; + sessionSlice: SelectedAttemptView["sessionSlice"]; + isStreamingSource: boolean; +}; + +export type RunOutcomeView = { + status: FlowRunState["status"]; + headline: string; + detail: string; + shortLabel: string; + accent: "ok" | "active" | "failed" | "timed_out"; + nodeId: string | null; + attemptId: string | null; + isTerminal: boolean; +}; diff --git a/examples/flows/replay-viewer/src/lib/view-model.ts b/examples/flows/replay-viewer/src/lib/view-model.ts index 7d2507b2..f2f910b0 100644 --- a/examples/flows/replay-viewer/src/lib/view-model.ts +++ b/examples/flows/replay-viewer/src/lib/view-model.ts @@ -1,800 +1,28 @@ -import { Position, type Edge, type Node } from "@xyflow/react"; -import type { - FlowBundledSessionEvent, - FlowDefinitionSnapshot, - FlowEdge, - FlowNodeOutcome, - FlowRunState, - FlowStepRecord, - FlowTraceEvent, - LoadedRunBundle, - SessionRecord, -} from "../types"; - -export type ViewerNodeStatus = - | "queued" - | "active" - | "completed" - | "failed" - | "timed_out" - | "cancelled"; - -export type ViewerNodeData = { - nodeId: string; - nodeType: FlowStepRecord["nodeType"]; - status: ViewerNodeStatus; - attempts: number; - latestAttemptId?: string; - durationLabel?: string; - handleLabel?: string; -}; - -export type SelectedAttemptView = { - step: FlowStepRecord; - sessionSourceStep: FlowStepRecord | null; - sessionFromFallback: boolean; - sessionRecord: SessionRecord | null; - sessionEvents: FlowBundledSessionEvent[]; - sessionSlice: Array<{ - index: number; - role: "user" | "agent" | "unknown"; - title: string; - highlighted: boolean; - textBlocks: string[]; - toolUses: Array<{ - id: string; - name: string; - summary: string; - raw: unknown; - }>; - toolResults: Array<{ - id: string; - toolName: string; - status: string; - preview: string; - isError: boolean; - raw: unknown; - }>; - hiddenPayloads: Array<{ - label: string; - raw: unknown; - }>; - }>; - rawEventSlice: FlowBundledSessionEvent[]; - traceEvents: FlowTraceEvent[]; -}; - -export type RunOutcomeView = { - status: FlowRunState["status"]; - headline: string; - detail: string; - accent: "ok" | "active" | "failed" | "timed_out"; - nodeId: string | null; - attemptId: string | null; - isTerminal: boolean; -}; - -export function buildGraph( - bundle: LoadedRunBundle, - selectedStepIndex: number, -): { - nodes: Node[]; - edges: Edge[]; -} { - const orderedNodeIds = layoutNodeIds(bundle.flow, bundle.steps); - const selectedStep = bundle.steps[selectedStepIndex] ?? null; - const visibleSteps = bundle.steps.slice(0, Math.max(selectedStepIndex + 1, 0)); - const actualTransitions = new Set(); - - for (let index = 1; index < visibleSteps.length; index += 1) { - actualTransitions.add(`${visibleSteps[index - 1]?.nodeId}->${visibleSteps[index]?.nodeId}`); - } - - const levelByNode = computeLevels(bundle.flow, orderedNodeIds); - const nodesByLevel = new Map(); - - for (const nodeId of orderedNodeIds) { - const level = levelByNode.get(nodeId) ?? 0; - const existing = nodesByLevel.get(level) ?? []; - existing.push(nodeId); - nodesByLevel.set(level, existing); - } - - const graphNodes = orderedNodeIds.map((nodeId) => { - const nodeType = bundle.flow.nodes[nodeId]?.nodeType ?? "compute"; - const attemptsForNode = bundle.steps.filter((step) => step.nodeId === nodeId); - const visibleAttempt = findLatestVisibleAttempt(visibleSteps, nodeId); - const status = deriveNodeStatus(nodeId, visibleAttempt, selectedStep); - const level = levelByNode.get(nodeId) ?? 0; - const column = nodesByLevel.get(level)?.indexOf(nodeId) ?? 0; - const laneWidth = 456; - const laneNodes = nodesByLevel.get(level) ?? []; - const x = (column - (laneNodes.length - 1) / 2) * laneWidth; - const y = level * 284; - - return { - id: nodeId, - type: "flowNode", - data: { - nodeId, - nodeType, - status, - attempts: attemptsForNode.length, - latestAttemptId: visibleAttempt?.attemptId, - durationLabel: visibleAttempt - ? formatDuration( - Date.parse(visibleAttempt.finishedAt) - Date.parse(visibleAttempt.startedAt), - ) - : undefined, - handleLabel: visibleAttempt?.session?.handle ?? bundle.flow.nodes[nodeId]?.session?.handle, - }, - position: { x, y }, - sourcePosition: Position.Bottom, - targetPosition: Position.Top, - draggable: false, - selectable: true, - } satisfies Node; - }); - - const graphEdges = bundle.flow.edges.flatMap((edge, index) => - expandEdges(edge).map(({ target, label }, branchIndex) => { - const edgeId = `${edge.from}->${target}-${index}-${branchIndex}`; - const isTraversed = actualTransitions.has(`${edge.from}->${target}`); - const isSelected = Boolean( - selectedStep != null && - visibleSteps.at(-2)?.nodeId === edge.from && - selectedStep.nodeId === target, - ); - - return { - id: edgeId, - source: edge.from, - target, - type: "smoothstep", - animated: isSelected, - style: { - stroke: isSelected - ? "var(--edge-active)" - : isTraversed - ? "var(--edge-complete)" - : "var(--edge-pending)", - strokeWidth: isTraversed || isSelected ? 2.5 : 1.4, - opacity: 1, - }, - label, - labelStyle: { - fill: "var(--ink-soft)", - fontSize: 11, - fontWeight: 600, - }, - labelBgStyle: { - fill: "rgba(247, 244, 236, 0.9)", - }, - markerEnd: { - type: "arrowclosed", - color: isSelected - ? "var(--edge-active)" - : isTraversed - ? "var(--edge-complete)" - : "var(--edge-pending)", - }, - } satisfies Edge; - }), - ); - - return { - nodes: graphNodes, - edges: graphEdges, - }; -} - -export function selectAttemptView( - bundle: LoadedRunBundle, - selectedStepIndex: number, -): SelectedAttemptView | null { - const step = bundle.steps[selectedStepIndex]; - - if (!step) { - return null; - } - - const sessionSourceStep = resolveSessionSourceStep(bundle.steps, selectedStepIndex); - const sessionId = - sessionSourceStep?.trace?.conversation?.sessionId ?? sessionSourceStep?.trace?.sessionId; - const session = sessionId ? (bundle.sessions[sessionId] ?? null) : null; - const sessionRecord = session?.record ?? null; - const sessionEvents = session?.events ?? []; - const conversation = sessionSourceStep?.trace?.conversation; - const sessionSlice = createSessionSlice( - sessionRecord, - conversation?.messageStart, - conversation?.messageEnd, - ); - const rawEventSlice = createRawEventSlice( - sessionEvents, - conversation?.eventStartSeq, - conversation?.eventEndSeq, - ); - const traceEvents = bundle.trace.filter((event) => event.attemptId === step.attemptId); - - return { - step, - sessionSourceStep, - sessionFromFallback: - sessionSourceStep != null && sessionSourceStep.attemptId !== step.attemptId, - sessionRecord, - sessionEvents, - sessionSlice, - rawEventSlice, - traceEvents, - }; -} - -export function deriveRunOutcomeView(bundle: LoadedRunBundle): RunOutcomeView { - const lastStep = bundle.steps.at(-1) ?? null; - const activeNodeId = - bundle.run.currentNode ?? bundle.live?.currentNode ?? lastStep?.nodeId ?? null; - const activeAttemptId = - bundle.run.currentAttemptId ?? bundle.live?.currentAttemptId ?? lastStep?.attemptId ?? null; - const errorText = - typeof bundle.run.error === "string" && bundle.run.error.trim().length > 0 - ? bundle.run.error.trim() - : null; - const waitingOn = - typeof bundle.run.waitingOn === "string" && bundle.run.waitingOn.trim().length > 0 - ? bundle.run.waitingOn.trim() - : null; - - switch (bundle.run.status) { - case "completed": - return { - status: bundle.run.status, - headline: "Run completed", - detail: activeNodeId - ? `The final recorded step completed at ${activeNodeId}.` - : "The flow reached a completed terminal state.", - accent: "ok", - nodeId: activeNodeId, - attemptId: activeAttemptId, - isTerminal: true, - }; - case "running": - return { - status: bundle.run.status, - headline: activeNodeId ? `Running at ${activeNodeId}` : "Run is still active", - detail: - bundle.run.statusDetail?.trim() || - "The run is still in progress. Replay position shows recorded attempts only.", - accent: "active", - nodeId: activeNodeId, - attemptId: activeAttemptId, - isTerminal: false, - }; - case "waiting": - return { - status: bundle.run.status, - headline: waitingOn - ? `Waiting at ${waitingOn}` - : activeNodeId - ? `Waiting at ${activeNodeId}` - : "Run is waiting", - detail: - bundle.run.statusDetail?.trim() || - "The run paused at a checkpoint or external wait state.", - accent: "active", - nodeId: activeNodeId, - attemptId: activeAttemptId, - isTerminal: false, - }; - case "timed_out": - return { - status: bundle.run.status, - headline: activeNodeId ? `Timed out at ${activeNodeId}` : "Run timed out", - detail: errorText || "The run stopped because a node exceeded its timeout budget.", - accent: "timed_out", - nodeId: activeNodeId, - attemptId: activeAttemptId, - isTerminal: true, - }; - case "failed": - default: - return { - status: bundle.run.status, - headline: activeNodeId ? `Stopped at ${activeNodeId}` : "Run failed", - detail: - errorText || - "The run exited early because a node failed before reaching a completed terminal state.", - accent: "failed", - nodeId: activeNodeId, - attemptId: activeAttemptId, - isTerminal: true, - }; - } -} - -function resolveSessionSourceStep( - steps: FlowStepRecord[], - selectedStepIndex: number, -): FlowStepRecord | null { - const direct = steps[selectedStepIndex]; - if (direct?.trace?.conversation) { - return direct; - } - - for (let index = selectedStepIndex - 1; index >= 0; index -= 1) { - const candidate = steps[index]; - if (candidate?.trace?.conversation || candidate?.session) { - return candidate; - } - } - - if (direct?.session) { - return direct; - } - - return null; -} - -export function formatDuration(durationMs: number | undefined): string { - if (durationMs == null || Number.isNaN(durationMs)) { - return "n/a"; - } - if (durationMs < 1_000) { - return `${durationMs} ms`; - } - const seconds = durationMs / 1_000; - if (seconds < 60) { - return `${seconds.toFixed(1)} s`; - } - const minutes = Math.floor(seconds / 60); - const remainder = Math.round(seconds % 60); - return `${minutes}m ${remainder}s`; -} - -export function formatDate(iso: string | undefined): string { - if (!iso) { - return "n/a"; - } - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "medium", - }).format(new Date(iso)); -} - -export function formatJson(value: unknown): string { - return JSON.stringify(value, null, 2); -} - -function deriveNodeStatus( - nodeId: string, - visibleAttempt: FlowStepRecord | undefined, - selectedStep: FlowStepRecord | null, -): ViewerNodeStatus { - if (selectedStep?.nodeId === nodeId) { - return "active"; - } - if (!visibleAttempt) { - return "queued"; - } - return mapOutcomeToStatus(visibleAttempt.outcome); -} - -function mapOutcomeToStatus(outcome: FlowNodeOutcome): ViewerNodeStatus { - switch (outcome) { - case "ok": - return "completed"; - case "timed_out": - return "timed_out"; - case "failed": - return "failed"; - case "cancelled": - return "cancelled"; - default: - return "queued"; - } -} - -function findLatestVisibleAttempt( - steps: FlowStepRecord[], - nodeId: string, -): FlowStepRecord | undefined { - const matching = steps.filter((step) => step.nodeId === nodeId); - return matching.at(-1); -} - -function expandEdges(edge: FlowEdge): Array<{ target: string; label?: string }> { - if ("to" in edge) { - return [{ target: edge.to }]; - } - return Object.entries(edge.switch.cases).map(([caseKey, target]) => ({ - target, - label: caseKey, - })); -} - -function layoutNodeIds(flow: FlowDefinitionSnapshot, steps: FlowStepRecord[]): string[] { - const stepOrder = Array.from(new Set(steps.map((step) => step.nodeId))); - const queue = [flow.startAt]; - const visited = new Set(); - const ordered: string[] = []; - - while (queue.length > 0) { - const nodeId = queue.shift(); - if (!nodeId || visited.has(nodeId)) { - continue; - } - visited.add(nodeId); - ordered.push(nodeId); - - for (const edge of flow.edges) { - if (edge.from !== nodeId) { - continue; - } - if ("to" in edge) { - queue.push(edge.to); - continue; - } - for (const target of Object.values(edge.switch.cases)) { - queue.push(target); - } - } - } - - for (const nodeId of stepOrder) { - if (!visited.has(nodeId)) { - ordered.push(nodeId); - visited.add(nodeId); - } - } - - for (const nodeId of Object.keys(flow.nodes).toSorted()) { - if (!visited.has(nodeId)) { - ordered.push(nodeId); - } - } - - return ordered; -} - -function computeLevels( - flow: FlowDefinitionSnapshot, - orderedNodeIds: string[], -): Map { - const levelByNode = new Map(); - levelByNode.set(flow.startAt, 0); - - for (const nodeId of orderedNodeIds) { - const fromLevel = levelByNode.get(nodeId) ?? 0; - - for (const edge of flow.edges) { - if (edge.from !== nodeId) { - continue; - } - if ("to" in edge) { - if (!levelByNode.has(edge.to)) { - levelByNode.set(edge.to, fromLevel + 1); - } - continue; - } - for (const target of Object.values(edge.switch.cases)) { - if (!levelByNode.has(target)) { - levelByNode.set(target, fromLevel + 1); - } - } - } - } - - for (const nodeId of orderedNodeIds) { - if (!levelByNode.has(nodeId)) { - levelByNode.set(nodeId, levelByNode.size); - } - } - - return levelByNode; -} - -function createSessionSlice( - sessionRecord: SessionRecord | null, - start: number | undefined, - end: number | undefined, -): SelectedAttemptView["sessionSlice"] { - const messages = Array.isArray(sessionRecord?.messages) ? sessionRecord.messages : []; - return messages.map((message, index) => { - const role = detectMessageRole(message); - const contentView = describeMessage(message, role); - return { - index, - role, - title: role === "agent" ? "Agent" : role === "user" ? "User" : "Message", - highlighted: - typeof start === "number" && typeof end === "number" && index >= start && index <= end, - textBlocks: contentView.textBlocks, - toolUses: contentView.toolUses, - toolResults: contentView.toolResults, - hiddenPayloads: contentView.hiddenPayloads, - }; - }); -} - -function createRawEventSlice( - events: FlowBundledSessionEvent[], - startSeq: number | undefined, - endSeq: number | undefined, -): FlowBundledSessionEvent[] { - if (typeof startSeq !== "number" || typeof endSeq !== "number") { - return []; - } - return events.filter((event) => event.seq >= startSeq && event.seq <= endSeq); -} - -function detectMessageRole(message: unknown): "user" | "agent" | "unknown" { - if (message && typeof message === "object") { - if ("User" in message) { - return "user"; - } - if ("Agent" in message) { - return "agent"; - } - } - return "unknown"; -} - -function describeMessage( - message: unknown, - role: "user" | "agent" | "unknown", -): Pick< - SelectedAttemptView["sessionSlice"][number], - "textBlocks" | "toolUses" | "toolResults" | "hiddenPayloads" -> { - if (!message || typeof message !== "object") { - return { - textBlocks: [String(message ?? "")].filter(Boolean), - toolUses: [], - toolResults: [], - hiddenPayloads: [], - }; - } - - if (role === "user") { - const user = (message as { User?: { content?: unknown } }).User; - return describeStructuredMessage(user?.content, undefined); - } - - if (role === "agent") { - const agent = ( - message as { - Agent?: { - content?: unknown; - tool_results?: unknown; - }; - } - ).Agent; - return describeStructuredMessage(agent?.content, agent?.tool_results); - } - - return { - textBlocks: [], - toolUses: [], - toolResults: [], - hiddenPayloads: [{ label: "Raw message", raw: message }], - }; -} - -function describeStructuredMessage( - content: unknown, - toolResults: unknown, -): Pick< - SelectedAttemptView["sessionSlice"][number], - "textBlocks" | "toolUses" | "toolResults" | "hiddenPayloads" -> { - const textBlocks: string[] = []; - const toolUses: SelectedAttemptView["sessionSlice"][number]["toolUses"] = []; - const hiddenPayloads: SelectedAttemptView["sessionSlice"][number]["hiddenPayloads"] = []; - - if (Array.isArray(content)) { - for (const [index, part] of content.entries()) { - if (!part || typeof part !== "object") { - const text = String(part ?? "").trim(); - if (text) { - textBlocks.push(text); - } - continue; - } - - if ("Text" in part && typeof (part as { Text?: unknown }).Text === "string") { - const text = (part as { Text: string }).Text.trim(); - if (text) { - textBlocks.push(text); - } - continue; - } - - if ("ToolUse" in part) { - const toolUse = (part as { ToolUse?: Record }).ToolUse; - if (toolUse && typeof toolUse === "object") { - toolUses.push({ - id: String(toolUse.id ?? `tool-use-${index}`), - name: typeof toolUse.name === "string" ? toolUse.name : "Tool call", - summary: summarizeToolUse(toolUse), - raw: toolUse, - }); - continue; - } - } - - hiddenPayloads.push({ - label: `Structured content ${index + 1}`, - raw: part, - }); - } - } else if (content != null) { - hiddenPayloads.push({ - label: "Structured content", - raw: content, - }); - } - - return { - textBlocks, - toolUses, - toolResults: describeToolResults(toolResults), - hiddenPayloads, - }; -} - -function describeToolResults( - toolResults: unknown, -): SelectedAttemptView["sessionSlice"][number]["toolResults"] { - if (!toolResults || typeof toolResults !== "object") { - return []; - } - - return Object.entries(toolResults as Record).map(([id, entry]) => { - const result = entry as { - tool_name?: unknown; - is_error?: unknown; - output?: Record; - content?: unknown; - }; - - const toolName = - typeof result.tool_name === "string" && result.tool_name.trim().length > 0 - ? result.tool_name - : "Tool result"; - const preview = summarizeToolResult(result); - const status = - typeof result.output?.status === "string" - ? result.output.status - : result.is_error - ? "error" - : "completed"; - - return { - id, - toolName, - status, - preview, - isError: Boolean(result.is_error), - raw: result, - }; - }); -} - -function summarizeToolUse(toolUse: Record): string { - const parsed = - parsePossiblyEncodedJson(toolUse.input) ?? parsePossiblyEncodedJson(toolUse.raw_input); - const parsedCommand = findFirstParsedCommand(parsed); - if (parsedCommand) { - return parsedCommand; - } - const command = findShellCommand(parsed); - if (command) { - return command; - } - return "Structured input hidden by default"; -} - -function summarizeToolResult(result: { - output?: Record; - content?: unknown; -}): string { - const output = result.output ?? {}; - const preferredText = [ - typeof output.formatted_output === "string" ? output.formatted_output : null, - typeof output.aggregated_output === "string" ? output.aggregated_output : null, - typeof output.stderr === "string" && output.stderr.trim().length > 0 ? output.stderr : null, - typeof output.stdout === "string" && output.stdout.trim().length > 0 ? output.stdout : null, - extractTextFromToolContent(result.content), - ].find((value): value is string => Boolean(value && value.trim().length > 0)); - - if (!preferredText) { - return "Structured result hidden by default"; - } - - const normalized = preferredText.replace(/\s+/g, " ").trim(); - return normalized.length > 180 ? `${normalized.slice(0, 177)}…` : normalized; -} - -function parsePossiblyEncodedJson(value: unknown): Record | null { - if (!value) { - return null; - } - if (typeof value === "object" && !Array.isArray(value)) { - return value as Record; - } - if (typeof value === "string") { - try { - const parsed = JSON.parse(value) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : null; - } catch { - return null; - } - } - return null; -} - -function findFirstParsedCommand(payload: Record | null): string | null { - const parsedCmd = payload?.parsed_cmd; - if (!Array.isArray(parsedCmd) || parsedCmd.length === 0) { - return null; - } - const first = parsedCmd[0] as Record | undefined; - if (!first || typeof first !== "object") { - return null; - } - const name = typeof first.name === "string" ? first.name : null; - const cmd = typeof first.cmd === "string" ? first.cmd : null; - if (name && cmd) { - return `${name}: ${truncate(cmd, 96)}`; - } - if (cmd) { - return truncate(cmd, 96); - } - return name; -} - -function findShellCommand(payload: Record | null): string | null { - const command = payload?.command; - if (!Array.isArray(command) || command.length === 0) { - return null; - } - return truncate( - command.map((part) => (typeof part === "string" ? part : JSON.stringify(part))).join(" "), - 96, - ); -} - -function extractTextFromToolContent(content: unknown): string | null { - if (!content) { - return null; - } - if (typeof content === "string") { - return content; - } - if (Array.isArray(content)) { - const text = content - .map((entry) => - entry && typeof entry === "object" && "Text" in entry - ? (entry as { Text?: unknown }).Text - : null, - ) - .filter((entry): entry is string => typeof entry === "string") - .join("\n"); - return text || null; - } - if (typeof content === "object" && "Text" in content) { - const text = (content as { Text?: unknown }).Text; - return typeof text === "string" ? text : null; - } - return null; -} - -function truncate(value: string, maxLength: number): string { - const normalized = value.trim(); - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, maxLength - 1)}…`; -} +export { formatDate, formatDuration, formatJson, humanizeIdentifier } from "./view-model-format.js"; +export { buildGraph, buildGraphLayout, deriveRunOutcomeView } from "./view-model-graph.js"; +export { + countStreamedConversationChars, + listSessionViews, + revealConversationSlice, + revealConversationTranscript, + selectAttemptView, +} from "./view-model-conversation.js"; +export { + buildPlaybackTimeline, + derivePlaybackPreview, + playbackAnchorMs, + playbackSelectionMs, +} from "./view-model-playback.js"; +export type { + PlaybackPreview, + PlaybackSegment, + PlaybackTimeline, + RunOutcomeView, + SelectedAttemptView, + ViewerEdgeData, + ViewerGraphLayout, + SessionListItemView, + ViewerPoint, + ViewerNodeData, + ViewerNodeStatus, +} from "./view-model-types"; diff --git a/examples/flows/replay-viewer/src/styles.css b/examples/flows/replay-viewer/src/styles.css index 23d71068..6b239391 100644 --- a/examples/flows/replay-viewer/src/styles.css +++ b/examples/flows/replay-viewer/src/styles.css @@ -1,37 +1,38 @@ -@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600;700&family=Manrope:wght@400;500;700;800&family=Space+Grotesk:wght@500;700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600;700&family=Manrope:wght@500;700;800&family=Space+Grotesk:wght@500;700&display=swap"); :root { color-scheme: dark; font-family: "Inter", "Segoe UI", sans-serif; - --bg: #0b1017; - --bg-alt: #0f1620; - --panel: rgba(20, 27, 36, 0.96); - --panel-strong: rgba(24, 32, 42, 0.98); - --panel-border: rgba(148, 163, 184, 0.14); - --panel-muted: rgba(148, 163, 184, 0.06); - --ink: #e6edf3; - --ink-soft: rgba(230, 237, 243, 0.74); - --ink-faint: rgba(230, 237, 243, 0.5); + font-size: 13px; + --bg: #091018; + --bg-alt: #0d151f; + --panel: rgba(17, 24, 34, 0.98); + --panel-alt: rgba(13, 19, 28, 0.98); + --panel-border: rgba(133, 146, 166, 0.16); + --panel-muted: rgba(255, 255, 255, 0.04); + --panel-hover: rgba(255, 255, 255, 0.06); + --panel-selected: rgba(52, 121, 194, 0.14); + --ink: #e8eef5; + --ink-soft: rgba(232, 238, 245, 0.74); + --ink-faint: rgba(232, 238, 245, 0.5); --accent: #3ea6c6; - --accent-soft: rgba(62, 166, 198, 0.16); - --success: #54b37b; - --warning: #d69a43; - --danger: #d66161; + --success: #58bb81; + --warning: #d49b45; + --danger: #d86b6b; --muted: #8d99a7; - --shadow: 0 24px 60px rgba(3, 6, 10, 0.32); - --shadow-strong: 0 32px 80px rgba(3, 6, 10, 0.42); - --node-queued: #64748b; + --shadow: 0 20px 44px rgba(0, 0, 0, 0.34); + --node-queued: #66758b; --node-active: #3ea6c6; - --node-completed: #54b37b; - --node-failed: #d66161; - --node-timed_out: #d69a43; - --node-cancelled: #9b7bdb; - --edge-complete: #54b37b; + --node-completed: #58bb81; + --node-failed: #d86b6b; + --node-timed_out: #d49b45; + --node-cancelled: #9f84e0; + --edge-complete: #58bb81; --edge-active: #3ea6c6; - --edge-pending: rgba(148, 163, 184, 0.24); - --sidebar-width: 312px; - --sidebar-width-collapsed: 72px; - --shell-gap: 12px; + --edge-pending: rgba(133, 146, 166, 0.24); + --sidebar-width: 286px; + --sidebar-width-collapsed: 64px; + --shell-gap: 10px; } * { @@ -46,12 +47,12 @@ body, } body { + overflow: hidden; background: - radial-gradient(circle at top left, rgba(62, 166, 198, 0.08), transparent 28%), - radial-gradient(circle at top right, rgba(84, 179, 123, 0.08), transparent 24%), + radial-gradient(circle at top left, rgba(62, 166, 198, 0.09), transparent 26%), + radial-gradient(circle at top right, rgba(88, 187, 129, 0.08), transparent 22%), linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%); color: var(--ink); - overflow: hidden; } button, @@ -70,6 +71,7 @@ code { height: 100dvh; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); overflow: hidden; } @@ -78,168 +80,22 @@ code { } .app-main { + height: 100%; min-width: 0; min-height: 0; - display: grid; - grid-template-rows: auto auto minmax(0, 1fr); + display: flex; + flex-direction: column; gap: var(--shell-gap); - padding: 10px 12px 12px 0; + padding: 8px 10px 10px 0; overflow: hidden; } -.topbar { - display: flex; - justify-content: space-between; - gap: 18px; - align-items: center; - padding: 10px 14px; - border-radius: 16px; - border: 1px solid var(--panel-border); - background: linear-gradient(180deg, rgba(20, 27, 36, 0.98), rgba(16, 23, 31, 0.98)); - box-shadow: var(--shadow); - min-width: 0; -} - -.topbar__left { - min-width: 0; - display: flex; - align-items: center; - gap: 12px; -} - -.topbar__copy { - min-width: 0; -} - -.topbar__copy h1 { - margin: 2px 0 0; - font-family: "Space Grotesk", "Inter", sans-serif; - font-size: 1.32rem; - letter-spacing: -0.04em; -} - -.topbar__meta { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.topbar__pill, -.legend__item, -.outcome-pill, -.flow-node-card__status { - border-radius: 999px; - padding: 0.28rem 0.62rem; - font-size: 0.72rem; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.topbar__pill { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(148, 163, 184, 0.14); -} - -.topbar__pill--completed, -.outcome-pill--ok, -.flow-node-card__status--completed, -.legend__item--completed { - background: rgba(31, 122, 83, 0.12); - color: var(--success); -} - -.topbar__pill--running, -.topbar__pill--waiting, -.flow-node-card__status--active, -.legend__item--active { - background: rgba(11, 114, 133, 0.12); - color: var(--accent); -} - -.topbar__pill--failed, -.outcome-pill--failed, -.flow-node-card__status--failed, -.legend__item--failed { - background: rgba(190, 58, 58, 0.12); - color: var(--danger); -} - -.topbar__pill--timed_out, -.outcome-pill--timed_out, -.flow-node-card__status--timed_out { - background: rgba(197, 117, 24, 0.12); - color: var(--warning); -} - -.hero__eyebrow, -.canvas-card__eyebrow, -.timeline__label, -.inspector__eyebrow, -.summary-card__label { - text-transform: uppercase; - letter-spacing: 0.14em; - font-size: 0.72rem; - font-weight: 700; - color: var(--ink-faint); -} - -.primary-button, -.ghost-button, -.tab-button { - border-radius: 11px; - border: 1px solid transparent; - padding: 0.62rem 0.88rem; - cursor: pointer; - transition: - transform 140ms ease, - border-color 140ms ease, - background-color 140ms ease, - color 140ms ease, - box-shadow 140ms ease; -} - -.primary-button { - background: linear-gradient(135deg, #2785a7, #206d89); - color: #fff; - box-shadow: 0 12px 24px rgba(18, 91, 116, 0.32); -} - -.primary-button:hover, -.ghost-button:hover, -.tab-button:hover { - transform: translateY(-1px); -} - -.ghost-button, -.tab-button { - background: rgba(255, 255, 255, 0.03); - border-color: rgba(148, 163, 184, 0.14); - color: var(--ink); -} - -.ghost-button:disabled, -.primary-button:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.error-banner { - padding: 14px 16px; - border-radius: 12px; - border: 1px solid rgba(214, 97, 97, 0.28); - background: rgba(214, 97, 97, 0.12); - color: var(--danger); -} - .viewer-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(390px, 440px); - gap: var(--shell-gap); + flex: 1 1 auto; + height: 100%; min-height: 0; + display: flex; + gap: var(--shell-gap); overflow: hidden; } @@ -247,107 +103,111 @@ code { .player-card, .canvas-card, .inspector, -.summary-card, .empty-state { background: var(--panel); border: 1px solid var(--panel-border); - border-radius: 16px; box-shadow: var(--shadow); } .run-browser { - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - gap: 10px; - padding: 12px 10px; - width: auto; height: 100dvh; min-height: 0; - overflow: hidden; - border-radius: 0 18px 18px 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 8px; + padding: 10px 8px; border-left: 0; - background: linear-gradient(180deg, rgba(16, 23, 31, 0.98), rgba(11, 17, 24, 0.98)); + border-radius: 0 16px 16px 0; + overflow: hidden; + background: linear-gradient(180deg, rgba(14, 21, 31, 0.98), rgba(10, 16, 24, 0.98)); } .run-browser--collapsed { - padding-inline: 8px; + padding-inline: 6px; } .run-browser__header { display: flex; - justify-content: space-between; - gap: 8px; align-items: center; + justify-content: space-between; + gap: 6px; } .run-browser__header-copy { min-width: 0; } +.hero__eyebrow, +.timeline__label, +.inspector__eyebrow { + margin: 0 0 2px; + color: var(--ink-faint); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + .run-browser__header h2 { margin: 0; - font-size: 1rem; font-family: "Space Grotesk", "Inter", sans-serif; + font-size: 0.98rem; + font-weight: 700; + letter-spacing: -0.03em; } .run-browser__toggle { flex: 0 0 auto; - width: 42px; - height: 42px; + width: 34px; + height: 34px; padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - font-weight: 800; } .run-browser__list { - display: flex; - flex-direction: column; - align-items: stretch; - align-content: flex-start; - gap: 6px; min-height: 0; overflow: auto; - padding-right: 3px; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 2px; } .run-list-item { + min-height: 34px; display: flex; - flex: 0 0 auto; align-items: center; - justify-content: space-between; - gap: 10px; - min-height: 44px; - padding: 0 12px; + gap: 8px; + width: 100%; + padding: 0 10px; text-align: left; - border-radius: 12px; + border-radius: 10px; border: 1px solid transparent; background: transparent; + color: var(--ink); cursor: pointer; transition: border-color 140ms ease, background-color 140ms ease, - transform 140ms ease; + color 140ms ease; } .run-list-item:hover { - border-color: rgba(62, 166, 198, 0.22); - background: rgba(255, 255, 255, 0.05); + border-color: rgba(62, 166, 198, 0.24); + background: var(--panel-hover); } .run-list-item--active { - border-color: rgba(62, 166, 198, 0.42); - background: rgba(255, 255, 255, 0.08); + border-color: rgba(62, 166, 198, 0.44); + background: var(--panel-selected); } .run-list-item__line { + width: 100%; + min-width: 0; display: flex; - justify-content: space-between; - gap: 10px; align-items: center; - min-width: 0; - width: 100%; + justify-content: space-between; + gap: 8px; } .run-list-item__label { @@ -355,15 +215,22 @@ code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-weight: 700; - font-size: 0.92rem; - line-height: 1.2; font-family: "Manrope", "Inter", sans-serif; + font-size: 0.84rem; + font-weight: 700; + line-height: 1.1; +} + +.run-list-item__abbr { + font-family: "Space Grotesk", "Inter", sans-serif; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.08em; } .run-list-item__status-dot { - width: 9px; - height: 9px; + width: 8px; + height: 8px; flex: 0 0 auto; border-radius: 999px; } @@ -382,13 +249,27 @@ code { background: var(--danger); } +.run-list-item__status-dot--cancelled { + background: var(--warning); +} + +.run-browser--collapsed .run-browser__header { + justify-content: center; +} + +.run-browser--collapsed .run-list-item { + min-height: 34px; + justify-content: center; + padding: 0; +} + .run-browser__empty { - padding: 14px; - border-radius: 12px; - border: 1px dashed rgba(148, 163, 184, 0.18); - color: var(--ink-soft); display: grid; gap: 4px; + padding: 12px; + border-radius: 10px; + border: 1px dashed rgba(133, 146, 166, 0.18); + color: var(--ink-soft); } .run-browser__footer { @@ -397,234 +278,210 @@ code { gap: 6px; } -.run-list-item__abbr { - font-family: "Space Grotesk", "Inter", sans-serif; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.08em; +.primary-button, +.ghost-button, +.tab-button { + height: 34px; + border-radius: 10px; + border: 1px solid rgba(133, 146, 166, 0.16); + padding: 0 11px; + cursor: pointer; + transition: + border-color 140ms ease, + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; } -.run-browser--collapsed .run-list-item { - justify-content: center; - align-items: center; - min-height: 0; - padding: 10px 6px; +.primary-button:hover, +.ghost-button:hover, +.tab-button:hover { + transform: translateY(-1px); } -.run-browser--collapsed .run-browser__header { - justify-content: center; +.ghost-button, +.tab-button { + background: rgba(255, 255, 255, 0.03); + color: var(--ink); +} + +.primary-button { + background: linear-gradient(135deg, #2a8aac, #216f8b); + color: #fff; + border-color: rgba(62, 166, 198, 0.36); +} + +.ghost-button:disabled, +.primary-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.tab-button { + min-width: 76px; + text-transform: lowercase; +} + +.tab-button--active { + border-color: rgba(62, 166, 198, 0.3); + background: rgba(62, 166, 198, 0.16); + color: var(--ink); +} + +.error-banner { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(216, 107, 107, 0.28); + background: rgba(216, 107, 107, 0.12); + color: var(--danger); } .stage { - display: grid; + flex: 1 1 auto; + height: 100%; + min-width: 0; min-height: 0; - grid-template-rows: minmax(196px, auto) minmax(360px, 1fr); - gap: var(--shell-gap); + display: grid; + grid-template-rows: minmax(0, 1fr); overflow: hidden; } .player-card, .canvas-card, .inspector { - padding: 14px; min-height: 0; -} - -.player-card { - display: grid; - gap: 12px; - align-content: start; + padding: 10px; + border-radius: 14px; + overflow: hidden; } .canvas-card { + height: 100%; display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 12px; - overflow: hidden; - min-height: 360px; -} - -.canvas-card__header, -.timeline__toolbar, -.inspector__header { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; -} - -.canvas-card__header h2, -.inspector__title { - margin: 2px 0 0; -} - -.canvas-card__header h2 { - font-size: 1.24rem; - font-family: "Space Grotesk", "Inter", sans-serif; -} - -.legend { - display: flex; - flex-wrap: wrap; - gap: 6px; - justify-content: flex-end; + grid-template-rows: minmax(0, 1fr) auto; + gap: 10px; } -.legend__item--queued, -.flow-node-card__status--queued { - background: rgba(141, 153, 167, 0.14); - color: var(--muted); +.inspector { + flex: 0 0 402px; + height: 100%; } .canvas-card__flow { + position: relative; + min-height: 0; height: 100%; - min-height: 360px; - border-radius: 14px; + border-radius: 12px; + border: 1px solid rgba(133, 146, 166, 0.12); overflow: hidden; - border: 1px solid rgba(148, 163, 184, 0.12); background: - radial-gradient(circle at top left, rgba(62, 166, 198, 0.08), transparent 28%), - linear-gradient(180deg, rgba(16, 22, 30, 0.98), rgba(13, 18, 25, 0.98)); + radial-gradient(circle at top left, rgba(62, 166, 198, 0.08), transparent 26%), + linear-gradient(180deg, rgba(12, 19, 27, 0.98), rgba(10, 16, 24, 0.98)); } -.timeline { - display: grid; - gap: 10px; -} - -.timeline__outcome { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; - padding: 12px 14px; - border-radius: 14px; - border: 1px solid rgba(148, 163, 184, 0.14); - background: rgba(255, 255, 255, 0.03); +.canvas-card__camera { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 5; + pointer-events: none; } -.timeline__outcome--ok { - border-color: rgba(84, 179, 123, 0.26); - background: rgba(84, 179, 123, 0.1); +.canvas-card__camera .timeline__mode-switcher { + pointer-events: auto; } -.timeline__outcome--active { - border-color: rgba(62, 166, 198, 0.24); - background: rgba(62, 166, 198, 0.09); -} - -.timeline__outcome--failed { - border-color: rgba(214, 97, 97, 0.28); - background: rgba(214, 97, 97, 0.1); -} - -.timeline__outcome--timed_out { - border-color: rgba(214, 154, 67, 0.26); - background: rgba(214, 154, 67, 0.1); -} - -.timeline__outcome-copy { - min-width: 0; -} - -.timeline__outcome-headline { - margin-top: 4px; - font-size: 1rem; - font-weight: 800; - font-family: "Manrope", "Inter", sans-serif; -} - -.timeline__outcome-detail { - margin-top: 4px; - color: var(--ink-soft); - font-size: 0.84rem; - line-height: 1.5; -} - -.timeline__outcome-meta { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; +.timeline { + display: grid; gap: 8px; + padding: 0 2px 2px; } .timeline__headline { - margin-top: 4px; - font-size: 1.2rem; - font-weight: 800; font-family: "Manrope", "Inter", sans-serif; + font-size: 0.92rem; + font-weight: 700; + letter-spacing: -0.01em; } .timeline__subheadline { - margin-top: 4px; color: var(--ink-soft); - font-size: 0.84rem; + font-size: 0.74rem; + line-height: 1.35; } -.timeline__actions { - display: flex; - flex-wrap: wrap; - gap: 6px; +.outcome-pill, +.flow-node-card__status, +.flow-node-card__semantic, +.flow-node-card__type { + display: inline-flex; + align-items: center; + border-radius: 999px; + min-height: 24px; + padding: 0 8px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -.timeline__hero { - min-width: 0; +.flow-node-card__status { + min-width: 72px; + justify-content: center; } -.timeline__stats { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 10px; +.outcome-pill--ok, +.outcome-pill--completed, +.flow-node-card__status--completed { + background: rgba(88, 187, 129, 0.14); + color: var(--success); } -.timeline__stat { - display: inline-flex; - gap: 6px; - align-items: center; - padding: 0.42rem 0.62rem; - border-radius: 999px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(148, 163, 184, 0.14); - color: var(--ink-soft); - font-size: 0.75rem; +.outcome-pill--running, +.outcome-pill--waiting, +.flow-node-card__status--active { + background: rgba(62, 166, 198, 0.14); + color: var(--accent); } -.timeline__stat-label { - color: var(--ink-faint); - text-transform: uppercase; - letter-spacing: 0.08em; - font-weight: 700; +.outcome-pill--failed, +.flow-node-card__status--failed { + background: rgba(216, 107, 107, 0.14); + color: var(--danger); } -.timeline__meter { - display: grid; - gap: 8px; +.outcome-pill--timed_out, +.flow-node-card__status--timed_out { + background: rgba(212, 155, 69, 0.14); + color: var(--warning); } -.timeline__meter-labels { - display: flex; - justify-content: space-between; - gap: 12px; - font-size: 0.76rem; - color: var(--ink-faint); +.outcome-pill--cancelled, +.flow-node-card__status--cancelled { + background: rgba(159, 132, 224, 0.14); + color: var(--node-cancelled); +} + +.timeline__meter { + display: block; } .timeline__scrubber { width: 100%; + margin: 0; appearance: none; background: transparent; - margin: 0; } .timeline__scrubber::-webkit-slider-runnable-track { - height: 6px; + height: 5px; border-radius: 999px; background: rgba(255, 255, 255, 0.14); } .timeline__scrubber::-moz-range-track { - height: 6px; + height: 5px; border-radius: 999px; background: rgba(255, 255, 255, 0.14); } @@ -632,259 +489,470 @@ code { .timeline__scrubber::-webkit-slider-thumb { appearance: none; margin-top: -6px; - width: 20px; - height: 20px; + width: 17px; + height: 17px; border-radius: 999px; - border: 2px solid #0b1017; + border: 2px solid #0c1218; background: var(--accent); - box-shadow: 0 6px 12px rgba(11, 114, 133, 0.24); + box-shadow: 0 6px 14px rgba(32, 111, 139, 0.28); } .timeline__scrubber::-moz-range-thumb { - width: 20px; - height: 20px; + width: 17px; + height: 17px; border-radius: 999px; - border: 2px solid #0b1017; + border: 2px solid #0c1218; background: var(--accent); - box-shadow: 0 6px 12px rgba(11, 114, 133, 0.24); + box-shadow: 0 6px 14px rgba(32, 111, 139, 0.28); +} + +.timeline__transport { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 12px; } .timeline__current { + min-width: 0; display: grid; - gap: 4px; - padding: 10px 12px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(148, 163, 184, 0.12); + gap: 2px; + justify-self: start; } -.timeline__current-main, -.timeline__current-meta { +.timeline__actions { display: flex; - justify-content: space-between; - gap: 12px; align-items: center; + gap: 6px; + width: fit-content; + max-width: 100%; + margin-inline: auto; + justify-self: center; + flex-wrap: nowrap; + justify-content: center; } -.timeline__current-node { - font-size: 1rem; - font-weight: 800; +.timeline__camera { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; } -.timeline__current-attempt, -.timeline__current-meta { +.timeline__mode-switcher { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px; + border-radius: 999px; + border: 1px solid rgba(133, 146, 166, 0.14); + background: rgba(255, 255, 255, 0.03); +} + +.timeline__mode-button { + min-height: 30px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 0; + border-radius: 999px; + background: transparent; color: var(--ink-soft); - font-size: 0.8rem; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.01em; + transition: + background-color 180ms ease, + color 180ms ease, + transform 180ms ease; } -.timeline__current-attempt { - font-family: "IBM Plex Mono", "SFMono-Regular", "Cascadia Code", monospace; +.timeline__mode-button svg { + width: 14px; + height: 14px; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; } -.timeline__stops { - display: flex; - gap: 8px; - overflow-x: auto; - padding-bottom: 2px; +.timeline__mode-button--active { + background: rgba(62, 166, 198, 0.16); + color: var(--ink); } -.timeline__stop { +.timeline__icon-button { + width: 34px; + height: 34px; display: inline-flex; align-items: center; - gap: 6px; - padding: 5px 9px; - border-radius: 999px; - border: 1px solid rgba(148, 163, 184, 0.12); - background: rgba(255, 255, 255, 0.03); - cursor: pointer; - white-space: nowrap; + justify-content: center; + padding: 0; + border-radius: 10px; + border: 1px solid rgba(133, 146, 166, 0.16); + background: rgba(255, 255, 255, 0.02); color: var(--ink); + transition: + border-color 140ms ease, + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; } -.timeline__stop--active { - border-color: rgba(62, 166, 198, 0.35); - background: rgba(62, 166, 198, 0.14); +.timeline__icon-button:hover { + border-color: rgba(62, 166, 198, 0.24); + background: rgba(62, 166, 198, 0.1); } -.timeline__stop--completed { - background: rgba(84, 179, 123, 0.08); +.timeline__icon-button:disabled { + opacity: 0.42; } -.timeline__stop-dot { - width: 8px; - height: 8px; - border-radius: 999px; - background: rgba(22, 36, 50, 0.2); +.timeline__icon-button--primary { + border-color: rgba(62, 166, 198, 0.32); + background: rgba(62, 166, 198, 0.18); + color: #dff5ff; } -.timeline__stop--active .timeline__stop-dot { - background: var(--accent); +.timeline__icon-button svg { + width: 16px; + height: 16px; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; } -.timeline__stop--completed .timeline__stop-dot { - background: var(--success); +.timeline__speed-switcher { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px; + border-radius: 999px; + border: 1px solid rgba(133, 146, 166, 0.14); + background: rgba(255, 255, 255, 0.03); } -.timeline__stop-label { - font-size: 0.78rem; +.timeline__speed-button { + min-width: 40px; + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--ink-soft); + font-size: 0.68rem; font-weight: 700; + letter-spacing: 0.01em; + transition: + background-color 140ms ease, + color 140ms ease; +} + +.timeline__speed-button--active { + background: rgba(62, 166, 198, 0.16); + color: var(--ink); +} + +.timeline__empty, +.inspector__empty, +.empty-state, +.empty-card { + padding: 14px; + border-radius: 12px; + border: 1px solid rgba(133, 146, 166, 0.14); + background: rgba(255, 255, 255, 0.03); + color: var(--ink-soft); +} + +.empty-card--fill { + min-height: 100%; + display: grid; + place-items: center; + text-align: center; } .flow-node-card { - min-width: 236px; - border-radius: 14px; - border: 1px solid rgba(148, 163, 184, 0.12); - background: var(--panel-strong); - padding: 13px 14px; - box-shadow: 0 14px 30px rgba(3, 6, 10, 0.28); + position: relative; + overflow: hidden; + width: 264px; + min-width: 264px; + box-sizing: border-box; display: grid; gap: 6px; + padding: 10px 11px; + border-radius: 12px; + border: 1px solid rgba(133, 146, 166, 0.14); + background: rgba(18, 25, 35, 0.98); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26); +} + +.flow-node-card--type-acp { + background: + linear-gradient(180deg, rgba(56, 144, 194, 0.12), rgba(18, 25, 35, 0.98) 26%), + rgba(18, 25, 35, 0.98); +} + +.flow-node-card--type-action { + background: + linear-gradient(180deg, rgba(92, 128, 214, 0.1), rgba(18, 25, 35, 0.98) 26%), + rgba(18, 25, 35, 0.98); +} + +.flow-node-card--type-compute { + background: + linear-gradient(180deg, rgba(98, 176, 157, 0.08), rgba(18, 25, 35, 0.98) 26%), + rgba(18, 25, 35, 0.98); +} + +.flow-node-card--type-checkpoint { + background: + linear-gradient(180deg, rgba(193, 148, 68, 0.1), rgba(18, 25, 35, 0.98) 26%), + rgba(18, 25, 35, 0.98); +} + +.flow-node-card__progress { + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: rgba(255, 255, 255, 0.04); + pointer-events: none; +} + +.flow-node-card__progress-fill { + width: 100%; + height: 100%; + background: linear-gradient(90deg, rgba(62, 166, 198, 0.65), rgba(109, 230, 167, 0.92)); + transform-origin: left center; } .flow-node-card--queued { - border-top: 4px solid var(--node-queued); + border-top: 3px solid var(--node-queued); } .flow-node-card--active { - border-top: 4px solid var(--node-active); + border-top: 3px solid var(--node-active); } .flow-node-card--completed { - border-top: 4px solid var(--node-completed); + border-top: 3px solid var(--node-completed); } .flow-node-card--failed { - border-top: 4px solid var(--node-failed); + border-top: 3px solid var(--node-failed); } .flow-node-card--timed_out { - border-top: 4px solid var(--node-timed_out); + border-top: 3px solid var(--node-timed_out); } .flow-node-card--cancelled { - border-top: 4px solid var(--node-cancelled); + border-top: 3px solid var(--node-cancelled); } .flow-node-card--selected { box-shadow: - 0 18px 36px rgba(17, 31, 46, 0.14), - 0 0 0 3px rgba(11, 114, 133, 0.14); + 0 18px 38px rgba(0, 0, 0, 0.3), + 0 0 0 2px rgba(62, 166, 198, 0.24); } .flow-node-card__eyebrow, .flow-node-card__meta { display: flex; + align-items: center; justify-content: space-between; gap: 8px; - align-items: center; - color: var(--ink-soft); - font-size: 0.78rem; +} + +.flow-node-card__badges { + display: flex; + flex-wrap: wrap; + gap: 6px; } .flow-node-card__type { - text-transform: uppercase; - letter-spacing: 0.1em; - font-weight: 700; + background: rgba(255, 255, 255, 0.04); + color: var(--ink-soft); +} + +.flow-node-card--type-acp .flow-node-card__type { + background: rgba(62, 166, 198, 0.15); + color: #91d8ea; +} + +.flow-node-card--type-action .flow-node-card__type { + background: rgba(92, 128, 214, 0.16); + color: #aebdff; +} + +.flow-node-card--type-compute .flow-node-card__type { + background: rgba(109, 230, 167, 0.12); + color: #9ee1c6; +} + +.flow-node-card--type-checkpoint .flow-node-card__type { + background: rgba(212, 155, 69, 0.16); + color: #efc97c; +} + +.flow-node-card__semantic { + background: rgba(255, 255, 255, 0.04); + color: var(--ink-faint); +} + +.flow-node-card__status--queued { + background: rgba(141, 153, 167, 0.12); + color: var(--muted); } .flow-node-card__title { - font-size: 1rem; - font-weight: 800; font-family: "Manrope", "Inter", sans-serif; + font-size: 0.98rem; + font-weight: 800; + line-height: 1.2; } -.flow-node-card__session { - padding: 0.34rem 0.56rem; - border-radius: 999px; - background: rgba(255, 255, 255, 0.05); - color: var(--ink-soft); - font-size: 0.76rem; - width: fit-content; +.flow-node-card__subtitle { + color: var(--ink-faint); + font-family: "IBM Plex Mono", "SFMono-Regular", "Cascadia Code", monospace; + font-size: 0.72rem; + line-height: 1.35; + overflow-wrap: anywhere; } -.flow-node-card__handle { - width: 10px; - height: 10px; - border: 2px solid #fff; - background: var(--accent); +.flow-node-card__routes { + display: flex; + flex-wrap: wrap; + gap: 5px; } -.inspector { - display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr); - gap: 14px; - overflow: hidden; +.flow-node-card__route { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border-radius: 999px; + background: rgba(62, 166, 198, 0.1); + color: var(--ink-soft); + font-size: 0.68rem; + font-weight: 700; } -.inspector__subtitle { - margin-top: 4px; - font-family: "IBM Plex Mono", "SFMono-Regular", "Cascadia Code", monospace; - font-size: 0.78rem; +.flow-node-card__meta { color: var(--ink-soft); + font-size: 0.76rem; } -.inspector__meta { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; +.flow-node-card__outcome { + display: inline-flex; + width: fit-content; + min-height: 22px; + align-items: center; + padding: 0 7px; + border-radius: 999px; + background: rgba(62, 166, 198, 0.14); + color: var(--ink); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; } -.inspector__meta > div, -.panel-section, -.panel-disclosure, -.empty-card, -.event-card, -.conversation__message { - border-radius: 10px; - border: 1px solid rgba(148, 163, 184, 0.12); - background: rgba(255, 255, 255, 0.03); +.flow-node-card__handle { + width: 8px; + height: 8px; + border: 1px solid rgba(232, 238, 245, 0.82); + background: rgba(62, 166, 198, 0.96); } -.inspector__meta > div { - display: grid; - gap: 4px; - padding: 10px 11px; +.flow-node-card__handle--side { + opacity: 0.72; } -.inspector__meta-label, -.definition-grid dt { - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--ink-faint); +.inspector { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 10px; } .inspector__tabs { display: flex; - gap: 8px; + gap: 6px; + overflow-x: auto; } .inspector__body { min-height: 0; + display: flex; + flex-direction: column; overflow: auto; padding-right: 2px; } -.tab-button--active { - background: rgba(11, 114, 133, 0.14); - border-color: rgba(11, 114, 133, 0.22); - color: var(--accent); -} - .inspector__section-stack, +.inspector__fill-pane, +.session-pane, .conversation, .event-list { display: grid; - gap: 10px; + gap: 8px; +} + +.inspector__fill-pane { + height: 100%; +} + +.inspector__section-stack--fill { + min-height: 100%; + flex: 1 1 auto; + grid-template-rows: minmax(0, 1fr); +} + +.panel-section, +.panel-disclosure, +.event-card { + border-radius: 10px; + border: 1px solid rgba(133, 146, 166, 0.14); + background: rgba(255, 255, 255, 0.03); } .panel-section { - padding: 12px; + padding: 10px; +} + +.panel-section--fill { + min-height: 100%; + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.panel-section__header { + display: grid; + gap: 3px; + margin-bottom: 9px; } .panel-section h3 { - margin: 0 0 8px; - font-size: 0.94rem; + margin: 0; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-faint); +} + +.panel-section__subtitle { + color: var(--ink-soft); + font-size: 0.74rem; + font-family: "IBM Plex Mono", "SFMono-Regular", "Cascadia Code", monospace; } .panel-disclosure { @@ -893,8 +961,8 @@ code { .panel-disclosure summary, .conversation__nested-details summary { - cursor: pointer; list-style: none; + cursor: pointer; } .panel-disclosure summary::-webkit-details-marker, @@ -903,31 +971,31 @@ code { } .panel-disclosure summary { - padding: 12px 14px; - font-size: 0.9rem; + padding: 10px 11px; + font-size: 0.8rem; font-weight: 700; - font-family: "Manrope", "Inter", sans-serif; + color: var(--ink); } .panel-disclosure--compact summary { - padding: 10px 12px; - font-size: 0.82rem; + padding: 8px 10px; + font-size: 0.76rem; } .panel-disclosure__body { - padding: 0 12px 12px; + padding: 0 10px 10px; } .code-block { margin: 0; - padding: 12px; - max-height: 320px; + max-height: 280px; overflow: auto; - border-radius: 12px; - background: #0d131a; - color: #f3f5f7; + padding: 10px; + border-radius: 9px; + background: #0b1219; + color: #f4f7fb; + font-size: 0.74rem; line-height: 1.46; - font-size: 0.82rem; white-space: pre-wrap; word-break: break-word; } @@ -941,82 +1009,135 @@ code { .definition-grid div { display: grid; - gap: 4px; + gap: 3px; +} + +.definition-grid dt { + color: var(--ink-faint); + font-size: 0.66rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; } .definition-grid dd { margin: 0; - font-size: 0.9rem; + color: var(--ink); + font-size: 0.78rem; + line-height: 1.45; overflow-wrap: anywhere; } -.conversation__message { - padding: 12px; - display: grid; - gap: 10px; - scroll-margin-top: 12px; +.session-pane { + min-height: 100%; + align-content: start; } -.conversation__message--user { - border-left: 4px solid rgba(62, 166, 198, 0.7); +.session-switcher { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 2px; } -.conversation__message--agent { - border-left: 4px solid rgba(84, 179, 123, 0.7); +.session-switcher__button { + appearance: none; + border: 1px solid rgba(133, 146, 166, 0.14); + background: rgba(255, 255, 255, 0.02); + color: var(--ink-soft); + border-radius: 999px; + padding: 5px 10px; + font-size: 0.72rem; + line-height: 1.2; + white-space: nowrap; } -.conversation__message--highlighted { - background: rgba(62, 166, 198, 0.08); +.session-switcher__button--active { + border-color: rgba(62, 166, 198, 0.3); + background: rgba(62, 166, 198, 0.12); + color: var(--ink); } -.conversation__meta, -.event-card__meta, -.conversation__tool-head { - display: flex; - justify-content: space-between; - gap: 8px; - align-items: center; +.session-pane--empty { + min-height: 100%; } -.conversation__role { - font-weight: 800; - font-size: 0.78rem; - text-transform: uppercase; - letter-spacing: 0.08em; +.session-empty { + min-height: 100%; + display: grid; + place-items: center; + color: var(--ink-soft); + font-size: 0.88rem; + text-align: center; } -.conversation__role--user { - color: var(--accent); +.conversation__message { + padding: 0; + display: grid; + gap: 8px; + scroll-margin-top: 8px; + opacity: 0.001; + transform: translateY(8px); + transition: + opacity 180ms ease, + transform 240ms cubic-bezier(0.16, 1, 0.3, 1); + will-change: opacity, transform; } -.conversation__role--agent { - color: var(--success); +.conversation__message--entered { + opacity: 1; + transform: translateY(0); } -.conversation__role--unknown { - color: var(--muted); +.conversation__message--user { + justify-items: stretch; +} + +.event-card__meta, +.conversation__tool-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } -.conversation__meta-index, .event-card__meta, .conversation__tool-head span { color: var(--ink-faint); - font-size: 0.76rem; + font-size: 0.7rem; + font-family: "IBM Plex Mono", "SFMono-Regular", "Cascadia Code", monospace; } .conversation__text { display: grid; - gap: 8px; + gap: 7px; +} + +.conversation__message--user .conversation__text { + width: 100%; + max-width: none; + padding: 10px 12px; + border-radius: 14px; + background: rgba(62, 166, 198, 0.14); +} + +.conversation__message--agent .conversation__text, +.conversation__message--unknown .conversation__text { + padding: 0; + background: transparent; } .conversation__text p { margin: 0; - line-height: 1.55; + color: var(--ink); + font-size: 0.84rem; + line-height: 1.52; + overflow-wrap: anywhere; } .conversation__empty-text { color: var(--ink-faint); - font-size: 0.82rem; + font-size: 0.76rem; } .conversation__tool-list { @@ -1025,82 +1146,71 @@ code { } .conversation__tool-card { - padding: 10px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(148, 163, 184, 0.1); display: grid; gap: 6px; + padding: 9px; + border-radius: 9px; + border: 1px solid rgba(133, 146, 166, 0.12); + background: rgba(255, 255, 255, 0.03); } .conversation__tool-card p { margin: 0; - font-size: 0.84rem; - line-height: 1.45; + color: var(--ink-soft); + font-size: 0.76rem; + line-height: 1.44; + overflow-wrap: anywhere; } .conversation__nested-details { - border-top: 1px solid rgba(148, 163, 184, 0.12); + border-top: 1px solid rgba(133, 146, 166, 0.12); padding-top: 6px; } .conversation__nested-details summary { - font-size: 0.78rem; - font-weight: 700; - color: var(--ink-soft); -} - -.session-note { - margin-bottom: 10px; - padding: 10px 11px; - border-radius: 10px; - background: rgba(62, 166, 198, 0.1); - border: 1px solid rgba(62, 166, 198, 0.18); color: var(--ink-soft); - font-size: 0.82rem; - line-height: 1.45; -} - -.session-note code { - font-family: "IBM Plex Mono", "SFMono-Regular", "Cascadia Code", monospace; - font-size: 0.78rem; + font-size: 0.72rem; + font-weight: 700; } .event-card { - padding: 10px 11px; display: grid; - gap: 8px; + gap: 7px; + padding: 9px 10px; } -.empty-state, -.inspector__empty, -.timeline__empty, -.empty-card { - padding: 18px; +.react-flow__controls { + border-radius: 10px; + overflow: hidden; + border: 1px solid rgba(133, 146, 166, 0.14); + box-shadow: none; +} + +.react-flow__controls button { + width: 28px; + height: 28px; + background: rgba(18, 25, 35, 0.96); color: var(--ink-soft); + border-bottom-color: rgba(133, 146, 166, 0.14); } -.empty-state, -.inspector__empty, -.timeline__empty, -.empty-card { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(148, 163, 184, 0.12); - border-radius: 16px; +.react-flow__controls button:hover { + background: rgba(24, 34, 46, 0.96); } -@media (max-width: 1540px) { - .viewer-layout { - grid-template-columns: minmax(0, 1fr) minmax(340px, 390px); +@media (max-width: 1380px) { + .inspector { + flex-basis: 360px; } } -@media (max-width: 1180px) { +@media (max-width: 1160px) { .viewer-layout { - grid-template-columns: minmax(0, 1fr); + flex-direction: column; } .inspector { + flex: 0 0 auto; min-height: 320px; } } @@ -1111,54 +1221,46 @@ code { grid-template-columns: 1fr; } - .topbar, - .canvas-card__header, - .timeline__toolbar, - .inspector__header { - flex-direction: column; - align-items: stretch; + .app-main { + padding: 8px; } - .topbar__meta, - .timeline__actions, - .inspector__tabs { - justify-content: flex-start; + .run-browser { + height: auto; + border-left: 1px solid var(--panel-border); + border-radius: 14px; } - .viewer-layout, - .inspector__meta, - .definition-grid, - .run-browser__footer { - grid-template-columns: 1fr; + .run-browser--collapsed { + padding-inline: 8px; } - .app-main { - padding: 10px; + .timeline__transport { + grid-template-columns: 1fr; + justify-items: center; } - .run-browser { - width: auto; - height: auto; - border-radius: 18px; - border-left: 1px solid var(--panel-border); + .timeline__actions, + .inspector__tabs { + justify-content: flex-start; } - .run-browser--collapsed { - width: auto; + .timeline__actions { + justify-content: center; } - .canvas-card__flow { - min-height: 52vh; + .timeline__current, + .timeline__camera { + justify-self: center; } - .timeline__current-main, - .timeline__current-meta, - .run-list-item__line { - flex-direction: column; - align-items: flex-start; + .timeline__camera { + flex-wrap: wrap; + justify-content: center; } - .topbar__left { - align-items: flex-start; + .run-browser__footer, + .definition-grid { + grid-template-columns: 1fr; } } diff --git a/package.json b/package.json index d7d3e052..16cac0e5 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@typescript/native-preview": "7.0.0-dev.20260310.1", "@vitejs/plugin-react": "^6.0.1", "@xyflow/react": "^12.10.1", + "elkjs": "^0.11.1", "husky": "^9.1.7", "lint-staged": "^16.3.2", "markdownlint-cli2": "^0.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e89877d..20365b01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@xyflow/react': specifier: ^12.10.1 version: 12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + elkjs: + specifier: ^0.11.1 + version: 0.11.1 husky: specifier: ^9.1.7 version: 9.1.7 @@ -1056,6 +1059,9 @@ packages: oxc-resolver: optional: true + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2398,6 +2404,8 @@ snapshots: dts-resolver@2.1.3: {} + elkjs@0.11.1: {} + emoji-regex@10.6.0: {} empathic@2.0.0: {} diff --git a/test/pr-triage-example.test.ts b/test/pr-triage-example.test.ts index fe1eb7b8..76ac3dbb 100644 --- a/test/pr-triage-example.test.ts +++ b/test/pr-triage-example.test.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; import test from "node:test"; import { extractCodexReviewTail, @@ -41,3 +43,19 @@ test("extractCodexReviewTail falls back to the final non-log block", () => { ].join("\n"), ); }); + +test("fix_ci_failures owns CI monitoring until a terminal state", () => { + const sourcePath = path.join(process.cwd(), "examples/flows/pr-triage/pr-triage.flow.ts"); + + return fs.readFile(sourcePath, "utf8").then((source) => { + assert.match(source, /fix_ci_failures:\s*\{[\s\S]*?timeoutMs:\s*60 \* 60_000,/); + const edgeBlock = source.match(/\{\s*from:\s*"fix_ci_failures",[\s\S]*?\n\s*\},\n\s*\{/)?.[0]; + + assert.ok(edgeBlock, "Expected a fix_ci_failures edge block"); + assert.match( + edgeBlock, + /cases:\s*\{[\s\S]*?check_final_conflicts:\s*"check_final_conflicts",[\s\S]*?comment_and_escalate_to_human:\s*"comment_and_escalate_to_human",[\s\S]*?\}/, + ); + assert.doesNotMatch(edgeBlock, /collect_ci_state:/); + }); +}); diff --git a/test/replay-viewer-run-url.test.ts b/test/replay-viewer-run-url.test.ts new file mode 100644 index 00000000..2701b724 --- /dev/null +++ b/test/replay-viewer-run-url.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildRunLocation, + readRequestedRunId, +} from "../examples/flows/replay-viewer/src/lib/run-url.js"; + +test("readRequestedRunId returns the requested run query", () => { + assert.equal( + readRequestedRunId("?run=2026-03-28T000551318Z-pr-triage-dbda9214"), + "2026-03-28T000551318Z-pr-triage-dbda9214", + ); + assert.equal(readRequestedRunId("?foo=1&run=abc123"), "abc123"); + assert.equal(readRequestedRunId("", "/run/abc123"), "abc123"); + assert.equal(readRequestedRunId("?run=older", "/run/newer"), "newer"); + assert.equal(readRequestedRunId("?run= "), null); + assert.equal(readRequestedRunId(""), null); +}); + +test("buildRunLocation preserves unrelated params and hash", () => { + assert.equal( + buildRunLocation("http://127.0.0.1:4173/?tab=session#graph", "run-42"), + "/run/run-42?tab=session#graph", + ); + + assert.equal( + buildRunLocation("http://127.0.0.1:4173/run/old?tab=session&run=old#graph", null), + "/?tab=session#graph", + ); +}); diff --git a/test/replay-viewer-view-model.test.ts b/test/replay-viewer-view-model.test.ts index c2a56d1c..356da236 100644 --- a/test/replay-viewer-view-model.test.ts +++ b/test/replay-viewer-view-model.test.ts @@ -1,10 +1,23 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { + advancePlaybackPlayhead, + resolvePlaybackResumeMs, +} from "../examples/flows/replay-viewer/src/hooks/use-playback-controller.js"; import { buildGraph, + buildGraphLayout, + buildPlaybackTimeline, + derivePlaybackPreview, deriveRunOutcomeView, formatDuration, formatJson, + humanizeIdentifier, + listSessionViews, + playbackAnchorMs, + playbackSelectionMs, + revealConversationSlice, + revealConversationTranscript, selectAttemptView, } from "../examples/flows/replay-viewer/src/lib/view-model.js"; import type { @@ -33,7 +46,7 @@ test("selectAttemptView shapes ACP session content into readable conversation pa assert.equal(selected.traceEvents.length, 1); }); -test("buildGraph marks attempted, active, and queued nodes across switched edges", () => { +test("buildGraph infers start terminal and branch semantics across the full definition", () => { const load = baseStep("load_pr", "action", "ok"); load.startedAt = "2026-03-27T07:26:00.000Z"; load.finishedAt = "2026-03-27T07:26:01.000Z"; @@ -70,15 +83,155 @@ test("buildGraph marks attempted, active, and queued nodes across switched edges }); const graph = buildGraph(bundle, 1); - const nodeStatus = new Map(graph.nodes.map((node) => [node.id, node.data.status])); - const edgeLabels = new Map(graph.edges.map((edge) => [edge.target, edge.label])); - - assert.equal(nodeStatus.get("load_pr"), "completed"); - assert.equal(nodeStatus.get("review_loop"), "active"); - assert.equal(nodeStatus.get("check_ci"), "queued"); - assert.equal(nodeStatus.get("escalate"), "queued"); - assert.equal(edgeLabels.get("check_ci"), "clear"); - assert.equal(edgeLabels.get("escalate"), "blocked"); + const nodeMap = new Map(graph.nodes.map((node) => [node.id, node.data])); + + assert.equal(nodeMap.get("load_pr")?.status, "completed"); + assert.equal(nodeMap.get("load_pr")?.isStart, true); + assert.equal(nodeMap.get("review_loop")?.status, "active"); + assert.equal(nodeMap.get("review_loop")?.isDecision, true); + assert.equal(nodeMap.get("review_loop")?.playbackProgress, undefined); + assert.deepEqual(nodeMap.get("review_loop")?.branchLabels, ["clear", "blocked"]); + assert.equal(nodeMap.get("check_ci")?.status, "queued"); + assert.equal(nodeMap.get("check_ci")?.isTerminal, true); + assert.equal(nodeMap.get("escalate")?.status, "queued"); + assert.equal(nodeMap.get("escalate")?.isTerminal, true); + assert.ok(graph.edges.every((edge) => edge.label == null)); +}); + +test("buildGraph applies playback progress to the active node during preview", () => { + const load = baseStep("load_pr", "action", "ok"); + load.startedAt = "2026-03-27T07:26:00.000Z"; + load.finishedAt = "2026-03-27T07:26:01.000Z"; + const extract = baseStep("extract_intent", "acp", "ok"); + extract.startedAt = "2026-03-27T07:26:02.000Z"; + extract.finishedAt = "2026-03-27T07:26:20.000Z"; + + const bundle = makeBundle(extract, { + steps: [load, extract], + flow: { + schema: "acpx.flow-definition-snapshot.v1", + name: "playback-flow", + startAt: "load_pr", + nodes: { + load_pr: { nodeType: "action" }, + extract_intent: { nodeType: "acp", session: { handle: "main", isolated: false } }, + }, + edges: [{ from: "load_pr", to: "extract_intent" }], + }, + }); + + const timeline = buildPlaybackTimeline(bundle); + const preview = derivePlaybackPreview(timeline, timeline.segments[1]!.startMs + 200); + const graph = buildGraph(bundle, preview!.activeStepIndex, preview); + const nodeMap = new Map(graph.nodes.map((node) => [node.id, node.data])); + + assert.equal(nodeMap.get("load_pr")?.status, "completed"); + assert.equal(nodeMap.get("extract_intent")?.status, "active"); + assert.ok((nodeMap.get("extract_intent")?.playbackProgress ?? 0) > 0); +}); + +test("buildGraph pulls pre-terminal handoff chains toward the bottom automatically", () => { + const finalize = baseStep("finalize", "compute", "ok"); + finalize.startedAt = "2026-03-27T07:30:00.000Z"; + finalize.finishedAt = "2026-03-27T07:30:01.000Z"; + + const bundle = makeBundle(finalize, { + steps: [finalize], + flow: { + schema: "acpx.flow-definition-snapshot.v1", + name: "handoff-flow", + startAt: "judge_solution", + nodes: { + judge_solution: { nodeType: "acp", session: { handle: "main", isolated: false } }, + bug_or_feature: { nodeType: "acp", session: { handle: "main", isolated: false } }, + collect_review_state: { nodeType: "action" }, + comment_and_escalate_to_human: { + nodeType: "acp", + session: { handle: "main", isolated: false }, + }, + post_escalation_comment: { nodeType: "action" }, + finalize: { nodeType: "compute" }, + }, + edges: [ + { + from: "judge_solution", + switch: { + on: "route", + cases: { + continue: "bug_or_feature", + human: "comment_and_escalate_to_human", + }, + }, + }, + { from: "bug_or_feature", to: "collect_review_state" }, + { from: "collect_review_state", to: "comment_and_escalate_to_human" }, + { from: "comment_and_escalate_to_human", to: "post_escalation_comment" }, + { from: "post_escalation_comment", to: "finalize" }, + ], + }, + }); + + const graph = buildGraph(bundle, 0); + const positions = new Map(graph.nodes.map((node) => [node.id, node.position.y])); + + assert.ok( + (positions.get("comment_and_escalate_to_human") ?? 0) > (positions.get("judge_solution") ?? 0), + ); + assert.ok( + (positions.get("post_escalation_comment") ?? 0) > + (positions.get("comment_and_escalate_to_human") ?? 0), + ); + assert.ok((positions.get("finalize") ?? 0) > (positions.get("post_escalation_comment") ?? 0)); +}); + +test("buildGraphLayout uses layered routing and sinks terminal chains", async () => { + const bundle = makeBundle(baseStep("finalize", "compute", "ok"), { + flow: { + schema: "acpx.flow-definition-snapshot.v1", + name: "layout-flow", + startAt: "judge_solution", + nodes: { + judge_solution: { nodeType: "acp", session: { handle: "main", isolated: false } }, + bug_or_feature: { nodeType: "acp", session: { handle: "main", isolated: false } }, + check_initial_conflicts: { nodeType: "action" }, + judge_initial_conflicts: { + nodeType: "acp", + session: { handle: "main", isolated: false }, + }, + comment_and_escalate_to_human: { + nodeType: "acp", + session: { handle: "main", isolated: false }, + }, + post_escalation_comment: { nodeType: "action" }, + finalize: { nodeType: "compute" }, + }, + edges: [ + { + from: "judge_solution", + switch: { + on: "route", + cases: { + classify: "bug_or_feature", + human: "comment_and_escalate_to_human", + }, + }, + }, + { from: "bug_or_feature", to: "check_initial_conflicts" }, + { from: "check_initial_conflicts", to: "judge_initial_conflicts" }, + { from: "judge_initial_conflicts", to: "comment_and_escalate_to_human" }, + { from: "comment_and_escalate_to_human", to: "post_escalation_comment" }, + { from: "post_escalation_comment", to: "finalize" }, + ], + }, + }); + + const layout = await buildGraphLayout(bundle.flow); + + assert.ok(layout); + assert.ok(layout.nodePositions.finalize); + assert.ok(layout.nodePositions.comment_and_escalate_to_human); + assert.ok(layout.edgeRoutes["judge_solution->bug_or_feature-0-0"]?.points.length! >= 2); + assert.ok(layout.nodePositions.finalize.y > layout.nodePositions.comment_and_escalate_to_human.y); }); test("selectAttemptView falls back to hidden payloads for unknown structured messages", () => { @@ -180,11 +333,190 @@ test("selectAttemptView falls back to the latest visible ACP session for non-ACP assert.match(selected.sessionSlice[0]?.textBlocks[0] ?? "", /Please inspect the PR diff/); }); +test("revealConversationSlice shows user turns instantly and only streams assistant text", () => { + const step = baseStep("extract_intent", "acp", "ok"); + const bundle = makeBundle(step, {}); + const selected = selectAttemptView(bundle, 0); + + assert.ok(selected); + + const partial = revealConversationSlice(selected.sessionSlice, 0.25); + + assert.equal(partial.length, 2); + assert.equal(partial[0]?.textBlocks[0], "Please inspect the PR diff."); + assert.match(partial[1]?.textBlocks[0] ?? "", /^I am ch/); + assert.equal(partial[1]?.toolUses.length, 0); + assert.equal(partial[1]?.toolResults.length, 0); + + const full = revealConversationSlice(selected.sessionSlice, 1); + assert.equal(full.length, selected.sessionSlice.length); + assert.equal(full[1]?.toolUses.length, 1); +}); + +test("revealConversationTranscript keeps prior session messages visible while streaming the current slice", () => { + const step = baseStep("extract_intent", "acp", "ok"); + const bundle = makeBundle(step, { + sessions: { + "main-bundle": { + id: "main-bundle", + binding: step.session!, + record: { + cwd: "/tmp/replay", + agentCommand: "codex", + name: "main", + messages: [ + { User: { content: [{ Text: "Earlier context." }] } }, + { Agent: { content: [{ Text: "Older reply." }] } }, + { User: { content: [{ Text: "Current prompt." }] } }, + { Agent: { content: [{ Text: "Current streamed answer." }] } }, + ], + }, + events: [], + }, + }, + }); + bundle.steps[0]!.trace!.conversation = { + sessionId: "main-bundle", + messageStart: 2, + messageEnd: 3, + eventStartSeq: 0, + eventEndSeq: 0, + }; + + const selected = selectAttemptView(bundle, 0); + + assert.ok(selected); + + const partial = revealConversationTranscript(selected.sessionSlice, 0.25); + + assert.equal(partial.length, 4); + assert.equal(partial[0]?.textBlocks[0], "Earlier context."); + assert.equal(partial[1]?.textBlocks[0], "Older reply."); + assert.equal(partial[2]?.textBlocks[0], "Current prompt."); + assert.match(partial[3]?.textBlocks[0] ?? "", /^Cur/); +}); + +test("listSessionViews returns all run sessions and marks the current streaming source", () => { + const step = baseStep("extract_intent", "acp", "ok"); + const secondaryBinding = { + ...step.session!, + key: "secondary:/tmp", + handle: "secondary", + bundleId: "secondary-bundle", + name: "secondary", + acpxRecordId: "record-2", + acpSessionId: "session-2", + }; + const bundle = makeBundle(step, { + sessions: { + "main-bundle": { + id: "main-bundle", + binding: step.session!, + record: { + cwd: "/tmp/replay", + agentCommand: "codex", + name: "main", + messages: [{ User: { content: [{ Text: "Main session." }] } }], + }, + events: [], + }, + "secondary-bundle": { + id: "secondary-bundle", + binding: secondaryBinding, + record: { + cwd: "/tmp/replay-secondary", + agentCommand: "codex", + name: "secondary", + messages: [{ User: { content: [{ Text: "Secondary session." }] } }], + }, + events: [], + }, + }, + }); + + const selected = selectAttemptView(bundle, 0); + const sessions = listSessionViews(bundle, selected); + + assert.equal(sessions.length, 2); + assert.equal(sessions[0]?.label, "main"); + assert.equal(sessions[0]?.isStreamingSource, true); + assert.equal(sessions[1]?.label, "secondary"); + assert.equal(sessions[1]?.isStreamingSource, false); + assert.equal(sessions[1]?.sessionSlice[0]?.highlighted, false); +}); + +test("listSessionViews stays empty when the selected step has no ACP session source", () => { + const first = baseStep("load_pr", "action", "ok"); + delete first.trace; + const second = baseStep("extract_intent", "acp", "ok"); + const bundle = makeBundle(second, { steps: [first, second] }); + + const selected = selectAttemptView(bundle, 0); + const sessions = listSessionViews(bundle, selected); + + assert.ok(selected); + assert.equal(selected.sessionRecord, null); + assert.equal(sessions.length, 0); +}); + +test("buildPlaybackTimeline and anchors support continuous preview with discrete snapping", () => { + const first = baseStep("load_pr", "action", "ok"); + first.startedAt = "2026-03-27T07:26:00.000Z"; + first.finishedAt = "2026-03-27T07:26:01.000Z"; + const second = baseStep("extract_intent", "acp", "ok"); + second.startedAt = "2026-03-27T07:26:02.000Z"; + second.finishedAt = "2026-03-27T07:26:20.000Z"; + + const bundle = makeBundle(second, { steps: [first, second] }); + const timeline = buildPlaybackTimeline(bundle); + + assert.equal(timeline.segments.length, 2); + assert.equal(playbackAnchorMs(timeline, 0), 0); + assert.equal(playbackAnchorMs(timeline, 1), timeline.segments[1]?.startMs); + + const preview = derivePlaybackPreview(timeline, timeline.segments[1]!.startMs + 120); + + assert.equal(preview?.activeStepIndex, 1); + assert.equal(preview?.nearestStepIndex, 1); + assert.ok((preview?.stepProgress ?? 0) > 0); +}); + +test("resolvePlaybackResumeMs wraps terminal selections back to the start", () => { + const first = baseStep("load_pr", "action", "ok"); + const second = baseStep("finalize", "compute", "ok"); + const bundle = makeBundle(second, { steps: [first, second] }); + const timeline = buildPlaybackTimeline(bundle); + + assert.equal(resolvePlaybackResumeMs(timeline, null, 1, bundle.steps.length), 0); + assert.equal( + resolvePlaybackResumeMs(timeline, null, 0, bundle.steps.length), + playbackAnchorMs(timeline, 0), + ); + assert.equal(resolvePlaybackResumeMs(timeline, 123, 1, bundle.steps.length), 123); +}); + +test("playbackSelectionMs clamps the final discrete step to the true timeline end", () => { + const first = baseStep("load_pr", "action", "ok"); + const second = baseStep("finalize", "compute", "ok"); + const bundle = makeBundle(second, { steps: [first, second] }); + const timeline = buildPlaybackTimeline(bundle); + + assert.equal(playbackSelectionMs(timeline, 0, bundle.steps.length), 0); + assert.equal(playbackSelectionMs(timeline, 1, bundle.steps.length), timeline.totalDurationMs); +}); + +test("advancePlaybackPlayhead applies playback speed and clamps to the timeline end", () => { + assert.equal(advancePlaybackPlayhead(100, 400, 2, 1_000), 900); + assert.equal(advancePlaybackPlayhead(100, 400, 5, 3_000), 2_100); + assert.equal(advancePlaybackPlayhead(900, 400, 10, 1_000), 1_000); +}); + test("format helpers keep replay labels stable", () => { assert.equal(formatDuration(undefined), "n/a"); assert.equal(formatDuration(500), "500 ms"); assert.equal(formatDuration(1_500), "1.5 s"); assert.equal(formatJson({ ok: true }), '{\n "ok": true\n}'); + assert.equal(humanizeIdentifier("collect_review_state"), "Collect Review State"); }); test("deriveRunOutcomeView separates replay position from a failed run outcome", () => { @@ -201,7 +533,7 @@ test("deriveRunOutcomeView separates replay position from a failed run outcome", assert.equal(outcome.accent, "failed"); assert.equal(outcome.isTerminal, true); assert.equal(outcome.nodeId, "review_loop"); - assert.match(outcome.headline, /Stopped at review_loop/); + assert.match(outcome.headline, /Stopped at Review Loop/); assert.match(outcome.detail, /Timed out while waiting/); });