Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/components/shared/LocalPipelineMigration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,39 @@ it.each([false, true])(
},
);

it("finishes an already-consented move for a hidden tab that opted in", async () => {
render(
<QueryClientProvider client={client}>
<LocalPipelineMigration
pipelineRef={pipelineRef}
isActive={false}
allowBackgroundMigration
onMigrated={onMigrated}
>
<p>Local editor</p>
</LocalPipelineMigration>
</QueryClientProvider>,
);
await waitFor(() => expect(onMigrated).toHaveBeenCalledWith(remote));
expect(screen.queryByRole("alertdialog")).toBeNull();
});

it("keeps a cancelled pipeline local when consent is granted elsewhere", async () => {
localStorage.removeItem(consentKey);
open();
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await screen.findByText("Local editor");

act(() => {
localStorage.setItem(consentKey, "true");
window.dispatchEvent(new StorageEvent("storage", { key: consentKey }));
});

await act(async () => {});
expect(storage.moveLocalPipelineToRemote).not.toHaveBeenCalled();
expect(screen.getByText("Local editor")).toBeInTheDocument();
});

it("opens the local editor without offering a move when writing is unavailable", async () => {
vi.mocked(getRemotePipelineWriter).mockRejectedValue(
new Error("Write permission denied"),
Expand Down
18 changes: 15 additions & 3 deletions src/components/shared/LocalPipelineMigration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface LocalPipelineMigrationProps {
pipelineRef: PipelineRef;
projectId?: string;
isActive?: boolean;
allowBackgroundMigration?: boolean;
onMigrated: (file: PipelineFile, clearProjectContext?: boolean) => void;
children: ReactNode;
}
Expand Down Expand Up @@ -119,6 +120,7 @@ function MigrateLocalPipeline({
pipelineRef,
projectId,
isActive = true,
allowBackgroundMigration = false,
onMigrated,
children,
file,
Expand Down Expand Up @@ -191,16 +193,24 @@ function MigrateLocalPipeline({
const writer = useQuery({
queryKey: RemotePipelineQueryKeys.Writer(backendUrl),
queryFn: () => getRemotePipelineWriter(backendUrl),
enabled: ready && isActive && shouldMigrate && !isFetching,
enabled:
ready &&
!keepLocal &&
(isActive || (allowBackgroundMigration && hasConsent)) &&
shouldMigrate &&
!isFetching,
retry: false,
});
useEffect(() => {
if (
!isActive ||
(!isActive && !allowBackgroundMigration) ||
!file ||
isFetching ||
started.current ||
(!file.remote && (!shouldMigrate || !hasConsent || !writer.isSuccess))
// Declining the dialog keeps this pipeline local until the user opens it
// again; later consent from another tab must not override that choice.
(!file.remote &&
(keepLocal || !shouldMigrate || !hasConsent || !writer.isSuccess))
)
return;
started.current = true;
Expand All @@ -210,6 +220,8 @@ function MigrateLocalPipeline({
hasConsent,
isFetching,
isActive,
keepLocal,
allowBackgroundMigration,
migration,
shouldMigrate,
writer.isSuccess,
Expand Down
5 changes: 5 additions & 0 deletions src/routes/v2/pages/Editor/EmbeddedPipelineEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ import { TangentEditorAgentProvider } from "./TangentEditorAgentProvider";
interface EmbeddedPipelineEditorProps {
pipelineRef: EditorPipelineRef;
isActive: boolean;
// Lets an already-consented migration finish while this editor is mounted but
// inactive (its pane is collapsed). Hosts pass it only for the selected tab;
// it never bypasses consent nor shows a dialog the user cannot see.
allowBackgroundMigration?: boolean;
projectId: string;
onMigrated?: (file: PipelineFile) => void;
onTitleChange?: (title: string) => void;
Expand Down Expand Up @@ -281,6 +285,7 @@ export function EmbeddedPipelineEditor(props: EmbeddedPipelineEditorProps) {
pipelineRef={props.pipelineRef}
projectId={props.projectId}
isActive={props.isActive}
allowBackgroundMigration={props.allowBackgroundMigration ?? false}
onMigrated={(file) => props.onMigrated?.(file)}
>
<EditableEmbeddedPipelineEditor {...props} />
Expand Down
195 changes: 195 additions & 0 deletions src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { ReactFlowProvider, useReactFlow } from "@xyflow/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { Binding, ComponentSpec, Task } from "@/models/componentSpec";
import { DynamicWorkarea } from "@/routes/v2/pages/Tangent/components/DynamicWorkarea";
import { WorkareaToggle } from "@/routes/v2/pages/Tangent/components/ProjectHeader";
import { TangentProjectStore } from "@/routes/v2/pages/Tangent/store/TangentProjectStore";
import type { WorkareaHostProps } from "@/routes/v2/pages/Tangent/workarea/types";
import { useShortcutListener } from "@/routes/v2/shared/shortcuts/useShortcutListener";
import { KeyboardStore } from "@/routes/v2/shared/store/keyboardStore";

import { FlowCanvas } from "./FlowCanvas";

let project: TangentProjectStore;
let keyboard: KeyboardStore;
let spec: ComponentSpec;
const undoGroup = vi.fn((_label: string, apply: () => void) => apply());
const clearSelection = vi.fn();
const clearMultiSelection = vi.fn();

vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({
useTangentProject: () => project,
}));
vi.mock("@/routes/v2/pages/Tangent/workarea/registry", () => ({
registerWorkareaKind: vi.fn(),
getWorkareaKind: () => ({
keepMounted: true,
icon: "GitBranch",
render: (_tab: unknown, host: WorkareaHostProps) => (
<WorkareaCanvas active={host.isActive} />
),
}),
}));

vi.mock("@/routes/v2/shared/store/SharedStoreContext", () => ({
useSharedStores: () => ({
keyboard,
editor: { clearSelection, clearMultiSelection },
navigation: { parentContext: undefined },
}),
}));
vi.mock("@/routes/v2/pages/Editor/store/EditorSessionContext", () => ({
useEditorSession: () => ({ undo: { withGroup: undoGroup } }),
}));
vi.mock("@/routes/v2/shared/nodes/NodeRegistryContext", () => ({
useNodeRegistry: () => ({
getNodeTypes: () => ({}),
getEdgeTypes: () => ({}),
getByNodeId: () => ({
entityType: "task",
getPosition: () => ({ x: 0, y: 0 }),
}),
}),
}));
vi.mock("@/routes/v2/shared/hooks/useFlowCanvasState", () => ({
useFlowCanvasState: () => ({
displayNodes: [
{
id: "task_1",
position: { x: 0, y: 0 },
data: { label: "Selected task" },
selected: true,
},
],
displayEdges: [
{
id: "edge_binding_1",
source: "task_1",
target: "task_1",
selected: true,
},
],
selectionBehavior: {},
}),
}));
vi.mock("@/routes/v2/shared/components/SubgraphBreadcrumbs", () => ({
SubgraphBreadcrumbs: () => null,
}));
vi.mock("./components/CanvasUndoRedo", () => ({ CanvasUndoRedo: () => null }));
vi.mock("./components/FloatingSelectionToolbar", () => ({
FloatingSelectionToolbar: () => null,
}));
vi.mock("./hooks/useDropBehavior", () => ({ useDropBehavior: () => ({}) }));

let deleteProgrammatically: () => Promise<unknown>;
function FlowProbe() {
const flow = useReactFlow();
useEffect(() => {
deleteProgrammatically = () =>
flow.deleteElements({ nodes: [{ id: "task_1" }] });
}, [flow]);
return null;
}
function WorkareaCanvas({ active }: { active: boolean }) {
useShortcutListener(active);
return (
<ReactFlowProvider>
<FlowCanvas spec={spec} />
<FlowProbe />
</ReactFlowProvider>
);
}
function Fixture() {
return (
<>
<WorkareaToggle />
<DynamicWorkarea />
</>
);
}

beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
project = new TangentProjectStore("project-1");
project.setDefaultSessionId("session-1");
project.openResolvedView({
title: "Pipeline",
target: { type: "pipeline", identity: "id/p1" },
});
keyboard = new KeyboardStore();
spec = new ComponentSpec({ name: "Pipeline" });
spec.addTask(
new Task({ $id: "task_1", name: "Selected task", componentRef: {} }),
);
spec.addBinding(
new Binding({
$id: "binding_1",
sourceEntityId: "task_1",
sourcePortName: "output",
targetEntityId: "task_1",
targetPortName: "input",
}),
);
});

describe("real ReactFlow keyboard activity", () => {
it.each(["Delete", "Backspace"])(
"ignores %s while the workarea is hidden and deletes again once shown",
async (key) => {
render(<Fixture />);
expect(spec.tasks).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Hide workarea" }));
const show = screen.getByRole("button", { name: "Show workarea" });
show.focus();

await act(async () => {
fireEvent.keyDown(show, { key, code: key });
});
await act(async () => {
fireEvent.keyUp(show, { key, code: key });
});
expect(show).toHaveFocus();
expect(spec.tasks).toHaveLength(1);
expect(spec.bindings).toHaveLength(1);

fireEvent.click(show);
const hide = screen.getByRole("button", { name: "Hide workarea" });
hide.focus();
await act(async () => {
fireEvent.keyDown(hide, { key, code: key });
});
await waitFor(() => expect(spec.tasks).toHaveLength(0));
expect(spec.bindings).toHaveLength(0);
await act(async () => {
fireEvent.keyUp(hide, { key, code: key });
});
},
);

it("keeps deliberate programmatic/agent deletion available while hidden", async () => {
project.hideWorkarea();
render(<Fixture />);
await act(async () => {
await deleteProgrammatically();
});
expect(spec.tasks).toHaveLength(0);
expect(spec.bindings).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,11 @@ export const FlowCanvas = observer(function FlowCanvas({
onViewportChange={handleViewportChange}
onBeforeDelete={onBeforeDelete}
connectionLineComponent={ConnectionLine}
deleteKeyCode={["Delete", "Backspace"]}
deleteKeyCode={keyboard.listening ? ["Delete", "Backspace"] : null}
selectionKeyCode={keyboard.listening ? undefined : null}
multiSelectionKeyCode={keyboard.listening ? undefined : null}
panActivationKeyCode={keyboard.listening ? undefined : null}
zoomActivationKeyCode={keyboard.listening ? undefined : null}
className={cn(
shiftKeyPressed && !isConnecting && "cursor-crosshair",
!isDetailedView && "connections-disabled",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const AssetsWindowContent = observer(function AssetsWindowContent() {
function handleOpenAsset(asset: EmbedAsset) {
store.selectAsset(asset);
if (asset.kind !== "trigger") {
store.showWorkarea();
store.openArtifactTab(asset.url, asset.title);
}
}
Expand Down
Loading
Loading