sadpandajoe commented on code in PR #45090:
URL: https://github.com/apache/superset/pull/45090#discussion_r4365477196


##########
superset-frontend/src/features/canvas/CanvasGridSurface.tsx:
##########
@@ -0,0 +1,814 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview One grid of a canvas, with drag and resize built in.
+ *
+ * There is no edit mode: a user who may edit gets a drag grip and a resize
+ * corner on each widget, on the canvas as they view it. A user who may not
+ * gets the same grid with no handles. Dragging never covers the widget's own
+ * surface, so a filter or chart underneath stays clickable.
+ *
+ * While a gesture is live the widget follows the pointer and a dashed outline
+ * marks the cells it would take; releasing persists exactly those cells.
+ */
+
+import {
+  PointerEvent as ReactPointerEvent,
+  KeyboardEvent as ReactKeyboardEvent,
+  ReactNode,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
+import { css, styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components';
+import {
+  GridMetrics,
+  gridDelta,
+  movedPlacement,
+  NO_DELTA,
+  RESIZE_CORNERS,
+  type ResizeCorner,
+  resizedPlacement,
+  samePlacement,
+} from './gridGeometry';
+import { previewGrid, type GridLayout } from './resolveGrid';
+import type { GridPlacement, SpanConstraints } from './types';
+
+type GestureKind = 'move' | 'resize';
+
+interface Gesture {
+  nodeId: string;
+  kind: GestureKind;
+  pointerId: number;
+  startX: number;
+  startY: number;
+  /**
+   * Page scroll when the widget was grabbed. Pointer coordinates are relative
+   * to the viewport, so scrolling mid-gesture moves the content under a still
+   * pointer; without this the drag would report no movement at all.
+   */
+  startScrollX: number;
+  startScrollY: number;
+  dx: number;
+  dy: number;
+  /**
+   * Where the widget sat when it was grabbed. The gesture resolves against
+   * this, not against the current placement: a write landing mid-gesture (a
+   * sibling's drop pushing this one down, or a draft poll) would otherwise
+   * move the ground under the pointer and persist the drop relative to a row
+   * the user never aimed at.
+   */
+  origin: GridPlacement;
+  /**
+   * Whether this gesture is actually dragging yet. One begun on a handle is
+   * armed at once; one begun on the widget body waits until the pointer has
+   * travelled `DRAG_THRESHOLD`, so a press that turns out to be a click
+   * reaches the widget underneath instead of nudging it.
+   */
+  armed: boolean;
+  /**
+   * The element the press landed on, so a gesture that arms later can take
+   * the pointer from it. Held rather than looked up when it arms: by then the
+   * pointer may be over something else entirely.
+   */
+  capture: HTMLElement;
+  /** For a resize, the corner being dragged; the opposite one stays put. */
+  corner: ResizeCorner;
+}
+
+/** Pixels of travel before a press on a widget body becomes a drag. */
+const DRAG_THRESHOLD = 4;
+
+/**
+ * Presses already claimed by a grid, so one that bubbles through nested grids
+ * starts exactly one gesture: the innermost grid's.
+ */
+const claimedPresses = new WeakSet<Event>();
+
+/**
+ * Controls that handle press-and-drag themselves, so a press on one must not
+ * also move the widget: a filter's slider, a form control, a link, a handle
+ * for reordering something inside a widget. `data-canvas-no-drag` lets a
+ * widget opt anything else out.
+ */
+const OWN_GESTURE = [
+  'input',
+  'textarea',
+  'select',
+  'button',
+  'a[href]',
+  '[role="slider"]',
+  '[role="button"]',
+  '[role="scrollbar"]',
+  '[contenteditable=""]',
+  '[contenteditable="true"]',
+  '[draggable="true"]',
+  '[data-canvas-no-drag]',
+].join(',');
+
+/**
+ * Whether a press landed on a scrollbar rather than inside the content.
+ *
+ * A scrollbar belongs to the element it scrolls and dragging it scrolls, so a
+ * widget tall enough to scroll -- a table, usually -- would otherwise be
+ * impossible to scroll by hand in a plan. `clientWidth`/`clientHeight` stop
+ * short of the scrollbar, so a press past either is on it; the comparison is
+ * skipped for an element that isn't overflowing, which has no scrollbar to
+ * press. A left-hand scrollbar (RTL) is not detected.
+ */
+const onScrollbar = (
+  target: Element,
+  clientX: number,
+  clientY: number,
+): boolean => {
+  const box = target.getBoundingClientRect();
+  return (
+    (target.scrollHeight > target.clientHeight &&
+      clientX > box.left + target.clientWidth) ||
+    (target.scrollWidth > target.clientWidth &&
+      clientY > box.top + target.clientHeight)
+  );
+};
+
+/** Whether something inside the widget owns this press, so the grid doesn't. 
*/
+const ownsPress = (event: ReactPointerEvent<HTMLElement>): boolean => {
+  const { target } = event;
+  if (!(target instanceof Element)) return false;
+  const owner = target.closest(OWN_GESTURE);
+  // Bounded to this widget: a control that happens to sit above the grid in
+  // the page isn't what the press landed on.
+  return (
+    (owner !== null && event.currentTarget.contains(owner)) ||
+    onScrollbar(target, event.clientX, event.clientY)
+  );
+};
+
+/** A gesture's movement over the page content, scrolling included. */
+const travelled = (gesture: Gesture, clientX: number, clientY: number) => ({
+  dx: clientX - gesture.startX + (window.scrollX - gesture.startScrollX),
+  dy: clientY - gesture.startY + (window.scrollY - gesture.startScrollY),

Review Comment:
   Scroll compensation only tracks `window.scrollY`, but with the chat panel 
open `App.tsx` locks the shell and scrolls `Layout.Content` instead, and Canvas 
opens that panel automatically when a chat is registered. Grab a widget's move 
handle, wheel-scroll the canvas by two rows, and release at the same viewport 
position: `window.scrollY` never changes, so `dy` is 0, the gesture resolves 
back to `origin`, and nothing is saved even though the pointer ended over 
another row. Could this measure the grip's displacement from its bounding rect 
(or sum the scroll offsets of the scrolling ancestors) rather than reading the 
window?



##########
superset-frontend/src/features/canvas/useCanvasLayout.ts:
##########
@@ -0,0 +1,295 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { getClientErrorObject, SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { DRAFT_TOKEN_HEADER } from './useCanvasDefinition';
+import type {
+  ApplyOperationsResult,
+  CanvasDefinitionResult,
+  GridLayout,
+  GridPlacement,
+} from './types';
+
+/** The newest layout this client knows of, and the revision it belongs to. */
+interface AppliedLayout {
+  revision: number;
+  placements: Record<string, GridPlacement>;
+  /** The stored layouts behind `placements`; see `CanvasLayout.layouts`. */
+  layouts: Record<string, GridLayout>;
+}
+
+export interface CanvasLayout {
+  /** Placements to render: the newest local ones, else the server's. */
+  placements: Record<string, GridPlacement>;
+  /**
+   * The stored layouts behind those placements, which is what the server
+   * resolves from. Kept in step with every write rather than read from the
+   * fetched definition: a write can change a node's stored layout without
+   * being asked to, since a collided sibling is pushed down and its new `row`
+   * written back. A drag preview resolving from the fetched layouts would
+   * then disagree with the server until the next refetch.
+   */
+  layouts: Record<string, GridLayout>;
+  /**
+   * The newest revision the server is known to hold — the one the last write
+   * returned, else the one the definition was fetched at. Never an optimistic
+   * guess, so it is safe to send back as the revision a request is based on
+   * (a draft commit, say, which requires an exact match).
+   */
+  revision: number;
+  /**
+   * Whether a layout write is in flight or queued, so `revision` is about to
+   * move. A caller that sends `revision` to the server — publishing a draft —
+   * must wait for this to clear, or it names a revision that is already stale
+   * and loses the writes that had not landed.
+   */
+  saving: boolean;
+  /** Persist a node's new placement. A no-op when the user can't edit. */
+  place: (nodeId: string, placement: GridPlacement) => void;
+  /** Why the last write failed, for a dismissable notice. */
+  error?: string;
+  dismissError: () => void;
+}
+
+/**
+ * Persists drag and resize as `place` operations.
+ *
+ * The moved widget appears where it was dropped straight away, then takes the
+ * placements the server resolved — which may differ, since the server pushes
+ * overlapping widgets down.
+ *
+ * Each write is based on the newest revision the server has *confirmed*, taken
+ * from the previous write's response rather than from the definition. The
+ * definition's revision only advances when a refetch lands, and that refetch
+ * is driven by a realtime nudge; without one (no realtime service, a dropped
+ * socket) it would stay put and every drag after the first would collide with
+ * the user's own previous drag.
+ *
+ * A write while another is still in flight is queued rather than sent with a
+ * revision the server hasn't reached yet, so dragging quickly coalesces into
+ * one follow-up request instead of conflicting.
+ */
+export function useCanvasLayout(
+  canvasId: number,
+  result: CanvasDefinitionResult,
+  reload: () => void,
+  draftToken?: string,
+): CanvasLayout {
+  const [applied, setApplied] = useState<AppliedLayout>();
+  const [error, setError] = useState<string>();
+  // Mirrors `writing`/`queued` as state, since callers render against it.
+  const [saving, setSaving] = useState(false);
+  const { revision, canEdit } = result;
+
+  // Last revision the server acknowledged. Never an optimistic guess, so a
+  // write is never based on a revision the server hasn't reached.
+  const confirmed = useRef<number>();
+  // The definition's own revision, for the write that hasn't had a response
+  // yet (first drag after a load or a refetch).
+  const fetched = useRef(revision);
+  const writing = useRef(false);
+  // Newest intent per node while a write is in flight; sent as one batch.
+  const queued = useRef(new Map<string, GridPlacement>());
+  // The definition's placements, for an optimistic view that has no newer
+  // local layout to build on.
+  const fetchedPlacements = useRef(result.placements);
+  const fetchedLayouts = useRef(result.layouts);
+  // The last layout the server acknowledged, without any optimistic overlay.
+  // A failed write falls back to this rather than to the fetched definition,
+  // which may be several acknowledged drags behind.
+  const acknowledged = useRef<AppliedLayout>();
+
+  useEffect(() => {
+    fetched.current = revision;
+    fetchedPlacements.current = result.placements;
+    fetchedLayouts.current = result.layouts;
+  }, [revision, result.placements, result.layouts]);
+
+  // Which surface this state belongs to. A request already in flight when the
+  // surface changes still returns, and its response has to be dropped rather
+  // than written over the new surface's.
+  const generation = useRef(0);
+
+  useEffect(() => {
+    // A different canvas, or switching between a draft and the published
+    // canvas, shares none of this state -- a draft counts its own revisions
+    // from zero, so a revision confirmed on one surface means nothing on the
+    // other.
+    generation.current += 1;
+    confirmed.current = undefined;
+    acknowledged.current = undefined;
+    writing.current = false;
+    queued.current.clear();
+    setApplied(undefined);
+    setError(undefined);
+    setSaving(false);
+  }, [canvasId, draftToken]);
+
+  const send = useCallback(
+    (ops: Map<string, GridPlacement>) => {
+      writing.current = true;
+      setSaving(true);
+      // The surface this write belongs to, as of now. Everything below runs
+      // after the response, by which time the user may have published or
+      // discarded the plan, or navigated to another canvas.
+      const era = generation.current;
+      const base = Math.max(fetched.current, confirmed.current ?? 0);
+      const placeOps = [...ops].map(([id, layout]) => ({
+        op: 'place',
+        id,
+        layout,
+      }));
+      // While a draft is open the layout belongs to the draft, not the
+      // published canvas: same operations, but its own route, its own
+      // revision field, and the token that authorizes it.
+      const target = draftToken
+        ? {
+            endpoint: '/api/v1/canvas/draft',
+            headers: { [DRAFT_TOKEN_HEADER]: draftToken },
+            jsonPayload: { revision: base, ops: placeOps },
+          }
+        : {
+            endpoint: `/api/v1/canvas/${canvasId}/definition`,
+            jsonPayload: { base_revision: base, ops: placeOps },
+          };
+      SupersetClient.request({ ...target, method: 'PATCH' })
+        .then(({ json }) => {
+          // A response that outlived its surface. Letting it through would put
+          // the old surface's revision in `confirmed`, and the new surface's
+          // next write would be based on a revision it never had -- a draft
+          // counts from zero, so that is a 409 or the wrong layout.
+          if (era !== generation.current) return;
+          const written = json?.result as ApplyOperationsResult | undefined;
+          if (!written) return;
+          confirmed.current = written.revision;
+          acknowledged.current = {
+            revision: written.revision,
+            placements: written.placements,
+            layouts: written.layouts,
+          };
+          // Gestures queued while this write was in flight are newer than the
+          // placements it resolved, so they stay on top -- otherwise the
+          // widget would snap back until the follow-up request returned. The
+          // view is tagged a revision ahead, which is what the follow-up will
+          // produce, so a refetch of this revision doesn't drop them either.
+          const waiting = [...queued.current];
+          setApplied({
+            revision: written.revision + (waiting.length > 0 ? 1 : 0),
+            placements:
+              waiting.length > 0
+                ? { ...written.placements, ...Object.fromEntries(waiting) }
+                : written.placements,
+            layouts:
+              waiting.length > 0
+                ? { ...written.layouts, ...Object.fromEntries(waiting) }
+                : written.layouts,
+          });
+        })
+        .catch(async response => {
+          if (era !== generation.current) return;
+          queued.current.clear();
+          // Back to the last layout the server acknowledged, not to the
+          // fetched definition: dropping to the latter would make an earlier
+          // acknowledged drag vanish from the screen until the next poll,
+          // even though the server has it.
+          setApplied(acknowledged.current);
+          const { status } = response ?? {};
+          if (status === 409) {
+            setError(
+              t(
+                'This canvas changed while you were editing, so your change 
was undone.',
+              ),
+            );
+            reload();
+            return;
+          }
+          const { message } = await getClientErrorObject(response);
+          setError(
+            status === 403
+              ? t('You do not have permission to change this layout.')
+              : message || t('That change could not be saved.'),
+          );
+        })
+        .finally(() => {
+          if (era !== generation.current) return;
+          writing.current = false;
+          if (queued.current.size > 0) {
+            const next = new Map(queued.current);
+            queued.current.clear();
+            // Still saving: the follow-up sets the flag again straight away.
+            send(next);
+          } else {
+            setSaving(false);
+          }
+        });
+    },
+    [canvasId, draftToken, reload],
+  );
+
+  const place = useCallback(
+    (nodeId: string, placement: GridPlacement) => {
+      if (!canEdit) return;
+      setError(undefined);
+      // Show the drop straight away, built on the newest layout so an earlier
+      // gesture isn't undone -- including one from this same batch, which a
+      // ref read wouldn't see since refs only advance on render.
+      setApplied(current => {
+        const newer =
+          current && current.revision > fetched.current ? current : undefined;
+        return {
+          revision: Math.max(fetched.current, confirmed.current ?? 0) + 1,
+          placements: {
+            ...(newer?.placements ?? fetchedPlacements.current),
+            [nodeId]: placement,
+          },
+          // The op sends this placement as the node's layout, so that is
+          // exactly what the server will store for it. The siblings it pushes
+          // keep their old stored layout until the response lands.
+          layouts: {
+            ...(newer?.layouts ?? fetchedLayouts.current),
+            [nodeId]: placement,
+          },
+        };
+      });
+      if (writing.current) {
+        queued.current.set(nodeId, placement);

Review Comment:
   `Map.set` on an existing key keeps its original insertion order, but the 
batch is sent as ordered `place` ops and `bring_forward` makes later ops win a 
contested cell. While a write is in flight, move A to row 2, then B to row 3, 
then A to row 3: the queue holds `A, B`, so the server applies A and then B and 
B takes row 3 and pushes A down, although A was dropped last. Should the entry 
be deleted before it is set so the newest gesture stays last?



##########
superset-frontend/src/features/canvas/PlanModeButton.test.tsx:
##########
@@ -0,0 +1,98 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import fetchMock from 'fetch-mock';
+import {
+  fireEvent,
+  render,
+  screen,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import PlanModeButton from './PlanModeButton';
+
+const endpoint = 'glob:*/api/v1/canvas/7/draft';
+
+beforeEach(() => {
+  fetchMock.removeRoutes();
+  fetchMock.clearHistory();
+});
+
+const clickPlanMode = async (onStarted = jest.fn()) => {
+  render(<PlanModeButton canvasId={7} onStarted={onStarted} />, {
+    useRedux: true,
+  });
+  fireEvent.click(screen.getByRole('button', { name: 'Plan mode' }));
+  return onStarted;
+};
+
+test('starting a plan creates a draft and opens its token', async () => {
+  fetchMock.post(endpoint, { result: { token: 'tok-123', revision: 0 } });
+
+  const onStarted = await clickPlanMode();
+
+  await waitFor(() => expect(onStarted).toHaveBeenCalledWith('tok-123'));
+  expect(fetchMock.callHistory.calls()).toHaveLength(1);
+});
+
+test('a draft that cannot be started does not open anything', async () => {
+  fetchMock.post(endpoint, 403);
+
+  const onStarted = await clickPlanMode();
+
+  await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(1));
+  expect(onStarted).not.toHaveBeenCalled();
+});
+
+test('a response without a token is treated as a failure', async () => {
+  fetchMock.post(endpoint, { result: { revision: 0 } });
+
+  const onStarted = await clickPlanMode();
+
+  await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(1));
+  expect(onStarted).not.toHaveBeenCalled();
+});
+
+test('a draft that arrives after the canvas changed is not opened', async () 
=> {
+  // Starting a plan on one canvas and navigating to another before the
+  // response: the token belongs to the first canvas, and opening it on the
+  // second would show that canvas's draft and publish to it.
+  let respond: () => void = () => {};
+  const held = new Promise<Response>(resolve => {
+    respond = () =>
+      resolve(
+        new Response(JSON.stringify({ result: { token: 'tok-123' } }), {
+          headers: { 'Content-Type': 'application/json' },
+        }),
+      );
+  });
+  fetchMock.post(endpoint, () => held);
+  const onStarted = jest.fn();
+  const { rerender } = render(
+    <PlanModeButton canvasId={7} onStarted={onStarted} />,
+    { useRedux: true },
+  );
+
+  fireEvent.click(screen.getByRole('button', { name: 'Plan mode' }));
+  // The user moves on to another canvas before the draft comes back.
+  rerender(<PlanModeButton canvasId={8} onStarted={onStarted} />);
+
+  respond();
+
+  await waitFor(() => expect(fetchMock.callHistory.calls()).toHaveLength(1));

Review Comment:
   `waitFor` on `callHistory.calls()` having length 1 is already true as soon 
as the POST is issued, before `respond()` is consumed, so `onStarted` not being 
called is asserted before the response continuation runs. Deleting the 
`current.current !== startedFor` guard would still leave this green, even 
though canvas 7's token would then open on canvas 8. Could the test await the 
response being handled (for example waiting for `starting` to clear, or 
flushing the held promise) before asserting `onStarted` was not called?



##########
superset-frontend/src/features/canvas/useCanvasLayout.ts:
##########
@@ -0,0 +1,295 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { getClientErrorObject, SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { DRAFT_TOKEN_HEADER } from './useCanvasDefinition';
+import type {
+  ApplyOperationsResult,
+  CanvasDefinitionResult,
+  GridLayout,
+  GridPlacement,
+} from './types';
+
+/** The newest layout this client knows of, and the revision it belongs to. */
+interface AppliedLayout {
+  revision: number;
+  placements: Record<string, GridPlacement>;
+  /** The stored layouts behind `placements`; see `CanvasLayout.layouts`. */
+  layouts: Record<string, GridLayout>;
+}
+
+export interface CanvasLayout {
+  /** Placements to render: the newest local ones, else the server's. */
+  placements: Record<string, GridPlacement>;
+  /**
+   * The stored layouts behind those placements, which is what the server
+   * resolves from. Kept in step with every write rather than read from the
+   * fetched definition: a write can change a node's stored layout without
+   * being asked to, since a collided sibling is pushed down and its new `row`
+   * written back. A drag preview resolving from the fetched layouts would
+   * then disagree with the server until the next refetch.
+   */
+  layouts: Record<string, GridLayout>;
+  /**
+   * The newest revision the server is known to hold — the one the last write
+   * returned, else the one the definition was fetched at. Never an optimistic
+   * guess, so it is safe to send back as the revision a request is based on
+   * (a draft commit, say, which requires an exact match).
+   */
+  revision: number;
+  /**
+   * Whether a layout write is in flight or queued, so `revision` is about to
+   * move. A caller that sends `revision` to the server — publishing a draft —
+   * must wait for this to clear, or it names a revision that is already stale
+   * and loses the writes that had not landed.
+   */
+  saving: boolean;
+  /** Persist a node's new placement. A no-op when the user can't edit. */
+  place: (nodeId: string, placement: GridPlacement) => void;
+  /** Why the last write failed, for a dismissable notice. */
+  error?: string;
+  dismissError: () => void;
+}
+
+/**
+ * Persists drag and resize as `place` operations.
+ *
+ * The moved widget appears where it was dropped straight away, then takes the
+ * placements the server resolved — which may differ, since the server pushes
+ * overlapping widgets down.
+ *
+ * Each write is based on the newest revision the server has *confirmed*, taken
+ * from the previous write's response rather than from the definition. The
+ * definition's revision only advances when a refetch lands, and that refetch
+ * is driven by a realtime nudge; without one (no realtime service, a dropped
+ * socket) it would stay put and every drag after the first would collide with
+ * the user's own previous drag.
+ *
+ * A write while another is still in flight is queued rather than sent with a
+ * revision the server hasn't reached yet, so dragging quickly coalesces into
+ * one follow-up request instead of conflicting.
+ */
+export function useCanvasLayout(
+  canvasId: number,
+  result: CanvasDefinitionResult,
+  reload: () => void,
+  draftToken?: string,
+): CanvasLayout {
+  const [applied, setApplied] = useState<AppliedLayout>();
+  const [error, setError] = useState<string>();
+  // Mirrors `writing`/`queued` as state, since callers render against it.
+  const [saving, setSaving] = useState(false);
+  const { revision, canEdit } = result;
+
+  // Last revision the server acknowledged. Never an optimistic guess, so a
+  // write is never based on a revision the server hasn't reached.
+  const confirmed = useRef<number>();
+  // The definition's own revision, for the write that hasn't had a response
+  // yet (first drag after a load or a refetch).
+  const fetched = useRef(revision);
+  const writing = useRef(false);
+  // Newest intent per node while a write is in flight; sent as one batch.
+  const queued = useRef(new Map<string, GridPlacement>());
+  // The definition's placements, for an optimistic view that has no newer
+  // local layout to build on.
+  const fetchedPlacements = useRef(result.placements);
+  const fetchedLayouts = useRef(result.layouts);
+  // The last layout the server acknowledged, without any optimistic overlay.
+  // A failed write falls back to this rather than to the fetched definition,
+  // which may be several acknowledged drags behind.
+  const acknowledged = useRef<AppliedLayout>();
+
+  useEffect(() => {
+    fetched.current = revision;
+    fetchedPlacements.current = result.placements;
+    fetchedLayouts.current = result.layouts;
+  }, [revision, result.placements, result.layouts]);
+
+  // Which surface this state belongs to. A request already in flight when the
+  // surface changes still returns, and its response has to be dropped rather
+  // than written over the new surface's.
+  const generation = useRef(0);
+
+  useEffect(() => {
+    // A different canvas, or switching between a draft and the published
+    // canvas, shares none of this state -- a draft counts its own revisions
+    // from zero, so a revision confirmed on one surface means nothing on the
+    // other.
+    generation.current += 1;
+    confirmed.current = undefined;
+    acknowledged.current = undefined;
+    writing.current = false;
+    queued.current.clear();
+    setApplied(undefined);
+    setError(undefined);
+    setSaving(false);
+  }, [canvasId, draftToken]);
+
+  const send = useCallback(
+    (ops: Map<string, GridPlacement>) => {
+      writing.current = true;
+      setSaving(true);
+      // The surface this write belongs to, as of now. Everything below runs
+      // after the response, by which time the user may have published or
+      // discarded the plan, or navigated to another canvas.
+      const era = generation.current;
+      const base = Math.max(fetched.current, confirmed.current ?? 0);
+      const placeOps = [...ops].map(([id, layout]) => ({
+        op: 'place',
+        id,
+        layout,
+      }));
+      // While a draft is open the layout belongs to the draft, not the
+      // published canvas: same operations, but its own route, its own
+      // revision field, and the token that authorizes it.
+      const target = draftToken
+        ? {
+            endpoint: '/api/v1/canvas/draft',
+            headers: { [DRAFT_TOKEN_HEADER]: draftToken },
+            jsonPayload: { revision: base, ops: placeOps },
+          }
+        : {
+            endpoint: `/api/v1/canvas/${canvasId}/definition`,
+            jsonPayload: { base_revision: base, ops: placeOps },
+          };
+      SupersetClient.request({ ...target, method: 'PATCH' })
+        .then(({ json }) => {
+          // A response that outlived its surface. Letting it through would put
+          // the old surface's revision in `confirmed`, and the new surface's
+          // next write would be based on a revision it never had -- a draft
+          // counts from zero, so that is a 409 or the wrong layout.
+          if (era !== generation.current) return;
+          const written = json?.result as ApplyOperationsResult | undefined;
+          if (!written) return;
+          confirmed.current = written.revision;
+          acknowledged.current = {

Review Comment:
   A successful write keeps only `placements` and `layouts`, but the server's 
`place` also reorders the parent's `children` (`bring_forward`), and 
`CanvasGrid` keeps handing `previewGrid` the fetched definition's child order 
until the next poll. With full-width auto-placed `[A, B, C]`, drop C at row 1 
(server order `[C, A, B]`), then before the poll drag B to row 4: the preview 
resolves `[A, B, C]` and shows A at row 1 and C at row 2, while the server 
keeps C at row 1 and A at row 2, so the neighbours jump on release. Could the 
acknowledged order be kept alongside the layouts and fed to the preview, or the 
definition refetched after each write?



##########
superset-frontend/src/features/canvas/CanvasGridSurface.tsx:
##########
@@ -0,0 +1,814 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @fileoverview One grid of a canvas, with drag and resize built in.
+ *
+ * There is no edit mode: a user who may edit gets a drag grip and a resize
+ * corner on each widget, on the canvas as they view it. A user who may not
+ * gets the same grid with no handles. Dragging never covers the widget's own
+ * surface, so a filter or chart underneath stays clickable.
+ *
+ * While a gesture is live the widget follows the pointer and a dashed outline
+ * marks the cells it would take; releasing persists exactly those cells.
+ */
+
+import {
+  PointerEvent as ReactPointerEvent,
+  KeyboardEvent as ReactKeyboardEvent,
+  ReactNode,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
+import { css, styled } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
+import { Icons } from '@superset-ui/core/components';
+import {
+  GridMetrics,
+  gridDelta,
+  movedPlacement,
+  NO_DELTA,
+  RESIZE_CORNERS,
+  type ResizeCorner,
+  resizedPlacement,
+  samePlacement,
+} from './gridGeometry';
+import { previewGrid, type GridLayout } from './resolveGrid';
+import type { GridPlacement, SpanConstraints } from './types';
+
+type GestureKind = 'move' | 'resize';
+
+interface Gesture {
+  nodeId: string;
+  kind: GestureKind;
+  pointerId: number;
+  startX: number;
+  startY: number;
+  /**
+   * Page scroll when the widget was grabbed. Pointer coordinates are relative
+   * to the viewport, so scrolling mid-gesture moves the content under a still
+   * pointer; without this the drag would report no movement at all.
+   */
+  startScrollX: number;
+  startScrollY: number;
+  dx: number;
+  dy: number;
+  /**
+   * Where the widget sat when it was grabbed. The gesture resolves against
+   * this, not against the current placement: a write landing mid-gesture (a
+   * sibling's drop pushing this one down, or a draft poll) would otherwise
+   * move the ground under the pointer and persist the drop relative to a row
+   * the user never aimed at.
+   */
+  origin: GridPlacement;
+  /**
+   * Whether this gesture is actually dragging yet. One begun on a handle is
+   * armed at once; one begun on the widget body waits until the pointer has
+   * travelled `DRAG_THRESHOLD`, so a press that turns out to be a click
+   * reaches the widget underneath instead of nudging it.
+   */
+  armed: boolean;
+  /**
+   * The element the press landed on, so a gesture that arms later can take
+   * the pointer from it. Held rather than looked up when it arms: by then the
+   * pointer may be over something else entirely.
+   */
+  capture: HTMLElement;
+  /** For a resize, the corner being dragged; the opposite one stays put. */
+  corner: ResizeCorner;
+}
+
+/** Pixels of travel before a press on a widget body becomes a drag. */
+const DRAG_THRESHOLD = 4;
+
+/**
+ * Presses already claimed by a grid, so one that bubbles through nested grids
+ * starts exactly one gesture: the innermost grid's.
+ */
+const claimedPresses = new WeakSet<Event>();
+
+/**
+ * Controls that handle press-and-drag themselves, so a press on one must not
+ * also move the widget: a filter's slider, a form control, a link, a handle
+ * for reordering something inside a widget. `data-canvas-no-drag` lets a
+ * widget opt anything else out.
+ */
+const OWN_GESTURE = [
+  'input',
+  'textarea',
+  'select',
+  'button',
+  'a[href]',
+  '[role="slider"]',
+  '[role="button"]',
+  '[role="scrollbar"]',
+  '[contenteditable=""]',
+  '[contenteditable="true"]',
+  '[draggable="true"]',
+  '[data-canvas-no-drag]',
+].join(',');
+
+/**
+ * Whether a press landed on a scrollbar rather than inside the content.
+ *
+ * A scrollbar belongs to the element it scrolls and dragging it scrolls, so a
+ * widget tall enough to scroll -- a table, usually -- would otherwise be
+ * impossible to scroll by hand in a plan. `clientWidth`/`clientHeight` stop
+ * short of the scrollbar, so a press past either is on it; the comparison is
+ * skipped for an element that isn't overflowing, which has no scrollbar to
+ * press. A left-hand scrollbar (RTL) is not detected.
+ */
+const onScrollbar = (
+  target: Element,
+  clientX: number,
+  clientY: number,
+): boolean => {
+  const box = target.getBoundingClientRect();
+  return (
+    (target.scrollHeight > target.clientHeight &&
+      clientX > box.left + target.clientWidth) ||
+    (target.scrollWidth > target.clientWidth &&
+      clientY > box.top + target.clientHeight)
+  );
+};
+
+/** Whether something inside the widget owns this press, so the grid doesn't. 
*/
+const ownsPress = (event: ReactPointerEvent<HTMLElement>): boolean => {
+  const { target } = event;
+  if (!(target instanceof Element)) return false;
+  const owner = target.closest(OWN_GESTURE);
+  // Bounded to this widget: a control that happens to sit above the grid in
+  // the page isn't what the press landed on.
+  return (
+    (owner !== null && event.currentTarget.contains(owner)) ||
+    onScrollbar(target, event.clientX, event.clientY)
+  );
+};
+
+/** A gesture's movement over the page content, scrolling included. */
+const travelled = (gesture: Gesture, clientX: number, clientY: number) => ({
+  dx: clientX - gesture.startX + (window.scrollX - gesture.startScrollX),
+  dy: clientY - gesture.startY + (window.scrollY - gesture.startScrollY),
+});
+
+const Grid = styled.div<GridMetrics>`
+  display: grid;
+  grid-template-columns: repeat(${({ columns }) => columns}, minmax(0, 1fr));
+  grid-auto-rows: ${({ rowUnit }) => rowUnit}px;
+  gap: ${({ gap }) => gap}px;
+`;
+
+const GridItem = styled.div<{
+  placement?: GridPlacement;
+  dragging?: boolean;
+  offset?: { x: number; y: number };
+}>`
+  ${({ theme, placement, dragging, offset }) => css`
+    min-width: 0;
+    min-height: 0;
+    position: relative;
+    ${
+      placement &&
+      css`
+        grid-column: ${placement.col} / span ${placement.colSpan};
+        grid-row: ${placement.row} / span ${placement.rowSpan};
+      `
+    }
+    ${
+      dragging &&
+      offset &&
+      css`
+        /* Follow the pointer without reflowing the grid under it. */
+        transform: translate(${offset.x}px, ${offset.y}px);
+        z-index: 2;
+        opacity: 0.85;
+        /*
+         * A widget draws no background of its own, so at less than full
+         * opacity the neighbours and the drop outline show through the one
+         * being dragged and it reads as sliding underneath them. The fill and
+         * the shadow are what make it read as lifted.
+         */
+        background: ${theme.colorBgContainer};
+        box-shadow: ${theme.boxShadowSecondary};
+        border-radius: ${theme.borderRadius}px;
+      `
+    }
+  `}
+`;
+
+/**
+ * Where the widget being dragged will land. No error state: the neighbours
+ * are already rendered where they will end up, so an overlap shows as them
+ * moving aside rather than as a warning colour here.
+ */
+const DropPreview = styled.div<{ placement: GridPlacement }>`
+  ${({ theme, placement }) => css`
+    grid-column: ${placement.col} / span ${placement.colSpan};
+    grid-row: ${placement.row} / span ${placement.rowSpan};
+    border: 2px dashed ${theme.colorPrimary};
+    border-radius: ${theme.borderRadius}px;
+    background: ${theme.colorPrimaryBg};
+    pointer-events: none;
+    z-index: 1;
+  `}
+`;
+
+/**
+ * Wraps a widget so its handles can sit on top of it. The handles stay hidden
+ * until the pointer is over the widget or a handle has focus, so a canvas at
+ * rest looks the same whether or not the user may edit it.
+ *
+ * Only the innermost hovered widget shows its handles and its outline:
+ * `:hover` matches every ancestor, so a container would otherwise light up
+ * alongside whichever of its children the pointer is actually over.
+ */
+const Interactive = styled.div`
+  ${({ theme }) => css`
+    height: 100%;
+    position: relative;
+    border-radius: ${theme.borderRadius}px;
+    transition: box-shadow ${theme.motionDurationMid};
+
+    /* Direct children only: hovering a container must not reveal the
+       handles of every widget nested inside it. */
+    & > .canvas-handle {
+      opacity: 0;
+      /*
+       * Transparent is not absent: without this a hidden handle still wins
+       * the hit test over whatever it happens to sit above.
+       */
+      pointer-events: none;
+      transition: opacity ${theme.motionDurationMid};
+    }
+
+    &:hover:not(:has(.canvas-interactive:hover)) {
+      /* Borderless widgets (a metric tile, say) otherwise give no clue which
+         widget the handles belong to. */
+      box-shadow: 0 0 0 1px ${theme.colorBorder};
+    }
+
+    &:hover:not(:has(.canvas-interactive:hover)) > .canvas-handle,
+    & > .canvas-handle:focus-visible {
+      opacity: 1;
+      pointer-events: auto;
+    }
+  `}
+`;
+
+/** Step between a container's handles and its children's, in size units. */
+const HANDLE_STEP = 8;
+/** Size units a handle itself needs, so an offset can leave room for it. */
+const HANDLE_BOX = 7;
+
+/**
+ * How far a nested grid's handles sit from the widget's edge.
+ *
+ * Nesting steps the offset so a container's handle never lands exactly on its
+ * child's, but it is clamped to the widget's own rendered box: pushing past
+ * that would put the handle over the *next* widget, where it would take
+ * presses meant for that one.
+ */
+const handleOffset = (
+  sizeUnit: number,
+  depth: number,
+  extent: number,
+): number => {
+  const room = Math.max(sizeUnit, extent - sizeUnit * HANDLE_BOX);
+  return Math.min(sizeUnit * (1 + depth * HANDLE_STEP), room);
+};
+
+/**
+ * The widget itself, kept inside the cells it was given.
+ *
+ * A widget renders at its natural height — a table with every row, say — so
+ * without this it spills past its own box and over whatever is below. The
+ * handles are siblings rather than children, so they stay pinned to the box
+ * while the content scrolls behind them.
+ */
+const Content = styled.div<{ editable: boolean }>`
+  ${({ editable }) => css`
+    height: 100%;
+    overflow: auto;
+    ${
+      editable &&
+      css`
+        /*
+       * Dragging across text would otherwise select it and move the widget at
+       * once. In a plan the drag wins -- arranging is what a plan is for --
+       * and the published canvas selects normally.
+       */
+        user-select: none;
+
+        /*
+         * While a widget inside this one is being dragged, stop clipping. A
+         * child dragged towards the edge of its container is leaving the
+         * container's box, and scrolling it back into view is the opposite of
+         * what the gesture means -- it would cut the widget off at the edge
+         * and grow a scrollbar. Only an ancestor matches: the dragged widget's
+         * own content does not contain its grid cell.
+         */
+        &:has([data-dragging='true']) {
+          overflow: visible;
+        }
+      `
+    }
+  `}
+`;
+
+const Grip = styled.button<{ depth: number; extent: number }>`
+  ${({ theme, depth, extent }) => css`
+    position: absolute;
+    top: ${handleOffset(theme.sizeUnit, depth, extent)}px;
+    /* Deeper handles above shallower ones: the innermost wins a shared spot. 
*/
+    z-index: ${3 + depth};
+    /*
+     * Left of both the north-east resize corner (4 units at the corner) and a
+     * widget's own controls, which step aside to 5 units while a plan is open
+     * and are 8 units wide (see WithQuery's .canvas-widget-controls). These
+     * handles sit above them in the stack, so overlapping would swallow
+     * presses meant for the menu.
+     */
+    right: ${theme.sizeUnit * 14}px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: ${theme.sizeUnit / 2}px;
+    border: 1px solid ${theme.colorBorder};
+    border-radius: ${theme.borderRadiusSM}px;
+    background: ${theme.colorBgElevated};
+    color: ${theme.colorTextSecondary};
+    cursor: grab;
+    touch-action: none;
+
+    &:active {
+      cursor: grabbing;
+    }
+  `}
+`;
+
+/** Human-readable names for the four corners, for the handle labels. */
+const CORNER_NAMES: Record<ResizeCorner, () => string> = {
+  nw: () => t('top left'),
+  ne: () => t('top right'),
+  sw: () => t('bottom left'),
+  se: () => t('bottom right'),
+};
+
+const ResizeCorner = styled.button<{
+  corner: ResizeCorner;
+  depth: number;
+  extent: number;
+}>`
+  ${({ theme, corner, depth, extent }) => {
+    const west = corner === 'nw' || corner === 'sw';
+    const north = corner === 'nw' || corner === 'ne';
+    /*
+     * Stepped like the grip: a container's box shares its edges with its
+     * first and last child's, and the container's handles paint later, so
+     * sharing the pixel would make the child's corner unreachable.
+     */
+    const inset =
+      depth === 0 ? 0 : handleOffset(theme.sizeUnit, depth - 1, extent);
+    return css`
+      position: absolute;
+      ${west ? 'left' : 'right'}: ${inset}px;
+      ${north ? 'top' : 'bottom'}: ${inset}px;
+      z-index: ${3 + depth};
+      width: ${theme.sizeUnit * 4}px;
+      height: ${theme.sizeUnit * 4}px;
+      padding: 0;
+      border: none;
+      background: transparent;
+      cursor: ${west === north ? 'nwse-resize' : 'nesw-resize'};
+      touch-action: none;
+
+      /* A two-line corner, the conventional resize affordance. */
+      &::after {
+        content: '';
+        position: absolute;
+        ${west ? 'left' : 'right'}: ${theme.sizeUnit / 2}px;
+        ${north ? 'top' : 'bottom'}: ${theme.sizeUnit / 2}px;
+        width: ${theme.sizeUnit * 2}px;
+        height: ${theme.sizeUnit * 2}px;
+        border-${west ? 'left' : 'right'}: 2px solid 
${theme.colorTextTertiary};
+        border-${north ? 'top' : 'bottom'}: 2px solid
+          ${theme.colorTextTertiary};
+      }
+    `;
+  }}
+`;
+
+/** The grid's content width, kept current as the viewport changes. */
+function useElementWidth(): [(element: HTMLDivElement | null) => void, number] 
{
+  const [element, setElement] = useState<HTMLDivElement | null>(null);
+  const [width, setWidth] = useState(0);
+
+  useEffect(() => {
+    if (!element) return undefined;
+    const measure = () => setWidth(element.clientWidth);
+    measure();
+    if (typeof ResizeObserver === 'undefined') return undefined;
+    const observer = new ResizeObserver(measure);
+    observer.observe(element);
+    return () => observer.disconnect();
+  }, [element]);
+
+  return [setElement, width];
+}
+
+export interface CanvasGridSurfaceProps {
+  childIds: string[];
+  columns: number;
+  gap: number;
+  rowUnit: number;
+  placements: Record<string, GridPlacement>;
+  /**
+   * Each child's stored layout. The preview resolves from these, as the
+   * server does: an auto-placed child has no `col`/`row`, and resolving from
+   * the rectangle it currently occupies would pin it there in the preview
+   * while the server still places it freely.
+   */
+  layouts: Record<string, GridLayout>;
+  constraints: Record<string, SpanConstraints>;
+  /** Whether the user may drag and resize these widgets. */
+  editable: boolean;
+  /**
+   * How deeply this grid is nested. A container's box begins where its first
+   * child's does and its handles paint last, so without an offset per level a
+   * container's grip would sit over its child's and swallow the click.
+   */
+  depth: number;
+  onPlace: (nodeId: string, placement: GridPlacement) => void;
+  renderNode: (nodeId: string) => ReactNode;
+}
+
+export default function CanvasGridSurface({
+  childIds,
+  columns,
+  gap,
+  rowUnit,
+  placements,
+  layouts,
+  constraints,
+  editable,
+  depth,
+  onPlace,
+  renderNode,
+}: CanvasGridSurfaceProps) {
+  const [gridRef, width] = useElementWidth();
+  const [gesture, setGesture] = useState<Gesture>();
+  // The handlers read the gesture directly, so committing never runs as a
+  // side effect inside a state updater.
+  const live = useRef<Gesture>();
+
+  const update = useCallback((next: Gesture | undefined) => {
+    live.current = next;
+    setGesture(next);
+  }, []);
+
+  /** Where `from` would land given a pointer delta, in whole grid units. */
+  const targetOf = useCallback(
+    (
+      nodeId: string,
+      kind: GestureKind,
+      from: GridPlacement,
+      dx: number,
+      dy: number,
+      corner: ResizeCorner = 'se',
+    ) => {
+      const metrics: GridMetrics = { columns, gap, rowUnit };
+      const delta = width > 0 ? gridDelta(dx, dy, width, metrics) : NO_DELTA;
+      return kind === 'move'
+        ? movedPlacement(from, delta, columns)
+        : resizedPlacement(from, delta, columns, constraints[nodeId], corner);
+    },
+    [width, columns, gap, rowUnit, constraints],
+  );
+
+  /**
+   * Persists `next` for `nodeId`, where `from` is the placement the gesture
+   * resolved against.
+   *
+   * Both no-op checks matter. A gesture that moved nothing resolves back to
+   * `from`, and sending that would write the geometry the widget had when it
+   * was grabbed over anything that landed while the button was held -- a plain
+   * click on a handle would revert a draft poll or another editor's resize.
+   * Comparing with the current placement then drops a gesture that happens to
+   * land exactly where the widget already sits.
+   */
+  const commit = useCallback(
+    (nodeId: string, next: GridPlacement | undefined, from: GridPlacement) => {
+      const placement = placements[nodeId];
+      if (!next || !placement) return;
+      if (samePlacement(next, from) || samePlacement(next, placement)) return;
+      onPlace(nodeId, next);
+    },
+    [onPlace, placements],
+  );
+
+  const begin =
+    (
+      nodeId: string,
+      kind: GestureKind,
+      armed = true,
+      corner: ResizeCorner = 'se',
+    ) =>
+    (event: ReactPointerEvent<HTMLElement>) => {
+      if (event.button !== 0) return;
+      // Only a deferred press is checked. A handle press is unambiguous, and
+      // a handle is itself a button, so checking it would reject every one.
+      if (!armed && ownsPress(event)) return;

Review Comment:
   React delivers events from portalled children to this handler, and 
`ownsPress` only rejects a target when its `OWN_GESTURE` owner is inside the 
widget, so a press inside a portal passes straight through. Open a widget's 
View query modal in a draft and drag across the query text to select it: the 
`pointerdown` bubbles to the widget's wrapper, the gesture arms after 
`DRAG_THRESHOLD`, and releasing persists a move of the widget underneath. 
Should the body press be ignored when 
`event.currentTarget.contains(event.target)` is false?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to