mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-10-02 19:57:59 +00:00
Move Task V3 operator defaults into a registry the charter points to (#8719)
This commit is contained in:
parent
6b11959ab2
commit
28be5d4822
10 changed files with 375 additions and 205 deletions
|
|
@ -386,9 +386,11 @@ describe("BrowserTab view machine", () => {
|
|||
.getQueryCache()
|
||||
.getAll()
|
||||
.filter(
|
||||
(query) => query.queryKey[query.queryKey.length - 1] === "artifacts",
|
||||
(query) =>
|
||||
query.queryKey[query.queryKey.length - 1] === "artifacts" &&
|
||||
query.queryKey[1] != null,
|
||||
);
|
||||
expect(queries).toHaveLength(3);
|
||||
expect(queries).toHaveLength(1);
|
||||
for (const query of queries) {
|
||||
expect(query.observers[0]?.options.refetchInterval).toBe(interval);
|
||||
}
|
||||
|
|
@ -426,8 +428,13 @@ describe("BrowserTab view machine", () => {
|
|||
expectArtifactPolling(5000);
|
||||
});
|
||||
|
||||
it("keeps historical screenshots and recordings accessible during a retry wait", () => {
|
||||
it("keeps historical screenshots and recordings accessible during a retry wait", async () => {
|
||||
seedRun({ status: Status.Completed, recordingUrl: "https://r.test/1.mp4" });
|
||||
mocks.artifactsByUrl = {
|
||||
"workflow_run_block/wrb_historical/": [
|
||||
{ artifact_id: "art_h", artifact_type: "screenshot_llm" },
|
||||
],
|
||||
};
|
||||
mocks.workflowRun = Object.assign({}, mocks.workflowRun, {
|
||||
retry_pending: true,
|
||||
attempt: 2,
|
||||
|
|
@ -459,7 +466,7 @@ describe("BrowserTab view machine", () => {
|
|||
expect(screen.getByTestId("hero-recording")).toBeTruthy();
|
||||
expect(screen.queryByText(retryMessage)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Screenshots" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Screenshots" }));
|
||||
expect(screen.getByTestId("hero-screenshot")).toBeTruthy();
|
||||
expect(screen.queryByText(retryMessage)).toBeNull();
|
||||
|
||||
|
|
@ -773,16 +780,16 @@ describe("BrowserTab view machine", () => {
|
|||
expect(startRecording).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hides replay pills a finished run has nothing for, and restores them as artifacts arrive", () => {
|
||||
it("hides replay pills a run has nothing for, and restores them as artifacts arrive", () => {
|
||||
seedRun({ status: Status.Running, browserSessionId: "pbs_test" });
|
||||
mocks.timeline = [];
|
||||
mocks.debugSession = { browser_session_id: "pbs_test" };
|
||||
const { rerenderPane } = renderBrowserPane(
|
||||
`${STUDIO_PATH}&wr=wr_1&view=recording`,
|
||||
);
|
||||
// Artifacts are written at finalize, so a running run keeps both pills.
|
||||
expect(screen.getByRole("button", { name: "Recording" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Screenshots" })).toBeTruthy();
|
||||
// Nothing captured yet: a running run offers no replay pills either.
|
||||
expect(screen.queryByRole("button", { name: "Recording" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Screenshots" })).toBeNull();
|
||||
|
||||
mocks.workflowRun = Object.assign({}, mocks.workflowRun, {
|
||||
status: Status.Completed,
|
||||
|
|
@ -803,20 +810,6 @@ describe("BrowserTab view machine", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "an earlier block's LLM screenshot, none on the last block",
|
||||
timeline: () => [
|
||||
buildBlockItem(
|
||||
buildBlock({
|
||||
workflow_run_block_id: "wrb_B",
|
||||
block_type: "text_prompt",
|
||||
created_at: "2026-01-01T00:01:00Z",
|
||||
}),
|
||||
),
|
||||
buildBlockItem(buildBlock({ workflow_run_block_id: "wrb_A" })),
|
||||
],
|
||||
artifacts: { "workflow_run_block/wrb_A/": "screenshot_llm" },
|
||||
},
|
||||
{
|
||||
name: "an earlier step's screenshot, none on the last step",
|
||||
timeline: () => [
|
||||
|
|
@ -862,6 +855,39 @@ describe("BrowserTab view machine", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("offers Screenshots for a block only once that block has one", async () => {
|
||||
// wrb_B, the last block, captured nothing; the earlier wrb_A has an LLM screenshot.
|
||||
const timeline = () => [
|
||||
buildBlockItem(
|
||||
buildBlock({
|
||||
workflow_run_block_id: "wrb_B",
|
||||
block_type: "text_prompt",
|
||||
created_at: "2026-01-01T00:01:00Z",
|
||||
}),
|
||||
),
|
||||
buildBlockItem(buildBlock({ workflow_run_block_id: "wrb_A" })),
|
||||
];
|
||||
seedRun({ status: Status.Completed });
|
||||
mocks.timeline = timeline();
|
||||
mocks.artifactsByUrl = {
|
||||
"workflow_run_block/wrb_A/": [
|
||||
{ artifact_id: "art_a", artifact_type: "screenshot_llm" },
|
||||
],
|
||||
};
|
||||
mocks.debugSession = { browser_session_id: "pbs_test" };
|
||||
renderBrowserPane(`${STUDIO_PATH}&wr=wr_1`);
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)));
|
||||
expect(screen.queryByRole("button", { name: "Screenshots" })).toBeNull();
|
||||
expect(screen.queryByText("Screenshot unavailable.")).toBeNull();
|
||||
|
||||
cleanup();
|
||||
mocks.timeline = timeline();
|
||||
renderBrowserPane(`${STUDIO_PATH}&wr=wr_1&active=wrb_A`);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Screenshots" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("never strands a finished run with nothing to replay and no debug browser on an endless warm-up", () => {
|
||||
seedRun({ status: Status.Running, browserSessionId: "pbs_run" });
|
||||
mocks.timeline = [];
|
||||
|
|
|
|||
|
|
@ -135,21 +135,14 @@ export function BrowserTab() {
|
|||
recordingUrls={recordingUrls}
|
||||
onPlay={onRecordingPlay}
|
||||
/>
|
||||
) : visuals.recordingArchived ? (
|
||||
) : (
|
||||
// The Recording view is only offered with URLs or an archived recording.
|
||||
<StreamStatusPanel
|
||||
diagnostic={{
|
||||
title: "Recording archived",
|
||||
detail: "To request restoration, contact support@skyvern.com.",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<StreamStatusPanel
|
||||
diagnostic={{
|
||||
title: "No recording for this run",
|
||||
detail:
|
||||
"Screenshots keep a frame for each action the run took — try that view instead.",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : heroSelection ? (
|
||||
<HeroScreenshot selection={heroSelection} running={executing} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
|
@ -31,8 +32,36 @@ vi.mock("../hooks/useWorkflowRunWithWorkflowQuery", () => ({
|
|||
workflowRunQueryMock(options),
|
||||
}));
|
||||
|
||||
// One captured action, so a pinned step (?active=act_1) has a frame to show.
|
||||
vi.mock("../hooks/useWorkflowRunTimelineQuery", () => ({
|
||||
useWorkflowRunTimelineQuery: () => ({ data: undefined }),
|
||||
useWorkflowRunTimelineQuery: () => ({
|
||||
data: [
|
||||
{
|
||||
type: "block",
|
||||
block: {
|
||||
workflow_run_block_id: "wrb_1",
|
||||
block_type: "task",
|
||||
status: "running",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
modified_at: "2026-01-01T00:00:00Z",
|
||||
actions: [
|
||||
{
|
||||
action_id: "act_1",
|
||||
action_type: "click",
|
||||
status: "completed",
|
||||
step_id: "step_1",
|
||||
action_order: 0,
|
||||
screenshot_artifact_id: "art_1",
|
||||
},
|
||||
],
|
||||
},
|
||||
children: [],
|
||||
thought: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
modified_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useWorkflowRunsQuery", () => ({
|
||||
|
|
@ -123,20 +152,25 @@ function renderStudioBrowserStream(
|
|||
initialPath: string,
|
||||
visiblePanes?: readonly StudioPaneId[],
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/workflows/:workflowPermanentId/studio"
|
||||
element={
|
||||
<StudioPaneDefaultsProvider hasBlocks={true}>
|
||||
<StudioBrowserStream visiblePanes={visiblePanes} />
|
||||
<OpenBrowserPaneButton />
|
||||
</StudioPaneDefaultsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/workflows/:workflowPermanentId/studio"
|
||||
element={
|
||||
<StudioPaneDefaultsProvider hasBlocks={true}>
|
||||
<StudioBrowserStream visiblePanes={visiblePanes} />
|
||||
<OpenBrowserPaneButton />
|
||||
</StudioPaneDefaultsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,21 +99,27 @@ describe("resolveBrowserPaneView", () => {
|
|||
).toBe("live");
|
||||
});
|
||||
|
||||
it("keeps a finished run's Screenshots pill until its timeline has loaded", () => {
|
||||
const finished = {
|
||||
finalized: true,
|
||||
it("offers a replay pill only when something exists behind it", () => {
|
||||
const empty = {
|
||||
recordingUrls: [],
|
||||
recordingArchived: false,
|
||||
hasScreenshots: false,
|
||||
};
|
||||
expect(resolveReplayAvailability(empty)).toEqual({
|
||||
recordingAvailable: false,
|
||||
screenshotsAvailable: false,
|
||||
});
|
||||
expect(
|
||||
resolveReplayAvailability({ ...finished, timeline: undefined })
|
||||
.screenshotsAvailable,
|
||||
resolveReplayAvailability({ ...empty, recordingUrls: ["r.webm"] }),
|
||||
).toEqual({ recordingAvailable: true, screenshotsAvailable: false });
|
||||
expect(
|
||||
resolveReplayAvailability({ ...empty, recordingArchived: true })
|
||||
.recordingAvailable,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveReplayAvailability({ ...finished, timeline: [] })
|
||||
resolveReplayAvailability({ ...empty, hasScreenshots: true })
|
||||
.screenshotsAvailable,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("overrides a stored replay intent when a recording starts", () => {
|
||||
|
|
|
|||
|
|
@ -40,27 +40,18 @@ type ResolveLiveSurfaceArgs = {
|
|||
hasRunId: boolean;
|
||||
};
|
||||
|
||||
// Artifacts are written at finalize, so a run still going keeps both pills.
|
||||
// The timeline check avoids hiding Screenshots before its data has loaded.
|
||||
// A pill is offered only once there is something behind it, running or not:
|
||||
// recordings land at finalize, screenshots as the run takes each action.
|
||||
export function resolveReplayAvailability(
|
||||
visuals: Pick<
|
||||
RunVisuals,
|
||||
| "finalized"
|
||||
| "recordingUrls"
|
||||
| "recordingArchived"
|
||||
| "timeline"
|
||||
| "hasScreenshots"
|
||||
"recordingUrls" | "recordingArchived" | "hasScreenshots"
|
||||
>,
|
||||
): { recordingAvailable: boolean; screenshotsAvailable: boolean } {
|
||||
return {
|
||||
recordingAvailable:
|
||||
!visuals.finalized ||
|
||||
visuals.recordingUrls.length > 0 ||
|
||||
visuals.recordingArchived,
|
||||
screenshotsAvailable:
|
||||
!visuals.finalized ||
|
||||
visuals.timeline === undefined ||
|
||||
visuals.hasScreenshots,
|
||||
visuals.recordingUrls.length > 0 || visuals.recordingArchived,
|
||||
screenshotsAvailable: visuals.hasScreenshots,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { ArtifactApiResponse, ArtifactType } from "@/api/types";
|
||||
import { useArtifactImageSrc } from "@/hooks/useArtifactImageSrc";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { apiPathPrefix } from "@/util/env";
|
||||
|
||||
import { selectBlockScreenshot } from "../../workflowRun/blockScreenshot";
|
||||
import { screenshotZoomClasses } from "./HeroScreenshot.utils";
|
||||
import { useHeroScreenshot } from "./useHeroScreenshot";
|
||||
|
||||
export type HeroSelection =
|
||||
| {
|
||||
|
|
@ -30,9 +25,7 @@ export type HeroSelection =
|
|||
|
||||
/**
|
||||
* The selected element's screenshot, fit to the run-hero width and scrollable for
|
||||
* long captures. An action shows its own post-action screenshot (by artifact id,
|
||||
* falling back to the step's action screenshots), and a block shows its
|
||||
* representative screenshot via `selectBlockScreenshot`.
|
||||
* long captures.
|
||||
*/
|
||||
export function HeroScreenshot({
|
||||
selection,
|
||||
|
|
@ -41,116 +34,12 @@ export function HeroScreenshot({
|
|||
selection: HeroSelection | null;
|
||||
running: boolean;
|
||||
}) {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const action = selection?.kind === "action" ? selection : null;
|
||||
const block = selection?.kind === "block" ? selection : null;
|
||||
const thought = selection?.kind === "thought" ? selection : null;
|
||||
|
||||
const { data: artifactById, isLoading: loadingArtifact } =
|
||||
useQuery<ArtifactApiResponse>({
|
||||
queryKey: ["artifact", action?.artifactId],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client
|
||||
.get(`/artifacts/${action!.artifactId}`)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
enabled: Boolean(action?.artifactId),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
// Fallback path only when the action carries no explicit screenshot id: the
|
||||
// step's action screenshots, indexed by action order (newest-first), like legacy.
|
||||
const useStepFallback =
|
||||
Boolean(action?.stepId) &&
|
||||
action?.actionOrder != null &&
|
||||
!action?.artifactId;
|
||||
const { data: stepArtifacts, isLoading: loadingStep } = useQuery<
|
||||
Array<ArtifactApiResponse>
|
||||
>({
|
||||
queryKey: ["step", action?.stepId, "artifacts"],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.get(`${apiPathPrefix}/step/${action!.stepId}/artifacts`)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
enabled: useStepFallback,
|
||||
refetchInterval: running ? 5000 : false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: running ? 0 : Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: blockArtifacts, isLoading: loadingBlock } = useQuery<
|
||||
Array<ArtifactApiResponse>
|
||||
>({
|
||||
queryKey: ["workflowRunBlock", block?.workflowRunBlockId, "artifacts"],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.get(
|
||||
`${apiPathPrefix}/workflow_run_block/${block!.workflowRunBlockId}/artifacts`,
|
||||
)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
enabled: Boolean(block?.workflowRunBlockId),
|
||||
refetchInterval: running ? 5000 : false,
|
||||
refetchOnWindowFocus: false,
|
||||
// Artifacts are immutable once the run finishes; only keep polling while live.
|
||||
staleTime: running ? 0 : Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: thoughtArtifacts, isLoading: loadingThought } = useQuery<
|
||||
Array<ArtifactApiResponse>
|
||||
>({
|
||||
queryKey: ["observerThought", thought?.thoughtId, "artifacts"],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.get(`${apiPathPrefix}/thought/${thought!.thoughtId}/artifacts`)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
enabled: Boolean(thought?.thoughtId),
|
||||
refetchInterval: running ? 5000 : false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: running ? 0 : Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
let screenshot: ArtifactApiResponse | undefined;
|
||||
if (action) {
|
||||
const actionShots = stepArtifacts?.filter(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.ActionScreenshot,
|
||||
);
|
||||
const fromStep =
|
||||
actionShots && action.actionOrder != null
|
||||
? actionShots[actionShots.length - action.actionOrder - 1]
|
||||
: undefined;
|
||||
screenshot = artifactById ?? fromStep;
|
||||
} else if (block) {
|
||||
screenshot = selectBlockScreenshot(
|
||||
blockArtifacts,
|
||||
block.blockType ?? undefined,
|
||||
);
|
||||
} else if (thought) {
|
||||
const thoughtShots = thoughtArtifacts?.filter(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
|
||||
);
|
||||
// Thought LLM screenshots arrive newest-first; the last is the capture.
|
||||
screenshot = thoughtShots?.[thoughtShots.length - 1];
|
||||
}
|
||||
const { screenshot, isLoading } = useHeroScreenshot(selection, running);
|
||||
|
||||
const screenshotId = screenshot?.artifact_id ?? null;
|
||||
const { src, onImageError, imageFailed } = useArtifactImageSrc(screenshot);
|
||||
const isLoading =
|
||||
loadingArtifact || loadingStep || loadingBlock || loadingThought;
|
||||
|
||||
useEffect(() => {
|
||||
setZoomed(false);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { ArtifactApiResponse, ArtifactType } from "@/api/types";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { apiPathPrefix } from "@/util/env";
|
||||
|
||||
import { selectBlockScreenshot } from "../../workflowRun/blockScreenshot";
|
||||
import type { HeroSelection } from "./HeroScreenshot";
|
||||
|
||||
/**
|
||||
* Resolves a selection to its screenshot artifact: an action by artifact id, falling
|
||||
* back to the step's action screenshots; a block via `selectBlockScreenshot`; a
|
||||
* thought by its LLM screenshot.
|
||||
*/
|
||||
export function useHeroScreenshot(
|
||||
selection: HeroSelection | null,
|
||||
running: boolean,
|
||||
): { screenshot: ArtifactApiResponse | undefined; isLoading: boolean } {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
const action = selection?.kind === "action" ? selection : null;
|
||||
const block = selection?.kind === "block" ? selection : null;
|
||||
const thought = selection?.kind === "thought" ? selection : null;
|
||||
|
||||
const { data: artifactById, isLoading: loadingArtifact } =
|
||||
useQuery<ArtifactApiResponse>({
|
||||
queryKey: ["artifact", action?.artifactId],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client
|
||||
.get(`/artifacts/${action!.artifactId}`)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
enabled: Boolean(action?.artifactId),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
// Fallback path only when the action carries no explicit screenshot id: the
|
||||
// step's action screenshots, indexed by action order (newest-first), like legacy.
|
||||
const useStepFallback =
|
||||
Boolean(action?.stepId) &&
|
||||
action?.actionOrder != null &&
|
||||
!action?.artifactId;
|
||||
const { data: stepArtifacts, isLoading: loadingStep } = useQuery<
|
||||
Array<ArtifactApiResponse>
|
||||
>({
|
||||
queryKey: ["step", action?.stepId, "artifacts"],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.get(`${apiPathPrefix}/step/${action!.stepId}/artifacts`)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
enabled: useStepFallback,
|
||||
refetchInterval: running ? 5000 : false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: running ? 0 : Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: blockArtifacts, isLoading: loadingBlock } = useQuery<
|
||||
Array<ArtifactApiResponse>
|
||||
>({
|
||||
queryKey: ["workflowRunBlock", block?.workflowRunBlockId, "artifacts"],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.get(
|
||||
`${apiPathPrefix}/workflow_run_block/${block!.workflowRunBlockId}/artifacts`,
|
||||
)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
enabled: Boolean(block?.workflowRunBlockId),
|
||||
refetchInterval: running ? 5000 : false,
|
||||
refetchOnWindowFocus: false,
|
||||
// Artifacts are immutable once the run finishes; only keep polling while live.
|
||||
staleTime: running ? 0 : Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const { data: thoughtArtifacts, isLoading: loadingThought } = useQuery<
|
||||
Array<ArtifactApiResponse>
|
||||
>({
|
||||
queryKey: ["observerThought", thought?.thoughtId, "artifacts"],
|
||||
queryFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
return client
|
||||
.get(`${apiPathPrefix}/thought/${thought!.thoughtId}/artifacts`)
|
||||
.then((response) => response.data);
|
||||
},
|
||||
enabled: Boolean(thought?.thoughtId),
|
||||
refetchInterval: running ? 5000 : false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: running ? 0 : Infinity,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
let screenshot: ArtifactApiResponse | undefined;
|
||||
if (action) {
|
||||
const actionShots = stepArtifacts?.filter(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.ActionScreenshot,
|
||||
);
|
||||
const fromStep =
|
||||
actionShots && action.actionOrder != null
|
||||
? actionShots[actionShots.length - action.actionOrder - 1]
|
||||
: undefined;
|
||||
screenshot = artifactById ?? fromStep;
|
||||
} else if (block) {
|
||||
screenshot = selectBlockScreenshot(
|
||||
blockArtifacts,
|
||||
block.blockType ?? undefined,
|
||||
);
|
||||
} else if (thought) {
|
||||
const thoughtShots = thoughtArtifacts?.filter(
|
||||
(artifact) => artifact.artifact_type === ArtifactType.LLMScreenshot,
|
||||
);
|
||||
// Thought LLM screenshots arrive newest-first; the last is the capture.
|
||||
screenshot = thoughtShots?.[thoughtShots.length - 1];
|
||||
}
|
||||
|
||||
return {
|
||||
screenshot,
|
||||
isLoading: loadingArtifact || loadingStep || loadingBlock || loadingThought,
|
||||
};
|
||||
}
|
||||
|
|
@ -141,7 +141,9 @@ export function useBrowserPaneView(): BrowserPaneViewState {
|
|||
running: visuals.running,
|
||||
hasRecording: visuals.recordingUrls.length > 0,
|
||||
recordingAvailable,
|
||||
screenshotsAvailable,
|
||||
// Until it is known whether screenshots exist, route as if they do, so the
|
||||
// view doesn't flip away and back; the pill itself waits for the answer.
|
||||
screenshotsAvailable: screenshotsAvailable || visuals.screenshotsPending,
|
||||
hasDebugSession: debugBrowserSessionId != null,
|
||||
failed: visuals.failed,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { type ReactNode } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { Status } from "@/api/types";
|
||||
import { useRunViewStore } from "@/store/RunViewStore";
|
||||
|
|
@ -22,6 +22,8 @@ const { mocks, getClientMock } = vi.hoisted(() => ({
|
|||
mocks: {
|
||||
workflowRun: undefined as unknown,
|
||||
timeline: undefined as unknown,
|
||||
timelinePlaceholder: false,
|
||||
timelineFetching: false,
|
||||
// The identity cases below need the real queries; the projection cases above
|
||||
// them only need a payload, so they keep the cheaper stub.
|
||||
useRealQueries: false,
|
||||
|
|
@ -61,7 +63,12 @@ vi.mock("../hooks/useWorkflowRunTimelineQuery", async (importOriginal) => {
|
|||
) =>
|
||||
mocks.useRealQueries
|
||||
? actual.useWorkflowRunTimelineQuery(options)
|
||||
: { data: mocks.timeline, isLoading: false },
|
||||
: {
|
||||
data: mocks.timeline,
|
||||
isLoading: false,
|
||||
isPlaceholderData: mocks.timelinePlaceholder,
|
||||
isFetching: mocks.timelineFetching,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -148,17 +155,26 @@ function seedLoopRun() {
|
|||
};
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/?active=wrb_loop"]}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
);
|
||||
function mockedQueryWrapper(initialEntries?: string[]) {
|
||||
return function MockedQueryWrapper({ children }: { children: ReactNode }) {
|
||||
const [client] = useState(
|
||||
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const wrapper = mockedQueryWrapper(["/?active=wrb_loop"]);
|
||||
|
||||
afterEach(() => {
|
||||
mocks.workflowRun = undefined;
|
||||
mocks.timeline = undefined;
|
||||
mocks.timelinePlaceholder = false;
|
||||
mocks.timelineFetching = false;
|
||||
mocks.useRealQueries = false;
|
||||
getClientMock.mockReset();
|
||||
});
|
||||
|
|
@ -207,7 +223,7 @@ describe("useRunVisuals loop-iteration threading", () => {
|
|||
}),
|
||||
};
|
||||
},
|
||||
{ wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter> },
|
||||
{ wrapper: mockedQueryWrapper() },
|
||||
);
|
||||
|
||||
expect(result.current.view).toBe("live");
|
||||
|
|
@ -328,3 +344,58 @@ describe("useRunVisuals identity", () => {
|
|||
expect(result.current.heroSelection).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRunVisuals same-run refetch", () => {
|
||||
test("keeps a run's timeline while its own status change refetches it, but not another run's", () => {
|
||||
mocks.workflowRun = { workflow_run_id: RUN_A_ID, status: Status.Running };
|
||||
mocks.timeline = [
|
||||
buildBlockItem(
|
||||
buildBlock({
|
||||
workflow_run_id: RUN_A_ID,
|
||||
actions: [
|
||||
{
|
||||
action_id: "act_1",
|
||||
step_id: "step_1",
|
||||
action_order: 0,
|
||||
screenshot_artifact_id: "art_1",
|
||||
},
|
||||
] as WorkflowRunBlock["actions"],
|
||||
}),
|
||||
),
|
||||
];
|
||||
mocks.timelinePlaceholder = false;
|
||||
const { result, rerender } = renderHook(
|
||||
({ id }: { id: string }) => useRunVisuals(id),
|
||||
{
|
||||
wrapper: mockedQueryWrapper(),
|
||||
initialProps: { id: RUN_A_ID },
|
||||
},
|
||||
);
|
||||
expect(result.current.hasScreenshots).toBe(true);
|
||||
|
||||
mocks.timelinePlaceholder = true;
|
||||
rerender({ id: RUN_A_ID });
|
||||
expect(result.current.timeline).toHaveLength(1);
|
||||
expect(result.current.hasScreenshots).toBe(true);
|
||||
|
||||
rerender({ id: RUN_B_ID });
|
||||
expect(result.current.timeline).toBeUndefined();
|
||||
expect(result.current.hasScreenshots).toBe(false);
|
||||
});
|
||||
|
||||
test("treats screenshots as pending only while the timeline is actually loading", () => {
|
||||
mocks.workflowRun = { workflow_run_id: RUN_A_ID, status: Status.Completed };
|
||||
mocks.timeline = undefined;
|
||||
mocks.timelineFetching = true;
|
||||
const { result, rerender } = renderHook(() => useRunVisuals(RUN_A_ID), {
|
||||
wrapper: mockedQueryWrapper(),
|
||||
});
|
||||
expect(result.current.screenshotsPending).toBe(true);
|
||||
|
||||
// A failed fetch leaves no timeline; the view must not wait on it forever.
|
||||
mocks.timelineFetching = false;
|
||||
rerender();
|
||||
expect(result.current.screenshotsPending).toBe(false);
|
||||
expect(result.current.hasScreenshots).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
runIsLogicallyFinal,
|
||||
runIsExecuting,
|
||||
} from "@/routes/workflows/workflowRun/runRetryState";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Status, WorkflowRunStatusApiResponseWithWorkflow } from "@/api/types";
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
resolveScreenshotBlockId,
|
||||
} from "../workflowRun/workflowTimelineUtils";
|
||||
import { type HeroSelection } from "./runview/HeroScreenshot";
|
||||
import { useHeroScreenshot } from "./runview/useHeroScreenshot";
|
||||
import {
|
||||
buildFilmstrip,
|
||||
resolveLandingSelectionId,
|
||||
|
|
@ -45,22 +46,21 @@ export type RunVisuals = {
|
|||
recordingUrls: string[];
|
||||
recordingArchived: boolean;
|
||||
hasScreenshots: boolean;
|
||||
// Not yet known: the timeline or the selected block/thought's artifacts are loading.
|
||||
screenshotsPending: boolean;
|
||||
// ?active= pins a specific step (anything but the live-edge "stream" pin).
|
||||
scrubbing: boolean;
|
||||
heroSelection: HeroSelection | null;
|
||||
};
|
||||
|
||||
function hasScreenshotCandidate(selection: HeroSelection | null): boolean {
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (selection.kind === "action") {
|
||||
return Boolean(
|
||||
function hasActionScreenshot(selection: HeroSelection | null): boolean {
|
||||
return (
|
||||
selection?.kind === "action" &&
|
||||
Boolean(
|
||||
selection.artifactId ||
|
||||
(selection.stepId && selection.actionOrder != null),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,12 +72,29 @@ function hasScreenshotCandidate(selection: HeroSelection | null): boolean {
|
|||
export function useRunVisuals(workflowRunId: string | undefined): RunVisuals {
|
||||
const queryOptions = { workflowRunId };
|
||||
const { data: workflowRun } = useWorkflowRunWithWorkflowQuery(queryOptions);
|
||||
const { data: retainedTimeline, isPlaceholderData: timelineIsPlaceholder } =
|
||||
useWorkflowRunTimelineQuery(queryOptions);
|
||||
const {
|
||||
data: retainedTimeline,
|
||||
isPlaceholderData: timelineIsPlaceholder,
|
||||
isFetching: timelineFetching,
|
||||
} = useWorkflowRunTimelineQuery(queryOptions);
|
||||
// The timeline payload carries no run id of its own, so keepPreviousData serves
|
||||
// the previous run's timeline on both a switch and a clear.
|
||||
// the previous run's timeline on both a switch and a clear. It also bridges this
|
||||
// run's own refetch on a status change (status is in the query key); those rows
|
||||
// stay, or replay pills blink off mid-run.
|
||||
const [loadedTimelineRunId, setLoadedTimelineRunId] = useState<string>();
|
||||
if (
|
||||
workflowRunId &&
|
||||
!timelineIsPlaceholder &&
|
||||
retainedTimeline !== undefined &&
|
||||
loadedTimelineRunId !== workflowRunId
|
||||
) {
|
||||
setLoadedTimelineRunId(workflowRunId);
|
||||
}
|
||||
const timeline =
|
||||
!workflowRunId || timelineIsPlaceholder ? undefined : retainedTimeline;
|
||||
!workflowRunId ||
|
||||
(timelineIsPlaceholder && loadedTimelineRunId !== workflowRunId)
|
||||
? undefined
|
||||
: retainedTimeline;
|
||||
const currentTimeline = useMemo(
|
||||
() =>
|
||||
timeline
|
||||
|
|
@ -175,8 +192,20 @@ export function useRunVisuals(workflowRunId: string | undefined): RunVisuals {
|
|||
),
|
||||
[frames],
|
||||
);
|
||||
// A selected block or thought offers Screenshots only once its artifacts resolve
|
||||
// to one (the same lookup the hero renders); otherwise the pill opens nothing.
|
||||
const { screenshot: selectionScreenshot, isLoading: selectionLoading } =
|
||||
useHeroScreenshot(
|
||||
heroSelection?.kind === "action" ? null : heroSelection,
|
||||
executing,
|
||||
);
|
||||
const hasScreenshots =
|
||||
hasScreenshotFrame || hasScreenshotCandidate(heroSelection);
|
||||
heroSelection?.kind === "block" || heroSelection?.kind === "thought"
|
||||
? Boolean(selectionScreenshot && !selectionScreenshot.archived)
|
||||
: hasScreenshotFrame || hasActionScreenshot(heroSelection);
|
||||
const screenshotsPending =
|
||||
!hasScreenshots &&
|
||||
(selectionLoading || (timelineFetching && timeline === undefined));
|
||||
|
||||
return {
|
||||
workflowRun,
|
||||
|
|
@ -190,6 +219,7 @@ export function useRunVisuals(workflowRunId: string | undefined): RunVisuals {
|
|||
recordingUrls,
|
||||
recordingArchived: workflowRun?.recording_archived ?? false,
|
||||
hasScreenshots,
|
||||
screenshotsPending,
|
||||
scrubbing,
|
||||
heroSelection,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue