diff --git a/apps/fabro-web/app/components/run-waterfall.tsx b/apps/fabro-web/app/components/run-waterfall.tsx index 3e4959c72..62f4308ef 100644 --- a/apps/fabro-web/app/components/run-waterfall.tsx +++ b/apps/fabro-web/app/components/run-waterfall.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router"; import { StageState, type RunStage } from "@qltysh/fabro-api-client"; import { HoverCard, PopoverHeader, PopoverRow, PopoverRows } from "./ui"; +import { stagePath } from "../lib/stage-routes"; import { isVisibleStage } from "../data/runs"; import { formatAbsoluteTs, formatDurationMs } from "../lib/format"; import { @@ -150,7 +151,7 @@ function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null { endMs, durationMs, barClass: stageBarClass(stage.status), - href: `/runs/${runId}/stages/${encodeURIComponent(stage.id)}`, + href: stagePath(runId, stage.id), popover: stagePopover(stage, durationMs, inFlight), }; } diff --git a/apps/fabro-web/app/components/stage-popover.tsx b/apps/fabro-web/app/components/stage-popover.tsx index 0ea060770..450cbafe9 100644 --- a/apps/fabro-web/app/components/stage-popover.tsx +++ b/apps/fabro-web/app/components/stage-popover.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router"; import type { StageState } from "@qltysh/fabro-api-client"; +import { stagePath } from "../lib/stage-routes"; import { formatTokenCount } from "../lib/format"; import { formatStageLabel, @@ -106,7 +107,7 @@ export function StagePopover({ runId, stage, duration }: StagePopoverProps) { {stage.resumedFromStageId && ( {stage.resumedFromStageId} diff --git a/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx b/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx index 13727e1d5..18a859b04 100644 --- a/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx +++ b/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx @@ -5,6 +5,7 @@ import { ArrowsRightLeftIcon, } from "@heroicons/react/20/solid"; +import { stagePath } from "../../lib/stage-routes"; import type { Stage } from "../stage-sidebar"; import { StageMetaBar } from "./meta-bar"; import type { EdgeSelection } from "./helpers"; @@ -67,7 +68,7 @@ export function ConditionalDecision({ {edge ? ( targetStage ? (
diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx index a36305025..36016136f 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx @@ -119,8 +119,8 @@ describe("ParallelChildren", () => { expect(rows[1]).toContain("Running"); expect(rows[1]).toContain("review_opus"); expect(hrefs(renderer)).toEqual([ - "/runs/run-1/stages/review_glm@1", - "/runs/run-1/stages/review_opus@1", + "/runs/run-1/stages/review_glm%401", + "/runs/run-1/stages/review_opus%401", ]); expect(statValue(renderer, "Succeeded")).toBe("1"); expect(statValue(renderer, "Failed")).toBe("0"); @@ -154,7 +154,7 @@ describe("ParallelChildren", () => { ], ); - expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@1"]); + expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm%401"]); }); test("shows a late-starting branch when lower indexes have no stage yet", () => { @@ -168,7 +168,7 @@ describe("ParallelChildren", () => { "PendingBranch 2", "Runningreview_opus", ]); - expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_opus@1"]); + expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_opus%401"]); expect(statValue(renderer, "Branches")).toBe("3"); }); @@ -192,7 +192,7 @@ describe("ParallelChildren", () => { ); expect(branchRowText(renderer)).toEqual(["Runningreview_glm@2"]); - expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@2"]); + expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm%402"]); }); test("keeps duplicate branch targets in index order and only links recorded stages", () => { @@ -208,7 +208,7 @@ describe("ParallelChildren", () => { expect(rows).toHaveLength(2); expect(rows[0]).toContain("Succeeded"); expect(rows[1]).toContain("Failed"); - expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review@1"]); + expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review%401"]); }); test("renders a completed result without a matching stage as an unlinked row", () => { diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx index be7bff3f9..03b918002 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router"; import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import { StageState } from "@qltysh/fabro-api-client"; +import { stagePath } from "../../lib/stage-routes"; import type { Stage } from "../stage-sidebar"; import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar"; import { StageMetaBar } from "./meta-bar"; @@ -82,7 +83,7 @@ function ChildRow({
  • {row.stageId ? ( {inner} diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index 897fb0d53..9e37e4176 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -18,6 +18,7 @@ import { MapIcon, PaperClipIcon, } from "@heroicons/react/24/outline"; +import { stagePath } from "../lib/stage-routes"; import { formatDurationSecs } from "../lib/format"; import { ACTIVE_STAGE_STATES, formatStageLabel, type Stage } from "../lib/stage-sidebar"; import { elapsedSecsSince, useTickingNow } from "../lib/time"; @@ -207,7 +208,7 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta return ( (null); const openStage = useCallback( - (stageId: string) => navigate(`/runs/${id}/stages/${stageId}`), + (stageId: string) => { + if (id) navigate(stagePath(id, stageId)); + }, [id, navigate], ); useAnnotatedRunGraphSvg({ diff --git a/apps/fabro-web/app/routes/run-petri.render.test.tsx b/apps/fabro-web/app/routes/run-petri.render.test.tsx index 0da4e46a2..5c620be94 100644 --- a/apps/fabro-web/app/routes/run-petri.render.test.tsx +++ b/apps/fabro-web/app/routes/run-petri.render.test.tsx @@ -177,8 +177,8 @@ describe("a two-branch parallel run", () => { />, ); expect(html).toContain("Branches"); - expect(html).toContain("/runs/run-1/stages/a@1"); - expect(html).toContain("/runs/run-1/stages/b@1"); + expect(html).toContain("/runs/run-1/stages/a%401"); + expect(html).toContain("/runs/run-1/stages/b%401"); expect((html.match(/Succeeded/g) ?? []).length).toBeGreaterThanOrEqual(2); }); @@ -274,7 +274,7 @@ describe("a human gate answered through the API", () => { runId="run-1" />, ); - expect(html).toContain("/runs/run-1/stages/no@1"); + expect(html).toContain("/runs/run-1/stages/no%401"); expect(html).not.toContain("No target"); }); diff --git a/apps/fabro-web/app/routes/run-stages.navigation.test.tsx b/apps/fabro-web/app/routes/run-stages.navigation.test.tsx new file mode 100644 index 000000000..8f3d83c76 --- /dev/null +++ b/apps/fabro-web/app/routes/run-stages.navigation.test.tsx @@ -0,0 +1,331 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { ReactNode } from "react"; +import TestRenderer, { act } from "react-test-renderer"; +import { createMemoryRouter, Outlet, RouterProvider } from "react-router"; +import { loadPetriFixture } from "../lib/petri-fixtures"; +import { makeStage, setupReactTestEnv, textContent } from "../lib/test-utils"; + +import { ParallelChildren } from "../components/stage-renderers/parallel-children"; +import { ConditionalDecision } from "../components/stage-renderers/conditional-decision"; +import { StagePopover } from "../components/stage-popover"; +import { RunWaterfall } from "../components/run-waterfall"; + +const ids = [ + "reviewer@1", + "reviewer/e2@1", + "reviewer/50%@1", + "reviewer%20x@1", + "reviewer%2Fe2@1", + "reviewer%252Fe2@1", + "reviewer?x#y@1", + "réview/日本@1", +]; +const fixture = loadPetriFixture("command"); +const stages = ids.map((id) => ({ + id, + name: id.slice(0, -2), + node_id: id.slice(0, -2), + visit: 1, + handler: "command", + status: "succeeded", + usage: fixture.projection.stages["say@1"].usage, +})); +const projection = { + ...fixture.projection, + stages: Object.fromEntries( + ids.map((id) => [id, fixture.projection.stages["say@1"]]), + ), +}; +// Keep real Petri records and give each occurrence a visibly distinct command. +const stream = ids.flatMap((id) => + fixture.stream + .filter( + (entry) => + entry.kind === "petri" && entry.item.subject?.node?.name === "say", + ) + .map((entry) => { + const copy = structuredClone(entry); + copy.item.subject.node.name = id.slice(0, -2); + copy.item.subject.node.meta.script = `echo selected:${id}`; + return copy; + }), +); +let loading = false; +let available = stages; +const logRequests: string[] = []; +mock.module("../lib/queries", () => ({ + useRunGraph: () => ({ data: undefined }), + useRunGraphSource: () => ({ data: undefined }), + useRun: () => ({ data: undefined }), + useRunStages: () => ({ + data: loading ? undefined : { data: available }, + isLoading: loading, + }), + useRunState: () => ({ data: projection }), + useRunStream: () => ({ data: stream }), + useRunStageContextWindow: () => ({ data: undefined }), + useRunStageLog: (_runId: string, stageId: string) => { + logRequests.push(stageId); + const output = Buffer.from(`output:${stageId}`, "utf8"); + return { + data: { + bytes_base64: output.toString("base64"), + next_offset: output.length, + total_bytes: output.length, + }, + }; + }, +})); +mock.module("../components/run-summary-panel", () => ({ + RunSummaryPanel: () => null, +})); +let graphClick: (id: string) => void; +mock.module("../hooks/use-annotated-run-graph-svg", () => ({ + useAnnotatedRunGraphSvg: ({ + onStageClick, + }: { + onStageClick: (id: string) => void; + }) => { + graphClick = onStageClick; + }, +})); +const { default: RunOverview } = await import("./run-overview"); +const { default: RunStages } = await import("./run-stages"); +let renderer: TestRenderer.ReactTestRenderer; +let teardown: () => void; +beforeEach(() => { + teardown = setupReactTestEnv(); + loading = false; + available = stages; + logRequests.length = 0; +}); +afterEach(() => { + if (renderer) act(() => renderer.unmount()); + teardown(); +}); + +function render(entry = "/runs/run-1/stages", source?: ReactNode) { + const router = createMemoryRouter( + [ + { + path: "/runs/:id", + element: , + children: [ + { index: true, element: source ?? }, + { path: "stages", element: }, + { path: "stages/:stageId", element: }, + ], + }, + ], + { initialEntries: [entry] }, + ); + act(() => { + renderer = TestRenderer.create(); + }); + return router; +} +async function clickStage(id: string) { + const link = renderer.root + .findAllByType("a") + .find( + (node) => + node.props.href.includes("/stages/") && + node + .findAllByType("span") + .some((span) => textContent(span) === id.slice(0, -2)), + ); + expect(link).toBeDefined(); + await act(async () => + link!.props.onClick({ + button: 0, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }), + ); +} +function expectSelected(id: string) { + const text = textContent(renderer.root); + expect(text).toContain(`echo selected:${id}`); + expect(text).toContain(`output:${id}`); + expect(logRequests.at(-1)).toBe(id); + const active = renderer.root + .findAllByType("a") + .filter((node) => node.props.className?.includes("bg-overlay text-fg")); + expect(active).toHaveLength(1); + expect(active[0].props.href).toBe( + `/runs/run-1/stages/${encodeURIComponent(id)}`, + ); + for (const other of ids.filter((value) => value !== id)) { + expect(text).not.toContain(`echo selected:${other}`); + expect(text).not.toContain(`output:${other}`); + } +} + +describe("stage occurrence navigation", () => { + test.each(ids)("clicking the sidebar opens exactly %s", async (id) => { + const router = render(); + await clickStage(id); + expect(router.state.location.pathname).toBe( + `/runs/run-1/stages/${encodeURIComponent(id)}`, + ); + expectSelected(id); + }); + test.each(ids)("direct links preserve %s through the nested route", (id) => { + render(`/runs/run-1/stages/${encodeURIComponent(id)}/?view=detail#output`); + expectSelected(id); + }); + test("back and forward restore the exact occurrence", async () => { + const router = render(); + await clickStage(ids[1]); + await clickStage(ids[4]); + await act(async () => { + await router.navigate(-1); + }); + expectSelected(ids[1]); + await act(async () => { + await router.navigate(1); + }); + expectSelected(ids[4]); + }); + test("the stage index still selects the first stage", () => { + render(); + expectSelected(ids[0]); + }); + test.each(["missing%401", "bad%ZZ"])( + "an explicit unavailable ID %s never selects another stage", + (segment) => { + render(`/runs/run-1/stages/${segment}`); + expect(textContent(renderer.root)).toContain("Stage not found"); + expect(logRequests).toEqual([]); + }, + ); + test("an explicit ID waits for the stage list then selects it", async () => { + loading = true; + const router = render(`/runs/run-1/stages/${encodeURIComponent(ids[1])}`); + expect(textContent(renderer.root)).toContain("Loading stages"); + expect(textContent(renderer.root)).not.toContain("Stage not found"); + loading = false; + await act(async () => { + await router.navigate(router.state.location.pathname, { replace: true }); + }); + expectSelected(ids[1]); + }); +}); + +describe("other stage navigation surfaces", () => { + const target = makeStage({ + id: ids[1], + name: "reviewer/e2", + nodeId: "reviewer/e2", + handler: "command", + status: "succeeded", + parallelGroupId: "fork@1", + parallelBranchIndex: 0, + }); + const source = makeStage({ + id: "fork@1", + nodeId: "fork", + status: "succeeded", + }); + const producers = [ + [ + "parallel child", + , + ], + [ + "conditional target", + , + ], + [ + "resumed-stage popover", + , + ], + [ + "waterfall", + , + ], + ] as const; + test.each(producers)( + "%s opens the offered occurrence", + async (_name, element) => { + const router = render("/runs/run-1", element); + const link = renderer.root + .findAllByType("a") + .find((node) => node.props.href.includes("/stages/")); + expect(link).toBeDefined(); + await act(async () => + link!.props.onClick({ + button: 0, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }), + ); + expect(router.state.location.pathname).toBe( + `/runs/run-1/stages/${encodeURIComponent(target.id)}`, + ); + expectSelected(target.id); + }, + ); + test("the resumed-stage banner opens the source occurrence", async () => { + available = stages.map((stage, index) => + index === 0 ? { ...stage, resumed_from_stage_id: ids[1] } : stage, + ); + render(); + const link = renderer.root + .findAllByType("a") + .find((node) => textContent(node) === ids[1]); + await act(async () => + link!.props.onClick({ + button: 0, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + }), + ); + expectSelected(ids[1]); + }); + test("the graph stage-click handler navigates to the offered occurrence", async () => { + const router = render("/runs/run-1"); + await act(async () => graphClick(ids[1])); + expect(router.state.location.pathname).toBe( + `/runs/run-1/stages/${encodeURIComponent(ids[1])}`, + ); + expectSelected(ids[1]); + }); +}); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 49271cd4e..28110f83b 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -5,7 +5,7 @@ import { useState, type ReactNode, } from "react"; -import { Link, useParams } from "react-router"; +import { Link, useLocation, useParams } from "react-router"; import { ArrowDownTrayIcon, ChevronDownIcon, @@ -38,6 +38,7 @@ import type { ThreadDnaItem, ThreadDnaSelection, } from "../components/event-debug"; +import { stageIdFromPathname, stagePath } from "../lib/stage-routes"; import { StageContext } from "../components/stage-context"; import { StageInsightsSidebar } from "../components/stage-insights-sidebar"; import { StageSidebar } from "../components/stage-sidebar"; @@ -2374,7 +2375,7 @@ function RunStageActivityStage({

    Resumed from{" "} {selectedStage.resumedFromStageId} @@ -2526,7 +2527,9 @@ function RunStageActivity({ } export default function RunStages() { - const { id, stageId } = useParams(); + const { id, stageId: stageParam } = useParams(); + const { pathname } = useLocation(); + const stageId = stageIdFromPathname(pathname); const runQuery = useRun(id); const stagesQuery = useRunStages(id); const stages = useMemo( @@ -2535,7 +2538,9 @@ export default function RunStages() { ); const selectedStage = - stages.find((s: Stage) => s.id === stageId) ?? stages[0]; + stageParam === undefined + ? stages[0] + : stages.find((s: Stage) => s.id === stageId); const selectedStageId = selectedStage?.id; const runStart = selectedStage?.startedAt ?? @@ -2563,6 +2568,25 @@ export default function RunStages() { ? runStateQuery.data?.stages[selectedStageId] : undefined; + if (stagesQuery.isLoading && !stagesQuery.data) { + return ( +

    + +
    + ); + } + + if (id && stageParam !== undefined && !selectedStage) { + return ( +
    + +
    + ); + } + if (!id || !selectedStage) { return (