@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.
- package/README.md +5 -1
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/api.ts +69 -4
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +187 -39
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/styles/3-shell-maude.css +56 -2
- package/apps/studio/client/styles/6-acp-chat.css +79 -0
- package/apps/studio/dist/client.bundle.js +1436 -1436
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +59 -2
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/whats-new.ts +34 -10
- 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
|
-
|
|
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: `
|
|
270
|
-
title: `
|
|
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
|
|
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} — ${
|
|
281
|
-
title:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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=
|
|
176
|
+
className={'chat-perm' + (outOfProject ? ' chat-perm--outside' : '')}
|
|
85
177
|
role="alertdialog"
|
|
86
|
-
aria-label=
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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 && (
|