mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
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
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:
parent
34d3196c64
commit
6bb409814a
26 changed files with 375 additions and 252 deletions
|
|
@ -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}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(",")}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[]>(() =>
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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/));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue