feat(SKY-11758): timeline pane rename, cold-entry layout mappings, append-only continuity (SKY-11763) (#7046)
Some checks failed
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Auto Create GitHub Release on Version Change / check-version-change (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / check-version-change (push) Has been cancelled
Auto Create GitHub Release on Version Change / create-release (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / run-ci (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / build-sdk (push) Has been cancelled
Build Skyvern SDK and publish to PyPI / publish-sdk (push) Has been cancelled

This commit is contained in:
Celal Zamanoğlu 2026-07-03 15:30:20 +03:00 committed by GitHub
parent 34d3196c64
commit 6bb409814a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 375 additions and 252 deletions

View file

@ -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}`,
);

View file

@ -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 (

View file

@ -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",

View file

@ -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,

View file

@ -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(",")}`,
)
}
/>
);

View file

@ -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 (
<div>
<output data-testid="panes">{panes.join(",")}</output>
<button onClick={() => togglePane("run")}>toggle-run</button>
<button onClick={() => togglePane("timeline")}>toggle-timeline</button>
<button onClick={() => openPane("editor")}>open-editor</button>
<button onClick={() => openPane("browser")}>open-browser</button>
</div>
@ -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(
<MemoryRouter initialEntries={["/workflows/wpid_1/studio"]}>
<StudioPaneDefaultsProvider hasBlocks={true}>
<PanesProbe />
</StudioPaneDefaultsProvider>
</MemoryRouter>,
);
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();
});

View file

@ -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<readonly StudioPaneId[]>(() =>
defaultPanesForWorkflowState({ hasRuns: knownHasRuns, hasBlocks }),
defaultPanesForWorkflowState({ hasBlocks }),
);
const [initialPanes] = useState<readonly StudioPaneId[]>(() =>

View file

@ -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) {
<BrowserTab />
</StudioPane>
<StudioPane
{...paneProps("run")}
{...paneProps("timeline")}
headerExtras={<RunPaneStatusBadge />}
headerActions={<RunPaneActions />}
>

View file

@ -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;
};

View file

@ -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/));
});

View file

