@1agh/maude 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +2 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +93 -0
  3. package/apps/studio/acp/bridge.ts +114 -1
  4. package/apps/studio/acp/index.ts +184 -21
  5. package/apps/studio/acp/plugin-bootstrap.ts +115 -0
  6. package/apps/studio/acp/transcript.ts +36 -3
  7. package/apps/studio/activity.ts +45 -0
  8. package/apps/studio/api.ts +265 -47
  9. package/apps/studio/bin/_ensure-browser.mjs +305 -0
  10. package/apps/studio/bin/ensure-browser.sh +26 -0
  11. package/apps/studio/bin/screenshot.sh +33 -8
  12. package/apps/studio/bin/smoke.sh +46 -0
  13. package/apps/studio/canvas-edit.ts +422 -6
  14. package/apps/studio/canvas-lib.tsx +48 -0
  15. package/apps/studio/canvas-shell.tsx +684 -12
  16. package/apps/studio/client/app.jsx +683 -33
  17. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  18. package/apps/studio/client/panels/acp-runtime.js +227 -70
  19. package/apps/studio/client/panels/chat-context.js +124 -0
  20. package/apps/studio/client/panels/slash-commands.js +147 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  22. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  23. package/apps/studio/commands/reorder-command.ts +77 -0
  24. package/apps/studio/config.schema.json +6 -0
  25. package/apps/studio/dist/client.bundle.js +25 -53617
  26. package/apps/studio/dist/styles.css +1 -1
  27. package/apps/studio/hmr-broadcast.ts +27 -19
  28. package/apps/studio/http.ts +168 -26
  29. package/apps/studio/inspect.ts +108 -1
  30. package/apps/studio/paths.ts +32 -0
  31. package/apps/studio/readiness.ts +79 -30
  32. package/apps/studio/server.ts +11 -2
  33. package/apps/studio/test/acp-activity.test.ts +154 -0
  34. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  35. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  36. package/apps/studio/test/acp-commands.test.ts +108 -0
  37. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  39. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  40. package/apps/studio/test/acp-transcript.test.ts +53 -0
  41. package/apps/studio/test/active-state.test.ts +41 -0
  42. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  45. package/apps/studio/test/chat-context.test.ts +129 -0
  46. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  47. package/apps/studio/test/edit-suppress.test.ts +170 -0
  48. package/apps/studio/test/ensure-browser.test.ts +92 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  50. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  51. package/apps/studio/test/inspect-selections.test.ts +219 -0
  52. package/apps/studio/test/paths.test.ts +57 -0
  53. package/apps/studio/test/readiness.test.ts +83 -13
  54. package/apps/studio/test/reorder-api.test.ts +210 -0
  55. package/apps/studio/test/slash-commands.test.ts +117 -0
  56. package/apps/studio/undo-stack.ts +6 -0
  57. package/apps/studio/whats-new.json +45 -0
  58. package/apps/studio/ws.ts +37 -0
  59. package/cli/commands/design.mjs +1 -0
  60. package/package.json +8 -8
  61. package/plugins/design/dependencies.json +3 -3
@@ -5148,38 +5148,123 @@ const LAYER_TYPE_ICON = {
5148
5148
  box: 'box',
5149
5149
  };
5150
5150
 
