@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
@@ -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 && (