@1agh/maude 0.56.0 → 0.58.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 (58) hide show
  1. package/README.md +5 -1
  2. package/apps/studio/acp/bridge.ts +385 -26
  3. package/apps/studio/acp/index.ts +498 -102
  4. package/apps/studio/acp/running.ts +97 -0
  5. package/apps/studio/acp/transcript.ts +64 -0
  6. package/apps/studio/acp/write-scope.ts +459 -0
  7. package/apps/studio/api.ts +69 -4
  8. package/apps/studio/build.ts +28 -1
  9. package/apps/studio/client/app.jsx +187 -39
  10. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  11. package/apps/studio/client/panels/CloudBar.jsx +72 -13
  12. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  13. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  14. package/apps/studio/client/panels/acp-runtime.js +96 -5
  15. package/apps/studio/client/panels/chat-markdown.jsx +76 -5
  16. package/apps/studio/client/panels/file-preview.jsx +115 -0
  17. package/apps/studio/client/styles/3-shell-maude.css +56 -2
  18. package/apps/studio/client/styles/6-acp-chat.css +79 -0
  19. package/apps/studio/dist/client.bundle.js +1436 -1436
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/http.ts +59 -2
  23. package/apps/studio/input-router.tsx +39 -1
  24. package/apps/studio/server.ts +11 -0
  25. package/apps/studio/sync/connection-state.ts +116 -6
  26. package/apps/studio/sync/index.ts +50 -1
  27. package/apps/studio/sync/presentation.ts +273 -0
  28. package/apps/studio/sync/remote-docs.ts +191 -0
  29. package/apps/studio/sync/status.ts +4 -1
  30. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  31. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  32. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  33. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  34. package/apps/studio/test/acp-commands.test.ts +5 -0
  35. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  36. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  37. package/apps/studio/test/acp-permission.test.ts +18 -2
  38. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  39. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  40. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  41. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  42. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  45. package/apps/studio/test/cloud-endpoints.test.ts +11 -4
  46. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  47. package/apps/studio/test/config-version.test.ts +88 -0
  48. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  50. package/apps/studio/test/input-router.test.ts +56 -0
  51. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  52. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  53. package/apps/studio/test/sync-presentation.test.ts +185 -0
  54. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  55. package/apps/studio/test/whats-new.test.ts +13 -1
  56. package/apps/studio/whats-new.json +27 -0
  57. package/apps/studio/whats-new.ts +34 -10
  58. package/package.json +8 -8
