From 5f9102c075c5a7a5eb03b4b09d86622a2f025e94 Mon Sep 17 00:00:00 2001 From: Volv G Date: Thu, 5 Nov 2026 19:06:47 -0800 Subject: [PATCH 1/4] feat: collapsible dynamic workarea Add an always-reachable Hide/Show control for the right-hand Dynamic Workarea. A hidden workarea takes zero width so the chat fills the row, and showing it restores the previous split clamped to the viewport. Hiding keeps the workarea mounted: tabs, selected documents, live pipeline-run providers, agent environments and tool bridges all stay registered, and new hidden agent targets still become ready. Hidden tabs are inert and their host shortcuts are inactive, including ReactFlow's own document-level Delete/Backspace, selection, multi-select, pan and zoom bindings, which are now gated on the live keyboard listening state instead of staying armed off-screen. Workarea hosts now distinguish a selected tab from a selected and visible one. Collapsing clears only the latter, so an already-consented pipeline migration on the selected tab can finish while hidden, while unselected tabs keep upstream behaviour. Background migration is opt-in and off by default, and a migration the user cancelled stays local even if consent arrives later from another tab. Agent-initiated opens respect a manual collapse; explicit human clicks on a resource, artifact, run or entity reveal the pane, and the reveal happens before the async open starts so a newer manual collapse wins. --- .../shared/LocalPipelineMigration.tsx | 18 +- .../pages/Editor/EmbeddedPipelineEditor.tsx | 5 + .../components/FlowCanvas/FlowCanvas.test.tsx | 238 ++++++++++++ .../components/FlowCanvas/FlowCanvas.tsx | 6 +- .../components/AssetsWindowContent.tsx | 1 + .../components/DynamicWorkarea.test.tsx | 206 +++++++++++ .../Tangent/components/DynamicWorkarea.tsx | 16 +- .../Tangent/components/ProjectChatArea.tsx | 5 +- .../Tangent/components/ProjectHeader.test.tsx | 62 +++- .../Tangent/components/ProjectHeader.tsx | 50 ++- .../ResourcesWindowContent.test.tsx | 12 +- .../components/ResourcesWindowContent.tsx | 1 + .../components/RunsWindowContent.test.tsx | 10 +- .../Tangent/components/RunsWindowContent.tsx | 1 + .../TangentProjectWorkspace.test.tsx | 147 ++++++++ .../components/TangentProjectWorkspace.tsx | 24 +- .../WorkareaPipelineMigration.test.tsx | 340 ++++++++++++++++++ .../tangentAnchorProtocols.test.tsx | 4 +- .../components/tangentAnchorProtocols.tsx | 1 + .../components/workareaHumanReveals.test.tsx | 78 ++++ .../hooks/usePrepareProjectArrival.test.tsx | 14 + .../Tangent/hooks/usePrepareProjectArrival.ts | 1 + src/routes/v2/pages/Tangent/layout.ts | 2 + .../Tangent/store/TangentProjectStore.test.ts | 52 +++ .../Tangent/store/TangentProjectStore.ts | 10 + .../Tangent/workarea/pipelineKind.test.ts | 2 + .../pages/Tangent/workarea/pipelineKind.tsx | 3 + src/routes/v2/pages/Tangent/workarea/types.ts | 6 + 28 files changed, 1289 insertions(+), 26 deletions(-) create mode 100644 src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.test.tsx create mode 100644 src/routes/v2/pages/Tangent/components/DynamicWorkarea.test.tsx create mode 100644 src/routes/v2/pages/Tangent/components/TangentProjectWorkspace.test.tsx create mode 100644 src/routes/v2/pages/Tangent/components/WorkareaPipelineMigration.test.tsx create mode 100644 src/routes/v2/pages/Tangent/components/workareaHumanReveals.test.tsx 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/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..3ae9b97714 --- /dev/null +++ b/src/routes/v2/pages/Editor/components/FlowCanvas/FlowCanvas.test.tsx @@ -0,0 +1,238 @@ +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) => ( + + ), + }), +})); + +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/pages/Editor/nodes", () => ({ + editorRegistry: { + getByNodeId: () => ({ + deleteNode: (_undo: unknown, current: ComponentSpec, id: string) => + current.deleteTaskById(id), + }), + }, +})); +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/hooks/useViewportScaling", () => ({ + useViewportScaling: () => ({ + containerRef: undefined, + handleViewportChange: undefined, + }), +})); +vi.mock("@/routes/v2/shared/hooks/useIsDetailedView", () => ({ + useIsDetailedView: () => true, +})); +vi.mock("@/routes/v2/shared/components/SubgraphBreadcrumbs", () => ({ + SubgraphBreadcrumbs: () => null, +})); +vi.mock("./components/CanvasUndoRedo", () => ({ CanvasUndoRedo: () => null })); +vi.mock("./components/FloatingSelectionToolbar", () => ({ + FloatingSelectionToolbar: () => null, +})); +vi.mock("@/routes/v2/shared/hooks/useFitViewOnFocus", () => ({ + useFitViewOnFocus: vi.fn(), +})); +vi.mock("@/routes/v2/shared/hooks/useFitViewOnRequest", () => ({ + useFitViewOnRequest: vi.fn(), +})); +vi.mock("@/routes/v2/pages/Editor/hooks/useAutoLayout", () => ({ + useAutoLayout: vi.fn(), +})); +vi.mock("./hooks/useClipboardShortcuts", () => ({ + useClipboardShortcuts: vi.fn(), +})); +vi.mock("./hooks/useNodeEdgeChanges", () => ({ + useNodeEdgeChanges: () => ({}), +})); +vi.mock("./hooks/useConnectionBehavior", () => ({ + useConnectionBehavior: () => ({}), +})); +vi.mock("./hooks/useDropBehavior", () => ({ useDropBehavior: () => ({}) })); +vi.mock("@/routes/v2/shared/hooks/useDoubleClickBehavior", () => ({ + useDoubleClickBehavior: () => ({}), +})); +vi.mock("./hooks/usePaneClickBehavior", () => ({ + usePaneClickBehavior: () => ({}), +})); + +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 ( + + + + + ); +} +function Fixture() { + return ( + <> + + + + ); +} + +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"])( + "does not mutate hidden spec/undo on %s at the Show button, retains selection and resumes after restore", + async (key) => { + render(); + expect(keyboard.listening).toBe(true); + expect(spec.tasks).toHaveLength(1); + expect(spec.bindings).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Hide workarea" })); + const show = screen.getByRole("button", { name: "Show workarea" }); + show.focus(); + expect(keyboard.listening).toBe(false); + await act(async () => { + fireEvent.keyDown(show, { key, code: key }); + fireEvent.keyUp(show, { key, code: key }); + }); + expect(show).toHaveFocus(); + expect(spec.tasks).toHaveLength(1); + expect(spec.bindings).toHaveLength(1); + expect(undoGroup).not.toHaveBeenCalled(); + expect(clearSelection).not.toHaveBeenCalled(); + expect(clearMultiSelection).not.toHaveBeenCalled(); + fireEvent.click(show); + const hide = screen.getByRole("button", { name: "Hide workarea" }); + hide.focus(); + fireEvent.keyDown(hide, { key, code: key }); + await waitFor(() => expect(undoGroup).toHaveBeenCalledOnce()); + expect(spec.tasks).toHaveLength(0); + expect(spec.bindings).toHaveLength(0); + expect(clearSelection).toHaveBeenCalledOnce(); + expect(clearMultiSelection).toHaveBeenCalledOnce(); + fireEvent.keyUp(hide, { 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); + expect(undoGroup).toHaveBeenCalledOnce(); + }); +}); 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/DynamicWorkarea.test.tsx b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.test.tsx new file mode 100644 index 0000000000..3576975510 --- /dev/null +++ b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.test.tsx @@ -0,0 +1,206 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { useEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ToolBridgeApi } from "@/agent/toolBridgeApi"; +import { WORKAREA_ID } from "@/routes/v2/pages/Tangent/layout"; +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 { DynamicWorkarea } from "./DynamicWorkarea"; +import { TangentRemoteEnvProvider } from "./TangentRemoteEnvProvider"; + +let store: TangentProjectStore; +const mounted = vi.fn(); +const unmounted = vi.fn(); +const disconnected = vi.fn(); +const terminated = vi.fn(); +const stopConnection = vi.fn(); +const bridge = {} as ToolBridgeApi; + +vi.mock("comlink", () => ({ + proxy: (value: unknown) => value, + wrap: () => ({ + init: vi.fn(), + setContext: vi.fn(), + setAiConfig: vi.fn(), + setTraceScope: vi.fn(), + }), +})); +vi.mock("@/hooks/useAiProviderSettings", () => ({ + useAiProviderSettings: () => ({ config: {} }), +})); +vi.mock("@/hooks/useToastNotification", () => ({ default: () => vi.fn() })); +vi.mock("@/routes/v2/pages/Tangent/hooks/useRemoteEnvAuthToken", () => ({ + useRemoteEnvAuthToken: () => undefined, +})); +vi.mock("@/routes/v2/pages/Tangent/hooks/useTangentBaseUrl", () => ({ + useTangentBaseUrl: () => ({ baseUrl: "https://tangent.example.com" }), +})); +vi.mock("@/routes/v2/pages/Tangent/services/remoteEnvAgentWorker", () => ({ + createRemoteEnvAgentWorker: () => ({ terminate: terminated }), +})); +vi.mock("@/routes/v2/pages/Tangent/services/remoteEnvHost", () => ({ + createRemoteEnvHost: () => ({ disconnect: disconnected }), + describeBrowserEnv: vi.fn(), +})); +vi.mock( + "@/routes/v2/pages/Tangent/services/connectRemoteEnvWithRefresh", + () => ({ + connectRemoteEnvWithRefresh: ({ + initialEnvironmentId, + onConnected, + }: { + initialEnvironmentId: string; + onConnected: (id: string) => void; + }) => { + onConnected(initialEnvironmentId); + return stopConnection; + }, + }), +); + +vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({ + useTangentProject: () => store, +})); +vi.mock("@/routes/v2/pages/Tangent/workarea/registry", () => ({ + getWorkareaKind: (type: string) => ({ + icon: "FileText", + keepMounted: type === "pipeline" || type === "run", + render: (tab: WorkareaTab, host: WorkareaHostProps) => ( + + ), + }), +})); + +function TestView({ + tab, + host, +}: { + tab: WorkareaTab; + host: WorkareaHostProps; +}) { + useEffect(() => { + mounted(tab.id); + return () => { + unmounted(tab.id); + }; + }, [tab.id]); + return ( + host.registerTabEnvironment(tab.id, id)} + onEnvironmentClosed={() => host.unregisterTabEnvironment(tab.id)} + onBridgeReady={(value) => + host.registerTabBridge(tab.id, "pipeline", value) + } + onBridgeClosed={() => host.unregisterTabBridge(tab.id)} + > + + + ); +} + +function open(type: "pipeline" | "run" | "document", title = type) { + return store.openResolvedView({ + title, + target: { type, identity: idIdentity(title) }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + store = new TangentProjectStore("project-1"); + store.setDefaultSessionId("session-1"); +}); + +describe("DynamicWorkarea visibility", () => { + it("keeps live views connected and selected but inactive while hidden", async () => { + const pipeline = open("pipeline"); + const run = open("run"); + render(); + const runInput = screen.getByTestId("view-run"); + fireEvent.change(runInput, { target: { value: "edited canvas" } }); + expect(runInput).toHaveAttribute("data-active", "true"); + const unregister = vi.spyOn(store, "unregisterTabEnvironment"); + + act(() => store.hideWorkarea()); + + const pane = document.getElementById(WORKAREA_ID); + expect(pane).not.toBeVisible(); + expect(pane).toHaveStyle({ display: "none" }); + expect(pane).toHaveAttribute("inert"); + expect(runInput).toHaveAttribute("data-active", "false"); + expect(store.activeWorkareaTabId).toBe(run.id); + expect(store.getTabEnvironmentId(pipeline.id)).toBe( + `session-1:${pipeline.id}`, + ); + expect(store.getTabEnvironmentId(run.id)).toBe(`session-1:${run.id}`); + expect(mounted).toHaveBeenCalledTimes(2); + expect(unmounted).not.toHaveBeenCalled(); + expect(unregister).not.toHaveBeenCalled(); + expect(disconnected).not.toHaveBeenCalled(); + expect(terminated).not.toHaveBeenCalled(); + expect(stopConnection).not.toHaveBeenCalled(); + + act(() => store.showWorkarea()); + expect(pane).toBeVisible(); + expect(runInput).toHaveAttribute("data-active", "true"); + expect(runInput).toHaveValue("edited canvas"); + expect(mounted).toHaveBeenCalledTimes(2); + }); + + it.each(["pipeline", "run"] as const)( + "mounts new hidden %s agent targets and satisfies environment readiness without stealing focus", + async (type) => { + store.hideWorkarea(); + render( + <> + + + , + ); + const composer = screen.getByLabelText("Chat composer"); + composer.focus(); + let tab: WorkareaTab | undefined; + act(() => { + tab = open(type); + }); + if (!tab) throw new Error("Expected an opened tab"); + await expect(store.waitForTabEnvironment(tab.id)).resolves.toBe( + `session-1:${tab.id}`, + ); + expect(store.workareaCollapsed).toBe(true); + expect(screen.getByTestId(`view-${type}`)).toHaveAttribute( + "data-active", + "false", + ); + expect(composer).toHaveFocus(); + }, + ); + + it("does not unmount the logically selected keepMounted:false document on hide", () => { + open("document"); + render(); + const documentInput = screen.getByTestId("view-document"); + fireEvent.change(documentInput, { target: { value: "unsaved document" } }); + act(() => store.hideWorkarea()); + act(() => store.showWorkarea()); + expect(screen.getByTestId("view-document")).toBe(documentInput); + expect(documentInput).toHaveValue("unsaved document"); + expect(mounted).toHaveBeenCalledTimes(1); + expect(unmounted).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/v2/pages/Tangent/components/DynamicWorkarea.tsx b/src/routes/v2/pages/Tangent/components/DynamicWorkarea.tsx index 0be5998d33..8db644d1f2 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"; @@ -31,8 +34,10 @@ export const DynamicWorkarea = observer(function DynamicWorkarea() { 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,8 +56,13 @@ export const DynamicWorkarea = observer(function DynamicWorkarea() { return (
{workareaTabs.length > 0 ? ( store.setChatActiveTab(value)} onCloseTab={(id) => store.closeChatTab(id)} - onOpenArtifact={(url, title) => store.openArtifactTab(url, title)} + onOpenArtifact={(url, title) => { + store.showWorkarea(); + store.openArtifactTab(url, title); + }} onSendPrompt={(content) => store.recordSessionPrompt(content)} onError={(message) => notify(message, "error")} headerAction={ diff --git a/src/routes/v2/pages/Tangent/components/ProjectHeader.test.tsx b/src/routes/v2/pages/Tangent/components/ProjectHeader.test.tsx index 45ef81ce7c..cc1014b341 100644 --- a/src/routes/v2/pages/Tangent/components/ProjectHeader.test.tsx +++ b/src/routes/v2/pages/Tangent/components/ProjectHeader.test.tsx @@ -1,12 +1,14 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DialogProvider } from "@/providers/DialogProvider/DialogProvider"; +import { WORKAREA_ID } from "@/routes/v2/pages/Tangent/layout"; +import { TangentProjectStore } from "@/routes/v2/pages/Tangent/store/TangentProjectStore"; import { useProject } from "@/services/projects/useProjects"; -import { ProjectHeader } from "./ProjectHeader"; +import { ProjectHeader, WorkareaToggle } from "./ProjectHeader"; class ResizeObserverMock { observe = vi.fn(); @@ -15,6 +17,11 @@ class ResizeObserverMock { } vi.stubGlobal("ResizeObserver", ResizeObserverMock); +let store: TangentProjectStore; +beforeEach(() => { + store = new TangentProjectStore("project-1"); +}); + const updateProject = vi.fn(); const share = vi.fn(); @@ -54,7 +61,7 @@ vi.mock("@tanstack/react-router", () => ({ })); vi.mock("@/routes/v2/pages/Tangent/context/TangentProjectContext", () => ({ - useTangentProject: () => ({ projectId: "project-1" }), + useTangentProject: () => store, })); vi.mock("@/providers/AnalyticsProvider", () => ({ @@ -178,3 +185,52 @@ describe("Tangent ProjectHeader", () => { ); }); }); + +describe("workarea header toggle", () => { + it("remains reachable without project metadata or any open tabs", async () => { + const user = userEvent.setup(); + vi.mocked(useProject).mockReturnValue({ data: undefined } as ReturnType< + typeof useProject + >); + render(); + const toggle = screen.getByRole("button", { name: "Hide workarea" }); + expect(toggle).toHaveAttribute("type", "button"); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(toggle).toHaveAttribute("aria-controls", WORKAREA_ID); + await user.click(toggle); + expect(screen.getByRole("button", { name: "Show workarea" })).toBeVisible(); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(store.workareaTabs).toEqual([]); + }); + + it("supports Enter and Space and leaves focus on the restore control", async () => { + const user = userEvent.setup(); + render(); + const toggle = screen.getByRole("button", { name: "Hide workarea" }); + toggle.focus(); + await user.keyboard("{Enter}"); + expect(store.workareaCollapsed).toBe(true); + expect(toggle).toHaveFocus(); + await user.keyboard(" "); + expect(store.workareaCollapsed).toBe(false); + expect(toggle).toHaveFocus(); + }); + + it("moves focus out of the workarea before hiding, including iframe focus", () => { + render( + <> + +
+