From 54dc0e51a567276961aa0c150c7e2f8800aafddf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:42:23 +0000 Subject: [PATCH] fix(app-shell): Studio on a smaller or resized window (objectui#11795) The flow canvas re-fits when its own width changes (a window resize, the chat dock, a side panel), capped at 100%; node adds, drags, edits and height-only changes leave the viewport where it is. On an Automations row too narrow for the Configuration aside beside one flow column, the aside folds away and a selection opens the configuration in a drawer over the canvas. The row is measured on its own, so the chat dock beside it counts; wider rows keep the aside unchanged. On a phone, one line under the Studio header says Studio is built for a desktop screen. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11795-studio-narrow-screens.md | 9 + .../src/views/metadata-admin/i18n.ts | 3 + .../FlowCanvas.resizeFit-11795.test.tsx | 212 +++++++++++++++ .../metadata-admin/previews/FlowCanvas.tsx | 37 +++ ...AutomationsPillar.narrowRow-11795.test.tsx | 250 ++++++++++++++++++ ...ioDesignSurface.desktopHint-11795.test.tsx | 127 +++++++++ .../studio-design/StudioDesignSurface.tsx | 111 +++++--- .../src/views/studio-design/wideViewport.ts | 50 ++++ 8 files changed, 768 insertions(+), 31 deletions(-) create mode 100644 .changeset/11795-studio-narrow-screens.md create mode 100644 packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.resizeFit-11795.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/AutomationsPillar.narrowRow-11795.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.desktopHint-11795.test.tsx diff --git a/.changeset/11795-studio-narrow-screens.md b/.changeset/11795-studio-narrow-screens.md new file mode 100644 index 0000000000..b9116d1ecc --- /dev/null +++ b/.changeset/11795-studio-narrow-screens.md @@ -0,0 +1,9 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio on a smaller or resized window (objectui#11795). Studio stays a desktop tool; three things change below desktop width: + +- **The flow canvas re-fits when its width changes.** A window resize, the Studio chat dock opening or closing, or a side panel toggled beside the canvas now frames the diagram again: centred, and zoomed out only as far as the whole flow needs, never past 100%. A flow opened at a narrow width and then widened used to keep the narrow framing, with its Start and End cards clipped at the left edge. Adding, dragging or editing a node still leaves the viewport where it is, and so does a change in the canvas's height alone, such as a notice appearing above it. +- **On a narrow Automations row the Configuration panel opens as a drawer.** When the pillar's row has no room for the 288px Configuration panel beside one flow column, the panel no longer reserves that width. Selecting a node opens its configuration in a drawer over the canvas, and closing the drawer clears the selection. The row is measured on its own, so the Studio chat dock beside it counts. Wider rows keep the panel as before. +- **On a phone, one line under the Studio header says that Studio is built for a desktop screen.** diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 1aab2d7ecf..d8f7457a19 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2860,6 +2860,8 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.deselect': 'Clear selection', 'engine.studio.toggleRail': 'Toggle sidebar', 'engine.studio.home': 'Back to home', + // objectui#11795 — the one line a phone shows under the Studio header. + 'engine.studio.desktopHint': 'Studio is built for a desktop screen.', // Pillar tab labels 'engine.fieldType.text': 'Text', 'engine.fieldType.textarea': 'Text Area', @@ -6048,6 +6050,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.deselect': '取消选择', 'engine.studio.toggleRail': '切换侧栏', 'engine.studio.home': '返回主页', + 'engine.studio.desktopHint': 'Studio 专为桌面屏幕设计。', // Pillar tab labels 'engine.fieldType.text': '单行文本', 'engine.fieldType.textarea': '多行文本', diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.resizeFit-11795.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.resizeFit-11795.test.tsx new file mode 100644 index 0000000000..45b94043b9 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.resizeFit-11795.test.tsx @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11795 — the flow canvas re-fits when its own width changes, and + * nothing an author does to the diagram moves the viewport. + * + * Measured in Chromium before the fix: Studio's Automations pillar opened at a + * 390px window framed the diagram for a ~34px canvas (centred, so it hung off + * both edges); widened to 1024px, the canvas grew to ~460px and the diagram + * stayed where the narrow box had put it, its Start/End cards clipped at the + * left edge. The mount framing ran once and nothing re-ran it. + * + * The canvas is the real one. happy-dom lays nothing out, so the viewport's + * `clientWidth` / `clientHeight` are read from a box this file sets, and + * `ResizeObserver` is a double whose notifications the tests deliver, the way + * a browser delivers them: once after `observe`, then on each resize. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, act } from '@testing-library/react'; +import { FlowCanvas } from './FlowCanvas'; +import { NODE_W, PADDING, type FlowDesignerEdge, type FlowDesignerNode } from './flow-canvas-layout'; + +// The palette's engine overlay answers "engine absent" (its offline answer). +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('not found', { status: 404 })), +); + +/** The diagram's width at 100% for a linear flow: one node column plus the layout's padding. */ +const DIAGRAM_W = NODE_W + 2 * PADDING; + +const box = { w: 0, h: 0 }; +const observers = new Set<{ cb: ResizeObserverCallback; self: ResizeObserver }>(); +class ResizeObserverDouble { + private entry: { cb: ResizeObserverCallback; self: ResizeObserver }; + constructor(cb: ResizeObserverCallback) { + this.entry = { cb, self: this as unknown as ResizeObserver }; + } + observe() { + observers.add(this.entry); + } + unobserve() { + observers.delete(this.entry); + } + disconnect() { + observers.delete(this.entry); + } +} +/** Deliver a notification to every live observer, as a browser does after a resize. */ +function notify() { + act(() => { + for (const o of [...observers]) o.cb([], o.self); + }); +} +function resizeTo(w: number, h: number) { + box.w = w; + box.h = h; + notify(); +} + +/** The prototype that owns `name` on the viewport element. */ +function ownerOf(name: 'clientWidth' | 'clientHeight'): object { + let p: object | null = HTMLDivElement.prototype; + while (p && !Object.getOwnPropertyDescriptor(p, name)) p = Object.getPrototypeOf(p); + if (!p) throw new Error(`no prototype defines ${name}`); + return p; +} + +let realResizeObserver: typeof ResizeObserver | undefined; +beforeEach(() => { + box.w = 0; + box.h = 0; + observers.clear(); + realResizeObserver = globalThis.ResizeObserver; + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverDouble; + const isViewport = (el: Element) => el.getAttribute('role') === 'application'; + vi.spyOn(ownerOf('clientWidth') as HTMLElement, 'clientWidth', 'get').mockImplementation(function (this: Element) { + return isViewport(this) ? box.w : 0; + }); + vi.spyOn(ownerOf('clientHeight') as HTMLElement, 'clientHeight', 'get').mockImplementation(function (this: Element) { + return isViewport(this) ? box.h : 0; + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = realResizeObserver; + localStorage.clear(); +}); + +type Draft = { nodes: FlowDesignerNode[]; edges: FlowDesignerEdge[] }; + +const flow = (): Draft => ({ + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'assign', type: 'assignment', label: 'Set owner' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'assign' }, + { id: 'e2', source: 'assign', target: 'end' }, + ], +}); + +/** Mount the canvas in a host that keeps the draft, as Studio does; `edit` changes it from outside. */ +function mountAt(w: number, h: number) { + box.w = w; + box.h = h; + const host: { edit: (fn: (d: Draft) => Draft) => void } = { edit: () => {} }; + function Host() { + const [draft, setDraft] = React.useState(flow); + host.edit = (fn) => act(() => setDraft(fn)); + return ( + {}} + onPatch={(patch) => setDraft((d) => ({ ...d, ...(patch as Partial) }))} + /> + ); + } + render(); + // The browser's first notification after `observe`, at the width just framed. + notify(); + return host; +} + +/** The pan and zoom the canvas applied to the diagram, read off its transform. */ +function view(): { x: number; y: number; zoom: number } { + const layer = screen.getByRole('application').firstElementChild as HTMLElement; + const m = /translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/.exec(layer.style.transform); + expect(m, `diagram transform: ${layer.style.transform}`).not.toBeNull(); + return { x: Number(m![1]), y: Number(m![2]), zoom: Number(m![3]) }; +} + +describe('FlowCanvas — a width change re-fits the diagram (objectui#11795)', () => { + it('opened narrow and then widened, the diagram is framed for the new width, not left where the narrow box put it', () => { + mountAt(34, 541); + // The mount framing at 34px: centred at 100%, so the diagram's left edge is off-canvas. + expect(view().x).toBeLessThan(0); + + resizeTo(460, 570); + + const v = view(); + expect(v.zoom).toBe(1); + expect(v.x).toBe((460 - DIAGRAM_W) / 2); + expect(v.x).toBeGreaterThanOrEqual(0); + expect(v.x + DIAGRAM_W * v.zoom).toBeLessThanOrEqual(460); + }); + + it('narrowed below the diagram, it zooms out until the whole flow fits, centred', () => { + mountAt(800, 600); + expect(view().zoom).toBe(1); + + resizeTo(200, 600); + + const v = view(); + expect(v.zoom).toBeLessThan(1); + expect(v.x).toBeGreaterThanOrEqual(0); + expect(v.x + DIAGRAM_W * v.zoom).toBeLessThanOrEqual(200); + expect(screen.getByText(`${Math.round(v.zoom * 100)}%`)).toBeTruthy(); + }); + + it('a wider canvas does not zoom a small flow past the 100% it opens at', () => { + mountAt(460, 570); + + resizeTo(1400, 900); + + expect(view()).toMatchObject({ zoom: 1, x: (1400 - DIAGRAM_W) / 2 }); + }); +}); + +describe('FlowCanvas — what an author does to the diagram never moves the viewport (objectui#11795, the control)', () => { + it('a node add re-arms the observer at the width already framed, and the view stays put', () => { + const host = mountAt(800, 600); + const before = view(); + + host.edit((d) => ({ + nodes: [...d.nodes, { id: 'extra', type: 'assignment', label: 'Extra' }], + edges: [...d.edges, { id: 'e3', source: 'end', target: 'extra' }], + })); + // The re-armed observer's first notification arrives at the same width. + notify(); + + expect(document.querySelector('[data-node-id="extra"]')).not.toBeNull(); + expect(view()).toEqual(before); + }); + + it('a height-only change — a notice landing above the canvas after an edit — does not re-fit', () => { + mountAt(800, 600); + const before = view(); + + resizeTo(800, 560); + + expect(view()).toEqual(before); + }); + + it('a hidden canvas (0 wide) is not fitted into nothing', () => { + mountAt(800, 600); + const before = view(); + + resizeTo(0, 0); + + expect(view()).toEqual(before); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx index 8b64a27f2d..d42d997b91 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx @@ -212,6 +212,9 @@ export function FlowCanvas({ const nodes = React.useMemo(() => withCanonicalGeometry(storedNodes), [storedNodes]); const viewportRef = React.useRef(null); + // objectui#11795 — the viewport width the diagram was last framed at (the + // mount centering, or a re-fit after the canvas's width changed). + const framedWidthRef = React.useRef(null); const [zoom, setZoom] = React.useState(1); const [pan, setPan] = React.useState({ x: 0, y: 0 }); // objectui#11778 — which "+" has the add-node palette open: `toolbar`, @@ -737,9 +740,43 @@ export function FlowCanvas({ x: (vp.clientWidth - size.width) / 2, y: Math.max(16, (vp.clientHeight - size.height) / 2), }); + framedWidthRef.current = vp.clientWidth; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // objectui#11795 — re-fit when the canvas's own WIDTH changes: a window + // resize, the Studio chat dock opening or closing, a side panel toggled + // beside the canvas. Without it the mount framing outlived its width: opened + // narrow and then widened, the diagram stayed where the narrow box put it, + // its cards clipped at the left edge. The fit is the toolbar's, capped at + // 100% so a small flow is not zoomed up past the scale it opens at. + // + // Width only, never height, which keeps the promise above: the size changes + // an edit causes are vertical — a held-edit or refused-save notice landing + // above the canvas, a Problems list growing under it — and must not yank the + // viewport from under the author. A node add, drag or edit changes the + // diagram, not the canvas, and re-arms this observer at the width it already + // framed, so it moves nothing. A 0 width is a hidden canvas: nothing to fit. + React.useEffect(() => { + const vp = viewportRef.current; + if (!vp || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => { + const width = vp.clientWidth; + if (width === 0 || width === framedWidthRef.current) return; + framedWidthRef.current = width; + const pad = 32; + // 1 is under MAX_ZOOM, so only the MIN_ZOOM floor of `clampZoom` can bind. + const z = Math.max(MIN_ZOOM, Math.min(1, (width - pad) / size.width, (vp.clientHeight - pad) / size.height)); + setZoom(z); + setPan({ + x: (width - size.width * z) / 2, + y: Math.max(16, (vp.clientHeight - size.height * z) / 2), + }); + }); + observer.observe(vp); + return () => observer.disconnect(); + }, [size.height, size.width]); + // Pan to center an element when the Problems panel asks to reveal it. Driven // by a changing `nonce` so re-clicking the same problem re-centers it. React.useEffect(() => { diff --git a/packages/app-shell/src/views/studio-design/AutomationsPillar.narrowRow-11795.test.tsx b/packages/app-shell/src/views/studio-design/AutomationsPillar.narrowRow-11795.test.tsx new file mode 100644 index 0000000000..4383f03746 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/AutomationsPillar.narrowRow-11795.test.tsx @@ -0,0 +1,250 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11795 — on an Automations row too narrow for the Configuration + * aside to sit beside the flow canvas, the aside folds away and a selection + * opens the same configuration in a drawer over the canvas. Wider rows keep + * the aside exactly as before. + * + * Measured in Chromium before the fix: at a 390px window the fixed 288px + * aside left the canvas ~34px; at 1024px with the Studio chat dock open + * beside the pillar, ~40px. The aside reserved its width whether or not it + * had anything to show. + * + * The canvas and the inspector are the REAL registered `FlowPreview` and + * `FlowInspector`. happy-dom lays nothing out, so the row's width is read from + * a value this file sets, and `ResizeObserver` is a double whose notifications + * the tests deliver. A row that measures 0 is an unmeasured row — every other + * Automations pin in this directory renders one — and keeps the aside. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, within, act, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'com.acme.app'; + +const FLOW = { + name: 'notify_owner', + label: 'Notify owner', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}; + +const server = vi.hoisted(() => ({ + active: new Map>(), +})); + +const mockClient = vi.hoisted(() => { + const k = (type: string, name: string) => `${type}/${name}`; + return { + list: vi.fn(async (type: string) => + [...server.active.entries()] + .filter(([key]) => key.startsWith(`${type}/`)) + .map(([, row]) => ({ name: row.name, label: row.label ?? row.name })), + ), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + const eff = server.active.get(k(type, name)) ?? null; + return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' }; + }), + getDraft: vi.fn(async (type: string, name: string) => { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), + }; +}); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AutomationsPillar } from './StudioDesignSurface'; +import { AUTOMATIONS_CONFIG_FOLD_WIDTH } from './wideViewport'; +import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; +import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; +import { t } from '../metadata-admin/i18n'; +import { browserClick } from '../metadata-admin/previews/__tests__/browserClick'; + +const dataSource = createEmptyDataSource(); +failOnAbsorbedFetchError(); + +// The pillar's `/automation/_status` probe and the inspector's action-catalog +// read both answer "absent", so each keeps its documented fallback. +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +registerMetadataPreview('flow', FlowPreview); +registerMetadataInspector('flow', FlowInspector); + +const CONFIG = t('engine.studio.auto.config', 'en'); + +/** The width the pillar's row measures; 0 = not laid out. */ +let rowWidth = 0; +const observers = new Set<{ cb: ResizeObserverCallback; self: ResizeObserver }>(); +class ResizeObserverDouble { + private entry: { cb: ResizeObserverCallback; self: ResizeObserver }; + constructor(cb: ResizeObserverCallback) { + this.entry = { cb, self: this as unknown as ResizeObserver }; + } + observe() { + observers.add(this.entry); + } + unobserve() { + observers.delete(this.entry); + } + disconnect() { + observers.delete(this.entry); + } +} +function resizeRowTo(width: number) { + rowWidth = width; + act(() => { + for (const o of [...observers]) o.cb([], o.self); + }); +} + +let realResizeObserver: typeof ResizeObserver | undefined; +// Restored on its own: `failOnAbsorbedFetchError` reads its spy in an afterEach, +// so a blanket `vi.restoreAllMocks()` here would empty that record first. +let rectSpy: { mockRestore: () => void } | null = null; +beforeEach(() => { + server.active.clear(); + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + server.active.set(`flow/${FLOW.name}`, JSON.parse(JSON.stringify(FLOW))); + rowWidth = 0; + observers.clear(); + realResizeObserver = globalThis.ResizeObserver; + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverDouble; + const original = Element.prototype.getBoundingClientRect; + rectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (this.getAttribute('data-testid') !== 'auto-layout-row') return original.call(this); + return { x: 0, y: 0, top: 0, left: 0, right: rowWidth, bottom: 0, width: rowWidth, height: 0, toJSON: () => ({}) } as DOMRect; + }); +}); + +afterEach(() => { + cleanup(); + rectSpy?.mockRestore(); + rectSpy = null; + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = realResizeObserver; +}); + +function renderPillarAt(width: number) { + rowWidth = width; + return render( + + + , + ); +} + +async function openFlow(): Promise { + // Loaded once the header's Status pill reads the flow's `active` status (objectui#11779). + await waitFor(() => expect(screen.getByText('Status:').nextElementSibling?.textContent).toBe(t('engine.studio.auto.enabled', 'en')), { timeout: 8000 }); +} + +function startNodeCard(): HTMLElement { + const card = document.querySelector('[data-node-id="start"] [role="button"]') as HTMLElement | null; + expect(card, 'the canvas must render the start node').not.toBeNull(); + return card!; +} + +/** The asides the pillar's own row holds (the flow rail is a `nav`, not one). */ +function rowAsides(): Element[] { + return Array.from(screen.getByTestId('auto-layout-row').children).filter((el) => el.tagName === 'ASIDE'); +} + +describe('Automations pillar on a row too narrow for the Configuration aside (objectui#11795)', () => { + it('reserves no aside beside the canvas, and a selection opens the configuration in a drawer', async () => { + renderPillarAt(AUTOMATIONS_CONFIG_FOLD_WIDTH - 1); + await openFlow(); + + expect(rowAsides()).toEqual([]); + expect(screen.queryByRole('complementary')).toBeNull(); + expect(screen.queryByRole('dialog')).toBeNull(); + + browserClick(startNodeCard()); + + const drawer = await screen.findByRole('dialog', { name: CONFIG }, { timeout: 8000 }); + expect(await within(drawer).findByLabelText('Label', undefined, { timeout: 8000 })).toBeEnabled(); + expect(startNodeCard()).toHaveAttribute('aria-pressed', 'true'); + // Still no aside: the drawer lies over the canvas, it takes no width from it. + expect(rowAsides()).toEqual([]); + }); + + it('closing the drawer clears the selection, as the aside’s ✕ does', async () => { + renderPillarAt(390); + await openFlow(); + browserClick(startNodeCard()); + const drawer = await screen.findByRole('dialog', { name: CONFIG }, { timeout: 8000 }); + + fireEvent.keyDown(drawer, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(startNodeCard()).toHaveAttribute('aria-pressed', 'false'); + }); + + it('a row that narrows past the threshold folds its aside in place', async () => { + renderPillarAt(1200); + await openFlow(); + expect(rowAsides()).toHaveLength(1); + + resizeRowTo(600); + + expect(rowAsides()).toEqual([]); + browserClick(startNodeCard()); + expect(await screen.findByRole('dialog', { name: CONFIG }, { timeout: 8000 })).toBeInTheDocument(); + }); +}); + +describe('Automations pillar on a row with room for the aside — unchanged (objectui#11795, the control)', () => { + for (const [label, width] of [ + ['at the threshold', AUTOMATIONS_CONFIG_FOLD_WIDTH], + ['unmeasured (0 wide)', 0], + ] as const) { + it(`${label}: the aside stays beside the canvas and a selection fills it, with no drawer`, async () => { + renderPillarAt(width); + await openFlow(); + + expect(rowAsides()).toHaveLength(1); + const aside = screen.getByRole('complementary'); + expect(within(aside).getByText(CONFIG)).toBeInTheDocument(); + + browserClick(startNodeCard()); + + expect(await within(aside).findByLabelText('Label', undefined, { timeout: 8000 })).toBeEnabled(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + } +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.desktopHint-11795.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.desktopHint-11795.test.tsx new file mode 100644 index 0000000000..aa55711bdf --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.desktopHint-11795.test.tsx @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11795 — Studio stays a desktop tool, and on a phone it says so: one + * line under the header (`engine.studio.desktopHint`), shown below `md` (the + * width `useIsMobile` answers for), and nowhere wider. + * + * The REAL surface on its Data tab; the chat dock and the panels it does not + * need are stubbed as in the sibling surface pins. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PACKAGE_ID = 'app.b2r4'; + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, + Toaster: () => null, +})); + +const mockClient = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (_t: string, name: string) => ({ effective: { name } })), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => undefined), + save: vi.fn(async () => ({})), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => ({}) }; +}); + +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { t } from '../metadata-admin/i18n'; + +const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +const realInnerWidth = window.innerWidth; +function setViewportWidth(px: number) { + Object.defineProperty(window, 'innerWidth', { value: px, configurable: true, writable: true }); +} + +beforeEach(() => { + vi.stubGlobal( + 'matchMedia', + (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }), + ); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => + String(input) === '/api/v1/packages' + ? answer({ + success: true, + data: { packages: [{ manifest: { id: PACKAGE_ID, name: 'Field Ops' }, writable: true }] }, + }) + : answer([]), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + setViewportWidth(realInnerWidth); +}); + +function renderSurfaceAt(width: number) { + setViewportWidth(width); + return render( + + + } /> + + , + ); +} + +const HINT = t('engine.studio.desktopHint', 'en'); + +describe('StudioDesignSurface — the one-line desktop hint on a phone (objectui#11795)', () => { + it('a phone-width window shows the hint once, under the header', async () => { + renderSurfaceAt(390); + + const hint = await screen.findByText(HINT); + expect(hint).toHaveAttribute('role', 'note'); + expect(screen.getAllByText(HINT)).toHaveLength(1); + // Directly below the Studio header, above the pillar. + expect(hint.previousElementSibling?.tagName).toBe('HEADER'); + }); + + it('the control: from `md` up, no hint', async () => { + renderSurfaceAt(768); + + await waitFor(() => + expect(screen.getByTitle('Switch / create package')).toHaveAttribute('data-pkg-list-state', 'loaded'), + ); + expect(screen.queryByText(HINT)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index bc8bea0768..ff0517aa73 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -28,7 +28,7 @@ import type { FlowRuntimeState as SpecFlowRuntimeState } from '@objectstack/spec import type { I18nLabel } from '@objectstack/spec/ui'; import { StudioChatDock, type StudioSurfaceLabel } from './StudioAiCopilot.js'; import { nextCenterTab, type StudioCenterTab } from './centerTab.js'; -import { useIsWideViewport } from './wideViewport.js'; +import { AUTOMATIONS_CONFIG_FOLD_WIDTH, useIsNarrowerThan, useIsWideViewport } from './wideViewport.js'; import { GridFieldAuthoringProvider, cn, @@ -46,6 +46,10 @@ import { DropdownMenuItem, Badge, Separator, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, } from '@object-ui/components'; import { ObjectView as PluginObjectView } from '@object-ui/plugin-view'; import { ListView } from '@object-ui/plugin-list'; @@ -1543,6 +1547,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React // injected `aiSlot` (the cloud seam, ADR-0080) keeps the legacy left panel // — the cloud edition migrates on its own schedule. const chatDockMode = !aiSlot; + // objectui#11795 — Studio stays a desktop tool; on a phone it says so once, + // in one line under the header, rather than leave the cramped layout to + // speak for itself. + const isMobile = useIsMobile(); // objectui#8219 — the Interfaces pillar's open leaf, lifted to the dock so // the copilot's "discussing" chip reads its display label (objectui#7254). // Display-only; the agent's context stays URL-derived. Other pillars report @@ -1770,6 +1778,16 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React + {isMobile && ( +

+ {t('engine.studio.desktopHint', locale)} +

+ )} +
{/* objectui#11272 — Data and Automations are keyed by package too, for the reason written at `InterfacesPillar` below. Unkeyed, @@ -5825,6 +5843,12 @@ export function AutomationsPillar({ // See DataPillar's rail — same mobile-overlay treatment for the flow list. const isMobile = useIsMobile(); const [railOpen, setRailOpen] = React.useState(false); + // objectui#11795 — on a row too narrow for the Configuration aside to sit + // beside the canvas (`AUTOMATIONS_CONFIG_FOLD_WIDTH`), the aside folds away + // and a selection opens the same configuration as a drawer over the canvas, + // so the canvas keeps the row's width. Wider rows keep the aside as it was. + const layoutRowRef = React.useRef(null); + const configFolded = useIsNarrowerThan(layoutRowRef, AUTOMATIONS_CONFIG_FOLD_WIDTH); const [flows, setFlows] = React.useState([]); // objectui#7255 — same live-pulse subscription as the sibling rails; this // one only replaces the flow LIST, so it needs no edit-buffer hold either. @@ -6167,6 +6191,37 @@ export function AutomationsPillar({ } }, [saveFlowDraft, current, draft, draftPackageId, onDraftSaved, locale, readOnly, sendingFlowDraft]); + // The Configuration's body, in the aside beside the canvas or, on a folded + // row, in the drawer over it (objectui#11795). + const configBodyEl = + selection && inspector && current && flowLoaded ? ( + React.createElement(inspector, { + type: 'flow', + name: current.name, + draft, + selection, + onPatch, + onClearSelection: () => setSelection(null), + onSelectionChange: setSelection, + // objectui#11124 — the pillar's real flag, threaded exactly as + // the Data pillar threads it (objectui#2259). + readOnly, + locale, + }) + ) : designersUnregistered ? ( +
+ + {t('engine.studio.auto.designersMissing', locale)} +
+ ) : ( +
+ + {t('engine.studio.auto.emptyLine1', locale)} +
+ {t('engine.studio.auto.emptyLine2', locale)} +
+ ); + return (
{flowConflictDialog} @@ -6220,7 +6275,7 @@ export function AutomationsPillar({ )}
-
+
{isMobile && railOpen && (
+ {configFolded ? ( + // objectui#11795 — the folded row: no aside reserving width beside + // the canvas; the selection's configuration opens over it, and + // closing the drawer clears the selection, as the aside's ✕ does. + { if (!open) setSelection(null); }}> + + + + + {t('engine.studio.auto.config', locale)} + + +
{configBodyEl}
+
+
+ ) : ( + )}
, px: number): boolean { + const [narrower, setNarrower] = React.useState(false); + React.useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const read = () => { + const width = el.getBoundingClientRect().width; + setNarrower(width > 0 && width < px); + }; + read(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(read); + observer.observe(el); + return () => observer.disconnect(); + }, [ref, px]); + return narrower; +}