diff --git a/src/components/shared/LocalPipelineMigration.test.tsx b/src/components/shared/LocalPipelineMigration.test.tsx index 8e8f041e63..12c5b6fba8 100644 --- a/src/components/shared/LocalPipelineMigration.test.tsx +++ b/src/components/shared/LocalPipelineMigration.test.tsx @@ -168,6 +168,64 @@ it.each([false, true])( }, ); +it("finishes an already-consented move for a hidden tab that opted in", async () => { + render( + + +

Local editor

+
+
, + ); + await waitFor(() => expect(onMigrated).toHaveBeenCalledWith(remote)); + expect(screen.queryByRole("alertdialog")).toBeNull(); +}); + +it("never prompts, checks write access or uploads for a hidden opted-in tab without consent", async () => { + localStorage.removeItem(consentKey); + const view = (active: boolean) => ( + + +

Local editor

+
+
+ ); + const { rerender } = render(view(false)); + await waitFor(() => expect(storage.resolvePipeline).toHaveBeenCalled()); + await act(async () => {}); + expect(screen.queryByRole("alertdialog")).toBeNull(); + expect(getRemotePipelineWriter).not.toHaveBeenCalled(); + expect(storage.moveLocalPipelineToRemote).not.toHaveBeenCalled(); + + rerender(view(true)); + expect(await screen.findByRole("alertdialog")).toBeInTheDocument(); +}); + +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")); + }); + + 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"), diff --git a/src/components/shared/LocalPipelineMigration.tsx b/src/components/shared/LocalPipelineMigration.tsx index 27089f92ff..c95e21b1e0 100644 --- a/src/components/shared/LocalPipelineMigration.tsx +++ b/src/components/shared/LocalPipelineMigration.tsx @@ -43,6 +43,7 @@ interface LocalPipelineMigrationProps { pipelineRef: PipelineRef; projectId?: string; isActive?: boolean; + allowBackgroundMigration?: boolean; onMigrated: (file: PipelineFile, clearProjectContext?: boolean) => void; children: ReactNode; } @@ -119,6 +120,7 @@ function MigrateLocalPipeline({ pipelineRef, projectId, isActive = true, + allowBackgroundMigration = false, onMigrated, children, file, @@ -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; @@ -210,6 +220,8 @@ function MigrateLocalPipeline({ hasConsent, isFetching, isActive, + keepLocal, + allowBackgroundMigration, migration, shouldMigrate, writer.isSuccess, diff --git a/src/components/ui/resize-handle.tsx b/src/components/ui/resize-handle.tsx index d57ddf8bd2..6e5d6b535d 100644 --- a/src/components/ui/resize-handle.tsx +++ b/src/components/ui/resize-handle.tsx @@ -7,6 +7,8 @@ interface ResizeHandleProps { minWidth?: number; maxWidth?: number; side?: "left" | "right"; + // Names the handle as a separator for assistive tech and tests. + label?: string; onDoubleClick?: () => void; /** Called while dragging with the width requested before min/max constraints. */ onResize?: (attemptedWidth: number) => void; @@ -18,6 +20,7 @@ export const VerticalResizeHandle = ({ minWidth = 200, maxWidth = 600, side = "left", + label, onDoubleClick, onResize, onResizeEnd, @@ -92,6 +95,9 @@ export const VerticalResizeHandle = ({ "w-2 -mx-0.5", )} ref={captureParentElement} + role={label ? "separator" : undefined} + aria-orientation={label ? "vertical" : undefined} + aria-label={label} onMouseDown={handleMouseDown} onDoubleClick={onDoubleClick} > diff --git a/src/routes/v2/pages/Editor/EmbeddedPipelineEditor.tsx b/src/routes/v2/pages/Editor/EmbeddedPipelineEditor.tsx index 5456ecb1be..8d5ea9bb87 100644 --- a/src/routes/v2/pages/Editor/EmbeddedPipelineEditor.tsx +++ b/src/routes/v2/pages/Editor/EmbeddedPipelineEditor.tsx @@ -63,6 +63,7 @@ import { TangentEditorAgentProvider } from "./TangentEditorAgentProvider"; interface EmbeddedPipelineEditorProps { pipelineRef: EditorPipelineRef; isActive: boolean; + allowBackgroundMigration?: boolean; projectId: string; onMigrated?: (file: PipelineFile) => void; onTitleChange?: (title: string) => void; @@ -281,6 +282,7 @@ export function EmbeddedPipelineEditor(props: EmbeddedPipelineEditorProps) { pipelineRef={props.pipelineRef} projectId={props.projectId} isActive={props.isActive} + allowBackgroundMigration={props.allowBackgroundMigration} onMigrated={(file) => props.onMigrated?.(file)} > diff --git a/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.test.tsx b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.test.tsx new file mode 100644 index 0000000000..264b2e1710 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.test.tsx @@ -0,0 +1,145 @@ +import { act, fireEvent, render, 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 { KeyboardStore } from "@/routes/v2/shared/store/keyboardStore"; + +import { FlowCanvas } from "./FlowCanvas"; + +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/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; +function FlowProbe() { + const flow = useReactFlow(); + useEffect(() => { + deleteProgrammatically = () => + flow.deleteElements({ nodes: [{ id: "task_1" }] }); + }, [flow]); + return null; +} +function Canvas() { + return ( + + + + + ); +} + +async function press(key: string) { + await act(async () => { + fireEvent.keyDown(document.body, { key, code: key }); + }); + await act(async () => { + fireEvent.keyUp(document.body, { key, code: key }); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + 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 editor is not listening and deletes once it is", + async (key) => { + render(); + + await press(key); + expect(spec.tasks).toHaveLength(1); + expect(spec.bindings).toHaveLength(1); + + act(() => keyboard.setListening(true)); + await press(key); + await waitFor(() => expect(spec.tasks).toHaveLength(0)); + expect(spec.bindings).toHaveLength(0); + }, + ); + + it("keeps deliberate programmatic/agent deletion available while not listening", async () => { + render(); + await act(async () => { + await deleteProgrammatically(); + }); + expect(spec.tasks).toHaveLength(0); + expect(spec.bindings).toHaveLength(0); + }); +}); diff --git a/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx index bcf3c5bbb8..fdc25716e1 100644 --- a/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx +++ b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.tsx @@ -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", diff --git a/src/routes/v2/pages/Tangent/components/AssetsWindowContent.tsx b/src/routes/v2/pages/Tangent/components/AssetsWindowContent.tsx index 4eaac8eaba..6b1e5c6d20 100644 --- a/src/routes/v2/pages/Tangent/components/AssetsWindowContent.tsx +++ b/src/routes/v2/pages/Tangent/components/AssetsWindowContent.tsx @@ -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); } } diff --git a/src/routes/v2/pages/Tangent/components/CollapsedWorkareaRail.tsx b/src/routes/v2/pages/Tangent/components/CollapsedWorkareaRail.tsx new file mode 100644 index 0000000000..2f6b216c91 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/CollapsedWorkareaRail.tsx @@ -0,0 +1,107 @@ +import { observer } from "mobx-react-lite"; +import { type Ref, useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Icon, type IconName } from "@/components/ui/icon"; +import { BlockStack } from "@/components/ui/layout"; +import { VerticalResizeHandle } from "@/components/ui/resize-handle"; +import { useTangentProject } from "@/routes/v2/pages/Tangent/context/TangentProjectContext"; +import { + MIN_WORKAREA_WIDTH, + WORKAREA_ID, +} from "@/routes/v2/pages/Tangent/layout"; +import { workareaTabIcon } from "@/routes/v2/pages/Tangent/workarea/workareaTabIcon"; +import { + COLLAPSED_DOCK_AREA_WIDTH, + DOCK_AREA_EXPAND_SNAP_THRESHOLD, +} from "@/routes/v2/shared/windows/types"; + +interface RailButtonProps { + label: string; + icon: IconName; + onClick: () => void; + ref?: Ref; +} + +function RailButton({ label, icon, onClick, ref }: RailButtonProps) { + return ( + + ); +} + +export const CollapsedWorkareaRail = observer(function CollapsedWorkareaRail() { + const store = useTangentProject(); + const [expandPreview, setExpandPreview] = useState(false); + const showButton = useRef(null); + + // Hiding the workarea strands any focus inside it; land it on the rail. + useEffect(() => { + const focused = document.activeElement; + const workarea = document.getElementById(WORKAREA_ID); + if (!focused || focused === document.body || workarea?.contains(focused)) + showButton.current?.focus(); + }, []); + + const shouldExpand = (attemptedWidth: number) => + attemptedWidth >= DOCK_AREA_EXPAND_SNAP_THRESHOLD; + + return ( +
+ {expandPreview && ( + + ); +}); diff --git a/src/routes/v2/pages/Tangent/components/DynamicWorkarea.test.tsx b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.test.tsx new file mode 100644 index 0000000000..7376416d02 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.test.tsx @@ -0,0 +1,123 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { observer } from "mobx-react-lite"; +import { useEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TangentProjectStore } from "@/routes/v2/pages/Tangent/store/TangentProjectStore"; +import type { + WorkareaHostProps, + WorkareaTab, +} from "@/routes/v2/pages/Tangent/workarea/types"; +import { idIdentity } from "@/services/projects/resourceTarget"; + +import { CollapsedWorkareaRail } from "./CollapsedWorkareaRail"; +import { DynamicWorkarea } from "./DynamicWorkarea"; + +let store: TangentProjectStore; +const unmounted = vi.fn(); +const listening = new Map(); + +vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({ + useTangentProject: () => store, +})); +vi.mock("@/routes/v2/pages/Tangent/workarea/registry", () => ({ + registerWorkareaKind: vi.fn(), + getWorkareaKind: () => ({ + icon: "FileText", + keepMounted: true, + render: (tab: WorkareaTab, host: WorkareaHostProps) => ( + + ), + }), +})); + +// Stands in for an editor's shortcut listener, which follows `isActive`. +function TestView({ + tab, + host, +}: { + tab: WorkareaTab; + host: WorkareaHostProps; +}) { + listening.set(tab.title, host.isActive); + useEffect(() => () => unmounted(tab.id), [tab.id]); + return ( + + ); +} + +function open(title: string) { + return store.openResolvedView({ + title, + target: { type: "pipeline", identity: idIdentity(title) }, + }); +} + +const Workarea = observer(function Workarea() { + return ( + <> + + {store.workareaCollapsed && } + + ); +}); + +beforeEach(() => { + vi.clearAllMocks(); + listening.clear(); + store = new TangentProjectStore("project-1"); + store.setDefaultSessionId("session-1"); +}); + +describe("collapsing the workarea to its rail", () => { + it("keeps the open view's work while collapsed and reopens on its tab", () => { + open("First"); + open("Second"); + render(); + const first = screen.getByTestId("view-First"); + fireEvent.change(first, { target: { value: "edited canvas" } }); + first.focus(); + + act(() => store.hideWorkarea()); + expect(first).not.toBeVisible(); + expect(listening.get("Second")).toBe(false); + expect(screen.getByRole("button", { name: "Show workarea" })).toHaveFocus(); + expect(unmounted).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Show First" })); + expect(first).toBeVisible(); + expect(first).toHaveValue("edited canvas"); + expect(listening.get("First")).toBe(true); + }); + + it("reopens when the rail's edge is double-clicked", () => { + open("Pipeline"); + store.hideWorkarea(); + render(); + + fireEvent.doubleClick( + screen.getByRole("separator", { name: "Expand workarea" }), + ); + + expect(screen.getByTestId("view-Pipeline")).toBeVisible(); + }); + + it("opens an agent's new tab while collapsed without reopening or taking focus", () => { + store.hideWorkarea(); + render( + <> + + + , + ); + const composer = screen.getByLabelText("Chat composer"); + composer.focus(); + + act(() => { + open("Agent pipeline"); + }); + + expect(screen.getByTestId("view-Agent pipeline")).not.toBeVisible(); + expect(composer).toHaveFocus(); + }); +}); diff --git a/src/routes/v2/pages/Tangent/components/DynamicWorkarea.tsx b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.tsx index 0be5998d33..220669770d 100644 --- a/src/routes/v2/pages/Tangent/components/DynamicWorkarea.tsx +++ b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.tsx @@ -12,9 +12,17 @@ import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; import { CloseableTabTrigger } from "@/routes/v2/pages/Tangent/components/CloseableTabTrigger"; import { useTangentProject } from "@/routes/v2/pages/Tangent/context/TangentProjectContext"; -import { MIN_WORKAREA_WIDTH } from "@/routes/v2/pages/Tangent/layout"; +import { + MIN_WORKAREA_WIDTH, + WORKAREA_ID, +} from "@/routes/v2/pages/Tangent/layout"; import { getWorkareaKind } from "@/routes/v2/pages/Tangent/workarea/registry"; import type { WorkareaHostProps } from "@/routes/v2/pages/Tangent/workarea/types"; +import { workareaTabIcon } from "@/routes/v2/pages/Tangent/workarea/workareaTabIcon"; + +interface DynamicWorkareaProps { + collapsePreview?: boolean; +} /** * The right-hand "Dynamic Workarea": a tabbed surface people and Tangent agents @@ -25,14 +33,18 @@ import type { WorkareaHostProps } from "@/routes/v2/pages/Tangent/workarea/types * A `keepMounted` view stays mounted while inactive (hidden via CSS) so a live * view survives background switches; other views mount only when active. */ -export const DynamicWorkarea = observer(function DynamicWorkarea() { +export const DynamicWorkarea = observer(function DynamicWorkarea({ + collapsePreview = false, +}: DynamicWorkareaProps) { const store = useTangentProject(); const activeSessionId = store.activeSessionId; const workareaTabs = store.workareaTabs; const activeWorkareaTabId = store.activeWorkareaTabId; function hostPropsFor(tabId: string): WorkareaHostProps { + const isSelected = tabId === activeWorkareaTabId; return { - isActive: tabId === activeWorkareaTabId, + isSelected, + isActive: !store.workareaCollapsed && isSelected, projectId: store.projectId, sessionId: activeSessionId, registerTabStore: (id, tabStore) => store.registerTabStore(id, tabStore), @@ -51,9 +63,19 @@ export const DynamicWorkarea = observer(function DynamicWorkarea() { return (