@1agh/maude 0.30.0 → 0.32.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 (93) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -13,11 +13,22 @@ import { createRoot } from 'react-dom/client';
13
13
  // bundle — no React, no input-router. See the tool-cursor handler below.
14
14
  import { resolveToolCursor } from '../canvas-cursors.ts';
15
15
  import { canvasUrl } from './canvas-url.js';
16
+ import ChatPanel from './panels/ChatPanel.jsx';
17
+ import DiffView from './panels/DiffView.jsx';
18
+ import GitPanel from './panels/GitPanel.jsx';
19
+ import IdentityBar from './panels/IdentityBar.jsx';
20
+ import OnboardingWizard from './panels/OnboardingWizard.jsx';
21
+ import { ReadinessDialog } from './panels/ReadinessList.jsx';
22
+ import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
23
+ import { appIsFirstRun, isNativeApp, onUpdateReady, restartToUpdate } from './github.js';
24
+ import { COLLAB_TOUR } from './tour/collab-tour.js';
16
25
  import { TourOverlay } from './tour/overlay.jsx';
17
26
  import { USAGE_TOUR } from './tour/usage-tour.js';
18
27
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
19
28
 
20
29
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
30
+ // Phase 29 (E4) — the collab "rychlý kurz" is offered once after onboarding.
31
+ const COLLAB_TOUR_STORE = 'mdcc-collab-tour-seen';
21
32
 
22
33
  const SYSTEM_TAB = '__system__';
23
34
  const THEME_STORE = 'mdcc-theme';
@@ -296,6 +307,13 @@ const STICONS = {
296
307
  sparkle: (
297
308
  <path d="M8 1.8l1.4 4.8L14 8l-4.6 1.4L8 14.2l-1.4-4.8L2 8l4.6-1.4z" fill="currentColor" stroke="none" />
298
309
  ),
310
+ megaphone: (
311
+ <>
312
+ <path d="M2 6.7 11 4v8L2 9.3z" />
313
+ <path d="M11 5.2a2.4 2.4 0 0 1 0 5.6" />
314
+ <path d="M4.3 9.5v2.3a1.2 1.2 0 0 0 2.4 0v-1.7" />
315
+ </>
316
+ ),
299
317
  'panel-left': (
300
318
  <>
301
319
  <rect x="2.5" y="3" width="11" height="10" rx="1.5" />
@@ -1063,7 +1081,7 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
1063
1081
  );
1064
1082
  }
1065
1083
 
1066
- function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kind, sidecar }) {
1084
+ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kind, sidecar, dirty }) {
1067
1085
  const isSel = file.path === activePath;
1068
1086
  const isCanvas = CANVAS_EXT_RE.test(file.name);
1069
1087
  // Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
@@ -1094,6 +1112,11 @@ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kin
1094
1112
  <StIcon name="file" size={13} />
1095
1113
  </span>
1096
1114
  <span className="st-row-name">{label}</span>
1115
+ {dirty && (
1116
+ <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
1117
+ {dirty}
1118
+ </span>
1119
+ )}
1097
1120
  {oc > 0 && <span className="st-row-badge">{oc}</span>}
1098
1121
  </button>
1099
1122
  );
