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 (
{panes.join(",")} - +
@@ -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 (