diff --git a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx
index 620005e2e..643452d26 100644
--- a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx
+++ b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx
@@ -6,7 +6,7 @@ import {
} from "@radix-ui/react-icons";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { type FieldErrors, useForm } from "react-hook-form";
-import { Link, useNavigate, useParams } from "react-router-dom";
+import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
@@ -47,6 +47,13 @@ import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQu
import { constructCacheKeyValueFromParameters } from "@/routes/workflows/editor/utils";
import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
import { useWorkflowStudioEnabled } from "@/hooks/useWorkflowStudioEnabled";
+import { liveSearch } from "@/routes/workflows/studio/liveSearch";
+import {
+ RUN_APPEND_PANES,
+ STUDIO_PANES_PARAM,
+ parsePanesParam,
+ withPanesOpen,
+} from "@/routes/workflows/studio/panes";
import { workflowEditorPath } from "./studioNavigation";
import { CredentialSetupPrompt } from "@/components/onboarding/CredentialSetupPrompt";
import { useFeatureFlagVariantKey } from "posthog-js/react";
@@ -324,6 +331,7 @@ function RunWorkflowForm({
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const navigate = useNavigate();
+ const location = useLocation();
const studioEnabled = useWorkflowStudioEnabled();
const queryClient = useQueryClient();
const apiCredential = useApiCredential();
@@ -402,8 +410,23 @@ function RunWorkflowForm({
queryClient.invalidateQueries({
queryKey: ["runs"],
});
- if (studioEnabled) {
- // Land in the studio shell; the ?wr= deep link opens the Run pane.
+ // Only studio-originated launches (Run button, Timeline retry) carry
+ // ?panes=; its presence — even explicitly empty (all panes closed) —
+ // marks the origin and holds the layout to return to, so the round-trip
+ // appends the run surfaces instead of remapping. liveSearch guards this
+ // mutation callback against a stale ?panes= closure.
+ const carriedPanes = parsePanesParam(
+ new URLSearchParams(liveSearch(location.search)).get(
+ STUDIO_PANES_PARAM,
+ ),
+ );
+ if (studioEnabled && carriedPanes !== null) {
+ const panes = withPanesOpen(carriedPanes, RUN_APPEND_PANES);
+ navigate(
+ `/agents/${workflowPermanentId}/studio?wr=${response.data.workflow_run_id}&${STUDIO_PANES_PARAM}=${panes.join(",")}`,
+ );
+ } else if (studioEnabled) {
+ // Land in the studio shell; the ?wr= deep link maps the panes.
navigate(
`/agents/${workflowPermanentId}/studio?wr=${response.data.workflow_run_id}`,
);
diff --git a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx
index d4ffcb52b..dacb9a8bb 100644
--- a/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx
+++ b/skyvern-frontend/src/routes/workflows/copilot/WorkflowCopilotChat.tsx
@@ -329,11 +329,17 @@ const MessageItem = memo(({ message, footer }: MessageItemProps) => {
);
});
+// `persisted` true = atomic accept (server already wrote new version); false/undefined = local edit.
+// `applied` marks a turn's accepted terminal apply; drafts and snap-backs omit it.
+export type WorkflowUpdateOptions = {
+ persisted?: boolean;
+ applied?: boolean;
+};
+
interface WorkflowCopilotChatProps {
- // `options.persisted` true = atomic accept (server already wrote new version); false/undefined = local edit.
onWorkflowUpdate?: (
workflow: WorkflowApiResponse,
- options?: { persisted?: boolean },
+ options?: WorkflowUpdateOptions,
) => void;
onReviewWorkflow?: (
workflow: WorkflowApiResponse,
@@ -803,7 +809,7 @@ export function WorkflowCopilotChat({
const applyWorkflowUpdate = useCallback(
(
workflow: WorkflowApiResponse,
- options?: { persisted?: boolean },
+ options?: WorkflowUpdateOptions,
): boolean => {
if (!onWorkflowUpdate) {
return true;
@@ -842,7 +848,7 @@ export function WorkflowCopilotChat({
if (!chatId) {
// No chat id: apply locally and best-effort clear the server proposal so reload doesn't resurrect it.
- if (!applyWorkflowUpdate(workflow)) {
+ if (!applyWorkflowUpdate(workflow, { applied: true })) {
return;
}
setProposedWorkflow(null);
@@ -863,7 +869,9 @@ export function WorkflowCopilotChat({
} as WorkflowCopilotApplyProposedWorkflowRequest,
);
// persisted=true loads as clean baseline; without it, Save would create a duplicate version.
- if (!applyWorkflowUpdate(response.data, { persisted: true })) {
+ if (
+ !applyWorkflowUpdate(response.data, { persisted: true, applied: true })
+ ) {
return;
}
setProposedWorkflow(null);
@@ -879,7 +887,7 @@ export function WorkflowCopilotChat({
"Atomic apply failed; falling back to client-side apply:",
applyError,
);
- if (!applyWorkflowUpdate(workflow)) {
+ if (!applyWorkflowUpdate(workflow, { applied: true })) {
toast({
title: "Accept failed",
description: "Could not apply the proposed agent. Please try again.",
@@ -1468,7 +1476,7 @@ export function WorkflowCopilotChat({
userCancelledThisTurn,
)
) {
- applyWorkflowUpdate(response.updated_workflow);
+ applyWorkflowUpdate(response.updated_workflow, { applied: true });
} else if (response.updated_workflow) {
setProposedWorkflow(response.updated_workflow);
} else if (
diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx
index ce4f298e5..f0d7c2b8e 100644
--- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx
+++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx
@@ -348,6 +348,12 @@ function Workspace({
const { copilotPortalEl: studioCopilotPortalEl } = useStudioShellContext();
const { panes: studioPanes, openPane: openStudioPane } = useStudioPanes();
const studioCopilotOpen = studioPanes.includes("copilot");
+ // Armed iff the workflow has no blocks at mount — the studio shell remounts
+ // Workspace per workflow, so `workflow` is always populated here. The first
+ // copilot build that lands blocks auto-opens the Editor pane, exactly once.
+ const editorAutoOpenArmedRef = useRef(
+ workflow.workflow_definition.blocks.length === 0,
+ );
const location = useLocation();
const navigate = useNavigate();
const locationState = location.state as {
@@ -2874,6 +2880,15 @@ function Workspace({
onWorkflowUpdate={(workflowData, options) => {
try {
applyWorkflowUpdate(workflowData, options);
+ if (
+ embedded &&
+ options?.applied &&
+ editorAutoOpenArmedRef.current &&
+ workflowData.workflow_definition.blocks.length > 0
+ ) {
+ editorAutoOpenArmedRef.current = false;
+ openStudioPane("editor");
+ }
} catch (error) {
console.error(
"Failed to parse and apply agent",
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx
index 29a728aa5..15f2093af 100644
--- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx
+++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx
@@ -59,10 +59,11 @@ import { useDebuggerLastRunValuesStore } from "@/store/DebuggerLastRunValuesStor
import { useBlockOutputStore } from "@/store/BlockOutputStore";
import { useDebugStore } from "@/store/useDebugStore";
import {
+ RUN_APPEND_PANES,
STUDIO_PANES_PARAM,
- resolveOpenPanes,
withPanesOpen,
} from "@/routes/workflows/studio/panes";
+import { useStudioPanes } from "@/routes/workflows/studio/useStudioPanes";
import { useRecordingStore } from "@/store/useRecordingStore";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import { useWorkflowSave } from "@/store/WorkflowHasChangesStore";
@@ -277,6 +278,7 @@ function NodeHeader({
const studioEnabled = useWorkflowStudioEnabled();
const queryClient = useQueryClient();
const location = useLocation();
+ const { resolveLivePanes } = useStudioPanes();
const isDebuggable = debuggableWorkflowBlockTypes.has(type);
const isScriptable = scriptableWorkflowBlockTypes.has(type);
const { data: workflowRun } = useWorkflowRunQuery();
@@ -563,13 +565,10 @@ function NodeHeader({
});
if (studioEnabled) {
- // One navigation carries the pane state (current panes plus Run and
- // Browser); other query params intentionally reset for the fresh run.
- const liveSearch = window.location.search || location.search;
- const panes = withPanesOpen(resolveOpenPanes(liveSearch), [
- "run",
- "browser",
- ]);
+ // One navigation carries the pane state (open panes never move or
+ // close; the run surfaces append); other query params intentionally
+ // reset for the fresh run.
+ const panes = withPanesOpen(resolveLivePanes(), RUN_APPEND_PANES);
const search = new URLSearchParams({
wr: response.data.run_id,
bl: label,
diff --git a/skyvern-frontend/src/routes/workflows/studio/RunTab.tsx b/skyvern-frontend/src/routes/workflows/studio/RunTab.tsx
index 3d26b8257..ad6fe8c23 100644
--- a/skyvern-frontend/src/routes/workflows/studio/RunTab.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/RunTab.tsx
@@ -1,19 +1,20 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
+import { STUDIO_PANES_PARAM } from "./panes";
import { RunView } from "./runview/RunView";
import { useStudioInspectedRun } from "./useStudioInspectedRun";
import { useStudioPanes } from "./useStudioPanes";
/**
- * Run pane of the studio shell — the run timeline + run-data view (the Browser
- * pane owns the visuals). Shows the run in the URL when present, otherwise the
- * workflow's most recent run.
+ * Timeline pane of the studio shell — the run timeline + run-data view (the
+ * Browser pane owns the visuals). Shows the run in the URL when present,
+ * otherwise the workflow's most recent run.
*/
export function RunTab() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { workflowPermanentId } = useParams();
- const { openPane } = useStudioPanes();
+ const { openPane, resolveLivePanes } = useStudioPanes();
const { runId, pending } = useStudioInspectedRun();
// ?bl= marks a block-scoped run; "Retry as-is" would rerun the whole workflow,
// so suppress that CTA for block runs (the block is rerun from the editor).
@@ -34,7 +35,12 @@ export function RunTab() {
onRetry={
isBlockRun
? undefined
- : () => navigate(`/agents/${workflowPermanentId}/run`)
+ : () =>
+ // ?panes= rides through the run form so the post-start navigate
+ // restores this exact layout (plus the run surfaces appended).
+ navigate(
+ `/agents/${workflowPermanentId}/run?${STUDIO_PANES_PARAM}=${resolveLivePanes().join(",")}`,
+ )
}
/>
);
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioPaneDefaults.test.tsx b/skyvern-frontend/src/routes/workflows/studio/StudioPaneDefaults.test.tsx
index 333e946dd..87986701b 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioPaneDefaults.test.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioPaneDefaults.test.tsx
@@ -11,15 +11,10 @@ import { StudioPaneDefaultsProvider } from "./StudioPaneDefaults";
import { useStudioPaneDefaults } from "./StudioPaneDefaultsContext";
import { useStudioPanes } from "./useStudioPanes";
-const { runSignalsMock, toastMock } = vi.hoisted(() => ({
- runSignalsMock: vi.fn(),
+const { toastMock } = vi.hoisted(() => ({
toastMock: vi.fn(),
}));
-vi.mock("./useStudioRunSignals", () => ({
- useStudioRunSignals: () => runSignalsMock(),
-}));
-
vi.mock("@/components/ui/use-toast", () => ({
toast: toastMock,
}));
@@ -29,7 +24,7 @@ function PanesProbe() {
return (
-
+
@@ -81,92 +76,63 @@ beforeEach(() => {
coachMarkSeen: false,
narrowNudgeSeen: false,
});
- runSignalsMock.mockReturnValue({
- hasRun: false,
- runStatus: undefined,
- knownHasRuns: undefined,
- });
toastMock.mockReset();
});
-describe("state-aware default panes", () => {
- test("an agent with cached runs starts on Copilot + Browser", () => {
- runSignalsMock.mockReturnValue({
- hasRun: true,
- runStatus: undefined,
- knownHasRuns: true,
- });
- renderStudio();
- expect(panesText()).toBe("copilot,browser");
- });
-
- test("a never-run agent starts on Copilot + Editor", () => {
- runSignalsMock.mockReturnValue({
- hasRun: false,
- runStatus: undefined,
- knownHasRuns: false,
- });
- renderStudio();
- expect(panesText()).toBe("copilot,editor");
- });
-
- test("an empty agent starts on Copilot + Editor even before runs load", () => {
+describe("cold-entry default panes (the four contexts)", () => {
+ test("an empty agent starts on Copilot + Browser", () => {
renderStudio({ hasBlocks: false });
- expect(panesText()).toBe("copilot,editor");
+ expect(panesText()).toBe("copilot,browser");
});
- test("an agent with blocks keeps the legacy default while runs are unknown", () => {
+ test("a built agent starts on Copilot + Browser + Editor", () => {
renderStudio({ hasBlocks: true });
- expect(panesText()).toBe("copilot,browser");
+ expect(panesText()).toBe("copilot,browser,editor");
});
- test("a runs signal that arrives after mount does not reshuffle the panes", () => {
- const { rerender, tree } = renderStudio({ hasBlocks: true });
+ test("a run in the URL lands on Copilot + Browser + Timeline", () => {
+ renderStudio({ path: "/workflows/wpid_1/studio?wr=wr_1" });
+ expect(panesText()).toBe("copilot,browser,timeline");
+ });
+
+ test("a block-run deep link lands on Editor + Browser + Timeline", () => {
+ renderStudio({ path: "/workflows/wpid_1/studio?wr=wr_1&bl=block_1" });
+ expect(panesText()).toBe("editor,browser,timeline");
+ });
+
+ test("a blocks signal that changes after mount does not reshuffle the panes", () => {
+ const { rerender } = renderStudio({ hasBlocks: false });
expect(panesText()).toBe("copilot,browser");
- runSignalsMock.mockReturnValue({
- hasRun: false,
- runStatus: undefined,
- knownHasRuns: false,
- });
- rerender(tree);
+ rerender(
+
+
+
+
+ ,
+ );
expect(panesText()).toBe("copilot,browser");
});
test("an explicit ?panes= is never overridden by the state default", () => {
- runSignalsMock.mockReturnValue({
- hasRun: false,
- runStatus: undefined,
- knownHasRuns: false,
- });
renderStudio({ path: "/workflows/wpid_1/studio?panes=browser" });
expect(panesText()).toBe("browser");
});
- test("deep links keep their mapping regardless of the state default", () => {
- runSignalsMock.mockReturnValue({
- hasRun: true,
- runStatus: undefined,
- knownHasRuns: false,
- });
- renderStudio({ path: "/workflows/wpid_1/studio?wr=wr_1" });
- expect(panesText()).toBe("run");
+ test("the pre-rename ?panes=run alias presents the Timeline pane", () => {
+ renderStudio({ path: "/workflows/wpid_1/studio?panes=copilot,run" });
+ expect(panesText()).toBe("copilot,timeline");
});
test("toggling from the state default writes the default plus the change", () => {
- runSignalsMock.mockReturnValue({
- hasRun: true,
- runStatus: undefined,
- knownHasRuns: false,
- });
- renderStudio();
- fireEvent.click(screen.getByText("open-browser"));
- expect(panesText()).toBe("copilot,editor,browser");
+ renderStudio({ hasBlocks: false });
+ fireEvent.click(screen.getByText("open-editor"));
+ expect(panesText()).toBe("copilot,browser,editor");
});
});
describe("narrow-viewport clamp of shared links", () => {
const FOUR_PANES =
- "/workflows/wpid_1/studio?panes=copilot,editor,browser,run";
+ "/workflows/wpid_1/studio?panes=copilot,editor,browser,timeline";
test("an over-wide shared link degrades to its fitting prefix", () => {
renderStudio({ path: FOUR_PANES, stageWidth: 600 });
@@ -175,18 +141,18 @@ describe("narrow-viewport clamp of shared links", () => {
test("a wide viewport presents the shared link untouched", () => {
renderStudio({ path: FOUR_PANES, stageWidth: 2000 });
- expect(panesText()).toBe("copilot,editor,browser,run");
+ expect(panesText()).toBe("copilot,editor,browser,timeline");
});
test("without a measurable stage the list is presented as-is", () => {
renderStudio({ path: FOUR_PANES });
- expect(panesText()).toBe("copilot,editor,browser,run");
+ expect(panesText()).toBe("copilot,editor,browser,timeline");
});
test("the first pane write clears the clamp and builds on what is shown", () => {
renderStudio({ path: FOUR_PANES, stageWidth: 600 });
- fireEvent.click(screen.getByText("toggle-run"));
- expect(panesText()).toBe("copilot,editor,run");
+ fireEvent.click(screen.getByText("toggle-timeline"));
+ expect(panesText()).toBe("copilot,editor,timeline");
});
});
@@ -196,9 +162,9 @@ describe("narrow-viewport nudge", () => {
path: "/workflows/wpid_1/studio?panes=copilot,editor",
stageWidth: 600,
});
- fireEvent.click(screen.getByText("toggle-run"));
+ fireEvent.click(screen.getByText("toggle-timeline"));
expect(toastMock).toHaveBeenCalledTimes(1);
- expect(panesText()).toBe("copilot,editor,run");
+ expect(panesText()).toBe("copilot,editor,timeline");
fireEvent.click(screen.getByText("open-browser"));
expect(toastMock).toHaveBeenCalledTimes(1);
expect(useStudioFirstRunStore.getState().narrowNudgeSeen).toBe(true);
@@ -215,10 +181,10 @@ describe("narrow-viewport nudge", () => {
test("closing a pane never nudges", () => {
renderStudio({
- path: "/workflows/wpid_1/studio?panes=copilot,run",
+ path: "/workflows/wpid_1/studio?panes=copilot,timeline",
stageWidth: 600,
});
- fireEvent.click(screen.getByText("toggle-run"));
+ fireEvent.click(screen.getByText("toggle-timeline"));
expect(panesText()).toBe("copilot");
expect(toastMock).not.toHaveBeenCalled();
});
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioPaneDefaults.tsx b/skyvern-frontend/src/routes/workflows/studio/StudioPaneDefaults.tsx
index 35df63273..f7d69d030 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioPaneDefaults.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioPaneDefaults.tsx
@@ -17,13 +17,13 @@ import {
type PaneClamp,
type PaneWrite,
} from "./StudioPaneDefaultsContext";
-import { useStudioRunSignals } from "./useStudioRunSignals";
/**
* First-visit pane policy for one studio mount. The state-aware default and
* the narrow-viewport clamp are both latched exactly once (the shell remounts
- * per workflow via its key), so panes never reshuffle after first paint: a
- * runs signal that arrives later changes nothing until the next visit.
+ * per workflow via its key), so panes never reshuffle after first paint. The
+ * blocks signal is synchronous — the shell only mounts with the workflow
+ * loaded — so the latch decides from real data, never a placeholder.
*/
export function StudioPaneDefaultsProvider({
hasBlocks,
@@ -33,12 +33,9 @@ export function StudioPaneDefaultsProvider({
children: ReactNode;
}) {
const location = useLocation();
- const { knownHasRuns } = useStudioRunSignals();
- // Latched: cached runs data decides; on a cold cache an agent with blocks
- // keeps today's watch default while an empty agent starts on the editor.
const [defaultPanes] = useState(() =>
- defaultPanesForWorkflowState({ hasRuns: knownHasRuns, hasBlocks }),
+ defaultPanesForWorkflowState({ hasBlocks }),
);
const [initialPanes] = useState(() =>
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioShell.tsx b/skyvern-frontend/src/routes/workflows/studio/StudioShell.tsx
index 0b7412c81..e33261e69 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioShell.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioShell.tsx
@@ -139,7 +139,7 @@ function StudioPane({
/**
* Spine + panes shell: one vertical spine whose tabs (Copilot, Editor, Browser,
- * Run) each toggle a pane; open panes share the stage side by side in click
+ * Timeline) each toggle a pane; open panes share the stage side by side in click
* order (?panes=). The Copilot chat is portaled into its pane from Workspace.
*/
export function StudioShell(props: StudioWorkspaceProps) {
@@ -186,15 +186,15 @@ function StudioStage(props: StudioWorkspaceProps) {
const browserOpen = panes.includes("browser");
const editorOpen = panes.includes("editor");
- const runOpen = panes.includes("run");
+ const timelineOpen = panes.includes("timeline");
// Move the persistent stream node into the highest-priority open surface:
- // Browser pane > Run pane with a live block run (runStreamSlot registers only
- // then) > Editor PiP > offscreen park, which keeps the socket warm.
+ // Browser pane > Timeline pane with a live block run (runStreamSlot registers
+ // only then) > Editor PiP > offscreen park, which keeps the socket warm.
useLayoutEffect(() => {
const activeSlot = browserOpen
? browserStreamSlot
- : runOpen && runStreamSlot
+ : timelineOpen && runStreamSlot
? runStreamSlot
: editorOpen && !pipMinimized
? editorStreamSlot
@@ -208,7 +208,7 @@ function StudioStage(props: StudioWorkspaceProps) {
}, [
browserOpen,
editorOpen,
- runOpen,
+ timelineOpen,
pipMinimized,
editorStreamSlot,
browserStreamSlot,
@@ -317,7 +317,7 @@ function StudioStage(props: StudioWorkspaceProps) {
}
headerActions={}
>
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioShellContext.ts b/skyvern-frontend/src/routes/workflows/studio/StudioShellContext.ts
index 769ee1c21..ac7a20615 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioShellContext.ts
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioShellContext.ts
@@ -8,7 +8,7 @@ type StudioShellContextValue = {
// persistent stream node into the active surface without remounting it.
setEditorStreamSlot: (el: HTMLElement | null) => void;
setBrowserStreamSlot: (el: HTMLElement | null) => void;
- // The Run tab registers this for a block run, so the debug-session stream shows
+ // The Timeline pane registers this for a block run, so the debug-session stream shows
// there too (same node, view-only); null for a full run keeps it parked.
setRunStreamSlot: (el: HTMLElement | null) => void;
};
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioSpine.test.tsx b/skyvern-frontend/src/routes/workflows/studio/StudioSpine.test.tsx
index 770b3acf2..aef36060f 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioSpine.test.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioSpine.test.tsx
@@ -57,7 +57,7 @@ beforeEach(() => {
describe("StudioSpine structure", () => {
test("renders the four peer tabs with icon + label", () => {
renderAt();
- for (const label of ["Copilot", "Editor", "Browser", "Run"]) {
+ for (const label of ["Copilot", "Editor", "Browser", "Timeline"]) {
expect(tab(new RegExp(`^${label}`))).toBeTruthy();
}
});
@@ -80,13 +80,14 @@ describe("StudioSpine structure", () => {
runsQueryMock.mockReturnValue({ data: [{ status: Status.Completed }] });
renderAt("/workflows/wpid_abc/studio?wr=run_1&panes=copilot");
expect(tab(/^Copilot/).getAttribute("aria-expanded")).toBe("true");
- expect(tab(/^Run/).getAttribute("aria-expanded")).toBe("false");
+ expect(tab(/^Timeline/).getAttribute("aria-expanded")).toBe("false");
});
- test("a block-run deep link opens Run and Browser together", () => {
+ test("a block-run deep link opens Editor, Browser and Timeline", () => {
renderAt("/workflows/wpid_abc/studio?wr=run_1&bl=block_1");
- expect(tab(/^Run/).getAttribute("aria-expanded")).toBe("true");
+ expect(tab(/^Editor/).getAttribute("aria-expanded")).toBe("true");
expect(tab(/^Browser/).getAttribute("aria-expanded")).toBe("true");
+ expect(tab(/^Timeline/).getAttribute("aria-expanded")).toBe("true");
expect(tab(/^Copilot/).getAttribute("aria-expanded")).toBe("false");
});
});
@@ -119,25 +120,25 @@ describe("StudioSpine pane toggling", () => {
const params = new URLSearchParams(search);
expect(params.get("wr")).toBe("run_1");
expect(params.get("bl")).toBe("block_1");
- expect(params.get("panes")).toBe("run,browser,copilot");
+ expect(params.get("panes")).toBe("editor,browser,timeline,copilot");
});
});
-describe("StudioSpine Run gating", () => {
- test("disables the Run tab until a run exists", () => {
+describe("StudioSpine Timeline gating", () => {
+ test("disables the Timeline tab until a run exists", () => {
renderAt();
- expect(tab(/^Run/).disabled).toBe(true);
+ expect(tab(/^Timeline/).disabled).toBe(true);
});
- test("enables the Run tab when the workflow has a prior run", () => {
+ test("enables the Timeline tab when the workflow has a prior run", () => {
runsQueryMock.mockReturnValue({ data: [{ status: Status.Running }] });
renderAt();
- expect(tab(/^Run/).disabled).toBe(false);
+ expect(tab(/^Timeline/).disabled).toBe(false);
});
- test("enables the Run tab when the URL points at a run (?wr=)", () => {
+ test("enables the Timeline tab when the URL points at a run (?wr=)", () => {
renderAt("/workflows/wpid_abc/studio?wr=run_1&panes=copilot");
- expect(tab(/^Run/).disabled).toBe(false);
+ expect(tab(/^Timeline/).disabled).toBe(false);
});
});
@@ -145,23 +146,25 @@ describe("StudioSpine run-status dot", () => {
test("shows a status-colored dot for a finalized run", () => {
runsQueryMock.mockReturnValue({ data: [{ status: Status.Completed }] });
renderAt();
- const dot = tab(/^Run/).querySelector(
+ const dot = tab(/^Timeline/).querySelector(
"span.absolute.right-1",
) as HTMLElement | null;
expect(dot).not.toBeNull();
expect(dot?.className).toContain("bg-badge-success");
});
- test("includes the finalized run status in the Run tab accessible name", () => {
+ test("includes the finalized run status in the Timeline tab accessible name", () => {
runsQueryMock.mockReturnValue({ data: [{ status: Status.TimedOut }] });
renderAt();
- expect(screen.getByRole("button", { name: "Run, timed out" })).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "Timeline, timed out" }),
+ ).toBeTruthy();
});
test("omits the dot while the run is still in flight", () => {
runsQueryMock.mockReturnValue({ data: [{ status: Status.Running }] });
renderAt();
- expect(tab(/^Run/).querySelector("span.absolute.right-1")).toBeNull();
+ expect(tab(/^Timeline/).querySelector("span.absolute.right-1")).toBeNull();
});
});
@@ -201,7 +204,7 @@ describe("StudioSpine keyboard navigation", () => {
).toEqual([0, -1, -1]);
});
- test("ArrowDown moves focus and wraps past the disabled Run tab", () => {
+ test("ArrowDown moves focus and wraps past the disabled Timeline tab", () => {
renderAt();
tab(/^Copilot/).focus();
fireEvent.keyDown(tab(/^Copilot/), { key: "ArrowDown" });
@@ -217,8 +220,8 @@ describe("StudioSpine keyboard navigation", () => {
renderAt();
tab(/^Copilot/).focus();
fireEvent.keyDown(tab(/^Copilot/), { key: "ArrowUp" });
- expect(document.activeElement).toBe(tab(/^Run/));
- fireEvent.keyDown(tab(/^Run/), { key: "Home" });
+ expect(document.activeElement).toBe(tab(/^Timeline/));
+ fireEvent.keyDown(tab(/^Timeline/), { key: "Home" });
expect(document.activeElement).toBe(tab(/^Copilot/));
});
diff --git a/skyvern-frontend/src/routes/workflows/studio/StudioSpine.tsx b/skyvern-frontend/src/routes/workflows/studio/StudioSpine.tsx
index 58fdee076..bd51fdee8 100644
--- a/skyvern-frontend/src/routes/workflows/studio/StudioSpine.tsx
+++ b/skyvern-frontend/src/routes/workflows/studio/StudioSpine.tsx
@@ -33,8 +33,8 @@ function runStatusLabel(status: Status): string {
}
/**
- * The studio's single vertical tab spine: Copilot, Editor, Browser and Run are
- * peer tabs, each toggling its pane open or closed on the stage.
+ * The studio's single vertical tab spine: Copilot, Editor, Browser and
+ * Timeline are peer tabs, each toggling its pane open or closed on the stage.
*/
export function StudioSpine() {
const { panes, togglePane } = useStudioPanes();
@@ -55,7 +55,9 @@ export function StudioSpine() {
// Roving tabindex (WAI-ARIA toolbar): the rail is one tab stop; arrow keys
// move focus across the enabled tabs, Enter/Space toggles.
const [focusedId, setFocusedId] = useState(STUDIO_PANE_IDS[0]!);
- const enabledIds = STUDIO_PANE_IDS.filter((id) => !(id === "run" && !hasRun));
+ const enabledIds = STUDIO_PANE_IDS.filter(
+ (id) => !(id === "timeline" && !hasRun),
+ );
const tabStopId = enabledIds.includes(focusedId) ? focusedId : enabledIds[0];
const onKeyDown = (event: KeyboardEvent) => {
const keys = ["ArrowDown", "ArrowUp", "Home", "End"];
@@ -89,14 +91,14 @@ export function StudioSpine() {
{STUDIO_PANE_IDS.map((id) => {
const { label, icon: Icon } = STUDIO_PANE_META[id];
const open = panes.includes(id);
- const disabled = id === "run" && !hasRun;
+ const disabled = id === "timeline" && !hasRun;
const showActivityDot =
id === "browser" && hasUnseenBrowserActivity && !open;
- const showRunStatusDot = id === "run" && Boolean(runStatus);
+ const showRunStatusDot = id === "timeline" && Boolean(runStatus);
const ariaLabel = showActivityDot
? "Browser, new activity"
- : id === "run" && runStatus
- ? `Run, ${runStatusLabel(runStatus)}`
+ : id === "timeline" && runStatus
+ ? `Timeline, ${runStatusLabel(runStatus)}`
: undefined;
return (