5151
+
5152
+ // Optimistic layers-tree reorder — move the node with `draggedId` relative to
5153
+ // `refId` (before / after a sibling, or inside-end as a last child). Pure: clones
5154
+ // the node array and returns a new one, so React re-renders. Ids are distinct
5155
+ // here (repeated/list nodes can't be dragged), so first-match-by-id is safe.
5156
+ // Returns the input unchanged if either node isn't found (the HMR rebuild will
5157
+ // reconcile).
5158
+ function moveLayerNode(nodes, draggedId, refId, position) {
5159
+ if (!Array.isArray(nodes) || draggedId === refId) return nodes;
5160
+ const clone = JSON.parse(JSON.stringify(nodes));
5161
+ let dragged = null;
5162
+ const remove = (arr) => {
5163
+ for (let i = 0; i < arr.length; i++) {
5164
+ if (arr[i].id === draggedId) {
5165
+ dragged = arr[i];
5166
+ arr.splice(i, 1);
5167
+ return true;
5168
+ }
5169
+ if (arr[i].children && remove(arr[i].children)) return true;
5170
+ }
5171
+ return false;
5172
+ };
5173
+ remove(clone);
5174
+ if (!dragged) return nodes;
5175
+ const insert = (arr) => {
5176
+ for (let i = 0; i < arr.length; i++) {
5177
+ if (arr[i].id === refId) {
5178
+ if (position === 'inside-start' || position === 'inside-end') {
5179
+ arr[i].children = arr[i].children || [];
5180
+ if (position === 'inside-start') arr[i].children.unshift(dragged);
5181
+ else arr[i].children.push(dragged);
5182
+ } else {
5183
+ arr.splice(position === 'before' ? i : i + 1, 0, dragged);
5184
+ }
5185
+ return true;
5186
+ }
5187
+ if (arr[i].children && insert(arr[i].children)) return true;
5188
+ }
5189
+ return false;
5190
+ };
5191
+ return insert(clone) ? clone : nodes;
5192
+ }
5193
+
5194
+ // Void / self-closing tags can't hold children → not a nest target. Everything
5195
+ // else (div, section, span, …) can be nested into (matches "drop into any div").
5196
+ const LAYER_VOID_TAGS = new Set([
5197
+ 'img', 'input', 'br', 'hr', 'area', 'base', 'col', 'embed', 'link', 'meta',
5198
+ 'param', 'source', 'track', 'wbr',
5199
+ ]);
5200
+
5151
5201
  function LayerRow({
5152
5202
  node,
5153
5203
  depth,
5154
5204
  selectedId,
5205
+ selectedIndex,
5155
5206
  collapsed,
5156
5207
  hidden,
5157
5208
  onToggle,
5158
5209
  onSelect,
5159
5210
  onHover,
5160
5211
  onToggleVisibility,
5212
+ onReorder,
5213
+ onRowPointerDown,
5214
+ dragState,
5161
5215
  }) {
5162
5216
  const key = `${node.id}:${node.index}`;
5163
5217
  const hasKids = node.children && node.children.length > 0;
5164
5218
  const isCollapsed = collapsed.has(key);
5165
- const isSel = node.id === selectedId;
5219
+ // Match the specific INSTANCE (id + occurrence index), not every element that
5220
+ // shares this source id — otherwise a `.map`ed element highlights all its
5221
+ // clones at once. Fall back to id-only when the selection carries no index.
5222
+ const isSel =
5223
+ node.id === selectedId && (selectedIndex == null || node.index === selectedIndex);
5166
5224
  const isHidden = hidden?.has(key);
5225
+ // A shared data-cd-id (reused component instance) IS reorderable now — the
5226
+ // server maps the occurrence index to the parent <Component> usage — so these
5227
+ // are no longer greyed/blocked. A `.map()`ed single-usage element still can't
5228
+ // split; that move is refused server-side and reverts.
5229
+ const canDrag = !!onReorder;
5230
+ // Phase 12.1 — the row being dragged FLOATS with the cursor (same model as the
5231
+ // in-canvas drag): a transform follows the pointer, its layout box stays
5232
+ // reserved (empty slot at the origin), and pointer-events:none lets the row
5233
+ // under the pointer be hit-tested. A blue divider (rendered by the tree) marks
5234
+ // where it drops — indented to show the depth it will nest at (Figma-style).
5235
+ const isDragging = dragState?.key === key;
5236
+ const dragStyle = isDragging
5237
+ ? {
5238
+ transform: `translate(${dragState.dx}px, ${dragState.dy}px)`,
5239
+ opacity: 0.9,
5240
+ zIndex: 20,
5241
+ position: 'relative',
5242
+ pointerEvents: 'none',
5243
+ cursor: 'grabbing',
5244
+ boxShadow: '0 6px 18px rgba(0, 0, 0, 0.28)',
5245
+ }
5246
+ : null;
5167
5247
  return (
5168
5248
  <>
5169
5249
  <div
5170
5250
  className={
5171
5251
  'st-layer st-layer--row' + (isSel ? ' is-sel' : '') + (isHidden ? ' is-hidden' : '')
5172
5252
  }
5173
- style={{ paddingLeft: 6 + depth * 14 }}
5253
+ style={{ paddingLeft: 6 + depth * 14, ...dragStyle }}
5174
5254
  role="treeitem"
5175
5255
  aria-selected={isSel}
5176
5256
  aria-expanded={hasKids ? !isCollapsed : undefined}
5257
+ aria-grabbed={canDrag ? isDragging : undefined}
5177
5258
  tabIndex={0}
5178
5259
  title={`${node.tag} · ${node.type}`}
5260
+ data-layer-key={key}
5179
5261
  onClick={() => onSelect(node)}
5180
5262
  onMouseEnter={() => onHover(node)}
5181
5263
  onMouseLeave={() => onHover(null)}
5264
+ onPointerDown={canDrag ? (e) => onRowPointerDown(e, node, key) : undefined}
5182
5265
  onKeyDown={(e) => {
5266
+ // Enter/Space select; ↑/↓ (+ modifiers) are handled at the tree
5267
+ // container (selection-driven — survives the HMR re-render).
5183
5268
  if (e.key === 'Enter' || e.key === ' ') {
5184
5269
  e.preventDefault();
5185
5270
  onSelect(node);
@@ -5221,18 +5306,22 @@ function LayerRow({
5221
5306
  ) : null}
5222
5307
  </div>
5223
5308
  {hasKids && !isCollapsed
5224
- ? node.children.map((c) => (
5309
+ ? node.children.map((c, ci) => (
5225
5310
  <LayerRow
5226
5311
  key={`${c.id}:${c.index}`}
5227
5312
  node={c}
5228
5313
  depth={depth + 1}
5229
5314
  selectedId={selectedId}
5315
+ selectedIndex={selectedIndex}
5230
5316
  collapsed={collapsed}
5231
5317
  hidden={hidden}
5232
5318
  onToggle={onToggle}
5233
5319
  onSelect={onSelect}
5234
5320
  onHover={onHover}
5235
5321
  onToggleVisibility={onToggleVisibility}
5322
+ onReorder={onReorder}
5323
+ onRowPointerDown={onRowPointerDown}
5324
+ dragState={dragState}
5236
5325
  />
5237
5326
  ))
5238
5327
  : null}
@@ -5311,6 +5400,8 @@ function InspectorPanel({
5311
5400
  layersTree,
5312
5401
  onSelectLayer,
5313
5402
  onHoverLayer,
5403
+ onReorderLayer,
5404
+ layersBusyRef,
5314
5405
  cfg,
5315
5406
  onOptimistic,
5316
5407
  onRecordEdit,
@@ -5334,6 +5425,237 @@ function InspectorPanel({
5334
5425
  // Phase 12.3 (W3.1) — per-layer visibility toggle. Live-only (display:none via
5335
5426
  // the optimistic apply bus); not persisted to source. Keyed by `${id}:${index}`.
5336
5427
  const [hiddenLayers, setHiddenLayers] = useState(() => new Set());
5428
+ // Phase 12.1 (DDR-138) — drag-to-reorder state (lifted so every row sees the
5429
+ // same drop target) + an aria-live announcement for keyboard moves.
5430
+ const [dragState, setDragState] = useState(null);
5431
+ const [reorderMsg, setReorderMsg] = useState('');
5432
+ const handleReorder = onReorderLayer
5433
+ ? (dragged, ref, position) => {
5434
+ // Gate keyboard + drop moves while a prior reorder is still landing — the
5435
+ // write churns positional ids, so acting on the stale tree would misfire.
5436
+ if (layersBusyRef?.current) return;
5437
+ const verb =
5438
+ position === 'before'
5439
+ ? `before ${ref.label}`
5440
+ : position === 'after'
5441
+ ? `after ${ref.label}`
5442
+ : `into ${ref.label}`;
5443
+ setReorderMsg(`Moved ${dragged.label} ${verb}`);
5444
+ // Pass occurrence indices so a reused-component instance maps to its
5445
+ // parent <Component> usage server-side (same-id instances are distinct).
5446
+ onReorderLayer(dragged.id, ref.id, position, {
5447
+ idIndex: dragged.index,
5448
+ refIndex: ref.index,
5449
+ });
5450
+ }
5451
+ : undefined;
5452
+ // Flatten the VISIBLE tree (respecting collapse) with each node's depth,
5453
+ // parent, siblings, and index — the basis for keyboard nav + moves.
5454
+ const flattenVisibleLayers = () => {
5455
+ const flat = [];
5456
+ (function walk(nodes, depth, parentNode) {
5457
+ (nodes || []).forEach((n, i) => {
5458
+ flat.push({ node: n, depth, parentNode, siblings: nodes, pos: i });
5459
+ if (n.children && n.children.length && !collapsed.has(`${n.id}:${n.index}`))
5460
+ walk(n.children, depth + 1, n);
5461
+ });
5462
+ })(layersTree?.nodes, 0, null);
5463
+ return flat;
5464
+ };
5465
+ // The keyboard cursor is the SELECTED element (NOT DOM focus, which the HMR
5466
+ // re-render churns). ↑/↓ move the selection through the flattened tree;
5467
+ // Alt+↑/↓ reorder within the parent; Alt+Shift+↑/↓ reorder across the parent
5468
+ // boundary (out at first/last, into an adjacent open container). After a move
5469
+ // the reorder re-selects the moved element (movedId), so the cursor follows
5470
+ // and the next press keeps working — the fix for "Alt+arrow works once".
5471
+ const onTreeKeyDown = (e) => {
5472
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
5473
+ if (!el) return;
5474
+ const flat = flattenVisibleLayers();
5475
+ const i = flat.findIndex((f) => f.node.id === el.id && f.node.index === el.index);
5476
+ if (i < 0) return;
5477
+ const dir = e.key === 'ArrowDown' ? 1 : -1;
5478
+ const cur = flat[i];
5479
+ if (!e.altKey) {
5480
+ const t = flat[i + dir];
5481
+ if (t) {
5482
+ e.preventDefault();
5483
+ onSelectLayer?.(t.node);
5484
+ }
5485
+ return;
5486
+ }
5487
+ if (!handleReorder || !Array.isArray(cur.siblings)) return;
5488
+ e.preventDefault();
5489
+ const expanded = (n) =>
5490
+ n && n.children && n.children.length && !collapsed.has(`${n.id}:${n.index}`);
5491
+ if (!e.shiftKey) {
5492
+ // Within-parent reorder; stop at the first/last sibling.
5493
+ if (dir < 0 && cur.pos > 0) handleReorder(cur.node, cur.siblings[cur.pos - 1], 'before');
5494
+ else if (dir > 0 && cur.pos < cur.siblings.length - 1)
5495
+ handleReorder(cur.node, cur.siblings[cur.pos + 1], 'after');
5496
+ return;
5497
+ }
5498
+ // Cross-parent reorder (flattened traversal).
5499
+ if (dir > 0) {
5500
+ const next = cur.siblings[cur.pos + 1];
5501
+ if (next) handleReorder(cur.node, next, expanded(next) ? 'inside-start' : 'after');
5502
+ else if (cur.parentNode) handleReorder(cur.node, cur.parentNode, 'after');
5503
+ } else {
5504
+ const prev = cur.siblings[cur.pos - 1];
5505
+ if (prev) handleReorder(cur.node, prev, expanded(prev) ? 'inside-end' : 'before');
5506
+ else if (cur.parentNode) handleReorder(cur.node, cur.parentNode, 'before');
5507
+ }
5508
+ };
5509
+ // Pointer-based drag-to-reorder in the Layers tree — same model as the
5510
+ // in-canvas drag: the row FLOATS with the cursor (transform, slot reserved), a
5511
+ // blue divider (or nest ring) marks the drop, and the move commits only on
5512
+ // release. Same-origin shell, so it's all local (no dgn bus).
5513
+ const layerDragRef = useRef(null);
5514
+ const layerTreeRef = useRef(null);
5515
+ const startLayerDrag = (e, node, key) => {
5516
+ if (!handleReorder || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
5517
+ if (layersBusyRef?.current) return; // a prior reorder is still landing (ids churning)
5518
+ const startX = e.clientX;
5519
+ const startY = e.clientY;
5520
+ // Forbid dropping a node into itself or its own subtree.
5521
+ const forbidden = new Set([key]);
5522
+ (function walk(n) {
5523
+ (n.children || []).forEach((c) => {
5524
+ forbidden.add(`${c.id}:${c.index}`);
5525
+ walk(c);
5526
+ });
5527
+ })(node);
5528
+ // Flatten the VISIBLE tree (respecting collapse) with depth. The drop is
5529
+ // computed dnd-kit-tree style: a GAP between rows + a DEPTH from the pointer's
5530
+ // X — so you can nest into any container (even an empty one) and the divider
5531
+ // INDENTS to show it's going into that nested list (Figma-style).
5532
+ const INDENT = 14;
5533
+ const BASE = 6;
5534
+ const flat = [];
5535
+ (function walk(nodes, depth) {
5536
+ (nodes || []).forEach((n) => {
5537
+ const k = `${n.id}:${n.index}`;
5538
+ flat.push({ node: n, key: k, id: n.id, depth, tag: n.tag });
5539
+ if (n.children && n.children.length && !collapsed.has(k)) walk(n.children, depth + 1);
5540
+ });
5541
+ })(layersTree?.nodes, 0);
5542
+ const flatByKey = new Map(flat.map((it) => [it.key, it]));
5543
+ let started = false;
5544
+ const onMove = (ev) => {
5545
+ if (!started) {
5546
+ if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 4) return;
5547
+ started = true;
5548
+ }
5549
+ const dx = ev.clientX - startX;
5550
+ const dy = ev.clientY - startY;
5551
+ let target = null;
5552
+ // Visible rows in DOM (== flat) order, minus the dragged one (its rect is
5553
+ // floated, so it's meaningless for gap math).
5554
+ const rows = [].slice
5555
+ .call(document.querySelectorAll('.st-layer--row[data-layer-key]'))
5556
+ .map((el) => ({ rect: el.getBoundingClientRect(), it: flatByKey.get(el.getAttribute('data-layer-key')) }))
5557
+ .filter((r) => r.it && r.it.key !== key);
5558
+ if (rows.length) {
5559
+ const rowLeft = rows[0].rect.left;
5560
+ const rowRight = rows[0].rect.right;
5561
+ // Gap = index of the first row whose vertical midpoint is below the pointer.
5562
+ let gap = rows.length;
5563
+ for (let i = 0; i < rows.length; i++) {
5564
+ if (ev.clientY < rows[i].rect.top + rows[i].rect.height / 2) {
5565
+ gap = i;
5566
+ break;
5567
+ }
5568
+ }
5569
+ const prev = rows[gap - 1]?.it || null;
5570
+ const nextIt = rows[gap]?.it || null;
5571
+ // Depth from pointer X. Clamp between the row below (min) and one level
5572
+ // under the row above (max, if it can hold children).
5573
+ const raw = Math.round((ev.clientX - rowLeft - BASE) / INDENT);
5574
+ const maxDepth = prev ? prev.depth + (LAYER_VOID_TAGS.has(prev.tag) ? 0 : 1) : 0;
5575
+ const minDepth = nextIt ? nextIt.depth : 0;
5576
+ const depth = Math.max(minDepth, Math.min(raw, maxDepth));
5577
+ // Resolve (prev, depth) → { refId, position, refNode, targetDepth }.
5578
+ let refIt = null;
5579
+ let position = 'before';
5580
+ let targetDepth = 0;
5581
+ if (!prev) {
5582
+ if (nextIt) {
5583
+ refIt = nextIt;
5584
+ position = 'before';
5585
+ targetDepth = nextIt.depth;
5586
+ }
5587
+ } else if (depth > prev.depth) {
5588
+ refIt = prev; // nest as prev's FIRST child
5589
+ position = 'inside-start';
5590
+ targetDepth = prev.depth + 1;
5591
+ } else if (depth === prev.depth) {
5592
+ refIt = prev;
5593
+ position = 'after';
5594
+ targetDepth = prev.depth;
5595
+ } else {
5596
+ for (let i = gap - 1; i >= 0; i--) {
5597
+ if (rows[i].it.depth === depth) {
5598
+ refIt = rows[i].it;
5599
+ break;
5600
+ }
5601
+ }
5602
+ if (!refIt) refIt = prev;
5603
+ position = 'after';
5604
+ targetDepth = depth;
5605
+ }
5606
+ // Guard: no self, no dragged-subtree, no repeated (list) target.
5607
+ // forbidden already holds the dragged node's own key + its subtree, so a
5608
+ // different INSTANCE of the same reused component (same id, other key) is
5609
+ // still a valid target.
5610
+ if (refIt && refIt.key !== key && !forbidden.has(refIt.key)) {
5611
+ const y = prev ? rows[gap - 1].rect.bottom : rows[0].rect.top;
5612
+ const left = rowLeft + BASE + targetDepth * INDENT;
5613
+ target = { refId: refIt.id, position, node: refIt.node, y, left, w: Math.max(24, rowRight - left) };
5614
+ }
5615
+ }
5616
+ const next = { key, node, dx, dy, target };
5617
+ layerDragRef.current = next;
5618
+ setDragState(next);
5619
+ };
5620
+ const teardown = () => {
5621
+ window.removeEventListener('pointermove', onMove);
5622
+ window.removeEventListener('pointerup', onUp);
5623
+ window.removeEventListener('keydown', onKey, true);
5624
+ };
5625
+ // Swallow the click that trails a drag so it doesn't also select a row.
5626
+ const suppressClick = () => {
5627
+ const sup = (ce) => {
5628
+ ce.preventDefault();
5629
+ ce.stopImmediatePropagation();
5630
+ document.removeEventListener('click', sup, true);
5631
+ };
5632
+ document.addEventListener('click', sup, true);
5633
+ setTimeout(() => document.removeEventListener('click', sup, true), 300);
5634
+ };
5635
+ const onUp = () => {
5636
+ teardown();
5637
+ const d = layerDragRef.current;
5638
+ layerDragRef.current = null;
5639
+ setDragState(null);
5640
+ if (started && d?.target) {
5641
+ suppressClick();
5642
+ handleReorder(d.node, d.target.node, d.target.position);
5643
+ }
5644
+ };
5645
+ // Esc — abort the drag: the row snaps back, nothing commits.
5646
+ const onKey = (ke) => {
5647
+ if (ke.key !== 'Escape') return;
5648
+ ke.preventDefault();
5649
+ ke.stopImmediatePropagation();
5650
+ teardown();
5651
+ layerDragRef.current = null;
5652
+ setDragState(null);
5653
+ if (started) suppressClick();
5654
+ };
5655
+ window.addEventListener('pointermove', onMove);
5656
+ window.addEventListener('pointerup', onUp);
5657
+ window.addEventListener('keydown', onKey, true);
5658
+ };
5337
5659
  const toggleCollapse = (key) =>
5338
5660
  setCollapsed((prev) => {
5339
5661
  const next = new Set(prev);
@@ -5454,22 +5776,58 @@ function InspectorPanel({
5454
5776
  <>
5455
5777
  <div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
5456
5778
  {layersTree?.nodes?.length ? (
5457
- <div role="tree" aria-label="Artboard layers">
5458
- {layersTree.nodes.map((n) => (
5459
- <LayerRow
5460
- key={`${n.id}:${n.index}`}
5461
- node={n}
5462
- depth={0}
5463
- selectedId={el.id}
5464
- collapsed={collapsed}
5465
- hidden={hiddenLayers}
5466
- onToggle={toggleCollapse}
5467
- onSelect={(node) => onSelectLayer?.(node)}
5468
- onHover={(node) => onHoverLayer?.(node)}
5469
- onToggleVisibility={toggleVisibility}
5779
+ <>
5780
+ {handleReorder ? (
5781
+ <div className="st-rp-hint" aria-hidden="true">
5782
+ Drag or ↑/↓ select · Alt+↑/↓ move · Alt+Shift+↑/↓ move across
5783
+ </div>
5784
+ ) : null}
5785
+ {/* Keyboard nav/move handled at the container (tabIndex=0), driven
5786
+ by the SELECTION not row focus — survives the HMR re-render. */}
5787
+ <div
5788
+ role="tree"
5789
+ aria-label="Artboard layers"
5790
+ tabIndex={0}
5791
+ ref={layerTreeRef}
5792
+ onKeyDown={onTreeKeyDown}
5793
+ >
5794
+ {layersTree.nodes.map((n, ni) => (
5795
+ <LayerRow
5796
+ key={`${n.id}:${n.index}`}
5797
+ node={n}
5798
+ depth={0}
5799
+ selectedId={el.id}
5800
+ selectedIndex={el.index}
5801
+ collapsed={collapsed}
5802
+ hidden={hiddenLayers}
5803
+ onToggle={toggleCollapse}
5804
+ onSelect={(node) => {
5805
+ onSelectLayer?.(node);
5806
+ layerTreeRef.current?.focus();
5807
+ }}
5808
+ onHover={(node) => onHoverLayer?.(node)}
5809
+ onToggleVisibility={toggleVisibility}
5810
+ onReorder={handleReorder}
5811
+ onRowPointerDown={startLayerDrag}
5812
+ dragState={dragState}
5813
+ />
5814
+ ))}
5815
+ </div>
5816
+ {dragState?.target ? (
5817
+ <div
5818
+ className="st-layer-divider"
5819
+ aria-hidden="true"
5820
+ style={{
5821
+ left: dragState.target.left,
5822
+ top: dragState.target.y - 1,
5823
+ width: dragState.target.w,
5824
+ }}
5470
5825
  />
5471
- ))}
5472
- </div>
5826
+ ) : null}
5827
+ <div className="sr-only" role="status" aria-live="polite">
5828
+ {reorderMsg}
5829
+ </div>
5830
+ </>
5473
5831
  ) : Array.isArray(el.dom_path) && el.dom_path.length ? (
5474
5832
  el.dom_path.map((node, i) => (
5475
5833
  <div
@@ -5516,6 +5874,56 @@ function App() {
5516
5874
  useEffect(() => {
5517
5875
  selectedRef.current = selected;
5518
5876
  }, [selected]);
5877
+ // feature-acp-context-hardening — halo re-apply retry ladder. A single
5878
+ // select-by-id post races the fresh iframe: dgn:'loaded' fires from the
5879
+ // inline inspector script at HTML-parse time, BEFORE the React canvas-shell
5880
+ // mounts its message listener (module fetch + mount takes 100ms–seconds), so
5881
+ // a one-shot post silently evaporates and the restored selection never gets
5882
+ // its halo back. Re-post on a fixed ladder; each attempt aborts when the
5883
+ // user meanwhile selected something else. select-by-id is idempotent
5884
+ // (replace-same → select-set echo → ws guard sees same id → no re-schedule),
5885
+ // so the ladder can't loop.
5886
+ const haloRestoreTimersRef = useRef([]);
5887
+ const scheduleHaloRestore = useCallback((one) => {
5888
+ if (!one?.id || !one.file) return;
5889
+ for (const t of haloRestoreTimersRef.current) clearTimeout(t);
5890
+ haloRestoreTimersRef.current = [50, 450, 1200, 2500, 5000].map((delay) =>
5891
+ setTimeout(() => {
5892
+ const cur = selectedRef.current;
5893
+ const c1 = Array.isArray(cur) ? cur[0] : cur;
5894
+ if (!c1 || c1.id !== one.id || c1.file !== one.file) return; // superseded
5895
+ const frame = iframesRef.current.get(one.file);
5896
+ if (!frame?.contentWindow) return;
5897
+ try {
5898
+ frame.contentWindow.postMessage(
5899
+ {
5900
+ dgn: 'select-by-id',
5901
+ id: one.id,
5902
+ artboardId: one.artboardId ?? null,
5903
+ index: one.index ?? 0,
5904
+ },
5905
+ '*'
5906
+ );
5907
+ } catch {}
5908
+ }, delay)
5909
+ );
5910
+ }, []);
5911
+ // Phase 12.1 (DDR-138) — after a reorder writes source, the HMR reload remounts
5912
+ // the canvas and the positional data-cd-id of the moved element (and everything
5913
+ // after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
5914
+ // so the dgn:'loaded' handler re-selects the moved element by its NEW id.
5915
+ const pendingReorderRef = useRef(null);
5916
+ // Latest `reorderLayer` (defined far below), read from the stale-closure
5917
+ // onMessage handler when the in-canvas grip posts dgn:'reorder-request'
5918
+ // (same freshness idiom as selectedRef; avoids a render-time TDZ on the
5919
+ // later useCallback).
5920
+ const reorderLayerRef = useRef(null);
5921
+ // Phase 12.1 — true between a layers-panel reorder and the fresh layers-tree
5922
+ // landing. A reorder churns positional data-cd-ids, so a rapid 2nd drag would
5923
+ // target stale ids; the Layers tree gates new drags on this until the rebuilt
5924
+ // tree (with correct ids) arrives. Cleared by the layers-tree message.
5925
+ const layersBusyRef = useRef(false);
5926
+ const layersBusyTimerRef = useRef(null);
5519
5927
  // Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
5520
5928
  const [layersTree, setLayersTree] = useState(null);
5521
5929
  const [wsConnected, setWsConnected] = useState(false);
@@ -6108,7 +6516,25 @@ function App() {
6108
6516
  if (m.type === 'snapshot' && m.state) {
6109
6517
  setSelected((prev) => mergeSelClientFields(m.state.selected, prev));
6110
6518
  } else if (m.type === 'selected') {
6111
- setSelected((prev) => mergeSelClientFields(m.selected, prev));
6519
+ // feature-acp-context-hardening a canvas switch RESTORES the
6520
+ // per-canvas selection server-side and broadcasts it here. The
6521
+ // dgn:'loaded' re-select (Phase 12.3 path below) only works when
6522
+ // this frame lands BEFORE the new iframe loads; on a fast cached
6523
+ // mount it lands after, so re-apply the halo from THIS edge too.
6524
+ // Guarded to restore-transitions (prev missing / different id or
6525
+ // file) so the select-set echo of our own re-select can't loop.
6526
+ const incoming = m.selected;
6527
+ const one = Array.isArray(incoming) ? incoming[0] : incoming;
6528
+ const prevSel = selectedRef.current;
6529
+ const prevOne = Array.isArray(prevSel) ? prevSel[0] : prevSel;
6530
+ setSelected((prev) => mergeSelClientFields(incoming, prev));
6531
+ if (
6532
+ one?.id &&
6533
+ one.file &&
6534
+ (!prevOne || prevOne.id !== one.id || prevOne.file !== one.file)
6535
+ ) {
6536
+ scheduleHaloRestore(one);
6537
+ }
6112
6538
  } else if (m.type === 'comments' && typeof m.file === 'string') {
6113
6539
  setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
6114
6540
  } else if (m.type === 'ai-activity' && typeof m.file === 'string') {
@@ -6546,6 +6972,24 @@ function App() {
6546
6972
  }
6547
6973
  return;
6548
6974
  }
6975
+ // SECURITY (attacker Finding 2, mirrors the phase-28 F-2 reorder/present
6976
+ // gate at the handlers below): selection posts are honored ONLY from the
6977
+ // ACTIVE canvas's window. Every open canvas iframe shares one
6978
+ // `canvasOrigin`, so the origin check above can't tell the foreground
6979
+ // canvas from a background/synced one — and a selection carries an
6980
+ // attacker-chosen `file` that the server trusts verbatim and write-throughs
6981
+ // into the persistent per-canvas `selections` map, then rides into the
6982
+ // auto-approving ACP agent's frozen context. A background (untrusted, DDR-054)
6983
+ // canvas must not be able to plant a selection with no user gesture. The
6984
+ // user can only click the visible active canvas, so a non-active source is
6985
+ // never a legitimate selection.
6986
+ if (m.dgn === 'select' || m.dgn === 'select-set' || m.dgn === 'clear-select') {
6987
+ const activeWin =
6988
+ activePath && activePath !== SYSTEM_TAB
6989
+ ? iframesRef.current.get(activePath)?.contentWindow
6990
+ : null;
6991
+ if (e.source !== activeWin) return;
6992
+ }
6549
6993
  if (m.dgn === 'select' && m.selection) {
6550
6994
  wsSend({ type: 'select', selection: m.selection });
6551
6995
  setSelected(m.selection);
@@ -6631,6 +7075,88 @@ function App() {
6631
7075
  } else if (m.dgn === 'layers-tree') {
6632
7076
  // Phase 12 Task 4 — browsable layers tree for the active artboard.
6633
7077
  setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
7078
+ // fresh tree (correct ids) landed — drags OK again
7079
+ layersBusyRef.current = false;
7080
+ if (layersBusyTimerRef.current) {
7081
+ clearTimeout(layersBusyTimerRef.current);
7082
+ layersBusyTimerRef.current = null;
7083
+ }
7084
+ // Phase 12.1 — a reorder writes source → SOFT HMR (no dgn:'loaded' fires,
7085
+ // so the loaded-handler re-select never runs). The live observer's fresh
7086
+ // tree is our signal instead: re-select the moved element by its NEW
7087
+ // positional id (movedId) so the selection halo + the keyboard cursor
7088
+ // follow it. Without this the next Alt+arrow acts on the STALE id, which
7089
+ // positionally now points at a different element. Only act once the tree
7090
+ // actually CONTAINS movedId — an in-canvas drag posts a PREVIEW tree
7091
+ // first (old ids, pre-write); consuming then would re-select nothing and
7092
+ // burn the pending ref before the real post-HMR tree lands.
7093
+ const pend = pendingReorderRef.current;
7094
+ if (pend && pend.movedId) {
7095
+ const has = (function find(nodes) {
7096
+ return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
7097
+ })(m.tree);
7098
+ if (has) {
7099
+ pendingReorderRef.current = null;
7100
+ const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
7101
+ if (win) {
7102
+ try {
7103
+ win.postMessage(
7104
+ { dgn: 'select-by-id', id: pend.movedId, artboardId: m.artboardId ?? null, index: 0 },
7105
+ '*'
7106
+ );
7107
+ } catch {}
7108
+ }
7109
+ }
7110
+ }
7111
+ } else if (m.dgn === 'reorder-revert') {
7112
+ // Phase 12.1 follow-up — Cmd+Z/Cmd+Shift+Z on a reorder. The canvas undo
7113
+ // stack (untrusted iframe) can't reach the main-origin-only
7114
+ // /_api/reorder-revert, so it REQUESTS; the shell writes. Active canvas
7115
+ // only. SECURITY (adversarial F4): pin the target to `activePath` — do NOT
7116
+ // forward m.canvas (an untrusted iframe could name a different canvas); the
7117
+ // undo stack is per active canvas anyway, and the server's seq→entry.abs +
7118
+ // 409 content-match are the backstop.
7119
+ const rvWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
7120
+ const rvShape = typeof m.seq === 'number' && (m.dir === 'undo' || m.dir === 'redo');
7121
+ if (e.source === rvWin && rvShape && activePath) {
7122
+ layersBusyRef.current = true;
7123
+ if (layersBusyTimerRef.current) clearTimeout(layersBusyTimerRef.current);
7124
+ layersBusyTimerRef.current = setTimeout(() => {
7125
+ layersBusyRef.current = false;
7126
+ layersBusyTimerRef.current = null;
7127
+ }, 700);
7128
+ fetch('/_api/reorder-revert', {
7129
+ method: 'POST',
7130
+ headers: { 'content-type': 'application/json' },
7131
+ body: JSON.stringify({ canvas: activePath, seq: m.seq, dir: m.dir }),
7132
+ })
7133
+ .then((r) => r.json().catch(() => ({})))
7134
+ .then((j) => {
7135
+ if (!j.ok) console.warn('[reorder-revert]', j.error || 'failed');
7136
+ })
7137
+ .catch(() => {});
7138
+ }
7139
+ } else if (m.dgn === 'reorder-request') {
7140
+ // Phase 12.1 (DDR-138) — the in-canvas ReorderGrip (untrusted canvas
7141
+ // iframe) can't reach the main-origin-only /_api/reorder (DDR-054), so it
7142
+ // REQUESTS a move; the shell performs the privileged write via reorderLayer
7143
+ // (same lane as edit-text → /_api/edit-text). Honor it ONLY from the ACTIVE
7144
+ // canvas (a background tab's untrusted canvas must not mutate source), and
7145
+ // only for well-formed ids/position (reorderLayer + the server re-validate).
7146
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
7147
+ const okShape =
7148
+ typeof m.id === 'string' &&
7149
+ typeof m.refId === 'string' &&
7150
+ (m.position === 'before' ||
7151
+ m.position === 'after' ||
7152
+ m.position === 'inside-start' ||
7153
+ m.position === 'inside-end');
7154
+ if (e.source === activeWin && okShape) {
7155
+ reorderLayerRef.current?.(m.id, m.refId, m.position, {
7156
+ idIndex: Number.isInteger(m.idIndex) ? m.idIndex : undefined,
7157
+ refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
7158
+ });
7159
+ }
6634
7160
  } else if (m.dgn === 'open-inspector') {
6635
7161
  // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
6636
7162
  openRightPanel('inspector');
@@ -6749,25 +7275,48 @@ function App() {
6749
7275
  el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
6750
7276
  } catch {}
6751
7277
  }
6752
- // Phase 12.3 (W1.1) — an edit-css/edit-attr commit triggers the file
6753
- // watcher's HMR reload, which remounts the canvas and drops the
6754
- // in-canvas selection halo. Re-select the same element by its stable
6755
- // data-cd-id so the user keeps focus on what they're editing. The
6756
- // canvas-shell `select-by-id` handler re-emits select-set, which keeps
6757
- // the Inspector panel + halo in sync. Guarded to the active file.
6758
- const sel = selectedRef.current;
6759
- if (sel && sel.id && sel.file === m.file) {
7278
+ // Phase 12.1 (DDR-138) — a reorder just wrote source: the moved
7279
+ // element's positional data-cd-id renumbered, so re-selecting by the
7280
+ // PRE-move id would land on the wrong node. Re-select by the recomputed
7281
+ // movedId instead (null leave selection to the user, never guess), and
7282
+ // rebuild the layers tree so the panel reflects the new order.
7283
+ const pend = pendingReorderRef.current;
7284
+ if (pend && pend.file === m.file) {
7285
+ pendingReorderRef.current = null;
6760
7286
  try {
6761
7287
  el.contentWindow.postMessage(
6762
- {
6763
- dgn: 'select-by-id',
6764
- id: sel.id,
6765
- artboardId: sel.artboardId ?? null,
6766
- index: sel.index ?? 0,
6767
- },
7288
+ { dgn: 'request-layers', artboardId: pend.artboardId ?? null },
6768
7289
  '*'
6769
7290
  );
6770
7291
  } catch {}
7292
+ if (pend.movedId) {
7293
+ try {
7294
+ el.contentWindow.postMessage(
7295
+ {
7296
+ dgn: 'select-by-id',
7297
+ id: pend.movedId,
7298
+ artboardId: pend.artboardId ?? null,
7299
+ index: 0,
7300
+ },
7301
+ '*'
7302
+ );
7303
+ } catch {}
7304
+ }
7305
+ } else {
7306
+ // Phase 12.3 (W1.1) — an edit-css/edit-attr commit triggers the file
7307
+ // watcher's HMR reload, which remounts the canvas and drops the
7308
+ // in-canvas selection halo. Re-select the same element by its stable
7309
+ // data-cd-id so the user keeps focus on what they're editing. The
7310
+ // canvas-shell `select-by-id` handler re-emits select-set, which keeps
7311
+ // the Inspector panel + halo in sync. Guarded to the active file.
7312
+ // Retry-laddered: dgn:'loaded' fires from the inline inspector script
7313
+ // BEFORE the React canvas-shell mounts its listener, so a one-shot
7314
+ // post is lost on fresh iframes (canvas-switch restore case).
7315
+ const sel0 = selectedRef.current;
7316
+ const sel = Array.isArray(sel0) ? sel0[0] : sel0;
7317
+ if (sel && sel.id && sel.file === m.file) {
7318
+ scheduleHaloRestore(sel);
7319
+ }
6771
7320
  }
6772
7321
  }
6773
7322
  } else if (m.dgn === 'export-request' && m.id && m.payload) {
@@ -6903,6 +7452,104 @@ function App() {
6903
7452
  // iframe sink is fire-and-forget, so without this the POSTs could race).
6904
7453
  const editApplyChainRef = useRef(Promise.resolve());
6905
7454
 
7455
+ // Phase 12.1 (DDR-138) — commit a Layers-panel drag/keyboard reorder. The shell
7456
+ // is main-origin, so it calls the privileged /_api/reorder directly (the CSS/
7457
+ // text edits go the other way — canvas requests, shell writes; here the gesture
7458
+ // originates in the shell). Serialized on the same chain as apply-edit so a
7459
+ // reorder can't race an in-flight edit write to the same file.
7460
+ const reorderLayer = useCallback(
7461
+ (draggedId, refId, position, occ) => {
7462
+ const idIndex = Number.isInteger(occ?.idIndex) ? occ.idIndex : undefined;
7463
+ const refIndex = Number.isInteger(occ?.refIndex) ? occ.refIndex : undefined;
7464
+ // A same-id move is valid for two INSTANCES of a reused component (distinct
7465
+ // occurrence indices → distinct usages); only a true self-move is a no-op.
7466
+ if (!draggedId || !refId) return;
7467
+ if (draggedId === refId && (idIndex ?? 0) === (refIndex ?? 0)) return;
7468
+ const sel = selectedRef.current;
7469
+ const one = Array.isArray(sel) ? sel[0] : sel;
7470
+ // SECURITY (DDR-139 / adversarial F1): a reorder ALWAYS applies to the
7471
+ // ACTIVE canvas — both the in-canvas drag and the Layers panel operate on
7472
+ // what the user is viewing. Pin the target to `activePath`; never derive it
7473
+ // from `selection.canvas`, which an untrusted iframe can spoof (an ungated
7474
+ // dgn:'select' followed by dgn:'reorder-request') to retarget the write to
7475
+ // a DIFFERENT canvas (confused deputy, breaks DDR-054/DDR-138 containment).
7476
+ const canvas = activePath;
7477
+ if (!canvas) return;
7478
+ const file = activePath;
7479
+ const artboardId = layersTree?.artboardId ?? one?.artboardId ?? null;
7480
+ // Optimistically reorder the layers tree so the panel reflects the move
7481
+ // instantly; the HMR rebuild (request-layers, dgn:'loaded') confirms it a
7482
+ // beat later. Ids here are distinct (repeated/list nodes are non-draggable).
7483
+ setLayersTree((prev) =>
7484
+ prev ? { ...prev, nodes: moveLayerNode(prev.nodes, draggedId, refId, position) } : prev
7485
+ );
7486
+ // Gate the next layers-panel drag until the rebuilt tree lands — the write
7487
+ // churns positional ids, so a rapid 2nd drag on the optimistic tree would
7488
+ // carry stale ids. Cleared by the incoming layers-tree message, with a
7489
+ // hard timeout fallback so the list can NEVER stay frozen if that message
7490
+ // doesn't arrive (a no-op reorder, no HMR, etc.).
7491
+ layersBusyRef.current = true;
7492
+ if (layersBusyTimerRef.current) clearTimeout(layersBusyTimerRef.current);
7493
+ layersBusyTimerRef.current = setTimeout(() => {
7494
+ layersBusyRef.current = false;
7495
+ layersBusyTimerRef.current = null;
7496
+ }, 700);
7497
+ editApplyChainRef.current = editApplyChainRef.current
7498
+ .catch(() => {})
7499
+ .then(() =>
7500
+ fetch('/_api/reorder', {
7501
+ method: 'POST',
7502
+ headers: { 'content-type': 'application/json' },
7503
+ body: JSON.stringify({ canvas, id: draggedId, refId, position, idIndex, refIndex }),
7504
+ })
7505
+ .then((r) => r.json().catch(() => ({})))
7506
+ .then((j) => {
7507
+ if (!j.ok) {
7508
+ console.warn('[reorder]', j.error || 'failed');
7509
+ // The move was REJECTED (e.g. a .map()ed/looped element, or a
7510
+ // reparent that would break the JSX) — but the canvas already
7511
+ // applied it optimistically (applyDrop) and the layers panel via
7512
+ // moveLayerNode. Nothing was written, so without a revert the user
7513
+ // sees a phantom change that vanishes on the next canvas switch
7514
+ // ("it didn't save") and Cmd+Z has no entry to undo. Tell the
7515
+ // canvas to put the node back; its observer re-posts the tree, so
7516
+ // the panel reverts too. For a LAYERS-panel reorder (no canvas DOM
7517
+ // move to observe) also re-request the tree to drop the optimistic
7518
+ // moveLayerNode.
7519
+ postToActiveCanvas({ dgn: 'reorder-failed' });
7520
+ postToActiveCanvas({ dgn: 'request-layers', artboardId });
7521
+ layersBusyRef.current = false;
7522
+ if (layersBusyTimerRef.current) {
7523
+ clearTimeout(layersBusyTimerRef.current);
7524
+ layersBusyTimerRef.current = null;
7525
+ }
7526
+ return;
7527
+ }
7528
+ // The write triggers an HMR reload; the dgn:'loaded' handler
7529
+ // re-selects the moved element by its recomputed id + rebuilds the
7530
+ // tree. movedId is best-effort — null means "leave selection to the
7531
+ // user" (never re-select by the stale pre-move id).
7532
+ pendingReorderRef.current = { file, movedId: j.movedId || null, artboardId };
7533
+ // Record onto the canvas undo stack so Cmd+Z reverts the move via
7534
+ // the server's reorder log (id-churn-proof whole-file swap).
7535
+ if (typeof j.seq === 'number') {
7536
+ postToActiveCanvas({
7537
+ dgn: 'record-edit',
7538
+ payload: { op: 'reorder', canvas, seq: j.seq, label: 'move element' },
7539
+ });
7540
+ }
7541
+ })
7542
+ .catch(() => {})
7543
+ );
7544
+ },
7545
+ [activePath, layersTree, postToActiveCanvas]
7546
+ );
7547
+ // Keep the ref the (stale-closure) onMessage reorder-request handler reads
7548
+ // pointed at the latest reorderLayer.
7549
+ useEffect(() => {
7550
+ reorderLayerRef.current = reorderLayer;
7551
+ }, [reorderLayer]);
7552
+
6906
7553
  const resolveComment = useCallback((id) => {
6907
7554
  wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
6908
7555
  }, []);
@@ -7531,6 +8178,8 @@ function App() {
7531
8178
  index: n ? n.index : 0,
7532
8179
  })
7533
8180
  }
8181
+ onReorderLayer={reorderLayer}
8182
+ layersBusyRef={layersBusyRef}
7534
8183
  width={rpSize.w}
7535
8184
  resizing={dragSide === 'rp'}
7536
8185
  />
@@ -7560,6 +8209,7 @@ function App() {
7560
8209
  ? activePath
7561
8210
  : null
7562
8211
  }
8212
+ selected={selected}
7563
8213
  width={rpSize.w}
7564
8214
  resizing={dragSide === 'rp'}
7565
8215
  onClose={() => setAssistantOpen(false)}