@1agh/maude 0.55.0 → 0.57.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 (85) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -21,6 +21,11 @@ import { GRID_KEYWORD_UNITS, parseTrackList, serializeTrackList } from '../grid-
21
21
  // canvas-cursors.ts above.
22
22
  import { PAPER_PRESETS, resolvePrintArtboard } from '../print/units.ts';
23
23
  import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
24
+ // The single "what is the hub link doing" rule, shared with the cloud rail's
25
+ // connect note so the two surfaces can never disagree. Pure data + strings —
26
+ // same "pull only pure logic into the client bundle" shape as the imports
27
+ // above (a type-only SyncStatusSnapshot import that Bun erases).
28
+ import { syncPresentation } from '../sync/presentation.ts';
24
29
  import { canvasUrl } from './canvas-url.js';
25
30
  import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
26
31
  import { useTreeDrag } from './use-tree-drag.js';
@@ -77,6 +82,7 @@ const Lu = ({ as: C, size = 14 }) => <C size={size} strokeWidth={1.75} style={{
77
82
  import { PhotoKnobs } from './photo-knobs.jsx';
78
83
  import {
79
84
  appIsFirstRun,
85
+ invoke,
80
86
  isNativeApp,
81
87
  onMenuReportBug,
82
88
  onUpdateReady,
@@ -156,7 +162,7 @@ const LAYERS_MODE_STORE = 'mdcc-layers-mode';
156
162
  // resizable width + a tab strip over the panels assigned to the slot; renders
157
163
  // the active panel (passed as children). Collapses to 0 width when nothing is
158
164
  // visibly open (it may still host an always-mounted hidden ChatPanel).
159
- function DockSlot({ side, width, open, ids, activeId, onPick, children }) {
165
+ function DockSlot({ side, width, open, ids, activeId, onPick, children, labels = null }) {
160
166
  return (
161
167
  <div
162
168
  className={'st-dockslot st-dockslot--' + side + (open ? '' : ' is-collapsed')}
@@ -173,7 +179,7 @@ function DockSlot({ side, width, open, ids, activeId, onPick, children }) {
173
179
  className={'st-docktab' + (activeId === id ? ' is-active' : '')}
174
180
  onClick={() => onPick(id)}
175
181
  >
176
- {(DOCK_PANELS.find((p) => p.id === id) || {}).label || id}
182
+ {labels?.[id] || (DOCK_PANELS.find((p) => p.id === id) || {}).label || id}
177
183
  </button>
178
184
  ))}
179
185
  </div>
@@ -203,14 +209,30 @@ const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__
203
209
  // handleAssistantAttention below (identical support/permission check +
204
210
  // try/catch, only the title/body differ). The in-app badge stays the
205
211
  // reliable signal if this silently fails for any reason.
212
+ //
213
+ // feature-acp-turn-notifications Task 6 — under Tauri, route through the
214
+ // native `send_notification` command (notify.rs) instead of the Web `Notification` API.
215
+ // This is the SAME native notifier the shell's cross-project poller uses for
216
+ // every OTHER project, so the visible project now goes through the identical
217
+ // OS-level path rather than a webview API whose behavior inside WKWebView was
218
+ // never empirically confirmed (see Task 1's finding in the plan). Falls back
219
+ // to the Web API on any failure — an older desktop build without the command,
220
+ // or a plain browser tab — so the signal degrades rather than disappearing.
206
221
  function notifyDesktop(title, body) {
207
- try {
208
- if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
209
- new Notification(title, { body });
222
+ const webFallback = () => {
223
+ try {
224
+ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
225
+ new Notification(title, { body });
226
+ }
227
+ } catch {
228
+ /* best-effort — the in-app badge is the reliable signal */
210
229
  }
211
- } catch {
212
- /* best-effort — the in-app badge is the reliable signal */
230
+ };
231
+ if (isNativeApp()) {
232
+ invoke('send_notification', { title, body }).catch(webFallback);
233
+ return;
213
234
  }
235
+ webFallback();
214
236
  }
215
237
 
216
238
  function readInitialTheme() {
@@ -2473,6 +2495,9 @@ function Sidebar({
2473
2495
  onMoveCanvas,
2474
2496
  onNewFolder,
2475
2497
  onDeleteFolder,
2498
+ // Passed through to CloudBar only — the live `sync:status` payload that makes
2499
+ // the connect note follow the link instead of freezing at attach time.
2500
+ syncStatus,
2476
2501
  }) {
2477
2502
  const filteredGroups = useMemo(() => {
2478
2503
  if (!search) return groups;
@@ -2846,7 +2871,7 @@ function Sidebar({
2846
2871
  Absent rather than disabled, because unlike the agent chat there is
2847
2872
  nothing here to explain — the capability is not missing, it is
2848
2873
  already satisfied. */}
2849
- {cloud === null ? <CloudBar /> : null}
2874
+ {cloud === null ? <CloudBar syncStatus={syncStatus} /> : null}
2850
2875
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
2851
2876
  sign in, connected account + New/Pull/Share, sign out. Self-contained
2852
2877
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -3733,7 +3758,14 @@ function Menubar({
3733
3758
  { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
3734
3759
  {
3735
3760
  id: 'changes',
3736
- label: changesCount > 0 ? `Changes · ${changesCount} unsaved` : 'Changes',
3761
+ // In a cell this panel is History (the hub already committed the work),
3762
+ // so the menu names what it opens rather than an unsaved count there is
3763
+ // no way — and no reason — to act on.
3764
+ label: cloud
3765
+ ? 'History'
3766
+ : changesCount > 0
3767
+ ? `Changes · ${changesCount} unsaved`
3768
+ : 'Changes',
3737
3769
  shortcut: '⌘ ⇧ G',
3738
3770
  checked: changesOpen,
3739
3771
  disabled: false,
@@ -4530,6 +4562,7 @@ function StatusBar({
4530
4562
  onToggleTheme,
4531
4563
  onClearSelected,
4532
4564
  syncStatus,
4565
+ syncProject,
4533
4566
  changesCount = 0,
4534
4567
  unpushed = 0,
4535
4568
  changesOpen = false,
@@ -4552,32 +4585,21 @@ function StatusBar({
4552
4585
  // shapes: solo `{linked:false}` (hide), DDR-060 `{notSyncable,tsxCount,reason}`
4553
4586
  // (linked but 0 syncable), or the connection-state machine `{state,queuedOps,
4554
4587
  // flash,…}` (the common linked case the old notSyncable-only guard never
4555
- // showed — the "hub sync se neukazuje" bug). Map each to a label + dot tone.
4588
+ // showed — the "hub sync se neukazuje" bug).
4589
+ //
4590
+ // The mapping used to live here, and it referenced `docs` ZERO times: it read
4591
+ // `state` alone, so a link whose every document the hub had refused still
4592
+ // showed a green dot and the word "synced". `syncPresentation` is now the one
4593
+ // rule, shared with the cloud rail's connect note, so the two surfaces cannot
4594
+ // give a person different answers about the same payload.
4556
4595
  const syncSlot = (() => {
4557
- if (!syncStatus || syncStatus.linked === false) return null;
4558
- if (syncStatus.notSyncable) {
4559
- return {
4560
- online: false,
4561
- label: `0 syncable${syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}`,
4562
- title: syncStatus.reason || 'Linked to a hub, but no canvases are syncable.',
4563
- };
4564
- }
4565
- const q = syncStatus.queuedOps ?? 0;
4566
- const synced = syncStatus.state === 'online' || syncStatus.flash === 'synced';
4567
- if (synced) {
4568
- return {
4569
- online: true,
4570
- label: q > 0 ? `${q} ↑` : 'synced',
4571
- title: q > 0 ? `${q} edit(s) queued to push` : 'All changes synced to the hub',
4572
- };
4573
- }
4596
+ const p = syncPresentation(syncStatus, { project: syncProject });
4597
+ if (!p) return null;
4598
+ const detail = p.names.length ? ` (${p.names.join(', ')})` : '';
4574
4599
  return {
4575
- online: false,
4576
- label: `${q} ↑`,
4577
- title:
4578
- syncStatus.state === 'connecting'
4579
- ? 'Connecting to the hub…'
4580
- : 'Offline — edits queued, will sync when the hub reconnects',
4600
+ online: p.online,
4601
+ label: p.label,
4602
+ title: `${p.title}${detail}${p.next ? ` — ${p.next}` : ''}`,
4581
4603
  };
4582
4604
  })();
4583
4605
 
@@ -14019,6 +14041,11 @@ function App() {
14019
14041
  changes: changesOpen,
14020
14042
  assistant: assistantOpen,
14021
14043
  };
14044
+ // Per-shell overrides for the dock tab strip. In a cell the `changes` panel
14045
+ // IS the history (the hub commits server-side), so its tab must say so —
14046
+ // otherwise the one visible word still promises a working-tree surface that
14047
+ // panel no longer has.
14048
+ const dockLabels = cfg.cloud ? { changes: 'History' } : null;
14022
14049
  const leftIds = idsForSide('left');
14023
14050
  const rightIds = idsForSide('right');
14024
14051
  const leftActive = leftIds.find((id) => panelIsOpen[id]) || null;
@@ -14075,6 +14102,7 @@ function App() {
14075
14102
  remoteSync={remoteSync}
14076
14103
  onGetLatest={gitGetLatest}
14077
14104
  canvasKinds={cfg?.canvasKinds}
14105
+ syncStatus={syncStatus}
14078
14106
  />
14079
14107
  );
14080
14108
  if (id === 'changes')
@@ -14083,6 +14111,20 @@ function App() {
14083
14111
  status={gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus}
14084
14112
  project={project}
14085
14113
  readOnly={!isNativeApp() || viewerMode}
14114
+ // A cloud cell commits every edit server-side as it lands, so the
14115
+ // working-tree half of this panel describes work that is already
14116
+ // saved. `cfg.cloud` is present exactly when the hub runs the
14117
+ // workspace agent that owns this project's history, which is the
14118
+ // same condition.
14119
+ //
14120
+ // PRESENTATION, NOT A CONTROL — nothing may come to depend on this.
14121
+ // `/_api/git/commit` and `/_api/git/discard` are classified `edit` in
14122
+ // the cell's route manifest and stay reachable by any member with a
14123
+ // session; withdrawing the buttons removes an offer that would
14124
+ // mislead, it does not remove a capability. The real gates are
14125
+ // server-side (`projectReadOnly`, the manifest's role matrix), and
14126
+ // they are unchanged by this flag.
14127
+ historyOnly={!!cfg.cloud}
14086
14128
  resizing={resizingFor('changes')}
14087
14129
  onClose={() => setChangesOpen(false)}
14088
14130
  onCommit={gitCommit}
@@ -14330,6 +14372,7 @@ function App() {
14330
14372
  ids={leftIds}
14331
14373
  activeId={leftActive}
14332
14374
  onPick={togglePanel}
14375
+ labels={dockLabels}
14333
14376
  >
14334
14377
  {leftHostsAssistant && (
14335
14378
  <ChatPanel
@@ -14402,6 +14445,7 @@ function App() {
14402
14445
  ids={rightIds}
14403
14446
  activeId={rightActive}
14404
14447
  onPick={togglePanel}
14448
+ labels={dockLabels}
14405
14449
  >
14406
14450
  {rightHostsAssistant && (
14407
14451
  <ChatPanel
@@ -15119,6 +15163,7 @@ function App() {
15119
15163
  onToggleTheme={toggleTheme}
15120
15164
  onClearSelected={clearSelected}
15121
15165
  syncStatus={syncStatus}
15166
+ syncProject={cfg?.cloud?.projectName || project}
15122
15167
  changesCount={unsavedCount}
15123
15168
  unpushed={gitStatus?.unpushed || 0}
15124
15169
  changesOpen={changesOpen}
@@ -1855,6 +1855,11 @@ export default function ChatPanel({
1855
1855
  const connsRef = useRef(new Map()); // chatId → connection
1856
1856
  const busyRef = useRef(new Map()); // chatId → busy (aggregated for the menubar)
1857
1857
  const hydratedRef = useRef(new Map()); // chatId → initial messages
1858
+ // chatId → the transcript LINE the hydration above covered (the
1859
+ // `X-Maude-Chat-Seq` response header). Handed to the connection's `attach`
1860
+ // so the server replays only what the client missed — Addendum Task 8's
1861
+ // re-attach seam. Absent/0 for a chat that was never hydrated (a new chat).
1862
+ const hydratedSeqRef = useRef(new Map());
1858
1863
  const [openChatIds, setOpenChatIds] = useState([]);
1859
1864
  const [activeChatId, setActiveChatId] = useState(null);
1860
1865
  const [busyChats, setBusyChats] = useState({}); // reactive: chatId → busy (for the dot)
@@ -1872,6 +1877,13 @@ export default function ChatPanel({
1872
1877
  const existing = connsRef.current.get(chatId);
1873
1878
  if (existing) return existing;
1874
1879
  const conn = createAcpConnection();
1880
+ // Addendum Task 8 — bind BEFORE anything can open the socket. The `attach`
1881
+ // frame goes out from `onopen` and is what subscribes this socket to the
1882
+ // chat's server-side bridge (which now outlives its socket, so it may
1883
+ // already be mid-turn from before a reload). `hydratedSeqRef` holds the
1884
+ // transcript line the history fetch covered, so the server replays exactly
1885
+ // the gap — no duplicated tail, no hole.
1886
+ conn.bindChat(chatId, hydratedSeqRef.current.get(chatId) || 0);
1875
1887
  connsRef.current.set(chatId, conn);
1876
1888
  // Agent-generated title (session_info_update — fires at turn-end, so a
1877
1889
  // fresh chat shows the client's "New chat" heuristic until the first
@@ -1949,7 +1961,12 @@ export default function ChatPanel({
1949
1961
  return;
1950
1962
  }
1951
1963
  fetch(`/_api/acp/chat?id=${encodeURIComponent(id)}`)
1952
- .then((r) => r.json())
1964
+ .then(async (r) => {
1965
+ // Capture the seam marker alongside the body — see hydratedSeqRef.
1966
+ const seq = Number(r.headers.get('X-Maude-Chat-Seq') || 0);
1967
+ hydratedSeqRef.current.set(id, Number.isFinite(seq) ? seq : 0);
1968
+ return r.json();
1969
+ })
1953
1970
  .then((msgs) => openChat(id, toThreadMessages(msgs)))
1954
1971
  .catch(() => {});
1955
1972
  },
@@ -1965,6 +1982,7 @@ export default function ChatPanel({
1965
1982
  connsRef.current.delete(id);
1966
1983
  busyRef.current.delete(id);
1967
1984
  hydratedRef.current.delete(id);
1985
+ hydratedSeqRef.current.delete(id);
1968
1986
  setBusyChats((prev) => {
1969
1987
  const next = { ...prev };
1970
1988
  delete next[id];
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { useEffect, useRef, useState } from 'react';
16
16
 
17
+ import { safeName, syncPresentation } from '../../sync/presentation.ts';
17
18
  import { invoke, isNativeApp, listen, openCloudUrl } from '../github.js';
18
19
 
19
20
  const api = async (path, init) => {
@@ -247,6 +248,73 @@ export function localIdentityHint({
247
248
  return related ? 'similar' : 'mismatch';
248
249
  }
249
250
 
251
+ /**
252
+ * What to say after a successful Connect.
253
+ *
254
+ * The old copy said "Linked to <X> — restart the studio server to start
255
+ * syncing", which was three problems in one line: it named a thing the person
256
+ * cannot see (there is no visible server in the desktop app), gave them a task
257
+ * instead of a result, and never explained what connecting had achieved. The
258
+ * server now cycles the sync runtime itself, so this reports an OUTCOME —
259
+ * and on the paths where syncing genuinely didn't start, it says which one and
260
+ * what to do, rather than prescribing a restart for every cause.
261
+ *
262
+ * Returns `{ text, title }` — `text` is the rail's one line, `title` the full
263
+ * sentence on hover (the rail truncates).
264
+ */
265
+ export function connectOutcomeNote(project, sync, live) {
266
+ // The project name and `sync.detail` both originate at the hub, and the
267
+ // fallback path below puts them in trusted app chrome uncapped — including a
268
+ // `title=` tooltip, where a newline renders. `safeName` strips control and
269
+ // bidi-override characters, collapses whitespace and bounds the length; the
270
+ // live path already went through it inside `syncPresentation`.
271
+ const name = safeName(project, 'the workspace');
272
+
273
+ // The LIVE payload wins whenever there is one.
274
+ //
275
+ // `sync` is the attach response, and it reports an intention: the supervisor
276
+ // answers `{ syncing: true }` as soon as `runtime.start()` did not throw and
277
+ // one local canvas qualified — before a socket, before a token was accepted,
278
+ // before a byte moved. Rendered once and never updated, it was the whole of
279
+ // the reported complaint ("vyskočí modal, pak vidím jen syncing canvases, ale
280
+ // reálně se nic nestane"). `live` comes off the `sync:status` bus that is
281
+ // already wired to this app, so the same sentence now MOVES: connecting →
282
+ // syncing 40/75 → synced. That motion is the feedback that was missing.
283
+ //
284
+ // Same rule as the status bar (`syncPresentation`), deliberately — the old
285
+ // hover text told the person to go look at that slot for the real answer,
286
+ // which only worked as long as the two agreed, and they did not.
287
+ if (live) {
288
+ const p = syncPresentation(live, { project: name });
289
+ if (p) {
290
+ const detail = p.names.length ? ` (${p.names.join(', ')})` : '';
291
+ return {
292
+ text: p.title,
293
+ title: `${p.title}${detail}${p.next ? ` — ${p.next}` : ''}`,
294
+ };
295
+ }
296
+ }
297
+
298
+ if (sync?.syncing) {
299
+ const n = sync.canvases ?? 0;
300
+ return {
301
+ text: `Connecting to ${name} — ${n} canvas${n === 1 ? '' : 'es'}…`,
302
+ title: `Opening ${n} canvas${n === 1 ? '' : 'es'} against the ${name} workspace. This line updates as they settle.`,
303
+ };
304
+ }
305
+ if (sync?.reason === 'nothing-syncable') {
306
+ return {
307
+ text: `Connected to ${name} — nothing to sync yet.`,
308
+ title: safeName(sync.detail, 'No canvases in this project are syncable yet.'),
309
+ };
310
+ }
311
+ const detail = safeName(sync?.detail, 'restart Maude to start syncing.');
312
+ return {
313
+ text: `Connected to ${name} — ${detail}`,
314
+ title: detail,
315
+ };
316
+ }
317
+
250
318
  function Spark({ size = 15 }) {
251
319
  return (
252
320
  <svg width={size} height={size} viewBox="0 0 32 32" fill="currentColor" aria-hidden="true">
@@ -301,14 +369,21 @@ function Icon({ name, size = 15 }) {
301
369
  );
302
370
  }
303
371
 
304
- export default function CloudBar() {
372
+ export default function CloudBar({ syncStatus }) {
305
373
  const [state, setState] = useState('loading'); // loading | out | in
306
374
  const [email, setEmail] = useState(null);
307
375
  const [menuOpen, setMenuOpen] = useState(false);
308
376
  const [device, setDevice] = useState(null); // { userCode, verificationUrl, deviceCode }
309
377
  const [projects, setProjects] = useState(null);
310
378
  const [busy, setBusy] = useState('');
311
- const [note, setNote] = useState('');
379
+ // What the last Connect attached, NOT the sentence it produced.
380
+ //
381
+ // This used to hold the rendered `{ text, title }`, which is why the note was
382
+ // frozen: a string computed once at the moment the dialog closed could only
383
+ // ever describe that moment. Keeping the INPUTS and deriving the sentence on
384
+ // every render is the whole liveness mechanism — `syncStatus` arrives on the
385
+ // already-wired `sync:status` bus, so no polling and no second fetch.
386
+ const [connected, setConnected] = useState(null); // { project, sync }
312
387
  const [error, setError] = useState('');
313
388
  const [copied, setCopied] = useState(false);
314
389
  const [copiedLink, setCopiedLink] = useState(false);
@@ -321,6 +396,15 @@ export default function CloudBar() {
321
396
  const railRef = useRef(null);
322
397
  const pollRef = useRef(null);
323
398
 
399
+ // Derived, not stored — so the sentence follows the link instead of freezing
400
+ // at the instant the confirm dialog closed. `syncStatus` is the live payload
401
+ // App already receives; when it is absent (older server, first render) the
402
+ // attach response is still the fallback and the note degrades to what it
403
+ // said before, never to nothing.
404
+ const note = connected
405
+ ? connectOutcomeNote(connected.project, connected.sync, syncStatus)
406
+ : null;
407
+
324
408
  useEffect(() => {
325
409
  api('/_api/cloud/status')
326
410
  .then((r) => {
@@ -391,7 +475,7 @@ export default function CloudBar() {
391
475
  setBusy('');
392
476
  setPending(null);
393
477
  if (r.ok && r.json?.ok) {
394
- setNote(`Linked to ${r.json.project ?? pending.project} — restart the studio server to start syncing.`);
478
+ setConnected({ project: r.json.project ?? pending.project, sync: r.json.sync });
395
479
  } else {
396
480
  setError(r.json?.error || 'The workspace could not be connected.');
397
481
  }
@@ -468,7 +552,7 @@ export default function CloudBar() {
468
552
 
469
553
  async function connect(projectId) {
470
554
  setBusy(projectId);
471
- setNote('');
555
+ setConnected(null);
472
556
  setError('');
473
557
  const r = await api('/_api/cloud/attach', {
474
558
  method: 'POST',
@@ -477,7 +561,7 @@ export default function CloudBar() {
477
561
  });
478
562
  setBusy('');
479
563
  if (r.ok && r.json?.ok) {
480
- setNote(`Linked to ${projectId} — restart the studio server to start syncing.`);
564
+ setConnected({ project: r.json.project ?? projectId, sync: r.json.sync });
481
565
  } else {
482
566
  setError(r.json?.error || 'The workspace could not be connected.');
483
567
  }
@@ -670,7 +754,21 @@ export default function CloudBar() {
670
754
  <span className="gi-rail-login">{email ?? 'Maude Cloud'}</span>
671
755
  <span className="gi-rail-caret"><Icon name="chevron-up" size={13} /></span>
672
756
  </button>
673
- {note && <span className="gi-rail-hint" title={note}>{note}</span>}
757
+ {/* A live region: the sentence now CHANGES as the link settles, and a
758
+ screen-reader user has to hear that transition rather than only
759
+ whatever text happened to be there when they last tabbed past.
760
+ `polite` — it is progress, never an interruption. */}
761
+ {note && (
762
+ <span
763
+ className="gi-rail-hint"
764
+ title={note.title}
765
+ role="status"
766
+ aria-live="polite"
767
+ data-testid="cloud-connect-note"
768
+ >
769
+ {note.text}
770
+ </span>
771
+ )}
674
772
  {error && <span className="gi-rail-err" title={error}>{error}</span>}
675
773
  {menuOpen && (
676
774
  <div className="gi-menu" role="menu" aria-label="Maude Cloud">
@@ -155,8 +155,19 @@ export default function GitPanel({
155
155
  // checkboxes) — the developer commits and pushes from their terminal
156
156
  // (DDR-119). Native app keeps the full plain-words cycle.
157
157
  readOnly = false,
158
+ // THE SERVER ALREADY COMMITTED IT. In a cloud cell the hub owns this
159
+ // project's history and commits every edit as it lands (the studio child's
160
+ // own autocommit is disabled precisely so there is one committer) — so an
161
+ // "unsaved changes" list is not a to-do, it is a lie about work that is
162
+ // already saved, and Save / Publish offer to do a thing that has happened.
163
+ // The read-only variant of this panel used to answer that with "save and
164
+ // publish from your terminal", which is advice a browser user cannot take.
165
+ //
166
+ // History STAYS, and is the point: what the server committed is exactly what
167
+ // someone wants to look back at. Only the working-tree half goes.
168
+ historyOnly = false,
158
169
  }) {
159
- const [tab, setTab] = useState('changes');
170
+ const [tab, setTab] = useState(historyOnly ? 'history' : 'changes');
160
171
  const [message, setMessage] = useState('');
161
172
  const [unchecked, setUnchecked] = useState(() => new Set()); // default = all checked
162
173
  const [expanded, setExpanded] = useState(() => new Set()); // unit keys with supporting files shown
@@ -443,13 +454,13 @@ export default function GitPanel({
443
454
  <aside
444
455
  className={'st-rpanel gp-panel' + (resizing ? ' is-resizing' : '')}
445
456
  style={width ? { width, flexBasis: width } : undefined}
446
- aria-label="Changes"
457
+ aria-label={historyOnly ? 'History' : 'Changes'}
447
458
  data-testid="git-panel"
448
459
  >
449
460
  <div className="gp-head">
450
461
  <div className="gp-panel-hd">
451
- <span className="gp-panel-title">Changes</span>
452
- {count > 0 && <span className="gp-count">{count} unsaved</span>}
462
+ <span className="gp-panel-title">{historyOnly ? 'History' : 'Changes'}</span>
463
+ {!historyOnly && count > 0 && <span className="gp-count">{count} unsaved</span>}
453
464
  <span className="gp-spacer" />
454
465
  <span className="gp-draft" title="Your project and shared draft">
455
466
  <Icon name="folder" size={12} />
@@ -465,26 +476,28 @@ export default function GitPanel({
465
476
  ×
466
477
  </button>
467
478
  </div>
468
- <div className="gp-tabs" role="tablist" aria-label="Changes and history">
469
- <button
470
- type="button"
471
- role="tab"
472
- aria-selected={tab === 'changes'}
473
- className={'gp-tab' + (tab === 'changes' ? ' is-active' : '')}
474
- onClick={() => setTab('changes')}
475
- >
476
- Changes
477
- </button>
478
- <button
479
- type="button"
480
- role="tab"
481
- aria-selected={tab === 'history'}
482
- className={'gp-tab' + (tab === 'history' ? ' is-active' : '')}
483
- onClick={openHistory}
484
- >
485
- History
486
- </button>
487
- </div>
479
+ {!historyOnly && (
480
+ <div className="gp-tabs" role="tablist" aria-label="Changes and history">
481
+ <button
482
+ type="button"
483
+ role="tab"
484
+ aria-selected={tab === 'changes'}
485
+ className={'gp-tab' + (tab === 'changes' ? ' is-active' : '')}
486
+ onClick={() => setTab('changes')}
487
+ >
488
+ Changes
489
+ </button>
490
+ <button
491
+ type="button"
492
+ role="tab"
493
+ aria-selected={tab === 'history'}
494
+ className={'gp-tab' + (tab === 'history' ? ' is-active' : '')}
495
+ onClick={openHistory}
496
+ >
497
+ History
498
+ </button>
499
+ </div>
500
+ )}
488
501
  </div>
489
502
 
490
503
  {banner && (
@@ -530,7 +543,7 @@ export default function GitPanel({
530
543
  </div>
531
544
  )}
532
545
 
533
- {tab === 'changes' ? (
546
+ {tab === 'changes' && !historyOnly ? (
534
547
  notRepo ? (
535
548
  <div className="gp-empty">
536
549
  <span className="gp-empty-glyph">