diff --git a/src/components/shared/LocalPipelineMigration.test.tsx b/src/components/shared/LocalPipelineMigration.test.tsx index 8e8f041e63..a40f9fc528 100644 --- a/src/components/shared/LocalPipelineMigration.test.tsx +++ b/src/components/shared/LocalPipelineMigration.test.tsx @@ -168,6 +168,39 @@ 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("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"), 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..1e38a27f95 100644 --- a/src/routes/v2/pages/Editor/EmbeddedPipelineEditor.tsx +++ b/src/routes/v2/pages/Editor/EmbeddedPipelineEditor.tsx @@ -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; @@ -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)} > 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..b011d56bc3 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.test.tsx @@ -0,0 +1,196 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { ReactFlowProvider, useReactFlow } from "@xyflow/react"; +import { observer } from "mobx-react-lite"; +import { useEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Binding, ComponentSpec, Task } from "@/models/componentSpec"; +import { CollapsedWorkareaRail } from "@/routes/v2/pages/Tangent/components/CollapsedWorkareaRail"; +import { DynamicWorkarea } from "@/routes/v2/pages/Tangent/components/DynamicWorkarea"; +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) => ( + + ), + }), +})); + +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 WorkareaCanvas({ active }: { active: boolean }) { + useShortcutListener(active); + return ( + + + + + ); +} +const Fixture = observer(function Fixture() { + return ( + <> + + {project.workareaCollapsed && } + + ); +}); + +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(); + expect(spec.tasks).toHaveLength(1); + act(() => project.hideWorkarea()); + 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 body = document.body; + body.focus(); + await act(async () => { + fireEvent.keyDown(body, { key, code: key }); + }); + await waitFor(() => expect(spec.tasks).toHaveLength(0)); + expect(spec.bindings).toHaveLength(0); + await act(async () => { + fireEvent.keyUp(body, { key, code: key }); + }); + }, + ); + + it("keeps deliberate programmatic/agent deletion available while hidden", async () => { + project.hideWorkarea(); + 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..6d06e689f0 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/CollapsedWorkareaRail.tsx @@ -0,0 +1,102 @@ +import { observer } from "mobx-react-lite"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Icon } 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 { getWorkareaKind } from "@/routes/v2/pages/Tangent/workarea/registry"; +import { + COLLAPSED_DOCK_AREA_WIDTH, + DOCK_AREA_EXPAND_SNAP_THRESHOLD, +} from "@/routes/v2/shared/windows/types"; + +/** + * The collapsed workarea, shaped like the left dock's collapsed rail: a narrow + * strip of the open tabs. Double-clicking or dragging its edge out reopens the + * workarea, and each tab icon reopens it on that tab. + */ +export const CollapsedWorkareaRail = observer(function CollapsedWorkareaRail() { + const store = useTangentProject(); + const [expandPreview, setExpandPreview] = useState(false); + const firstButton = 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)) + firstButton.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..1a9f70ffd7 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.test.tsx @@ -0,0 +1,106 @@ +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 { 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(); + +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) => , + }), +})); + +function TestView({ tab }: { tab: WorkareaTab }) { + 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(); + 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(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"); + }); + + 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..c9873180fe 100644 --- a/src/routes/v2/pages/Tangent/components/DynamicWorkarea.tsx +++ b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.tsx @@ -12,7 +12,10 @@ 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"; @@ -25,14 +28,20 @@ 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, +}: { + collapsePreview?: boolean; +}) { 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 +60,22 @@ export const DynamicWorkarea = observer(function DynamicWorkarea() { return (
+ {collapsePreview && ( + diff --git a/src/routes/v2/pages/Tangent/components/WorkareaDivider.test.tsx b/src/routes/v2/pages/Tangent/components/WorkareaDivider.test.tsx new file mode 100644 index 0000000000..bc8bf8c2f5 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/WorkareaDivider.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TangentProjectStore } from "@/routes/v2/pages/Tangent/store/TangentProjectStore"; + +import { WorkareaDivider } from "./WorkareaDivider"; + +let store: TangentProjectStore; +const resize = vi.fn(); + +vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({ + useTangentProject: () => store, +})); + +const MAX_CHAT = 760; + +function renderDivider() { + render( +
+ +
, + ); + return screen.getByRole("separator", { name: "Resize workarea" }); +} + +// jsdom has no layout, so a drag starts from a 0px-wide chat pane and the +// pointer's x position is the width the user is asking for. +function dragTo(divider: HTMLElement, x: number) { + fireEvent.mouseDown(divider, { clientX: 0 }); + fireEvent.mouseMove(document, { clientX: x }); + fireEvent.mouseUp(document, { clientX: x }); +} + +beforeEach(() => { + vi.clearAllMocks(); + store = new TangentProjectStore("project-1"); +}); + +describe("chat/workarea divider", () => { + it("collapses the workarea on double-click", () => { + fireEvent.doubleClick(renderDivider()); + + expect(store.workareaCollapsed).toBe(true); + }); + + it("snaps the workarea shut when dragged past where it stops", () => { + dragTo(renderDivider(), MAX_CHAT + 40); + + expect(store.workareaCollapsed).toBe(true); + expect(resize).not.toHaveBeenCalled(); + }); + + it("only resizes the chat on a shorter drag", () => { + dragTo(renderDivider(), 600); + + expect(store.workareaCollapsed).toBe(false); + expect(resize).toHaveBeenCalledWith(600); + }); +}); diff --git a/src/routes/v2/pages/Tangent/components/WorkareaDivider.tsx b/src/routes/v2/pages/Tangent/components/WorkareaDivider.tsx new file mode 100644 index 0000000000..149e151119 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/WorkareaDivider.tsx @@ -0,0 +1,45 @@ +import { VerticalResizeHandle } from "@/components/ui/resize-handle"; +import { useTangentProject } from "@/routes/v2/pages/Tangent/context/TangentProjectContext"; +import { + MIN_CHAT_WIDTH, + snapsWorkareaShut, +} from "@/routes/v2/pages/Tangent/layout"; + +interface WorkareaDividerProps { + rowMeasured: boolean; + maxChatWidth: number; + onResize: (chatWidth: number) => void; + onCollapsePreview: (snapping: boolean) => void; +} + +/** + * The divider between chat and workarea, behaving like the left dock's edge: + * dragging it past where it stops snaps the workarea shut, double-clicking it + * collapses the workarea, and any shorter drag just resizes the chat. + */ +export function WorkareaDivider({ + rowMeasured, + maxChatWidth, + onResize, + onCollapsePreview, +}: WorkareaDividerProps) { + const store = useTangentProject(); + const snapsShut = (attempted: number) => + snapsWorkareaShut(rowMeasured, maxChatWidth, attempted); + + return ( + store.hideWorkarea()} + onResize={(width) => onCollapsePreview(snapsShut(width))} + onResizeEnd={(width) => { + onCollapsePreview(false); + if (snapsShut(width)) store.hideWorkarea(); + else onResize(width); + }} + /> + ); +} diff --git a/src/routes/v2/pages/Tangent/components/tangentAnchorProtocols.test.tsx b/src/routes/v2/pages/Tangent/components/tangentAnchorProtocols.test.tsx index 33263fbbd6..997bc90f5e 100644 --- a/src/routes/v2/pages/Tangent/components/tangentAnchorProtocols.test.tsx +++ b/src/routes/v2/pages/Tangent/components/tangentAnchorProtocols.test.tsx @@ -5,10 +5,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { tangentAnchorProtocols } from "./tangentAnchorProtocols"; const openWorkareaTarget = vi.fn(); +const showWorkarea = vi.fn(); const notify = vi.fn(); vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({ - useTangentProject: () => ({ openWorkareaTarget }), + useTangentProject: () => ({ openWorkareaTarget, showWorkarea }), })); vi.mock("@/hooks/useToastNotification", () => ({ @@ -43,6 +44,7 @@ describe("tangentAnchorProtocols run anchor", () => { render(); fireEvent.click(screen.getByRole("button")); + expect(showWorkarea).toHaveBeenCalled(); expect(openWorkareaTarget).toHaveBeenCalledWith( { type: "run", identity: "id/run_1" }, "Training run", diff --git a/src/routes/v2/pages/Tangent/components/tangentAnchorProtocols.tsx b/src/routes/v2/pages/Tangent/components/tangentAnchorProtocols.tsx index a5ac2657a7..0a15045808 100644 --- a/src/routes/v2/pages/Tangent/components/tangentAnchorProtocols.tsx +++ b/src/routes/v2/pages/Tangent/components/tangentAnchorProtocols.tsx @@ -36,6 +36,7 @@ function RunAnchor({ path, label }: AnchorProtocolProps) { async function openRun() { if (!target) return; try { + store.showWorkarea(); await store.openWorkareaTarget(target, label); } catch (error) { notify(getErrorMessage(error), "error"); diff --git a/src/routes/v2/pages/Tangent/hooks/useChatPaneWidth.ts b/src/routes/v2/pages/Tangent/hooks/useChatPaneWidth.ts index f3bd93899e..ca40b21a3c 100644 --- a/src/routes/v2/pages/Tangent/hooks/useChatPaneWidth.ts +++ b/src/routes/v2/pages/Tangent/hooks/useChatPaneWidth.ts @@ -35,6 +35,7 @@ export function useChatPaneWidth(dockWidth: number) { return { rowRef, + rowWidth, maxWidth, width: Math.min(requestedWidth, maxWidth), requestWidth: (attempted: number) => diff --git a/src/routes/v2/pages/Tangent/hooks/useNarrowLayoutDock.test.tsx b/src/routes/v2/pages/Tangent/hooks/useNarrowLayoutDock.test.tsx index ca1a37b505..f4c9f8a972 100644 --- a/src/routes/v2/pages/Tangent/hooks/useNarrowLayoutDock.test.tsx +++ b/src/routes/v2/pages/Tangent/hooks/useNarrowLayoutDock.test.tsx @@ -1,7 +1,10 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { NARROW_LAYOUT_WIDTH } from "@/routes/v2/pages/Tangent/layout"; +import { + NARROW_LAYOUT_WIDTH, + narrowLayoutWidth, +} from "@/routes/v2/pages/Tangent/layout"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { useNarrowLayoutDock } from "./useNarrowLayoutDock"; @@ -62,6 +65,14 @@ describe("useNarrowLayoutDock", () => { expect(setDockAreaCollapsed).toHaveBeenLastCalledWith("left", false); }); + it("keeps the dock open where only a collapsed workarea leaves room for it", () => { + window.innerWidth = (narrowLayoutWidth(true) + NARROW_LAYOUT_WIDTH) / 2; + + renderHook(() => useNarrowLayoutDock(true)); + + expect(setDockAreaCollapsed).not.toHaveBeenCalled(); + }); + /** Reopening a dock someone shut reads as the app fighting the mouse. */ it("leaves a dock the user collapsed alone", () => { collapsed = true; diff --git a/src/routes/v2/pages/Tangent/hooks/useNarrowLayoutDock.ts b/src/routes/v2/pages/Tangent/hooks/useNarrowLayoutDock.ts index af6eba36ec..e49a15bf70 100644 --- a/src/routes/v2/pages/Tangent/hooks/useNarrowLayoutDock.ts +++ b/src/routes/v2/pages/Tangent/hooks/useNarrowLayoutDock.ts @@ -1,20 +1,22 @@ import { useEffect, useRef } from "react"; -import { NARROW_LAYOUT_WIDTH } from "@/routes/v2/pages/Tangent/layout"; +import { narrowLayoutWidth } from "@/routes/v2/pages/Tangent/layout"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; /** * Only a collapse this hook performed is undone: someone who collapsed the dock * themselves and then widened the window meant to keep it collapsed, and * reopening it under them reads as the app fighting the mouse. + * + * A collapsed workarea only needs its rail, so it frees room for the dock. */ -export function useNarrowLayoutDock() { +export function useNarrowLayoutDock(workareaCollapsed = false) { const { windows } = useSharedStores(); const collapsedByUs = useRef(false); useEffect(() => { const apply = () => { - const narrow = window.innerWidth < NARROW_LAYOUT_WIDTH; + const narrow = window.innerWidth < narrowLayoutWidth(workareaCollapsed); const { collapsed } = windows.getDockAreaConfig("left"); if (narrow && !collapsed) { @@ -31,5 +33,5 @@ export function useNarrowLayoutDock() { apply(); window.addEventListener("resize", apply); return () => window.removeEventListener("resize", apply); - }, [windows]); + }, [windows, workareaCollapsed]); } diff --git a/src/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival.test.tsx b/src/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival.test.tsx index e0e14af0d5..ade87b0b59 100644 --- a/src/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival.test.tsx +++ b/src/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival.test.tsx @@ -105,6 +105,7 @@ function makeStore() { isStartingSession: false, startSession: vi.fn().mockResolvedValue(true), openWorkareaTarget: vi.fn().mockResolvedValue(undefined), + showWorkarea: vi.fn(), } as unknown as TangentProjectStore; } @@ -303,6 +304,19 @@ describe("usePrepareProjectArrival", () => { await waitFor(() => expect(store.openWorkareaTarget).toHaveBeenCalled()); }); + it("does not reveal again after pending arrival work can be manually collapsed", async () => { + const store = makeStore(); + const pending = Promise.withResolvers(); + vi.mocked(store.startSession).mockReturnValue(pending.promise); + prepare(store); + await waitFor(() => expect(store.startSession).toHaveBeenCalled()); + expect(store.showWorkarea).toHaveBeenCalledOnce(); + vi.mocked(store.showWorkarea).mockClear(); + await act(async () => pending.resolve(true)); + await waitFor(() => expect(store.openWorkareaTarget).toHaveBeenCalled()); + expect(store.showWorkarea).not.toHaveBeenCalled(); + }); + it("leaves a project that already has a session alone", async () => { const store = makeStore(); diff --git a/src/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival.ts b/src/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival.ts index 4cf37d9d19..9f0119d50e 100644 --- a/src/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival.ts +++ b/src/routes/v2/pages/Tangent/hooks/usePrepareProjectArrival.ts @@ -116,6 +116,7 @@ export function usePrepareProjectArrival( const { mutate, isIdle } = useMutation({ mutationFn: async (askedToOpen: WorkareaTarget | undefined) => { + if (askedToOpen || isEmpty) store.showWorkarea(); const started = await store.startSession( starting && { ...starting, name: askedFor }, ); diff --git a/src/routes/v2/pages/Tangent/layout.test.ts b/src/routes/v2/pages/Tangent/layout.test.ts new file mode 100644 index 0000000000..b3fe026200 --- /dev/null +++ b/src/routes/v2/pages/Tangent/layout.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { chatWidthCeiling } from "./hooks/useChatPaneWidth"; +import { snapsWorkareaShut } from "./layout"; + +const DOCK = 320; + +describe("when dragging the chat divider snaps the workarea shut", () => { + it.each([1512, 1920, 2560])( + "snaps just past where the divider stops on a %ipx row", + (row) => { + const stop = chatWidthCeiling(row, DOCK); + + expect(snapsWorkareaShut(true, stop, stop + 10)).toBe(false); + expect(snapsWorkareaShut(true, stop, stop + 30)).toBe(true); + }, + ); + + it("never snaps before the row has been measured", () => { + expect(snapsWorkareaShut(false, 760, 5000)).toBe(false); + }); +}); diff --git a/src/routes/v2/pages/Tangent/layout.ts b/src/routes/v2/pages/Tangent/layout.ts index 508c6df4fb..262ec0b9e8 100644 --- a/src/routes/v2/pages/Tangent/layout.ts +++ b/src/routes/v2/pages/Tangent/layout.ts @@ -1,4 +1,8 @@ -import { MIN_DOCK_AREA_WIDTH } from "@/routes/v2/shared/windows/types"; +import { + COLLAPSED_DOCK_AREA_WIDTH, + DOCK_AREA_SNAP_GIVE, + MIN_DOCK_AREA_WIDTH, +} from "@/routes/v2/shared/windows/types"; export const DEFAULT_CHAT_WIDTH = 480; export const MIN_CHAT_WIDTH = 380; @@ -6,5 +10,27 @@ export const MAX_CHAT_WIDTH = 760; export const MIN_WORKAREA_WIDTH = 420; -export const NARROW_LAYOUT_WIDTH = - MIN_CHAT_WIDTH + MIN_WORKAREA_WIDTH + MIN_DOCK_AREA_WIDTH; +export function narrowLayoutWidth(workareaCollapsed: boolean): number { + return ( + MIN_CHAT_WIDTH + + (workareaCollapsed ? COLLAPSED_DOCK_AREA_WIDTH : MIN_WORKAREA_WIDTH) + + MIN_DOCK_AREA_WIDTH + ); +} + +export const NARROW_LAYOUT_WIDTH = narrowLayoutWidth(false); + +// Like the left dock, the workarea snaps shut once the divider is dragged a +// little past where it stops: the chat's maximum or the workarea's minimum, +// whichever binds first. +export function snapsWorkareaShut( + rowMeasured: boolean, + maxChatWidth: number, + attemptedChatWidth: number, +): boolean { + return ( + rowMeasured && attemptedChatWidth >= maxChatWidth + DOCK_AREA_SNAP_GIVE + ); +} + +export const WORKAREA_ID = "tangent-project-workarea"; diff --git a/src/routes/v2/pages/Tangent/store/TangentProjectStore.test.ts b/src/routes/v2/pages/Tangent/store/TangentProjectStore.test.ts index bea2116c19..981ef2d7fc 100644 --- a/src/routes/v2/pages/Tangent/store/TangentProjectStore.test.ts +++ b/src/routes/v2/pages/Tangent/store/TangentProjectStore.test.ts @@ -382,7 +382,9 @@ describe("TangentProjectStore.revealEntity", () => { store.registerTabStore(p2.id, shared); store.selectWorkareaTab(p1.id); + store.hideWorkarea(); expect(store.revealEntity("task_1", "Load CSV")).toBe(true); + expect(store.workareaCollapsed).toBe(false); expect(store.activeWorkareaTabId).toBe(p2.id); expect(shared.navigation.navigateToPath).toHaveBeenCalledWith([ "MyPipeline", @@ -405,7 +407,9 @@ describe("TangentProjectStore.revealEntity", () => { const p1 = store.openResolvedView({ title: "P1", target: pipeline("p1") }); store.registerTabStore(p1.id, makeSharedStore(specWithTask())); + store.hideWorkarea(); expect(store.revealEntity("task_missing", "Nope")).toBe(false); + expect(store.workareaCollapsed).toBe(true); }); it("returns false when the pipeline tab has no registered store yet", () => { diff --git a/src/routes/v2/pages/Tangent/store/TangentProjectStore.ts b/src/routes/v2/pages/Tangent/store/TangentProjectStore.ts index abce37cb81..37d20bb015 100644 --- a/src/routes/v2/pages/Tangent/store/TangentProjectStore.ts +++ b/src/routes/v2/pages/Tangent/store/TangentProjectStore.ts @@ -111,6 +111,7 @@ export class TangentProjectStore { @observable accessor selectedSessionId: string | undefined = undefined; @observable accessor defaultSessionId: string | undefined = undefined; @observable accessor isStartingSession = false; + @observable accessor workareaCollapsed = false; @observable.shallow accessor workareaBySession = new Map< string, @@ -138,6 +139,14 @@ export class TangentProjectStore { makeObservable(this); } + @action hideWorkarea() { + this.workareaCollapsed = true; + } + + @action showWorkarea() { + this.workareaCollapsed = false; + } + @action setSessionIo(io: TangentSessionIo) { this.#io = io; } @@ -469,6 +478,7 @@ export class TangentProjectStore { const store = this.#tabStores.get(tab.id); if (store && this.#focusEntityInStore(store, entityId, label)) { this.selectWorkareaTab(tab.id); + this.showWorkarea(); return true; } } diff --git a/src/routes/v2/pages/Tangent/workarea/pipelineKind.test.ts b/src/routes/v2/pages/Tangent/workarea/pipelineKind.test.ts index 190b0524f7..e2938c49ee 100644 --- a/src/routes/v2/pages/Tangent/workarea/pipelineKind.test.ts +++ b/src/routes/v2/pages/Tangent/workarea/pipelineKind.test.ts @@ -53,7 +53,13 @@ function resolvePipelineTitle( return kind.resolveTitle({ type: "pipeline", identity }); } -function renderPipeline(identity: WorkareaIdentity) { +function renderPipeline( + identity: WorkareaIdentity, + visibility: { isSelected: boolean; isActive: boolean } = { + isSelected: true, + isActive: true, + }, +) { const kind = getWorkareaKind("pipeline")!; render( kind.render( @@ -63,7 +69,7 @@ function renderPipeline(identity: WorkareaIdentity) { target: { type: "pipeline", identity }, }, { - isActive: true, + ...visibility, projectId: "project-1", tabEnvironmentId: () => undefined, registerTabStore: vi.fn(), @@ -77,6 +83,23 @@ function renderPipeline(identity: WorkareaIdentity) { ); } +describe("pipelineKind background migration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([true, false])( + "only lets the selected tab migrate while hidden (selected: %s)", + (isSelected) => { + renderPipeline("id/abc", { isSelected, isActive: false }); + expect(mocks.editor.mock.calls.at(-1)?.[0]).toMatchObject({ + isActive: false, + allowBackgroundMigration: isSelected, + }); + }, + ); +}); + describe("pipelineKind resolveTitle", () => { beforeEach(() => { vi.clearAllMocks(); @@ -172,6 +195,7 @@ it("shows a recoverable message when an old draft target is unavailable", () => target: { type: "pipeline", identity: "draft/missing" }, } as Parameters[0], { + isSelected: true, isActive: true, projectId: "project-1", tabEnvironmentId: () => undefined, diff --git a/src/routes/v2/pages/Tangent/workarea/pipelineKind.tsx b/src/routes/v2/pages/Tangent/workarea/pipelineKind.tsx index f23083b08f..510f079ab2 100644 --- a/src/routes/v2/pages/Tangent/workarea/pipelineKind.tsx +++ b/src/routes/v2/pages/Tangent/workarea/pipelineKind.tsx @@ -78,6 +78,9 @@ registerWorkareaKind({ target={tab.target} title={tab.title} isActive={hostProps.isActive} + // Hiding the workarea must not strand the selected tab mid-migration, but + // it must not start migrating the other mounted tabs either. + allowBackgroundMigration={hostProps.isSelected} projectId={hostProps.projectId} onStoreReady={(store) => hostProps.registerTabStore(tab.id, store)} onStoreClosed={() => hostProps.unregisterTabStore(tab.id)} diff --git a/src/routes/v2/pages/Tangent/workarea/types.ts b/src/routes/v2/pages/Tangent/workarea/types.ts index 737b0cb0e1..9f592ca7b6 100644 --- a/src/routes/v2/pages/Tangent/workarea/types.ts +++ b/src/routes/v2/pages/Tangent/workarea/types.ts @@ -36,6 +36,12 @@ export type WorkareaTab = ResolvedWorkareaView & { id: string }; * they need into their view. */ export interface WorkareaHostProps { + // Selected tab, whether or not the workarea is visible. Collapsing does not + // clear it, so selected-tab work continues while hidden without spreading to + // the other mounted-but-unselected tabs. + isSelected: boolean; + // Selected AND visible: what the user can see and type into. Gates keyboard + // shortcuts and any UI put in front of the user, such as a consent dialog. isActive: boolean; projectId: string; sessionId?: string; diff --git a/src/routes/v2/shared/windows/types.ts b/src/routes/v2/shared/windows/types.ts index 831a968775..26db7564da 100644 --- a/src/routes/v2/shared/windows/types.ts +++ b/src/routes/v2/shared/windows/types.ts @@ -90,7 +90,11 @@ export const DEFAULT_DOCK_AREA_WIDTH = 320; export const MIN_DOCK_AREA_WIDTH = 220; -export const DOCK_AREA_COLLAPSE_SNAP_THRESHOLD = MIN_DOCK_AREA_WIDTH - 20; +// How far past a panel's resize limit a drag must go before it snaps shut. +export const DOCK_AREA_SNAP_GIVE = 20; + +export const DOCK_AREA_COLLAPSE_SNAP_THRESHOLD = + MIN_DOCK_AREA_WIDTH - DOCK_AREA_SNAP_GIVE; export const MAX_DOCK_AREA_WIDTH = 600;