@@ -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) => {
@@ -261,24 +262,56 @@ export function localIdentityHint({
261
262
  * Returns `{ text, title }` — `text` is the rail's one line, `title` the full
262
263
  * sentence on hover (the rail truncates).
263
264
  */
264
- export function connectOutcomeNote(project, sync) {
265
- const name = project || 'the workspace';
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
+
266
298
  if (sync?.syncing) {
267
299
  const n = sync.canvases ?? 0;
268
300
  return {
269
- text: `Syncing with ${name} — ${n} canvas${n === 1 ? '' : 'es'}.`,
270
- title: `This project’s canvases now sync with the ${name} workspace. Live status is in the “hub sync” slot in the status bar.`,
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.`,
271
303
  };
272
304
  }
273
305
  if (sync?.reason === 'nothing-syncable') {
274
306
  return {
275
307
  text: `Connected to ${name} — nothing to sync yet.`,
276
- title: sync.detail || 'No canvases in this project are syncable yet.',
308
+ title: safeName(sync.detail, 'No canvases in this project are syncable yet.'),
277
309
  };
278
310
  }
311
+ const detail = safeName(sync?.detail, 'restart Maude to start syncing.');
279
312
  return {
280
- text: `Connected to ${name} — ${sync?.detail ?? 'restart Maude to start syncing.'}`,
281
- title: sync?.detail || 'Restart Maude to start syncing.',
313
+ text: `Connected to ${name} — ${detail}`,
314
+ title: detail,
282
315
  };
283
316
  }
284
317
 
@@ -336,14 +369,21 @@ function Icon({ name, size = 15 }) {
336
369
  );
337
370
  }
338
371
 
339
- export default function CloudBar() {
372
+ export default function CloudBar({ syncStatus }) {
340
373
  const [state, setState] = useState('loading'); // loading | out | in
341
374
  const [email, setEmail] = useState(null);
342
375
  const [menuOpen, setMenuOpen] = useState(false);
343
376
  const [device, setDevice] = useState(null); // { userCode, verificationUrl, deviceCode }
344
377
  const [projects, setProjects] = useState(null);
345
378
  const [busy, setBusy] = useState('');
346
- const [note, setNote] = useState(null); // { text, title } — connectOutcomeNote
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 }
347
387
  const [error, setError] = useState('');
348
388
  const [copied, setCopied] = useState(false);
349
389
  const [copiedLink, setCopiedLink] = useState(false);
@@ -356,6 +396,15 @@ export default function CloudBar() {
356
396
  const railRef = useRef(null);
357
397
  const pollRef = useRef(null);
358
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
+
359
408
  useEffect(() => {
360
409
  api('/_api/cloud/status')
361
410
  .then((r) => {
@@ -426,7 +475,7 @@ export default function CloudBar() {
426
475
  setBusy('');
427
476
  setPending(null);
428
477
  if (r.ok && r.json?.ok) {
429
- setNote(connectOutcomeNote(r.json.project ?? pending.project, r.json.sync));
478
+ setConnected({ project: r.json.project ?? pending.project, sync: r.json.sync });
430
479
  } else {
431
480
  setError(r.json?.error || 'The workspace could not be connected.');
432
481
  }
@@ -503,7 +552,7 @@ export default function CloudBar() {
503
552
 
504
553
  async function connect(projectId) {
505
554
  setBusy(projectId);
506
- setNote(null);
555
+ setConnected(null);
507
556
  setError('');
508
557
  const r = await api('/_api/cloud/attach', {
509
558
  method: 'POST',
@@ -512,7 +561,7 @@ export default function CloudBar() {
512
561
  });
513
562
  setBusy('');
514
563
  if (r.ok && r.json?.ok) {
515
- setNote(connectOutcomeNote(r.json.project ?? projectId, r.json.sync));
564
+ setConnected({ project: r.json.project ?? projectId, sync: r.json.sync });
516
565
  } else {
517
566
  setError(r.json?.error || 'The workspace could not be connected.');
518
567
  }
@@ -705,8 +754,18 @@ export default function CloudBar() {
705
754
  <span className="gi-rail-login">{email ?? 'Maude Cloud'}</span>
706
755
  <span className="gi-rail-caret"><Icon name="chevron-up" size={13} /></span>
707
756
  </button>
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. */}
708
761
  {note && (
709
- <span className="gi-rail-hint" title={note.title} data-testid="cloud-connect-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
+ >
710
769
  {note.text}
711
770
  </span>
712
771
  )}
@@ -54,10 +54,102 @@ export function pickDefaultReject(options) {
54
54
  return list.find((o) => o?.kind === 'reject_once' || o?.kind === 'reject_always') ?? null;
55
55
  }
56
56
 
57
+ // feature-acp-write-path-scope Task 4 — the out-of-project write case.
58
+ //
59
+ // `scope` is attached by the SERVER (bridge.ts `classifyWrite` → index.ts's
60
+ // `permission-request` frame) and only ever for a write tool the path gate
61
+ // refused to auto-approve. Everything this renders comes from that payload: the
62
+ // paths are the bridge's REALPATH-RESOLVED absolutes, never `toolCall.rawInput`,
63
+ // because the model's own string is what makes the write look harmless
64
+ // (`docs/../../../.zshenv` reads as a docs edit). Same lesson as the deep-link
65
+ // modal's truncated project name.
66
+ //
67
+ // Exported pure so the copy is unit-testable without rendering (mirrors
68
+ // pickDefaultAllow's rationale — the affordance IS the security control here).
69
+ // SECURITY (security-auditor F3) — the resolved path is REAL filesystem data,
70
+ // but its non-existent tail is whatever the model asked to create, so it is
71
+ // attacker-influenced text rendered in the one card whose entire job is to be
72
+ // read accurately. React escapes markup; it does NOT neutralize bidi overrides
73
+ // or C0 controls, so `~/safe/‮gnp.esriv/…` can be made to READ as a
74
+ // harmless path while resolving somewhere else entirely.
75
+ //
76
+ // Stripped at RENDER time rather than in the frame: the frame should carry the
77
+ // true path (it is also an audit record), and only the display needs to be
78
+ // unspoofable. Replaced with U+FFFD rather than deleted so a tampered path is
79
+ // visibly odd instead of silently shorter.
80
+ // Written as \u escapes, never literal characters (security-auditor F3 nit) —
81
+ // the same reasoning applied to the tests: a literal RLO in this source would
82
+ // reorder THIS FILE in every editor and code-review diff that renders it.
83
+ // \p{Cc} C0/C1 controls (newline, CR, NUL — hide a whole segment)
84
+ // 202A-202E,2066-2069 bidi embeddings/overrides/isolates (visual reversal)
85
+ // 200E,200F,061C LRM/RLM/ALM — directional marks, same class of trick
86
+ // 200B-200D,FEFF zero-width space/non-joiner/joiner/BOM (invisible splits)
87
+ // 00AD soft hyphen (invisible, but may render as a break)
88
+ const UNSAFE_DISPLAY =
89
+ /[\p{Cc}\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C\u200B-\u200D\uFEFF\u00AD]/gu;
90
+ export function sanitizePathForDisplay(p) {
91
+ return String(p).replace(UNSAFE_DISPLAY, '�');
92
+ }
93
+
94
+ export function describeOutOfProject(scope) {
95
+ if (!scope || scope.outOfProjectWrite !== true) return null;
96
+ const paths = Array.isArray(scope.resolvedPaths)
97
+ ? scope.resolvedPaths.filter(Boolean).map(sanitizePathForDisplay)
98
+ : [];
99
+ const lead =
100
+ scope.reason === 'no-target'
101
+ ? 'Claude asked to write a file but did not say which one.'
102
+ : scope.reason === 'disagreement'
103
+ ? 'Claude named two different files for one write.'
104
+ : // `in-project-denied` is INSIDE the project — saying "outside" would be
105
+ // a plain lie, and the reason it's being asked about is different in
106
+ // kind: not "where is this going" but "this file runs".
107
+ scope.reason === 'in-project-denied'
108
+ ? 'This file is part of how your project RUNS, not part of its design.'
109
+ : paths.length > 1
110
+ ? 'Claude wants to write files outside this project.'
111
+ : 'Claude wants to write a file outside this project.';
112
+ return {
113
+ lead,
114
+ paths,
115
+ scopeRoot: typeof scope.scopeRoot === 'string' ? sanitizePathForDisplay(scope.scopeRoot) : '',
116
+ // Decision D — the server already stripped every `allow_always` option, so
117
+ // there is no "always allow" to explain away. Say so, rather than leaving
118
+ // the user wondering where the usual option went.
119
+ note: 'This is allowed once, for this request only.',
120
+ /** Inside the project, but denied for an execution reason — the copy above
121
+ * must not claim "outside". */
122
+ inProjectDenied: scope.reason === 'in-project-denied',
123
+ };
124
+ }
125
+
126
+ // Decision D is enforced SERVER-side (bridge.ts filters `allow_always` out of
127
+ // the offered set, and validates a response against that same filtered set).
128
+ // This is a second, client-side pass purely so a stale/racing frame can never
129
+ // paint a button that the server would reject anyway — belt-and-braces, not the
130
+ // control itself.
131
+ function visibleOptions(options, outOfProject) {
132
+ const list = Array.isArray(options) ? options : [];
133
+ return outOfProject ? list.filter((o) => o?.kind !== 'allow_always') : list;
134
+ }
135
+
57
136
  export default function PermissionPrompt({ request, onRespond, queueLength = 1 }) {
58
137
  const cardRef = useRef(null);
59
- const { toolCall, options } = request;
138
+ const { toolCall } = request;
60
139
  const target = toolTarget(toolCall);
140
+ const outOfProject = describeOutOfProject(request.scope);
141
+ const options = visibleOptions(request.options, !!outOfProject);
142
+ // See the header comment in the JSX below for why an out-of-project write
143
+ // gets a substituted headline + target instead of the adapter's own.
144
+ const headline =
145
+ request.scope?.reason === 'in-project-denied'
146
+ ? 'Write to a file that runs'
147
+ : 'Write outside this project';
148
+ const headerTarget = outOfProject
149
+ ? outOfProject.paths.length === 1
150
+ ? String(outOfProject.paths[0]).split('/').pop()
151
+ : null
152
+ : target;
61
153
 
62
154
  const firstAllow = pickDefaultAllow(options);
63
155
  const firstReject = pickDefaultReject(options);
@@ -81,16 +173,33 @@ export default function PermissionPrompt({ request, onRespond, queueLength = 1 }
81
173
 
82
174
  return (
83
175
  <div
84
- className="chat-perm"
176
+ className={'chat-perm' + (outOfProject ? ' chat-perm--outside' : '')}
85
177
  role="alertdialog"
86
- aria-label="Permission request"
178
+ aria-label={
179
+ outOfProject
180
+ ? outOfProject.inProjectDenied
181
+ ? 'Permission request — file that runs code'
182
+ : 'Permission request — file outside this project'
183
+ : 'Permission request'
184
+ }
87
185
  tabIndex={-1}
88
186
  ref={cardRef}
89
187
  data-testid="chat-permission-prompt"
90
188
  >
91
189
  <div className="chat-perm-hd">
92
- <b title={toolCall?.title || 'Tool call'}>{toolCall?.title || 'Tool call'}</b>
93
- {target ? <span className="chat-tool-path">{target}</span> : null}
190
+ {/* SECURITY (feature-acp-write-path-scope Task 4) — for an out-of-project
191
+ write the header must NOT echo `toolCall.title`/`rawInput`. Both are
192
+ built from the model's own path string, and the adapter passes it
193
+ through verbatim (see acp/write-scope.ts's measured contract), so the
194
+ title of a write to `~/.zshenv` reads literally "Write
195
+ docs/../../../.zshenv" — a docs edit, to any human skimming the
196
+ header. The card's whole job is to be read; a truthful body under a
197
+ misleading headline is worse than no headline. Substitute a fixed
198
+ label + the RESOLVED basename, and let the body carry the full path. */}
199
+ <b title={outOfProject ? headline : toolCall?.title || 'Tool call'}>
200
+ {outOfProject ? headline : toolCall?.title || 'Tool call'}
201
+ </b>
202
+ {headerTarget ? <span className="chat-tool-path">{headerTarget}</span> : null}
94
203
  {/* SECURITY (ethical-hacker finding) — a growing backlog was
95
204
  invisible (always "1 of 1"), which is what manufactures the
96
205
  reflexive-Enter-mashing precondition the wrong-default bug
@@ -101,7 +210,38 @@ export default function PermissionPrompt({ request, onRespond, queueLength = 1 }
101
210
  </span>
102
211
  ) : null}
103
212
  </div>
104
- <p className="chat-perm-body">Claude wants permission to continue. Choose an option:</p>
213
+ {outOfProject ? (
214
+ <div className="chat-perm-outside" data-testid="chat-perm-outside">
215
+ <p className="chat-perm-body">{outOfProject.lead}</p>
216
+ {outOfProject.paths.length > 0 ? (
217
+ <ul className="chat-perm-paths" data-testid="chat-perm-paths">
218
+ {outOfProject.paths.map((p) => (
219
+ <li key={p} className="chat-perm-path">
220
+ {p}
221
+ </li>
222
+ ))}
223
+ </ul>
224
+ ) : null}
225
+ {outOfProject.scopeRoot ? (
226
+ <p className="chat-perm-scope">
227
+ {outOfProject.inProjectDenied ? (
228
+ <>
229
+ Git hooks and Claude settings can execute code later, so Maude asks even
230
+ though they live inside this project.
231
+ </>
232
+ ) : (
233
+ <>
234
+ This project is <span className="chat-perm-root">{outOfProject.scopeRoot}</span>.
235
+ Edits inside it never ask.
236
+ </>
237
+ )}
238
+ </p>
239
+ ) : null}
240
+ <p className="chat-perm-note">{outOfProject.note}</p>
241
+ </div>
242
+ ) : (
243
+ <p className="chat-perm-body">Claude wants permission to continue. Choose an option:</p>
244
+ )}
105
245
  <div className="chat-perm-actions">
106
246
  {options.map((o) => (
107
247
  <button
@@ -97,6 +97,30 @@ async function postJson(url, body, opts = {}) {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Copy for the "a chat is mid-turn" confirm (Addendum Task 9). Pure + exported
102
+ * so the WORDING is unit-testable without rendering the switcher (which needs a
103
+ * native shim and three fetches on mount) — same rationale as
104
+ * PermissionPrompt.jsx's `pickDefaultAllow`/`describeOutOfProject`: on a safety
105
+ * prompt the affordance IS the control, so the words are worth a test.
106
+ *
107
+ * The wording is deliberately "is editing files on this branch", NOT "this will
108
+ * stop it". Before Task 8 the reload genuinely killed the turn and "will stop
109
+ * it" was true; now the bridge outlives its socket, so the turn KEEPS GOING —
110
+ * into a worktree that has moved under it. Saying "will stop it" would now be
111
+ * false in the reassuring direction, which is the worst way for a safety prompt
112
+ * to be wrong.
113
+ */
114
+ export function chatGuardCopy({ count, branch, verb }) {
115
+ return {
116
+ title: count > 1 ? `${count} chats are working right now` : 'A chat is working right now',
117
+ body: `Claude is editing files on ${branch}. If you ${verb} now, those edits can land on the wrong branch — and Maude's History can't undo a cross-branch mix-up.`,
118
+ meta: 'Safest is to wait for the chat to finish, or stop it yourself first.',
119
+ confirm: 'Do it anyway',
120
+ cancel: 'Wait',
121
+ };
122
+ }
123
+
100
124
  export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, onGetLatest }) {
101
125
  const native = isNativeApp();
102
126
  const [status, setStatus] = useState(null); // { repo, branch }
@@ -115,6 +139,9 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
115
139
  const [pulling, setPulling] = useState(false);
116
140
  // "Continue on <draft>?" nudge — dismissed for this session once the user opts out.
117
141
  const [resumeDismissed, setResumeDismissed] = useState(false);
142
+ // Task 9 — a pending branch change held back because a chat is mid-turn:
143
+ // `{ count, verb, action }`. Null when nothing is being confirmed.
144
+ const [chatGuard, setChatGuard] = useState(null);
118
145
 
119
146
  // Open the PR in the OS browser. A WKWebView anchor can't reach the default browser,
120
147
  // so native goes through the Tauri opener (github.com host-locked in Rust); an older
@@ -241,9 +268,52 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
241
268
  catch { /* not a repo / offline — keep the prior list, never blank it */ }
242
269
  }
243
270
 
244
- async function switchDraft(name, where) {
245
- setOpen(false);
246
- if (name === branch) return;
271
+ // ── Task 9: warn before a branch change moves the ground under a live turn ──
272
+ //
273
+ // THREE call sites reload, not one — `switchDraft`, `createDraft` and the
274
+ // local-merge fold in `foldDraft`. All three need the gate; missing one is the
275
+ // easy mistake here.
276
+ //
277
+ // Task 8 means the reload itself no longer KILLS the chat (the bridge outlives
278
+ // its socket now). That makes this warning MORE necessary, not less: the turn
279
+ // survives into a worktree that is no longer the one it was reasoning about.
280
+ // The agent read `foo.tsx` on this draft and will write it back after the
281
+ // checkout — a silent cross-branch clobber. And the "it's reversible" story
282
+ // this whole feature leans on degrades exactly here, because the `_history/`
283
+ // snapshot stack is per-canvas-slug with NO branch awareness.
284
+ //
285
+ // Asked of the SERVER, not of local chat state: a bridge can be running
286
+ // detached (post-reload, pre-re-attach) with no client that knows about it —
287
+ // precisely the case this warning is for.
288
+ async function chatsRunning() {
289
+ try {
290
+ const r = await fetch('/_api/acp/running');
291
+ if (!r.ok) return 0;
292
+ const j = await r.json();
293
+ return Number(j?.running) || 0;
294
+ } catch {
295
+ // Never block a branch switch because the probe failed — a switch the
296
+ // user asked for must not be hostage to an unrelated fetch error.
297
+ return 0;
298
+ }
299
+ }
300
+
301
+ /** Run `action` after an explicit confirm if a turn is in flight. Resolves
302
+ * the pending action into state so the sheet below can render it; the sheet
303
+ * calls back in on confirm. */
304
+ async function guardedByChat(action, verb) {
305
+ const n = await chatsRunning();
306
+ if (n === 0) {
307
+ action();
308
+ return;
309
+ }
310
+ setChatGuard({ count: n, verb, action });
311
+ }
312
+
313
+ // The raw action. Every caller goes through the guarded wrapper below — the
314
+ // warning has to happen BEFORE the checkout, since warning after the worktree
315
+ // already moved would be theatre.
316
+ async function doSwitchDraft(name, where) {
247
317
  setSwitching(name);
248
318
  setDownloading(where === 'remote'); // a remote-only branch is downloaded on switch
249
319
  setErr('');
@@ -252,6 +322,12 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
252
322
  else { setErr(r.json?.error || 'Could not switch.'); setSwitching(''); setDownloading(false); }
253
323
  }
254
324
 
325
+ async function switchDraft(name, where) {
326
+ setOpen(false);
327
+ if (name === branch) return;
328
+ await guardedByChat(() => doSwitchDraft(name, where), `switch to “${name}”`);
329
+ }
330
+
255
331
  // "Fetch remote branches" — fetch all remote heads so a teammate's new branch
256
332
  // surfaces, then re-read the list. Explicit gesture (never auto-run); token is
257
333
  // server-held. A failure surfaces as an in-popup notice (DDR-133) — it NEVER
@@ -271,15 +347,21 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
271
347
  setRefreshing(false);
272
348
  }
273
349
 
274
- async function createDraft() {
275
- const name = slugify(draftName);
276
- if (!name) return;
350
+ async function doCreateDraft(name) {
277
351
  setBusy(true); setErr('');
278
352
  const r = await postJson('/_api/git/branch', { name });
279
353
  if (r.ok && r.json?.ok) window.location.reload();
280
354
  else { setErr(r.json?.error || 'Could not create the draft.'); setBusy(false); }
281
355
  }
282
356
 
357
+ // Call site 2 of 3 — creating a branch checks it out, so it moves the
358
+ // worktree exactly like a switch does.
359
+ async function createDraft() {
360
+ const name = slugify(draftName);
361
+ if (!name) return;
362
+ await guardedByChat(() => doCreateDraft(name), `create “${name}”`);
363
+ }
364
+
283
365
  // "Get latest" from the dock nudge — pull the shared version's new commits. Reuses
284
366
  // the app's pull handler (onGetLatest = gitGetLatest): on success it re-probes so the
285
367
  // nudge clears; a content conflict opens the visual resolver (handled in app.jsx).
@@ -297,7 +379,15 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
297
379
  // opens a pull request (the merge happens on GitHub, post-review) — surface the PR
298
380
  // link and keep the sheet open. A local project merges directly (reload). A draft that
299
381
  // pushed but couldn't open a PR (no sign-in / non-github) shows a heads-up.
382
+ // Call site 3 of 3 — the LOCAL-merge path reloads (the GitHub-remote path
383
+ // opens a PR and stays put). Guarded as a whole rather than only around the
384
+ // reload: a merge landing under a running agent is the same hazard whether or
385
+ // not the page happens to reload afterward.
300
386
  async function foldDraft() {
387
+ await guardedByChat(doFoldDraft, `merge “${branch}” into ${sharedName}`);
388
+ }
389
+
390
+ async function doFoldDraft() {
301
391
  setFolding(branch);
302
392
  setErr('');
303
393
  const r = await postJson('/_api/git/fold', { name: branch }, { timeoutMs: 45000 });
@@ -339,6 +429,10 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
339
429
  }
340
430
 
341
431
  const slug = slugify(draftName);
432
+ // Task 9 — the confirm's words, from the pure helper above so they stay testable.
433
+ const guardCopy = chatGuard
434
+ ? chatGuardCopy({ count: chatGuard.count, branch, verb: chatGuard.verb })
435
+ : null;
342
436
  const currentDraft = onShared ? null : branches.find((b) => b.current);
343
437
  // On the Shared version with a draft around? Offer to jump back to the most
344
438
  // recently-worked local draft. Maude shows whatever branch HEAD is, so after a
@@ -544,6 +638,51 @@ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, on
544
638
  {err && !open && !newDraft && !switching && <div className="rb-switcher-err" role="alert">{err}</div>}
545
639
  </div>
546
640
 
641
+ {/* Task 9 — a chat is mid-turn and the user asked for something that moves
642
+ the worktree. Silently reloading is the ONE outcome the Addendum calls
643
+ unacceptable, so this is an explicit confirm, not a toast. */}
644
+ {chatGuard && guardCopy && (
645
+ <div className="rb-scrim" role="presentation" onClick={() => setChatGuard(null)}>
646
+ <div
647
+ className="rb-sheet"
648
+ role="dialog"
649
+ aria-modal="true"
650
+ aria-labelledby="rb-chatguard-title"
651
+ aria-describedby="rb-chatguard-body"
652
+ data-testid="switcher-chat-guard"
653
+ onClick={(e) => e.stopPropagation()}
654
+ onKeyDown={(e) => { if (e.key === 'Escape') setChatGuard(null); }}
655
+ >
656
+ <span className="rb-sheet-icon"><Icon name="draft" size={20} /></span>
657
+ <h2 className="rb-sheet-title" id="rb-chatguard-title">{guardCopy.title}</h2>
658
+ <p className="rb-sheet-body" id="rb-chatguard-body">{guardCopy.body}</p>
659
+ <p className="rb-sheet-meta">{guardCopy.meta}</p>
660
+ <div className="rb-sheet-actions">
661
+ <button
662
+ type="button"
663
+ className="btn btn--ghost"
664
+ data-testid="switcher-chat-guard-cancel"
665
+ onClick={() => setChatGuard(null)}
666
+ >
667
+ {guardCopy.cancel}
668
+ </button>
669
+ <button
670
+ type="button"
671
+ className="btn btn--danger"
672
+ data-testid="switcher-chat-guard-confirm"
673
+ onClick={() => {
674
+ const { action } = chatGuard;
675
+ setChatGuard(null);
676
+ action();
677
+ }}
678
+ >
679
+ {guardCopy.confirm}
680
+ </button>
681
+ </div>
682
+ </div>
683
+ </div>
684
+ )}
685
+
547
686
  {/* Add-to-default confirm — the one modal in this surface. No 3-way merge UI.
548
687
  After a GitHub-remote fold it flips to a pull-request result view (DDR-162). */}
549
688
  {foldConfirm && (