@@ -1130,7 +1153,9 @@ function CanvasRow({
1130
1153
  openCount: oc,
1131
1154
  showHidden,
1132
1155
  forceOpen,
1156
+ dirtyByPath,
1133
1157
  }) {
1158
+ const dirty = dirtyByPath?.get(primary.path);
1134
1159
  const hasSidecars = sidecars.length > 0;
1135
1160
  const [openState, setOpenState] = useState(false);
1136
1161
  // Sidecars are only revealed when the user opts in via `showHidden` — the
@@ -1149,6 +1174,7 @@ function CanvasRow({
1149
1174
  openCount={oc}
1150
1175
  depth={depth}
1151
1176
  kind={kind}
1177
+ dirty={dirty}
1152
1178
  />
1153
1179
  );
1154
1180
  }
@@ -1182,6 +1208,11 @@ function CanvasRow({
1182
1208
  <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
1183
1209
  </span>
1184
1210
  <span className="st-row-name">{displayName(primary.name)}</span>
1211
+ {dirty && (
1212
+ <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
1213
+ {dirty}
1214
+ </span>
1215
+ )}
1185
1216
  {oc > 0 && <span className="st-row-badge">{oc}</span>}
1186
1217
  </button>
1187
1218
  {open &&
@@ -1214,6 +1245,7 @@ function Tree({
1214
1245
  activeDsName,
1215
1246
  onOpenSystem,
1216
1247
  onDelete,
1248
+ dirtyByPath,
1217
1249
  }) {
1218
1250
  const dirs = Object.keys(node)
1219
1251
  .filter((k) => k !== '_files')
@@ -1255,6 +1287,7 @@ function Tree({
1255
1287
  kind={kind}
1256
1288
  showHidden={showHidden}
1257
1289
  forceOpen={forceOpen}
1290
+ dirtyByPath={dirtyByPath}
1258
1291
  />
1259
1292
  );
1260
1293
  })}
@@ -1286,6 +1319,7 @@ function Tree({
1286
1319
  activeDsName={activeDsName}
1287
1320
  onOpenSystem={onOpenSystem}
1288
1321
  onDelete={onDelete}
1322
+ dirtyByPath={dirtyByPath}
1289
1323
  />
1290
1324
  );
1291
1325
  if (dsMatch && onOpenSystem) {
@@ -1353,6 +1387,9 @@ function Sidebar({
1353
1387
  onCollapse,
1354
1388
  width,
1355
1389
  resizing,
1390
+ dirtyByPath,
1391
+ project,
1392
+ gitBranch,
1356
1393
  }) {
1357
1394
  const filteredGroups = useMemo(() => {
1358
1395
  if (!search) return groups;
@@ -1569,6 +1606,7 @@ function Sidebar({
1569
1606
  activeDsName={activeDsName}
1570
1607
  onOpenSystem={isDs ? onOpenSystem : undefined}
1571
1608
  onDelete={isDs ? undefined : onDeleteBoard}
1609
+ dirtyByPath={dirtyByPath}
1572
1610
  />
1573
1611
  ) : (
1574
1612
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
@@ -1577,6 +1615,14 @@ function Sidebar({
1577
1615
  );
1578
1616
  })}
1579
1617
  </div>
1618
+ {/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
1619
+ opens UPWARD, sitting directly above the GitHub identity avatar so the two
1620
+ form one bottom dock. Renders nothing until the project is a git repo. */}
1621
+ <RepoBranchSwitcher project={project} liveBranch={gitBranch} />
1622
+ {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
1623
+ sign in, connected account + New/Pull/Share, sign out. Self-contained
1624
+ (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
1625
+ <IdentityBar />
1580
1626
  </nav>
1581
1627
  );
1582
1628
  }
@@ -2200,6 +2246,11 @@ function HelpDropdown({ onAction, onClose }) {
2200
2246
  { id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
2201
2247
  { sep: true },
2202
2248
  { id: 'tour', label: 'Take the tour' },
2249
+ // The collab "how sharing works" course teaches the plain-words Save →
2250
+ // Publish → Pull cycle — a non-technical, native-app concern. A web-studio
2251
+ // dev already knows git, so it's hidden there (DDR-119).
2252
+ ...(isNativeApp() ? [{ id: 'collab-tour', label: 'How sharing works' }] : []),
2253
+ ...(isNativeApp() ? [{ id: 'readiness', label: 'Check AI editing readiness…' }] : []),
2203
2254
  { id: 'whatsnew', label: "What's new" },
2204
2255
  ]}
2205
2256
  />
@@ -2296,6 +2347,9 @@ function Menubar({
2296
2347
  setOpenMenu,
2297
2348
  commentsPanelOpen,
2298
2349
  onToggleComments,
2350
+ changesOpen,
2351
+ changesCount,
2352
+ onToggleChanges,
2299
2353
  onOpenSystem,
2300
2354
  sidebarOpen,
2301
2355
  onToggleSidebar,
@@ -2304,10 +2358,18 @@ function Menubar({
2304
2358
  onOpenHelp,
2305
2359
  onOpenShortcuts,
2306
2360
  onStartTour,
2361
+ onStartCollabTour,
2307
2362
  annotationsVisible,
2308
2363
  onToggleAnnotations,
2364
+ minimapVisible,
2365
+ onToggleMinimap,
2366
+ zoomCtlVisible,
2367
+ onToggleZoomCtl,
2368
+ presentMode,
2369
+ onTogglePresent,
2309
2370
  postToActiveCanvas,
2310
2371
  onOpenWhatsNew,
2372
+ onOpenReadiness,
2311
2373
  whatsNewCount,
2312
2374
  artboardCount = 0,
2313
2375
  presence = null,
@@ -2315,6 +2377,10 @@ function Menubar({
2315
2377
  inspectorTab,
2316
2378
  onToggleInspector,
2317
2379
  onOpenLayers,
2380
+ assistantOpen,
2381
+ onToggleAssistant,
2382
+ assistantBusy,
2383
+ assistantUnseen,
2318
2384
  onNewCanvas,
2319
2385
  onOpenExport,
2320
2386
  onReload,
@@ -2334,6 +2400,13 @@ function Menubar({
2334
2400
 
2335
2401
  const panels = [
2336
2402
  { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
2403
+ {
2404
+ id: 'changes',
2405
+ label: changesCount > 0 ? `Changes · ${changesCount} unsaved` : 'Changes',
2406
+ shortcut: '⌘ ⇧ G',
2407
+ checked: changesOpen,
2408
+ disabled: false,
2409
+ },
2337
2410
  {
2338
2411
  id: 'comments',
2339
2412
  label: 'Comments Sidebar',
@@ -2362,6 +2435,18 @@ function Menubar({
2362
2435
  checked: inspectorOpen,
2363
2436
  disabled: false,
2364
2437
  },
2438
+ // Phase 31 (DDR-123) — native-only ACP chat sidepanel.
2439
+ ...(isNativeApp()
2440
+ ? [
2441
+ {
2442
+ id: 'assistant',
2443
+ label: 'Assistant',
2444
+ shortcut: '⌘ ⇧ A',
2445
+ checked: assistantOpen,
2446
+ disabled: false,
2447
+ },
2448
+ ]
2449
+ : []),
2365
2450
  {
2366
2451
  id: 'annotate',
2367
2452
  label: 'Annotations',
@@ -2369,7 +2454,27 @@ function Menubar({
2369
2454
  checked: annotationsVisible,
2370
2455
  disabled: false,
2371
2456
  },
2372
- { id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
2457
+ {
2458
+ id: 'minimap',
2459
+ label: 'Minimap',
2460
+ shortcut: '',
2461
+ checked: minimapVisible,
2462
+ disabled: !activePath || isSystem,
2463
+ },
2464
+ {
2465
+ id: 'zoomctl',
2466
+ label: 'Zoom controls',
2467
+ shortcut: '',
2468
+ checked: zoomCtlVisible,
2469
+ disabled: !activePath || isSystem,
2470
+ },
2471
+ {
2472
+ id: 'present',
2473
+ label: 'Presentation Mode',
2474
+ shortcut: '',
2475
+ checked: presentMode,
2476
+ disabled: !activePath || isSystem,
2477
+ },
2373
2478
  ];
2374
2479
 
2375
2480
  const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
@@ -2489,11 +2594,16 @@ function Menubar({
2489
2594
  panels={panels}
2490
2595
  onToggle={(id) => {
2491
2596
  if (id === 'tree') onToggleSidebar();
2597
+ else if (id === 'changes') onToggleChanges();
2492
2598
  else if (id === 'comments') onToggleComments();
2493
2599
  else if (id === 'hidden') onToggleShowHidden();
2494
2600
  else if (id === 'annotate') onToggleAnnotations();
2495
2601
  else if (id === 'inspector') onToggleInspector();
2602
+ else if (id === 'assistant') onToggleAssistant?.();
2496
2603
  else if (id === 'layers') onOpenLayers?.();
2604
+ else if (id === 'minimap') onToggleMinimap?.();
2605
+ else if (id === 'zoomctl') onToggleZoomCtl?.();
2606
+ else if (id === 'present') onTogglePresent?.();
2497
2607
  }}
2498
2608
  onZoom={(op) => postToActiveCanvas({ dgn: 'zoom', op })}
2499
2609
  hasCanvas={!!activePath && !isSystem}
@@ -2522,6 +2632,8 @@ function Menubar({
2522
2632
  if (id === 'shortcuts') onOpenShortcuts?.();
2523
2633
  else if (id === 'help') onOpenHelp?.();
2524
2634
  else if (id === 'tour') onStartTour?.();
2635
+ else if (id === 'collab-tour') onStartCollabTour?.();
2636
+ else if (id === 'readiness') onOpenReadiness?.();
2525
2637
  else if (id === 'whatsnew') onOpenWhatsNew?.();
2526
2638
  }}
2527
2639
  onClose={() => setOpenMenu(null)}
@@ -2529,6 +2641,20 @@ function Menubar({
2529
2641
  )}
2530
2642
  <div className="st-mb-right" data-tour="status">
2531
2643
  {presence ? <div className="st-presence">{presence}</div> : null}
2644
+ {isNativeApp() && (
2645
+ <button
2646
+ type="button"
2647
+ className="st-assistant"
2648
+ data-active={assistantOpen ? 'true' : 'false'}
2649
+ data-busy={assistantBusy ? 'true' : 'false'}
2650
+ data-unseen={assistantUnseen ? 'true' : 'false'}
2651
+ aria-label={`Assistant${assistantBusy ? ' — working' : assistantUnseen ? ' — new reply' : ''}`}
2652
+ data-tip="Assistant ⌘⇧A"
2653
+ onClick={onToggleAssistant}
2654
+ >
2655
+ <StIcon name="sparkle" size={15} />
2656
+ </button>
2657
+ )}
2532
2658
  <button
2533
2659
  type="button"
2534
2660
  className="st-whatsnew"
@@ -2538,7 +2664,7 @@ function Menubar({
2538
2664
  data-tip="What's new"
2539
2665
  onClick={onOpenWhatsNew}
2540
2666
  >
2541
- <StIcon name="sparkle" size={15} />
2667
+ <StIcon name="megaphone" size={15} />
2542
2668
  </button>
2543
2669
  <span className="st-stamp">{stamp}</span>
2544
2670
  <span className="st-mb-file" title={activePath || ''}>
@@ -2913,6 +3039,10 @@ function StatusBar({
2913
3039
  onToggleTheme,
2914
3040
  onClearSelected,
2915
3041
  syncStatus,
3042
+ changesCount = 0,
3043
+ unpushed = 0,
3044
+ changesOpen = false,
3045
+ onOpenChanges,
2916
3046
  }) {
2917
3047
  const isSystem = activePath === SYSTEM_TAB;
2918
3048
  const text =
@@ -2996,6 +3126,33 @@ function StatusBar({
2996
3126
  <span className="val">{openCount} open</span>
2997
3127
  </span>
2998
3128
 
3129
+ {/* Phase 28 — changes count, click to open the Changes panel (⌘⇧G). */}
3130
+ {onOpenChanges && (
3131
+ <button
3132
+ type="button"
3133
+ className={
3134
+ 'st-sb-slot st-sb-changes' +
3135
+ (changesOpen ? ' is-open' : '') +
3136
+ (changesCount > 0 ? ' has-changes' : unpushed > 0 ? ' has-unpushed' : '')
3137
+ }
3138
+ onClick={onOpenChanges}
3139
+ data-tip="Open Changes · ⌘⇧G"
3140
+ data-tip-pos="top"
3141
+ aria-label="Open Changes panel"
3142
+ aria-pressed={changesOpen}
3143
+ >
3144
+ <span className="st-sb-changes-dot" aria-hidden="true" />
3145
+ <span className="lbl">changes</span>
3146
+ <span className="val">
3147
+ {changesCount > 0
3148
+ ? `${changesCount} unsaved`
3149
+ : unpushed > 0
3150
+ ? `${unpushed} to publish`
3151
+ : 'all saved'}
3152
+ </span>
3153
+ </button>
3154
+ )}
3155
+
2999
3156
  <span className="st-sb-spacer" />
3000
3157
 
3001
3158
  <span className="st-sb-slot" role="group" aria-label="Connection">
@@ -3204,6 +3361,35 @@ function CommentsPanel({
3204
3361
  // while offline (with queued-edit count), red when offline > 24h, green flash
3205
3362
  // for 3s right after a reconnect. Driven entirely by the 'sync:status' payload
3206
3363
  // the dev-server's linked-mode sync runtime broadcasts.
3364
+ // Phase 32 (Task 1) — auto-update notice. Shown after the shell has downloaded +
3365
+ // staged a newer build in the background. Non-blocking: "Restart now" applies it,
3366
+ // "Later" dismisses (the next focus/4h check re-stages and re-surfaces it).
3367
+ function UpdateBanner({ update, onDismiss }) {
3368
+ const [restarting, setRestarting] = useState(false);
3369
+ if (!update) return null;
3370
+ const ver = update.version ? ` (v${update.version})` : '';
3371
+ return (
3372
+ <div role="status" aria-live="polite" className="st-banner st-banner--info">
3373
+ <span className="st-banner-dot" aria-hidden="true" />
3374
+ <span>Maude updated{ver} · restart to apply</span>
3375
+ <button
3376
+ type="button"
3377
+ className="btn btn--primary btn--sm"
3378
+ disabled={restarting}
3379
+ onClick={() => {
3380
+ setRestarting(true);
3381
+ restartToUpdate().catch(() => setRestarting(false));
3382
+ }}
3383
+ >
3384
+ {restarting ? 'Restarting…' : 'Restart now'}
3385
+ </button>
3386
+ <button type="button" className="btn btn--ghost btn--sm" onClick={onDismiss}>
3387
+ Later
3388
+ </button>
3389
+ </div>
3390
+ );
3391
+ }
3392
+
3207
3393
  function SyncBanner({ status }) {
3208
3394
  // Plan C follow-up — the banner overlapped the menubar and wasn't dismissable.
3209
3395
  // Dismissal is keyed on the connection state, so a transition (reconnect flash,
@@ -5260,6 +5446,20 @@ function App() {
5260
5446
  // Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
5261
5447
  // WS message the linked-mode sync runtime emits. null in solo mode.
5262
5448
  const [syncStatus, setSyncStatus] = useState(null);
5449
+ // Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
5450
+ // server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
5451
+ // `diffTarget` opens the before/after DiffView ({ file, conflict }).
5452
+ const [gitStatus, setGitStatus] = useState(null);
5453
+ // Phase 28 (E3) — remote ahead/behind ("Get latest" nudge). Kept in its OWN
5454
+ // slice, NOT folded into `gitStatus`, because the `git-status` WS broadcast
5455
+ // (line ~5791) replaces `gitStatus` on every dirty-state change and carries
5456
+ // only LOCAL status — merging remote-ahead into it would be clobbered on the
5457
+ // next keystroke. The probe is a real network `git fetch` (server-side, token
5458
+ // from the keychain bridge), so it runs on a slow cadence — mount, a periodic
5459
+ // tick, and after each git action — never on the per-edit WS path.
5460
+ const [remoteSync, setRemoteSync] = useState(null); // { remoteAhead, behind } | null
5461
+ const [changesOpen, setChangesOpen] = useState(false);
5462
+ const [diffTarget, setDiffTarget] = useState(null);
5263
5463
  const [search, setSearch] = useState('');
5264
5464
  const [systemData, setSystemData] = useState(null);
5265
5465
  // Canvas-compile skeleton (single-canvas model → one path at a time).
@@ -5356,6 +5556,20 @@ function App() {
5356
5556
  cancelled = true;
5357
5557
  };
5358
5558
  }, []);
5559
+ // Phase 27 (E2) — seed the git dirty-state on mount; live updates arrive over
5560
+ // the `git-status` WS broadcast (Task 5). Solo/non-git projects → repo:false.
5561
+ useEffect(() => {
5562
+ let cancelled = false;
5563
+ fetch('/_api/git/status')
5564
+ .then((r) => r.json())
5565
+ .then((data) => {
5566
+ if (!cancelled && data) setGitStatus(data);
5567
+ })
5568
+ .catch(() => {});
5569
+ return () => {
5570
+ cancelled = true;
5571
+ };
5572
+ }, []);
5359
5573
  const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
5360
5574
  // Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
5361
5575
  // a `draft` state. Mutations route through postMessage → WS instead.
@@ -5368,6 +5582,7 @@ function App() {
5368
5582
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
5369
5583
  const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
5370
5584
  const [helpOpen, setHelpOpen] = useState(false);
5585
+ const [readinessOpen, setReadinessOpen] = useState(false);
5371
5586
  // ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
5372
5587
  // Help modal (F1), which keeps commands & flows.
5373
5588
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -5376,10 +5591,144 @@ function App() {
5376
5591
  // The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
5377
5592
  const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
5378
5593
  const [inspectorOpen, setInspectorOpen] = useState(false);
5594
+ // Phase 31 (DDR-123) — the native ACP chat sidepanel (right dock, native-only).
5595
+ const [assistantOpen, setAssistantOpen] = useState(false);
5596
+ const [assistantBusy, setAssistantBusy] = useState(false);
5597
+ const [assistantUnseen, setAssistantUnseen] = useState(false);
5598
+ const assistantOpenRef = useRef(assistantOpen);
5599
+ useEffect(() => {
5600
+ assistantOpenRef.current = assistantOpen;
5601
+ if (assistantOpen) {
5602
+ setAssistantUnseen(false); // opening clears the unseen badge
5603
+ // Ask for notification permission on a real user gesture (panel open).
5604
+ try {
5605
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
5606
+ Notification.requestPermission();
5607
+ }
5608
+ } catch {
5609
+ /* notifications unavailable */
5610
+ }
5611
+ }
5612
+ }, [assistantOpen]);
5613
+ // ChatPanel owns one connection PER CHAT (so chats run in parallel in the
5614
+ // background) and reports up here: `onBusyChange` for the menubar pulse, and
5615
+ // `onFinished` when any chat's turn ends — badge + notify if you weren't looking.
5616
+ const handleAssistantFinished = useCallback(() => {
5617
+ if (!assistantOpenRef.current || document.hidden) {
5618
+ setAssistantUnseen(true);
5619
+ try {
5620
+ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
5621
+ new Notification('Claude finished', { body: 'Your assistant turn is ready in Maude.' });
5622
+ }
5623
+ } catch {
5624
+ /* best-effort — the in-app badge is the reliable signal */
5625
+ }
5626
+ }
5627
+ }, []);
5379
5628
  // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
5380
5629
  // tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
5381
5630
  const [inspectorTab, setInspectorTab] = useState('inspect');
5631
+ // The right dock holds exactly ONE panel (Changes / Inspector / Comments) at
5632
+ // a time — opening any panel REPLACES whatever was there. These two helpers
5633
+ // are the single source of that invariant; every open/toggle path routes
5634
+ // through them. (Before, the three booleans were flipped independently across
5635
+ // ~13 call sites and only some closed their siblings, so a panel opened via a
5636
+ // path that left a sibling `true` rendered *behind* it under the fixed
5637
+ // precedence — looking like the new panel "overlapped" the old one.)
5638
+ const openRightPanel = useCallback((which) => {
5639
+ setChangesOpen(which === 'changes');
5640
+ setInspectorOpen(which === 'inspector');
5641
+ setCommentsPanelOpen(which === 'comments');
5642
+ setAssistantOpen(which === 'assistant');
5643
+ }, []);
5644
+ // Functional updates so this is stale-closure-safe inside the keydown /
5645
+ // postMessage listeners; opening always clears the sibling panels.
5646
+ const toggleRightPanel = useCallback((which) => {
5647
+ if (which === 'inspector') {
5648
+ setInspectorOpen((v) => {
5649
+ if (!v) {
5650
+ setChangesOpen(false);
5651
+ setCommentsPanelOpen(false);
5652
+ setAssistantOpen(false);
5653
+ }
5654
+ return !v;
5655
+ });
5656
+ } else if (which === 'comments') {
5657
+ setCommentsPanelOpen((v) => {
5658
+ if (!v) {
5659
+ setChangesOpen(false);
5660
+ setInspectorOpen(false);
5661
+ setAssistantOpen(false);
5662
+ }
5663
+ return !v;
5664
+ });
5665
+ } else if (which === 'changes') {
5666
+ setChangesOpen((v) => {
5667
+ if (!v) {
5668
+ setInspectorOpen(false);
5669
+ setCommentsPanelOpen(false);
5670
+ setAssistantOpen(false);
5671
+ }
5672
+ return !v;
5673
+ });
5674
+ } else if (which === 'assistant') {
5675
+ setAssistantOpen((v) => {
5676
+ if (!v) {
5677
+ setChangesOpen(false);
5678
+ setInspectorOpen(false);
5679
+ setCommentsPanelOpen(false);
5680
+ }
5681
+ return !v;
5682
+ });
5683
+ }
5684
+ }, []);
5382
5685
  const whatsNew = useWhatsNew(MDCC_VERSION);
5686
+ // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
5687
+ // "welcome" project on first launch; we ask it whether this is a first run and, if
5688
+ // so, show the wizard OVER the (empty) canvas browser. Completing any door switches
5689
+ // the sidecar to a real project (the webview reloads → first-run is then false).
5690
+ const [firstRun, setFirstRun] = useState(false);
5691
+ // Offer the collab "rychlý kurz" once, AFTER onboarding (native app, not first run,
5692
+ // not yet seen). A returning user who already took it isn't re-nudged.
5693
+ const [collabNudge, setCollabNudge] = useState(false);
5694
+ useEffect(() => {
5695
+ if (!isNativeApp()) return undefined;
5696
+ let alive = true;
5697
+ appIsFirstRun()
5698
+ .then((v) => {
5699
+ if (!alive) return;
5700
+ setFirstRun(!!v);
5701
+ if (!v && !readBoolStore(COLLAB_TOUR_STORE, false)) setCollabNudge(true);
5702
+ })
5703
+ .catch(() => {});
5704
+ return () => {
5705
+ alive = false;
5706
+ };
5707
+ }, []);
5708
+ const markCollabSeen = useCallback(() => {
5709
+ setCollabNudge(false);
5710
+ try {
5711
+ localStorage.setItem(COLLAB_TOUR_STORE, '1');
5712
+ } catch {}
5713
+ }, []);
5714
+ // Phase 32 (Task 1) — auto-update. The native shell downloads + stages a newer
5715
+ // build in the background and emits `update-ready`; we surface a non-blocking
5716
+ // banner. Native-only (the web studio is updated by its own deploy).
5717
+ const [updateReady, setUpdateReady] = useState(null);
5718
+ useEffect(() => {
5719
+ if (!isNativeApp()) return undefined;
5720
+ let un;
5721
+ onUpdateReady((p) => setUpdateReady(p && typeof p === 'object' ? p : {}))
5722
+ .then((fn) => {
5723
+ un = fn;
5724
+ })
5725
+ .catch(() => {});
5726
+ return () => {
5727
+ try {
5728
+ un?.();
5729
+ } catch {}
5730
+ };
5731
+ }, []);
5383
5732
  const [tourSteps, setTourSteps] = useState(null);
5384
5733
  const [usageNudge, setUsageNudge] = useState(() => !readBoolStore(USAGE_TOUR_STORE, false));
5385
5734
  const startTour = useCallback((steps) => {
@@ -5401,8 +5750,11 @@ function App() {
5401
5750
  } catch {}
5402
5751
  }, 80);
5403
5752
  }
5404
- if (step.inspector || step.tab || step.requireSelection) setInspectorOpen(true);
5753
+ if (step.inspector || step.tab || step.requireSelection) openRightPanel('inspector');
5405
5754
  if (step.tab) setInspectorTab(step.tab);
5755
+ // Phase 29 (E4) collab tour — open the Changes panel so the Save / Publish /
5756
+ // Get-latest controls the action steps spotlight actually exist to anchor on.
5757
+ if (step.changes) openRightPanel('changes');
5406
5758
  },
5407
5759
  };
5408
5760
  const markUsageSeen = useCallback(() => {
@@ -5412,6 +5764,13 @@ function App() {
5412
5764
  } catch {}
5413
5765
  }, []);
5414
5766
  const [annotationsVisible, setAnnotationsVisible] = useState(true);
5767
+ // Canvas-chrome visibility (View menu). minimap + zoom-controls are
5768
+ // persistent prefs broadcast to every open canvas iframe; presentMode is a
5769
+ // non-destructive "hide ALL chrome + shell, artboards only" overlay with an
5770
+ // Esc / floating-pill escape hatch back to the chrome.
5771
+ const [minimapVisible, setMinimapVisible] = useState(true);
5772
+ const [zoomCtlVisible, setZoomCtlVisible] = useState(true);
5773
+ const [presentMode, setPresentMode] = useState(false);
5415
5774
  // P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
5416
5775
  // so the top bar no longer mirrors it.)
5417
5776
  // activeArtboards — real artboard count of the open canvas, read from its
@@ -5454,6 +5813,42 @@ function App() {
5454
5813
  });
5455
5814
  }, [activePath]);
5456
5815
 
5816
+ // Chrome visibility (minimap / zoom-controls / Presentation Mode) applies to
5817
+ // EVERY open canvas iframe, not just the active one — broadcast to all. A
5818
+ // freshly-loaded iframe is seeded from the dgn:'loaded' handler below.
5819
+ const broadcastChrome = useCallback((patch) => {
5820
+ for (const el of iframesRef.current.values()) {
5821
+ try {
5822
+ el.contentWindow.postMessage({ dgn: 'view-chrome', ...patch }, '*');
5823
+ } catch {}
5824
+ }
5825
+ }, []);
5826
+ const toggleMinimap = useCallback(() => {
5827
+ setMinimapVisible((v) => {
5828
+ const next = !v;
5829
+ broadcastChrome({ minimap: next });
5830
+ return next;
5831
+ });
5832
+ }, [broadcastChrome]);
5833
+ const toggleZoomCtl = useCallback(() => {
5834
+ setZoomCtlVisible((v) => {
5835
+ const next = !v;
5836
+ broadcastChrome({ zoom: next });
5837
+ return next;
5838
+ });
5839
+ }, [broadcastChrome]);
5840
+ const togglePresent = useCallback(() => {
5841
+ setPresentMode((v) => {
5842
+ const next = !v;
5843
+ broadcastChrome({ present: next });
5844
+ return next;
5845
+ });
5846
+ }, [broadcastChrome]);
5847
+ const exitPresent = useCallback(() => {
5848
+ setPresentMode(false);
5849
+ broadcastChrome({ present: false });
5850
+ }, [broadcastChrome]);
5851
+
5457
5852
  // P3 (Plan C) — local git user for the menubar presence avatar. One-shot.
5458
5853
  useEffect(() => {
5459
5854
  let cancelled = false;
@@ -5657,6 +6052,20 @@ function App() {
5657
6052
  } else if (m.type === 'sync:status' && m.payload) {
5658
6053
  // Phase 9 Task 8 — hub connection state for the offline banner.
5659
6054
  setSyncStatus(m.payload);
6055
+ } else if (m.type === 'canvas-list-update') {
6056
+ // Phase 30 — a canvas was created/deleted on THIS dev-server; re-read
6057
+ // the branch-scoped tree so other open tabs reflect it without a
6058
+ // reload. Cross-machine peers get a new canvas via git "Get latest".
6059
+ loadTree();
6060
+ } else if (m.type === 'acp-focus') {
6061
+ // Phase 31 (DDR-123) — `/design:chat` from the terminal asked us to
6062
+ // surface the native ACP chat sidepanel. Native-only (the panel
6063
+ // doesn't exist on the web surface).
6064
+ if (isNativeApp()) openRightPanel('assistant');
6065
+ } else if (m.type === 'git-status' && m.payload) {
6066
+ // Phase 27 (E2) Task 5 — live dirty-state. Updates the Changes-panel
6067
+ // count + tree M/A/D badges reactively, no polling.
6068
+ setGitStatus(m.payload);
5660
6069
  } else if (m.type === 'git-lifecycle' && m.payload) {
5661
6070
  // Phase 8 Task 7 — branch switch / pull mid-session. Server has
5662
6071
  // already flushed every dirty Y.Doc to JSON; just prompt the user.
@@ -5675,7 +6084,9 @@ function App() {
5675
6084
  }
5676
6085
  connect();
5677
6086
  return () => wsRef.current && wsRef.current.close();
5678
- }, []);
6087
+ // loadTree is a stable useCallback([]); listed so the canvas-list-update
6088
+ // handler always calls the live reference.
6089
+ }, [loadTree]);
5679
6090
 
5680
6091
  function wsSend(obj) {
5681
6092
  const ws = wsRef.current;
@@ -5684,6 +6095,144 @@ function App() {
5684
6095
  } catch {}
5685
6096
  }
5686
6097
 
6098
+ // ----- Phase 27 (E2) — git actions -----
6099
+ // All write actions POST same-origin (the dev-server's sameOriginWrite + the
6100
+ // dual-allowlist gate them main-origin only). After a mutation we refresh
6101
+ // status optimistically; the `git-status` WS broadcast also lands shortly.
6102
+ const refreshGitStatus = useCallback(async () => {
6103
+ try {
6104
+ const r = await fetch('/_api/git/status');
6105
+ if (r.ok) setGitStatus(await r.json());
6106
+ } catch {}
6107
+ }, []);
6108
+
6109
+ // Phase 28 (E3) — probe the tracking remote so the Changes panel can surface
6110
+ // the "Get latest" nudge (GitPanel reads `status.remoteAhead` / `status.behind`).
6111
+ // `?remote=1` is what makes the server do the `git fetch` + ahead/behind count;
6112
+ // without it the status is local-only and the nudge never fires. Network call —
6113
+ // call sparingly (mount / interval / post-action), never on the WS hot path.
6114
+ const refreshRemoteSync = useCallback(async () => {
6115
+ try {
6116
+ const r = await fetch('/_api/git/status?remote=1');
6117
+ if (!r.ok) return;
6118
+ const data = await r.json();
6119
+ if (data && data.repo !== false)
6120
+ setRemoteSync({ remoteAhead: !!data.remoteAhead, behind: data.behind || 0 });
6121
+ } catch {}
6122
+ }, []);
6123
+
6124
+ const gitPostJson = useCallback(async (path, body) => {
6125
+ try {
6126
+ const r = await fetch(path, {
6127
+ method: 'POST',
6128
+ headers: { 'Content-Type': 'application/json' },
6129
+ body: JSON.stringify(body || {}),
6130
+ });
6131
+ const data = await r.json().catch(() => ({}));
6132
+ return { ok: r.ok, ...data };
6133
+ } catch (e) {
6134
+ return { ok: false, error: 'Network error — is the project still open?' };
6135
+ }
6136
+ }, []);
6137
+
6138
+ const gitCommit = useCallback(
6139
+ async (message, files) => {
6140
+ const res = await gitPostJson('/_api/git/commit', { message, files });
6141
+ if (res.ok) await refreshGitStatus();
6142
+ return res;
6143
+ },
6144
+ [gitPostJson, refreshGitStatus]
6145
+ );
6146
+
6147
+ const gitDiscard = useCallback(
6148
+ async (files) => {
6149
+ const res = await gitPostJson('/_api/git/discard', { files });
6150
+ if (res.ok) await refreshGitStatus();
6151
+ return res;
6152
+ },
6153
+ [gitPostJson, refreshGitStatus]
6154
+ );
6155
+
6156
+ const gitPublish = useCallback(async () => {
6157
+ const res = await gitPostJson('/_api/git/push', {});
6158
+ // Refresh so the "N versions ready to publish" count clears to 0 after a
6159
+ // successful push (the server advanced the local remote-tracking ref), and
6160
+ // re-probe the remote so a stale "Get latest" nudge clears.
6161
+ if (res.ok) {
6162
+ await refreshGitStatus();
6163
+ refreshRemoteSync();
6164
+ }
6165
+ return res;
6166
+ }, [gitPostJson, refreshGitStatus, refreshRemoteSync]);
6167
+
6168
+ const gitGetLatest = useCallback(async () => {
6169
+ const res = await gitPostJson('/_api/git/pull', {});
6170
+ // On success the remote is merged in — clear the nudge by re-probing.
6171
+ if (res.ok) {
6172
+ await refreshGitStatus();
6173
+ refreshRemoteSync();
6174
+ }
6175
+ // A true content conflict → open the visual resolver on the first file.
6176
+ if (res.conflict && Array.isArray(res.files) && res.files.length) {
6177
+ setDiffTarget({ file: res.files[0], conflict: true });
6178
+ }
6179
+ return res;
6180
+ }, [gitPostJson, refreshGitStatus, refreshRemoteSync]);
6181
+
6182
+ // Phase 28 (E3) — finish a Get-latest conflict from the DiffView resolver.
6183
+ // `choice` is 'mine' | 'theirs' | 'both'; the server completes the two-parent
6184
+ // merge commit (and, for 'both', writes our version as a "(mine)" copy).
6185
+ const gitResolveConflict = useCallback(
6186
+ async (choice) => {
6187
+ const res = await gitPostJson('/_api/git/resolve', { choice });
6188
+ if (res.ok) {
6189
+ await refreshGitStatus();
6190
+ refreshRemoteSync();
6191
+ }
6192
+ return res;
6193
+ },
6194
+ [gitPostJson, refreshGitStatus, refreshRemoteSync]
6195
+ );
6196
+
6197
+ // `path` (optional) scopes History to one canvas — the per-file version list
6198
+ // behind the History click-to-preview + DiffView "Saved version" picker
6199
+ // (phase-27.1). Omit for the repo-wide log.
6200
+ const gitLoadLog = useCallback(async (path) => {
6201
+ try {
6202
+ const qs = '/_api/git/log?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
6203
+ const r = await fetch(qs);
6204
+ if (!r.ok) return [];
6205
+ const data = await r.json();
6206
+ return data.entries || [];
6207
+ } catch {
6208
+ return [];
6209
+ }
6210
+ }, []);
6211
+
6212
+ // Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
6213
+ // and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
6214
+ // live with the WS broadcast.
6215
+ const dirtyByPath = useMemo(() => {
6216
+ const KIND = { modified: 'M', added: 'A', deleted: 'D', untracked: 'U' };
6217
+ const m = new Map();
6218
+ for (const f of gitStatus?.files || []) m.set(f.path, KIND[f.status]);
6219
+ return m;
6220
+ }, [gitStatus]);
6221
+ const unsavedCount = gitStatus?.files?.length || 0;
6222
+
6223
+ // Phase 28 (E3) — keep remote ahead/behind fresh so the "Get latest" nudge
6224
+ // surfaces on its own: probe once a repo is known, again whenever the Changes
6225
+ // panel opens, and on a slow 60 s tick WHILE the panel is open (a teammate's
6226
+ // publish then shows up without the user first attempting their own publish).
6227
+ // Declared after `refreshRemoteSync` to avoid a temporal-dead-zone on the dep.
6228
+ useEffect(() => {
6229
+ if (gitStatus?.repo === false) return; // solo / non-git project — no remote
6230
+ refreshRemoteSync();
6231
+ if (!changesOpen) return; // only keep polling while the panel is visible
6232
+ const id = setInterval(refreshRemoteSync, 60000);
6233
+ return () => clearInterval(id);
6234
+ }, [gitStatus?.repo, changesOpen, refreshRemoteSync]);
6235
+
5687
6236
  // ----- Tab management (single-canvas) -----
5688
6237
  // Single-canvas model: opening a file REPLACES the active one (no tab strip).
5689
6238
  // The `tabs` state stays as a 0-or-1 array so the rest of the plumbing
@@ -5825,15 +6374,17 @@ function App() {
5825
6374
  }, [activePath]);
5826
6375
 
5827
6376
  // ----- Push comments to iframe whenever they change for active file -----
6377
+ // Presentation Mode hides comment pins: post an empty list while present
6378
+ // (re-posting the real list on exit, since this effect re-runs on the flag).
5828
6379
  useEffect(() => {
5829
6380
  if (!activePath || activePath === SYSTEM_TAB) return;
5830
6381
  const el = iframesRef.current.get(activePath);
5831
6382
  if (!el || !el.contentWindow) return;
5832
- const list = commentsByFile[activePath] || [];
6383
+ const list = presentMode ? [] : commentsByFile[activePath] || [];
5833
6384
  try {
5834
6385
  el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
5835
6386
  } catch {}
5836
- }, [activePath, commentsByFile]);
6387
+ }, [activePath, commentsByFile, presentMode]);
5837
6388
 
5838
6389
  // ----- Inbound messages from iframes -----
5839
6390
  useEffect(() => {
@@ -5917,7 +6468,25 @@ function App() {
5917
6468
  setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
5918
6469
  } else if (m.dgn === 'open-inspector') {
5919
6470
  // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
5920
- setInspectorOpen(true);
6471
+ openRightPanel('inspector');
6472
+ } else if (m.dgn === 'present-enter') {
6473
+ // Canvas tool-palette "Presentation mode" button — Present Mode is a
6474
+ // shell-level state (hides the menubar / sidebar / panels), so the
6475
+ // canvas requests it here and the shell flips it on + broadcasts
6476
+ // dgn:'view-chrome' back to every iframe. Enter-only (the palette is
6477
+ // hidden while presenting); exit is Esc or the floating pill. The
6478
+ // inbound origin gate above (DDR-054) already authenticates the canvas.
6479
+ // Hardening (phase-28 audit F-2): honor it ONLY from the ACTIVE canvas
6480
+ // (a background tab's untrusted canvas must not flip the foreground),
6481
+ // and NEVER while a modal dialog is open — present mode hides Sidebar-
6482
+ // descendant modals (OAuth device-code / Share-invite), so an untrusted
6483
+ // canvas could otherwise blank an in-flight confirmation.
6484
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
6485
+ const modalOpen = !!document.querySelector('[role="dialog"][aria-modal="true"]');
6486
+ if (e.source === activeWin && !modalOpen && !presentMode) {
6487
+ setPresentMode(true);
6488
+ broadcastChrome({ present: true });
6489
+ }
5921
6490
  } else if (m.dgn === 'comment-compose' && m.selection) {
5922
6491
  // Phase 6 — the iframe overlay owns the composer surface now. The
5923
6492
  // shell just mirrors `selected` so the StatusBar / sidebar still
@@ -5970,8 +6539,9 @@ function App() {
5970
6539
  // Same forwarding lane for the other shell chords (inspect.ts) — so
5971
6540
  // ⌘R / ⌘⇧I / ⌘⇧M / ⌘⇧E / ⌘⇧H behave identically wherever focus is.
5972
6541
  if (m.id === 'reload') reloadActive();
5973
- else if (m.id === 'inspector') setInspectorOpen((v) => !v);
5974
- else if (m.id === 'comments') setCommentsPanelOpen((v) => !v);
6542
+ else if (m.id === 'inspector') toggleRightPanel('inspector');
6543
+ else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
6544
+ else if (m.id === 'comments') toggleRightPanel('comments');
5975
6545
  else if (m.id === 'export') setExportDialog({ mode: 'export' });
5976
6546
  else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
5977
6547
  } else if (m.dgn === 'open-export') {
@@ -5986,7 +6556,9 @@ function App() {
5986
6556
  // iframe finished loading — drop the compile skeleton, push current
5987
6557
  // comments + carry over focused pin if any
5988
6558
  setLoadingPath((p) => (p === m.file ? null : p));
5989
- const list = commentsByFile[m.file] || [];
6559
+ // Presentation Mode suppresses comment pins (same gate as the push
6560
+ // effect above), so a canvas opened while presenting starts pin-free.
6561
+ const list = presentMode ? [] : commentsByFile[m.file] || [];
5990
6562
  const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
5991
6563
  if (el && el.contentWindow) {
5992
6564
  try {
@@ -5998,6 +6570,15 @@ function App() {
5998
6570
  try {
5999
6571
  el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
6000
6572
  } catch {}
6573
+ // Seed the just-loaded canvas with the current chrome-visibility
6574
+ // state (minimap / zoom-controls toggles + Presentation Mode) so a
6575
+ // canvas opened after a toggle starts in the right state.
6576
+ try {
6577
+ el.contentWindow.postMessage(
6578
+ { dgn: 'view-chrome', minimap: minimapVisible, zoom: zoomCtlVisible, present: presentMode },
6579
+ '*'
6580
+ );
6581
+ } catch {}
6001
6582
  if (focusedCommentId && list.some((c) => c.id === focusedCommentId)) {
6002
6583
  try {
6003
6584
  el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
@@ -6092,7 +6673,18 @@ function App() {
6092
6673
  }
6093
6674
  window.addEventListener('message', onMessage);
6094
6675
  return () => window.removeEventListener('message', onMessage);
6095
- }, [commentsByFile, focusedCommentId, cfg, theme, reloadActive]);
6676
+ }, [
6677
+ commentsByFile,
6678
+ focusedCommentId,
6679
+ cfg,
6680
+ theme,
6681
+ reloadActive,
6682
+ presentMode,
6683
+ minimapVisible,
6684
+ zoomCtlVisible,
6685
+ broadcastChrome,
6686
+ activePath,
6687
+ ]);
6096
6688
 
6097
6689
  // Tell the active canvas iframe to drop any persistent selection (canvas
6098
6690
  // SelectionSet) — used when the comment composer closes via submit /
@@ -6176,6 +6768,16 @@ function App() {
6176
6768
  // ⌘F) still fire regardless of focus, mirroring browser convention.
6177
6769
  const inCanvasIframe = document.activeElement?.tagName === 'IFRAME';
6178
6770
 
6771
+ // Esc exits Presentation Mode first — it's the primary way back to the
6772
+ // chrome (the menubar is hidden while presenting). Highest priority so it
6773
+ // wins over the focused-pin / deselect Esc handlers below, and fires even
6774
+ // when focus is inside the canvas iframe.
6775
+ if (presentMode && e.key === 'Escape') {
6776
+ e.preventDefault();
6777
+ exitPresent();
6778
+ return;
6779
+ }
6780
+
6179
6781
  // Cmd+K / Ctrl+K — toggle the command palette (works even in inputs).
6180
6782
  if (meta && (e.key === 'k' || e.key === 'K')) {
6181
6783
  e.preventDefault();
@@ -6191,14 +6793,27 @@ function App() {
6191
6793
  // Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
6192
6794
  if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
6193
6795
  e.preventDefault();
6194
- setCommentsPanelOpen((v) => !v);
6796
+ toggleRightPanel('comments');
6797
+ return;
6798
+ }
6799
+ // Cmd+Shift+G — toggle the Changes (git) panel. Opening it closes the
6800
+ // other right-dock panels (one panel at a time).
6801
+ if (meta && e.shiftKey && (e.key === 'g' || e.key === 'G')) {
6802
+ e.preventDefault();
6803
+ toggleRightPanel('changes');
6195
6804
  return;
6196
6805
  }
6197
6806
  // Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
6198
6807
  // canvas highlighter tool (same letter, different action by focus).
6199
6808
  if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
6200
6809
  e.preventDefault();
6201
- setInspectorOpen((v) => !v);
6810
+ toggleRightPanel('inspector');
6811
+ return;
6812
+ }
6813
+ // Phase 31 (DDR-123) — Cmd+Shift+A opens the native ACP chat sidepanel.
6814
+ if (meta && e.shiftKey && (e.key === 'a' || e.key === 'A') && isNativeApp()) {
6815
+ e.preventDefault();
6816
+ toggleRightPanel('assistant');
6202
6817
  return;
6203
6818
  }
6204
6819
  // Cmd+Shift+E / Cmd+Shift+H — the File-menu chords, previously
@@ -6325,6 +6940,8 @@ function App() {
6325
6940
  openSystem,
6326
6941
  closeTab,
6327
6942
  clearActiveCanvasSelection,
6943
+ presentMode,
6944
+ exitPresent,
6328
6945
  ]);
6329
6946
 
6330
6947
  const registerIframe = useCallback((path, el) => {
@@ -6399,7 +7016,7 @@ function App() {
6399
7016
  label: 'Toggle comments panel',
6400
7017
  icon: 'resolve',
6401
7018
  kbd: '⌘⇧M',
6402
- run: () => setCommentsPanelOpen((v) => !v),
7019
+ run: () => toggleRightPanel('comments'),
6403
7020
  },
6404
7021
  {
6405
7022
  id: 'inspector',
@@ -6407,7 +7024,7 @@ function App() {
6407
7024
  label: 'Open inspector',
6408
7025
  icon: 'sliders',
6409
7026
  kbd: '⌘⇧I',
6410
- run: () => setInspectorOpen(true),
7027
+ run: () => openRightPanel('inspector'),
6411
7028
  },
6412
7029
  {
6413
7030
  id: 'reload',
@@ -6467,7 +7084,13 @@ function App() {
6467
7084
  );
6468
7085
 
6469
7086
  return (
6470
- <div className="maude" data-theme={theme} onContextMenu={onShellContextMenu}>
7087
+ <div
7088
+ className={'maude' + (presentMode ? ' is-present' : '')}
7089
+ data-theme={theme}
7090
+ onContextMenu={onShellContextMenu}
7091
+ >
7092
+ {firstRun && <OnboardingWizard />}
7093
+ <UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
6471
7094
  <SyncBanner status={syncStatus} />
6472
7095
  {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
6473
7096
  {gitLifecycle && (
@@ -6498,7 +7121,10 @@ function App() {
6498
7121
  openMenu={openMenu}
6499
7122
  setOpenMenu={setOpenMenu}
6500
7123
  commentsPanelOpen={commentsPanelOpen}
6501
- onToggleComments={() => setCommentsPanelOpen((v) => !v)}
7124
+ onToggleComments={() => toggleRightPanel('comments')}
7125
+ changesOpen={changesOpen}
7126
+ changesCount={unsavedCount}
7127
+ onToggleChanges={() => toggleRightPanel('changes')}
6502
7128
  onOpenSystem={openSystem}
6503
7129
  sidebarOpen={sidebarOpen}
6504
7130
  onToggleSidebar={() => setSidebarOpen((v) => !v)}
@@ -6507,22 +7133,35 @@ function App() {
6507
7133
  onOpenHelp={() => setHelpOpen(true)}
6508
7134
  onOpenShortcuts={() => setShortcutsOpen(true)}
6509
7135
  onStartTour={() => startTour(USAGE_TOUR)}
7136
+ onStartCollabTour={() => startTour(COLLAB_TOUR)}
6510
7137
  annotationsVisible={annotationsVisible}
6511
7138
  onToggleAnnotations={toggleAnnotations}
7139
+ minimapVisible={minimapVisible}
7140
+ onToggleMinimap={toggleMinimap}
7141
+ zoomCtlVisible={zoomCtlVisible}
7142
+ onToggleZoomCtl={toggleZoomCtl}
7143
+ presentMode={presentMode}
7144
+ onTogglePresent={togglePresent}
6512
7145
  postToActiveCanvas={postToActiveCanvas}
7146
+ onOpenReadiness={() => setReadinessOpen(true)}
6513
7147
  onOpenWhatsNew={whatsNew.openPanel}
6514
7148
  whatsNewCount={whatsNew.unseen.length}
6515
7149
  artboardCount={activeArtboards}
6516
7150
  inspectorOpen={inspectorOpen}
6517
7151
  inspectorTab={inspectorTab}
6518
- onToggleInspector={() => setInspectorOpen((v) => !v)}
7152
+ onToggleInspector={() => toggleRightPanel('inspector')}
7153
+ assistantOpen={assistantOpen}
7154
+ onToggleAssistant={() => toggleRightPanel('assistant')}
7155
+ assistantBusy={assistantBusy}
7156
+ assistantUnseen={assistantUnseen}
6519
7157
  onOpenLayers={() => {
6520
- // Toggle: already open on Layers → close; otherwise open on Layers.
7158
+ // Toggle: already open on Layers → close; otherwise open on Layers
7159
+ // (clearing the sibling panels — one dock slot).
6521
7160
  if (inspectorOpen && inspectorTab === 'layers') {
6522
7161
  setInspectorOpen(false);
6523
7162
  } else {
6524
7163
  setInspectorTab('layers');
6525
- setInspectorOpen(true);
7164
+ openRightPanel('inspector');
6526
7165
  }
6527
7166
  }}
6528
7167
  onNewCanvas={() => {
@@ -6581,6 +7220,9 @@ function App() {
6581
7220
  onCollapse={() => setSidebarOpen(false)}
6582
7221
  width={sbSize.w}
6583
7222
  resizing={dragSide === 'sb'}
7223
+ dirtyByPath={dirtyByPath}
7224
+ project={project}
7225
+ gitBranch={gitStatus?.branch}
6584
7226
  />
6585
7227
  {sidebarOpen && (
6586
7228
  <PanelGrip
@@ -6608,7 +7250,7 @@ function App() {
6608
7250
  onIframeLoad={onIframeLoad}
6609
7251
  />
6610
7252
  </div>
6611
- {(inspectorOpen || commentsPanelOpen) && (
7253
+ {(inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen) && (
6612
7254
  <PanelGrip
6613
7255
  label="Resize side panel"
6614
7256
  dir="rtl"
@@ -6621,9 +7263,36 @@ function App() {
6621
7263
  }}
6622
7264
  />
6623
7265
  )}
6624
- {/* Right dock — one panel at a time. Inspector takes precedence when
6625
- open (T6); else the comments panel. */}
6626
- {inspectorOpen ? (
7266
+ {/* Right dock — one panel at a time. Changes (E2) takes precedence,
7267
+ then Inspector (T6), then the comments panel. */}
7268
+ {changesOpen ? (
7269
+ <GitPanel
7270
+ status={
7271
+ gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus
7272
+ }
7273
+ project={project}
7274
+ readOnly={!isNativeApp()}
7275
+ width={rpSize.w}
7276
+ resizing={dragSide === 'rp'}
7277
+ onClose={() => setChangesOpen(false)}
7278
+ onCommit={gitCommit}
7279
+ onDiscard={gitDiscard}
7280
+ onPublish={gitPublish}
7281
+ onGetLatest={gitGetLatest}
7282
+ loadLog={gitLoadLog}
7283
+ onOpenCanvas={(p) => openTab(p)}
7284
+ onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
7285
+ activeCanvas={
7286
+ activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
7287
+ ? activePath
7288
+ : null
7289
+ }
7290
+ onPreviewVersion={(sha) =>
7291
+ setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })
7292
+ }
7293
+ designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
7294
+ />
7295
+ ) : inspectorOpen ? (
6627
7296
  <InspectorPanel
6628
7297
  selected={selected}
6629
7298
  cfg={cfg}
@@ -6666,6 +7335,24 @@ function App() {
6666
7335
  resizing={dragSide === 'rp'}
6667
7336
  />
6668
7337
  ) : null}
7338
+ {/* Phase 31 (DDR-123) — the ACP chat panel stays MOUNTED (display:none
7339
+ when inactive) so the chat keeps streaming + its history survives a
7340
+ switch to Changes/Inspector/Comments. Native-only. */}
7341
+ {isNativeApp() && (
7342
+ <ChatPanel
7343
+ hidden={!assistantOpen}
7344
+ activeCanvas={
7345
+ activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
7346
+ ? activePath
7347
+ : null
7348
+ }
7349
+ width={rpSize.w}
7350
+ resizing={dragSide === 'rp'}
7351
+ onClose={() => setAssistantOpen(false)}
7352
+ onBusyChange={setAssistantBusy}
7353
+ onFinished={handleAssistantFinished}
7354
+ />
7355
+ )}
6669
7356
  </div>
6670
7357
  <StatusBar
6671
7358
  activePath={activePath}
@@ -6676,8 +7363,32 @@ function App() {
6676
7363
  onToggleTheme={toggleTheme}
6677
7364
  onClearSelected={clearSelected}
6678
7365
  syncStatus={syncStatus}
7366
+ changesCount={unsavedCount}
7367
+ unpushed={gitStatus?.unpushed || 0}
7368
+ changesOpen={changesOpen}
7369
+ onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
6679
7370
  />
6680
7371
  </div>
7372
+ {presentMode && (
7373
+ <button
7374
+ type="button"
7375
+ className="st-present-exit"
7376
+ onClick={exitPresent}
7377
+ aria-label="Exit presentation mode"
7378
+ title="Exit presentation mode (Esc)"
7379
+ >
7380
+ <svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden="true">
7381
+ <path
7382
+ d="M4 4l8 8M12 4l-8 8"
7383
+ stroke="currentColor"
7384
+ strokeWidth="1.6"
7385
+ strokeLinecap="round"
7386
+ />
7387
+ </svg>
7388
+ <span>Exit presentation</span>
7389
+ <kbd className="st-present-exit-kbd">Esc</kbd>
7390
+ </button>
7391
+ )}
6681
7392
  <CommandPalette
6682
7393
  open={paletteOpen}
6683
7394
  onClose={() => setPaletteOpen(false)}
@@ -6691,6 +7402,30 @@ function App() {
6691
7402
  onClose={() => setExportDialog(null)}
6692
7403
  />
6693
7404
  )}
7405
+ {diffTarget && (
7406
+ <DiffView
7407
+ target={diffTarget}
7408
+ cfg={cfg}
7409
+ loadLog={gitLoadLog}
7410
+ onClose={() => setDiffTarget(null)}
7411
+ onRestore={async (file) => {
7412
+ await gitDiscard([file]);
7413
+ setDiffTarget(null);
7414
+ }}
7415
+ onResolve={async (choice) => {
7416
+ // phase-28 (E3): apply the chosen side via /_api/git/resolve, which
7417
+ // completes the two-parent merge commit (and for "both" saves our
7418
+ // version as a "(mine)" copy — zero loss). Close on success; keep the
7419
+ // resolver open with the error otherwise.
7420
+ const res = await gitResolveConflict(choice);
7421
+ if (res.ok) {
7422
+ setDiffTarget(null);
7423
+ } else {
7424
+ window.alert(res.error || 'Could not finish the merge. Get the latest again, then retry.');
7425
+ }
7426
+ }}
7427
+ />
7428
+ )}
6694
7429
  <ShortcutsOverlay open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
6695
7430
  <HelpModal
6696
7431
  open={helpOpen}
@@ -6701,7 +7436,8 @@ function App() {
6701
7436
  }}
6702
7437
  />
6703
7438
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
6704
- {usageNudge && !tourSteps && (
7439
+ <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
7440
+ {usageNudge && !tourSteps && !collabNudge && (
6705
7441
  <div className="mdcc-tour-nudge" role="status" aria-live="polite">
6706
7442
  <div className="mdcc-tour-nudge__body">
6707
7443
  New here? Take a 60-second tour of the canvas browser.
@@ -6726,6 +7462,32 @@ function App() {
6726
7462
  </button>
6727
7463
  </div>
6728
7464
  )}
7465
+ {/* Phase 29 (E4) — the collab "rychlý kurz", offered once after onboarding. */}
7466
+ {collabNudge && !tourSteps && (
7467
+ <div className="mdcc-tour-nudge" role="status" aria-live="polite">
7468
+ <div className="mdcc-tour-nudge__body">
7469
+ New to working with a team? See how saving &amp; sharing works — 60 seconds.
7470
+ </div>
7471
+ <button
7472
+ type="button"
7473
+ className="mdcc-tour-nudge__cta"
7474
+ onClick={() => {
7475
+ markCollabSeen();
7476
+ startTour(COLLAB_TOUR);
7477
+ }}
7478
+ >
7479
+ Start
7480
+ </button>
7481
+ <button
7482
+ type="button"
7483
+ className="mdcc-tour-nudge__skip"
7484
+ aria-label="Dismiss"
7485
+ onClick={markCollabSeen}
7486
+ >
7487
+ ×
7488
+ </button>
7489
+ </div>
7490
+ )}
6729
7491
  <TourOverlay
6730
7492
  steps={tourSteps ?? []}
6731
7493
  open={!!tourSteps}