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