@ -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<StudioPaneId>(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<HTMLElement>) => {
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 (
<button
@ -111,9 +113,9 @@ export function StudioSpine() {
onFocus={() => setFocusedId(id)}
title={
disabled
? "Run: no runs yet"
: runStatus && id === "run"
? `Run: ${runStatusLabel(runStatus)}`
? "Timeline: no runs yet"
: runStatus && id === "timeline"
? `Timeline: ${runStatusLabel(runStatus)}`
: `${open ? "Close" : "Open"} ${label}`
}
onClick={() => onToggle(id)}

View file

@ -50,28 +50,28 @@ beforeEach(() => {
describe("StudioStageLauncher", () => {
test("offers every pane as a labeled button", () => {
renderAt();
for (const label of ["Copilot", "Editor", "Browser", "Run"]) {
for (const label of ["Copilot", "Editor", "Browser", "Timeline"]) {
expect(
screen.getByRole("button", { name: new RegExp(`^${label}`) }),
).toBeTruthy();
}
});
test("keeps the Run launcher gated until a run exists, with the reason readable", () => {
test("keeps the Timeline launcher gated until a run exists, with the reason readable", () => {
renderAt();
const run = screen.getByRole("button", { name: /no runs yet/ });
expect((run as HTMLButtonElement).disabled).toBe(true);
const timeline = screen.getByRole("button", { name: /no runs yet/ });
expect((timeline as HTMLButtonElement).disabled).toBe(true);
});
test("enables the Run launcher once a run exists", () => {
test("enables the Timeline launcher once a run exists", () => {
runSignalsMock.mockReturnValue({
hasRun: true,
runStatus: undefined,
knownHasRuns: true,
});
renderAt();
const run = screen.getByRole("button", { name: "Run" });
expect((run as HTMLButtonElement).disabled).toBe(false);
const timeline = screen.getByRole("button", { name: "Timeline" });
expect((timeline as HTMLButtonElement).disabled).toBe(false);
});
test("opens the clicked pane", () => {

View file

@ -8,7 +8,7 @@ import { useStudioRunSignals } from "./useStudioRunSignals";
/**
* Zero-panes stage: every pane stays one labeled click away instead of the
* stage dead-ending. Mirrors the spine's Run gating.
* stage dead-ending. Mirrors the spine's Timeline gating.
*/
export function StudioStageLauncher() {
const { openPane } = useStudioPanes();
@ -31,7 +31,7 @@ export function StudioStageLauncher() {
<div className="flex flex-wrap items-center justify-center gap-2">
{STUDIO_PANE_IDS.map((id) => {
const { label, icon: Icon } = STUDIO_PANE_META[id];
const disabled = id === "run" && !hasRun;
const disabled = id === "timeline" && !hasRun;
return (
<Button
key={id}
@ -39,7 +39,7 @@ export function StudioStageLauncher() {
variant="secondary"
size="sm"
disabled={disabled}
title={disabled ? "Run: no runs yet" : `Open ${label}`}
title={disabled ? "Timeline: no runs yet" : `Open ${label}`}
onClick={() => open(id)}
className="gap-2"
>

View file

@ -23,7 +23,12 @@ import { RunStopButton } from "./StudioTopBar";
function LocationProbe() {
const location = useLocation();
return <div data-testid="location">{location.pathname}</div>;
return (
<div data-testid="location">
{location.pathname}
{location.search}
</div>
);
}
function renderAt(path: string) {
@ -75,8 +80,9 @@ describe("RunStopButton concurrency with a live block run", () => {
expect(screen.queryByTestId("location")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Start full run" }));
// ?panes= round-trips through the run form.
expect(screen.getByTestId("location").textContent).toBe(
"/agents/wpid_1/run",
"/agents/wpid_1/run?panes=editor,browser,timeline",
);
});
@ -104,7 +110,18 @@ describe("RunStopButton concurrency with a live block run", () => {
expect(screen.queryByText("Start a full run?")).toBeNull();
expect(screen.getByTestId("location").textContent).toBe(
"/agents/wpid_1/run",
"/agents/wpid_1/run?panes=copilot,browser,timeline",
);
});
test("an explicit open-panes list is carried through to the run form", () => {
mockRun(Status.Completed);
renderAt("/workflows/wpid_1/studio?wr=wr_1&panes=editor,copilot");
fireEvent.click(screen.getByRole("button", { name: /Run/ }));
expect(screen.getByTestId("location").textContent).toBe(
"/agents/wpid_1/run?panes=editor,copilot",
);
});
});

View file

@ -36,7 +36,9 @@ import { MakeACopyButton } from "../editor/MakeACopyButton";
import { useSaveWorkflow } from "../editor/hooks/useSaveWorkflow";
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
import { useWorkflowRunWithWorkflowQuery } from "../hooks/useWorkflowRunWithWorkflowQuery";
import { STUDIO_PANES_PARAM } from "./panes";
import { runOutcomeFromStatus } from "./runProjections";
import { useStudioPanes } from "./useStudioPanes";
import { useStudioRunId } from "./useStudioRunId";
function TitleSection({ editable = true }: { editable?: boolean }) {
@ -128,6 +130,7 @@ export function RunStopButton() {
);
const activeRunId = workflowRun?.workflow_run_id;
const running = runOutcomeFromStatus(workflowRun?.status) === "running";
const { resolveLivePanes } = useStudioPanes();
// ?bl= marks the URL run as a block run; a full run can start alongside it
// (they execute concurrently), so Run stays available next to Stop.
const isBlockRun = searchParams.has("bl");
@ -160,7 +163,12 @@ export function RunStopButton() {
},
});
const startFullRun = () => navigate(`/agents/${workflowPermanentId}/run`);
// ?panes= rides through the run form so the post-start navigate restores
// this exact layout (plus the run surfaces appended) instead of remapping.
const startFullRun = () =>
navigate(
`/agents/${workflowPermanentId}/run?${STUDIO_PANES_PARAM}=${resolveLivePanes().join(",")}`,
);
if (running && activeRunId) {
const stopDialog = (
@ -217,8 +225,8 @@ export function RunStopButton() {
<DialogTitle>Start a full run?</DialogTitle>
<DialogDescription>
A block run is still executing. It will keep running you can
watch it in the Browser pane while the Run pane switches to the
new full run.
watch it in the Browser pane while the Timeline pane switches to
the new full run.
</DialogDescription>
</DialogHeader>
<DialogFooter>

View file

@ -5,7 +5,7 @@ import { WorkflowSchedulePanel } from "../editor/panels/schedulePanel/WorkflowSc
/**
* Inputs / Schedule panels rendered at shell level so they open over any tab
* the editor canvas is display:none on Browser/Run tabs and would hide them.
* the editor canvas is display:none on Browser/Timeline tabs and would hide them.
*/
export function StudioWorkflowPanels() {
const state = useWorkflowPanelStore((s) => s.workflowPanelState);

View file

@ -2,7 +2,7 @@ import { type ComponentType } from "react";
import {
ChatBubbleIcon,
GlobeIcon,
PlayIcon,
ListBulletIcon,
Share1Icon,
} from "@radix-ui/react-icons";
@ -15,5 +15,5 @@ export const STUDIO_PANE_META: Record<
copilot: { label: "Copilot", icon: ChatBubbleIcon },
editor: { label: "Editor", icon: Share1Icon },
browser: { label: "Browser", icon: GlobeIcon },
run: { label: "Run", icon: PlayIcon },
timeline: { label: "Timeline", icon: ListBulletIcon },
};

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import {
DEFAULT_STUDIO_PANES,
RUN_APPEND_PANES,
STUDIO_STAGE_GAP_PX,
STUDIO_STAGE_PADDING_PX,
STUDIO_PANE_MIN_WIDTH,
@ -29,13 +30,26 @@ describe("parsePanesParam", () => {
});
test("preserves order", () => {
expect(parsePanesParam("run,copilot,editor")).toEqual([
"run",
expect(parsePanesParam("timeline,copilot,editor")).toEqual([
"timeline",
"copilot",
"editor",
]);
});
test("accepts the pre-rename 'run' alias as timeline, in place", () => {
expect(parsePanesParam("copilot,run,browser")).toEqual([
"copilot",
"timeline",
"browser",
]);
});
test("dedupes the alias against its canonical id", () => {
expect(parsePanesParam("run,timeline")).toEqual(["timeline"]);
expect(parsePanesParam("timeline,run")).toEqual(["timeline"]);
});
test("drops unknown values", () => {
expect(parsePanesParam("copilot,bogus,browser")).toEqual([
"copilot",
@ -59,29 +73,29 @@ describe("parsePanesParam", () => {
});
describe("panesFromDeepLink", () => {
test("a run deep link opens the Run pane", () => {
test("a run deep link opens watch-and-review: Copilot, Browser, Timeline", () => {
expect(
panesFromDeepLink({ runId: "wr_123", active: null, blockLabel: null }),
).toEqual(["run"]);
).toEqual(["copilot", "browser", "timeline"]);
});
test("a pinned-item deep link opens the Run pane", () => {
test("an ?active= deep link opens the same run layout", () => {
expect(
panesFromDeepLink({ runId: null, active: "act_1", blockLabel: null }),
).toEqual(["run"]);
).toEqual(["copilot", "browser", "timeline"]);
});
test("a block run opens Run and Browser together", () => {
test("a block run opens iterate: Editor, Browser, Timeline", () => {
expect(
panesFromDeepLink({
runId: "wr_123",
active: null,
blockLabel: "block_1",
}),
).toEqual(["run", "browser"]);
).toEqual(["editor", "browser", "timeline"]);
});
test("a block label without a run does not force the Run pane", () => {
test("a block label without a run does not force the run layout", () => {
expect(
panesFromDeepLink({ runId: null, active: null, blockLabel: "block_1" }),
).toEqual([...DEFAULT_STUDIO_PANES]);
@ -99,19 +113,28 @@ describe("resolveOpenPanes", () => {
expect(resolveOpenPanes("")).toEqual(["copilot", "browser"]);
});
test("?wr= resolves to the Run pane", () => {
expect(resolveOpenPanes("?wr=wr_123")).toEqual(["run"]);
});
test("?wr= plus ?bl= resolves to Run and Browser", () => {
expect(resolveOpenPanes("?wr=wr_123&bl=block_1")).toEqual([
"run",
test("?wr= resolves to Copilot, Browser and Timeline", () => {
expect(resolveOpenPanes("?wr=wr_123")).toEqual([
"copilot",
"browser",
"timeline",
]);
});
test("?active= resolves to the Run pane", () => {
expect(resolveOpenPanes("?active=act_1")).toEqual(["run"]);
test("?wr= plus ?bl= resolves to Editor, Browser and Timeline", () => {
expect(resolveOpenPanes("?wr=wr_123&bl=block_1")).toEqual([
"editor",
"browser",
"timeline",
]);
});
test("?active= resolves like a run deep link", () => {
expect(resolveOpenPanes("?active=act_1")).toEqual([
"copilot",
"browser",
"timeline",
]);
});
test("an explicit ?panes= wins over the deep-link params", () => {
@ -120,6 +143,14 @@ describe("resolveOpenPanes", () => {
]);
});
test("an explicit ?panes=run keeps working as the Timeline pane", () => {
expect(resolveOpenPanes("?panes=run")).toEqual(["timeline"]);
expect(resolveOpenPanes("?wr=wr_123&panes=copilot,run")).toEqual([
"copilot",
"timeline",
]);
});
test("an explicit empty ?panes= wins over the deep-link params", () => {
expect(resolveOpenPanes("?wr=wr_123&panes=")).toEqual([]);
});
@ -133,34 +164,19 @@ describe("resolveOpenPanes", () => {
});
describe("defaultPanesForWorkflowState", () => {
test("an agent with runs starts on Copilot + Browser", () => {
expect(
defaultPanesForWorkflowState({ hasRuns: true, hasBlocks: true }),
).toEqual(["copilot", "browser"]);
test("an empty agent starts on prompt-and-watch: Copilot + Browser", () => {
expect(defaultPanesForWorkflowState({ hasBlocks: false })).toEqual([
"copilot",
"browser",
]);
});
test("a never-run agent starts on Copilot + Editor", () => {
expect(
defaultPanesForWorkflowState({ hasRuns: false, hasBlocks: true }),
).toEqual(["copilot", "editor"]);
});
test("a known runs signal outranks the blocks heuristic", () => {
expect(
defaultPanesForWorkflowState({ hasRuns: true, hasBlocks: false }),
).toEqual(["copilot", "browser"]);
});
test("unknown runs falls back to blocks: an empty agent starts on the editor", () => {
expect(
defaultPanesForWorkflowState({ hasRuns: undefined, hasBlocks: false }),
).toEqual(["copilot", "editor"]);
});
test("unknown runs with blocks keeps the legacy default", () => {
expect(
defaultPanesForWorkflowState({ hasRuns: undefined, hasBlocks: true }),
).toEqual([...DEFAULT_STUDIO_PANES]);
test("a built agent adds the Editor: Copilot + Browser + Editor", () => {
expect(defaultPanesForWorkflowState({ hasBlocks: true })).toEqual([
"copilot",
"browser",
"editor",
]);
});
});
@ -174,11 +190,13 @@ describe("resolveOpenPanes with custom defaults", () => {
test("deep links are unaffected by custom defaults", () => {
expect(resolveOpenPanes("?wr=wr_123", ["copilot", "editor"])).toEqual([
"run",
"copilot",
"browser",
"timeline",
]);
expect(
resolveOpenPanes("?wr=wr_123&bl=block_1", ["copilot", "editor"]),
).toEqual(["run", "browser"]);
).toEqual(["editor", "browser", "timeline"]);
});
test("an explicit ?panes= is never overridden by custom defaults", () => {
@ -222,27 +240,27 @@ describe("panesFitWidth", () => {
describe("fitPanesToWidth", () => {
test("returns the list unchanged when everything fits", () => {
expect(
fitPanesToWidth(["copilot", "editor", "browser", "run"], 2000),
).toEqual(["copilot", "editor", "browser", "run"]);
fitPanesToWidth(["copilot", "editor", "browser", "timeline"], 2000),
).toEqual(["copilot", "editor", "browser", "timeline"]);
});
test("keeps the longest leading prefix that fits", () => {
// copilot(260) + editor(220) + padding(24) + gap(12) = 516 fits in 600;
// adding browser(260) + gap(12) = 788 does not.
expect(
fitPanesToWidth(["copilot", "editor", "browser", "run"], 600),
fitPanesToWidth(["copilot", "editor", "browser", "timeline"], 600),
).toEqual(["copilot", "editor"]);
});
test("degrades deterministically by order, not by pane size", () => {
expect(fitPanesToWidth(["run", "copilot", "browser"], 600)).toEqual([
"run",
expect(fitPanesToWidth(["timeline", "copilot", "browser"], 600)).toEqual([
"timeline",
"copilot",
]);
});
test("always keeps the first pane even when nothing fits", () => {
expect(fitPanesToWidth(["browser", "run"], 100)).toEqual(["browser"]);
expect(fitPanesToWidth(["browser", "timeline"], 100)).toEqual(["browser"]);
});
test("keeps an empty list empty", () => {
@ -252,33 +270,31 @@ describe("fitPanesToWidth", () => {
describe("pane list operations", () => {
test("toggling a closed pane appends it (click order)", () => {
expect(togglePane(["copilot", "browser"], "run")).toEqual([
expect(togglePane(["copilot", "browser"], "timeline")).toEqual([
"copilot",
"browser",
"run",
"timeline",
]);
});
test("toggling an open pane splices it out, preserving the others' order", () => {
expect(togglePane(["copilot", "run", "browser"], "run")).toEqual([
expect(togglePane(["copilot", "timeline", "browser"], "timeline")).toEqual([
"copilot",
"browser",
]);
});
test("withPaneOpen is a no-op re-order-wise when already open", () => {
expect(withPaneOpen(["run", "copilot"], "copilot")).toEqual([
"run",
expect(withPaneOpen(["timeline", "copilot"], "copilot")).toEqual([
"timeline",
"copilot",
]);
});
test("withPanesOpen appends only the missing panes, in the given order", () => {
expect(withPanesOpen(["copilot", "browser"], ["run", "browser"])).toEqual([
"copilot",
"browser",
"run",
]);
expect(
withPanesOpen(["copilot", "browser"], ["timeline", "browser"]),
).toEqual(["copilot", "browser", "timeline"]);
});
test("withPaneClosed removes the pane", () => {
@ -288,6 +304,36 @@ describe("pane list operations", () => {
});
});
describe("in-app run starts append the run surfaces (continuity rule)", () => {
test("block ▶ from the workflow layout appends Timeline only", () => {
expect(
withPanesOpen(["copilot", "browser", "editor"], RUN_APPEND_PANES),
).toEqual(["copilot", "browser", "editor", "timeline"]);
});
test("a run start from a Browser-less layout appends Browser then Timeline", () => {
expect(withPanesOpen(["copilot", "editor"], RUN_APPEND_PANES)).toEqual([
"copilot",
"editor",
"browser",
"timeline",
]);
});
test("a run start from the run layout changes nothing", () => {
expect(
withPanesOpen(["copilot", "browser", "timeline"], RUN_APPEND_PANES),
).toEqual(["copilot", "browser", "timeline"]);
});
test("a run start from an empty stage opens just the run surfaces", () => {
expect(withPanesOpen([], RUN_APPEND_PANES)).toEqual([
"browser",
"timeline",
]);
});
});
describe("searchWithPanes", () => {
test("adds ?panes= to an empty search, with readable commas", () => {
expect(searchWithPanes("", ["copilot", "browser"])).toBe(
@ -296,10 +342,13 @@ describe("searchWithPanes", () => {
});
test("preserves unrelated params and replaces an existing ?panes=", () => {
const next = searchWithPanes("?wr=wr_1&panes=run", ["run", "editor"]);
const next = searchWithPanes("?wr=wr_1&panes=timeline", [
"timeline",
"editor",
]);
const params = new URLSearchParams(next);
expect(params.get("wr")).toBe("wr_1");
expect(params.get("panes")).toBe("run,editor");
expect(params.get("panes")).toBe("timeline,editor");
});
test("serializes an empty list as an explicit empty value", () => {
@ -312,4 +361,12 @@ describe("searchWithPanes", () => {
const next = searchWithPanes("?wr=wr_1", ["browser", "copilot"]);
expect(resolveOpenPanes(next)).toEqual(["browser", "copilot"]);
});
test("an aliased ?panes=run re-serializes canonically after a write", () => {
const parsed = parsePanesParam("run");
expect(parsed).toEqual(["timeline"]);
expect(
searchWithPanes("?panes=run", withPaneOpen(parsed!, "copilot")),
).toBe("?panes=timeline,copilot");
});
});

View file

@ -1,26 +1,39 @@
export type StudioPaneId = "copilot" | "editor" | "browser" | "run";
export type StudioPaneId = "copilot" | "editor" | "browser" | "timeline";
export const STUDIO_PANE_IDS: readonly StudioPaneId[] = [
"copilot",
"editor",
"browser",
"run",
"timeline",
];
export const STUDIO_PANES_PARAM = "panes";
// Accepted forever on parse so pre-rename ?panes= links keep working; the
// canonical id ("timeline") is what serializes back out.
const STUDIO_PANE_ID_ALIASES: Record<string, StudioPaneId> = {
run: "timeline",
};
export const DEFAULT_STUDIO_PANES: readonly StudioPaneId[] = [
"copilot",
"browser",
];
// In-app run starts (full run or block ▶) append the run surfaces to whatever
// is already open — they never rearrange or close panes.
export const RUN_APPEND_PANES: readonly StudioPaneId[] = [
"browser",
"timeline",
];
// Width floors from the approved mock; the stage clamps shared links and nudges
// on over-tight opens against these same numbers (fitPanesToWidth below).
export const STUDIO_PANE_MIN_WIDTH: Record<StudioPaneId, number> = {
copilot: 260,
editor: 220,
browser: 260,
run: 220,
timeline: 220,
};
// Stage chrome for the fit math; must match the p-3 + gap-3 on the stage div
@ -32,16 +45,15 @@ function isStudioPaneId(value: string): value is StudioPaneId {
return (STUDIO_PANE_IDS as readonly string[]).includes(value);
}
// First-visit defaults: someone who never ran the agent (or has nothing built)
// starts on the familiar build surface; an agent with history starts on watch.
// Cold-entry defaults when no deep link decides: an empty agent starts on
// prompt-and-watch (Copilot builds, the Browser shows it work — the Editor
// auto-appends once a build lands); a built agent adds the Editor.
export function defaultPanesForWorkflowState(state: {
hasRuns: boolean | undefined;
hasBlocks: boolean;
}): StudioPaneId[] {
if (state.hasRuns !== undefined) {
return state.hasRuns ? ["copilot", "browser"] : ["copilot", "editor"];
}
return state.hasBlocks ? [...DEFAULT_STUDIO_PANES] : ["copilot", "editor"];
return state.hasBlocks
? ["copilot", "browser", "editor"]
: [...DEFAULT_STUDIO_PANES];
}
export function panesListEqual(
@ -93,7 +105,8 @@ export function parsePanesParam(raw: string | null): StudioPaneId[] | null {
}
const result: StudioPaneId[] = [];
for (const token of raw.split(",")) {
const id = token.trim();
const name = token.trim();
const id = STUDIO_PANE_ID_ALIASES[name] ?? name;
if (isStudioPaneId(id) && !result.includes(id)) {
result.push(id);
}
@ -101,8 +114,8 @@ export function parsePanesParam(raw: string | null): StudioPaneId[] | null {
return result;
}
// Deep-link → panes mapping when ?panes= is absent: a block run shows its
// timeline beside the live debug stream; any other run reference lands on Run.
// Deep-link → panes mapping when ?panes= is absent: a block-run link lands on
// iterate (Editor leads); any other run reference lands on watch-and-review.
export function panesFromDeepLink(
params: {
runId: string | null;
@ -112,10 +125,10 @@ export function panesFromDeepLink(
defaultPanes: readonly StudioPaneId[] = DEFAULT_STUDIO_PANES,
): StudioPaneId[] {
if (params.runId && params.blockLabel) {
return ["run", "browser"];
return ["editor", "browser", "timeline"];
}
if (params.runId || params.active) {
return ["run"];
return ["copilot", "browser", "timeline"];
}
return [...defaultPanes];
}

View file

@ -30,7 +30,7 @@ type RunOverviewSectionProps = {
};
/**
* Overview body view of the Run pane: step / action / credit stat blocks plus
* Overview body view of the Timeline pane: step / action / credit stat blocks plus
* the run metadata that used to live in the header's Overview popover.
*/
export function RunOverviewSection({

View file

@ -15,7 +15,7 @@ import { useStudioPaneCompact } from "../StudioShellContext";
import { useStudioInspectedRun } from "../useStudioInspectedRun";
/**
* Left header cluster of the Run pane: the inspected run's status pill.
* Left header cluster of the Timeline pane: the inspected run's status pill.
* Queries dedupe with the pane body via react-query.
*/
export function RunPaneStatusBadge() {
@ -30,7 +30,7 @@ export function RunPaneStatusBadge() {
}
/**
* Right header cluster of the Run pane: the API & Webhooks menu for the
* Right header cluster of the Timeline pane: the API & Webhooks menu for the
* inspected run, relocated from the retired run-hero header.
*/
export function RunPaneActions() {

View file

@ -76,7 +76,7 @@ function normalizeRunOutputErrors(value: unknown): RunOutputError[] {
}
/**
* Run pane body: the run timeline + step detail, with Overview / Inputs /
* Timeline pane body: the run timeline + step detail, with Overview / Inputs /
* Outputs / Code as sibling views. Visuals (live stream, screenshots,
* recordings) live in the Browser pane, which follows this pane's selection
* via RunViewStore and ?active=.
@ -112,7 +112,7 @@ export function RunView({
const resetRunView = useRunViewStore((s) => s.reset);
const setBrowserPaneView = useStudioBrowserStore((s) => s.setView);
const { panes: studioPanes, openPane } = useStudioPanes();
const runPaneOpen = studioPanes.includes("run");
const runPaneOpen = studioPanes.includes("timeline");
const [searchParams, setSearchParams] = useSearchParams();
const searchParamsRef = useRef(searchParams);
searchParamsRef.current = searchParams;
@ -164,7 +164,7 @@ export function RunView({
}, [pinnedFrameId, workflowRunId, setSearchParams]);
// Stabilize an ?active=-only deep link by ADDING ?wr= when it's absent. Gated on
// the Run pane being open: RunView stays mounted while its pane is closed.
// the Timeline pane being open: RunView stays mounted while its pane is closed.
//
// The guard reads the LIVE URL, not this render's searchParams: a block-run launch
// navigates to ?wr=&bl= via a separate router update, and this effect can fire from

View file

@ -19,7 +19,7 @@ export function useExecutingBlockRun(): boolean {
const urlRunId = searchParams.get("wr");
const workflowRunId = isBlockRun && urlRunId ? urlRunId : undefined;
// Shares the run cache RunView/StudioSpine poll (5s while not finalized), so
// the gate releases on terminal status even with the Run pane closed.
// the gate releases on terminal status even with the Timeline pane closed.
const { data: workflowRun } = useWorkflowRunWithWorkflowQuery(
workflowRunId ? { workflowRunId } : undefined,
);

View file

@ -139,7 +139,7 @@ describe("useRunVisuals loop-iteration threading", () => {
});
});
test("resolves the Run pane's selected iteration from the shared store", () => {
test("resolves the Timeline pane's selected iteration from the shared store", () => {
seedLoopRun();
useRunViewStore.getState().pinFrame("wrb_loop", 1);
const { result } = renderHook(() => useRunVisuals("wr_1"), { wrapper });

View file

@ -68,7 +68,7 @@ export function useRunVisuals(workflowRunId: string | undefined): RunVisuals {
const { data: timeline } = useWorkflowRunTimelineQuery(queryOptions);
const [searchParams] = useSearchParams();
const activeParam = searchParams.get("active");
// The Run pane's loop-iteration selection isn't in the URL; read it from the
// The Timeline pane's loop-iteration selection isn't in the URL; read it from the
// shared store so a selected iteration's screenshot resolves here too.
const activeIteration = useRunViewStore((s) => s.activeIteration);

View file

@ -44,13 +44,22 @@ export function useStudioPanes() {
[location.search, defaultPanes, present],
);
// The open list as the live URL resolves it right now — what cross-route
// writers (block ▶, the Run form round-trip) must build on, per the
// continuity rule: in-app actions append, never rearrange or close.
const resolveLivePanes = useCallback(
(): StudioPaneId[] =>
present(resolveOpenPanes(liveSearch(location.search), defaultPanes)),
[location.search, defaultPanes, present],
);
const applyPanes = useCallback(
(
compute: (current: StudioPaneId[]) => StudioPaneId[],
options?: Pick<NavigateOptions, "state">,
) => {
const search = liveSearch(location.search);
const current = present(resolveOpenPanes(search, defaultPanes));
const current = resolveLivePanes();
const next = compute(current);
notePaneWrite({ previous: current, next });
navigate(
@ -58,7 +67,7 @@ export function useStudioPanes() {
{ replace: true, ...options },
);
},
[navigate, location.search, defaultPanes, present, notePaneWrite],
[navigate, location.search, resolveLivePanes, notePaneWrite],
);
const togglePane = useCallback(
@ -77,5 +86,5 @@ export function useStudioPanes() {
[applyPanes],
);
return { panes, togglePane, openPane, closePane };
return { panes, resolveLivePanes, togglePane, openPane, closePane };
}