@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.
- 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/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- 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 +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- 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/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -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/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- 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
|
|
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 && (
|