@1agh/maude 0.56.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/build.ts +28 -1
- package/apps/studio/client/app.jsx +47 -30
- 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/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/dist/client.bundle.js +1278 -1278
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +51 -2
- 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/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -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/whats-new.json +18 -0
- package/package.json +8 -8
|
@@ -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 && (
|
|
@@ -123,6 +123,20 @@ export function applyUpdate(parts, toolIndex, u) {
|
|
|
123
123
|
export function createAcpConnection() {
|
|
124
124
|
let ws = null;
|
|
125
125
|
let openPromise = null;
|
|
126
|
+
// ── The re-attach seam (Addendum Task 8) ──────────────────────────────────
|
|
127
|
+
// The chat this connection speaks for, and how far its history has been
|
|
128
|
+
// hydrated. Both are set by `bindChat()` BEFORE the socket opens, because the
|
|
129
|
+
// `attach` frame that carries them is sent from `onopen`.
|
|
130
|
+
//
|
|
131
|
+
// `hydratedSeq` is a transcript LINE NUMBER, not a message count — the server
|
|
132
|
+
// stamps every `update` with the line it occupies, so the two are directly
|
|
133
|
+
// comparable. Frames at or below it are things the panel already rendered
|
|
134
|
+
// from the HTTP hydration, and dropping them is what stops a reload from
|
|
135
|
+
// showing the last few seconds twice.
|
|
136
|
+
let attachedChatId = null;
|
|
137
|
+
let hydratedSeq = 0;
|
|
138
|
+
/** True when the server said we re-joined a turn that is STILL running. */
|
|
139
|
+
const attachedListeners = new Set();
|
|
126
140
|
let turnHandler = null; // the in-flight run()'s frame sink
|
|
127
141
|
const statusListeners = new Set();
|
|
128
142
|
const status = { available: null, reason: undefined, ready: false };
|
|
@@ -240,6 +254,26 @@ export function createAcpConnection() {
|
|
|
240
254
|
}
|
|
241
255
|
|
|
242
256
|
function onFrame(frame) {
|
|
257
|
+
// ── The re-attach seam (Addendum Task 8) ────────────────────────────────
|
|
258
|
+
// Drop anything the panel already rendered from its HTTP hydration. The
|
|
259
|
+
// server replays the tail on `attach` and the live stream continues past
|
|
260
|
+
// it, so without this filter a reload mid-turn would show the overlap
|
|
261
|
+
// twice. Frames with no `seq` (every non-`update` frame) are unaffected.
|
|
262
|
+
if (typeof frame.seq === 'number') {
|
|
263
|
+
if (frame.seq <= hydratedSeq) return;
|
|
264
|
+
// Advance the marker as we consume, so a SECOND reconnect on the same
|
|
265
|
+
// page (a dropped socket, not a page reload) re-attaches from where the
|
|
266
|
+
// live stream actually got to rather than from the original hydration.
|
|
267
|
+
hydratedSeq = frame.seq;
|
|
268
|
+
}
|
|
269
|
+
if (frame.t === 'attached') {
|
|
270
|
+
for (const fn of attachedListeners) fn({ chat: frame.chat, running: !!frame.running });
|
|
271
|
+
// Re-joined a turn that is STILL running: restore the busy state, or the
|
|
272
|
+
// panel would look idle while the agent keeps working (and the composer
|
|
273
|
+
// would happily start a second, concurrent turn).
|
|
274
|
+
if (frame.running) setBusy(true);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
243
277
|
if (frame.t === 'ready') {
|
|
244
278
|
status.ready = true;
|
|
245
279
|
status.available = frame.available;
|
|
@@ -274,7 +308,17 @@ export function createAcpConnection() {
|
|
|
274
308
|
if (frame.t === 'permission-request') {
|
|
275
309
|
pendingPermissions = [
|
|
276
310
|
...pendingPermissions,
|
|
277
|
-
{
|
|
311
|
+
{
|
|
312
|
+
id: frame.id,
|
|
313
|
+
toolCall: frame.toolCall,
|
|
314
|
+
options: frame.options ?? [],
|
|
315
|
+
// feature-acp-write-path-scope — present only when the server's
|
|
316
|
+
// write-path gate declined a write tool. `{ outOfProjectWrite,
|
|
317
|
+
// resolvedPaths, scopeRoot, reason }`; absent for every ordinary
|
|
318
|
+
// permission request, so PermissionPrompt's "outside the project"
|
|
319
|
+
// copy can't fire on one.
|
|
320
|
+
scope: frame.scope ?? null,
|
|
321
|
+
},
|
|
278
322
|
];
|
|
279
323
|
emitPermissions();
|
|
280
324
|
return;
|
|
@@ -329,7 +373,28 @@ export function createAcpConnection() {
|
|
|
329
373
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
330
374
|
const sock = new WebSocket(`${scheme}://${location.host}${WS_PATH}`);
|
|
331
375
|
ws = sock;
|
|
332
|
-
sock.onopen = () =>
|
|
376
|
+
sock.onopen = () => {
|
|
377
|
+
// Addendum Task 8 — bind to this chat's (possibly still running) bridge
|
|
378
|
+
// and close the re-attach seam in the same frame. `hydratedSeq` is the
|
|
379
|
+
// transcript line the panel hydrated over HTTP; the server replays
|
|
380
|
+
// exactly what came after it. A reload mid-turn therefore rejoins the
|
|
381
|
+
// live stream with no hole and no doubled text.
|
|
382
|
+
//
|
|
383
|
+
// Sent unconditionally, including for a brand-new chat (seq 0, nothing
|
|
384
|
+
// to replay) — the attach is what registers the socket as a sink, so
|
|
385
|
+
// skipping it for new chats would leave them detached until the first
|
|
386
|
+
// prompt.
|
|
387
|
+
if (attachedChatId) {
|
|
388
|
+
try {
|
|
389
|
+
sock.send(
|
|
390
|
+
JSON.stringify({ t: 'attach', chat: attachedChatId, seq: hydratedSeq })
|
|
391
|
+
);
|
|
392
|
+
} catch {
|
|
393
|
+
/* the frame handlers below surface a dead socket */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
resolve();
|
|
397
|
+
};
|
|
333
398
|
sock.onerror = () => reject(new Error('Could not reach the Claude bridge.'));
|
|
334
399
|
sock.onclose = () => {
|
|
335
400
|
ws = null;
|
|
@@ -423,13 +488,37 @@ export function createAcpConnection() {
|
|
|
423
488
|
* "resolved" frame; the turn just continues). A response for an id that's
|
|
424
489
|
* no longer pending (already timed out, or a duplicate click) is a no-op.
|
|
425
490
|
*/
|
|
491
|
+
/**
|
|
492
|
+
* Bind this connection to a chat and tell it how far the panel already
|
|
493
|
+
* hydrated that chat's transcript over HTTP (`seq` — a transcript LINE
|
|
494
|
+
* number, from the `X-Maude-Chat-Seq` response header).
|
|
495
|
+
*
|
|
496
|
+
* Must be called BEFORE the socket opens: the `attach` frame is sent from
|
|
497
|
+
* `onopen`, and it is what registers this socket as a sink for the chat's
|
|
498
|
+
* (possibly still running) server-side bridge. Calling it later still
|
|
499
|
+
* works for a subsequent reconnect, it just misses the current one.
|
|
500
|
+
*/
|
|
501
|
+
bindChat(chatId, seq = 0) {
|
|
502
|
+
attachedChatId = chatId || null;
|
|
503
|
+
hydratedSeq = Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0;
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
/** Subscribe to `{ chat, running }` — fires once per successful attach.
|
|
507
|
+
* `running: true` means this socket re-joined a LIVE turn. */
|
|
508
|
+
onAttached(fn) {
|
|
509
|
+
attachedListeners.add(fn);
|
|
510
|
+
return () => attachedListeners.delete(fn);
|
|
511
|
+
},
|
|
512
|
+
|
|
426
513
|
async respondPermission(id, decision) {
|
|
427
514
|
if (!pendingPermissions.some((p) => p.id === id)) return;
|
|
428
515
|
pendingPermissions = pendingPermissions.filter((p) => p.id !== id);
|
|
429
516
|
emitPermissions();
|
|
430
517
|
try {
|
|
431
518
|
await ensureOpen();
|
|
432
|
-
|
|
519
|
+
// `chat` is echoed back from the request frame — a bridge is keyed by
|
|
520
|
+
// chat now, not by socket, so the response has to name which one.
|
|
521
|
+
ws?.send(JSON.stringify({ t: 'permission-response', id, chat: attachedChatId, decision }));
|
|
433
522
|
} catch {
|
|
434
523
|
/* socket unavailable — the bridge's own timeout will deny this request */
|
|
435
524
|
}
|
|
@@ -446,7 +535,9 @@ export function createAcpConnection() {
|
|
|
446
535
|
emitElicitations();
|
|
447
536
|
try {
|
|
448
537
|
await ensureOpen();
|
|
449
|
-
ws?.send(
|
|
538
|
+
ws?.send(
|
|
539
|
+
JSON.stringify({ t: 'elicitation-response', id, chat: attachedChatId, ...response })
|
|
540
|
+
);
|
|
450
541
|
} catch {
|
|
451
542
|
/* socket unavailable — the bridge's own timeout will decline this request */
|
|
452
543
|
}
|
|
@@ -532,7 +623,7 @@ export function createAcpConnection() {
|
|
|
532
623
|
};
|
|
533
624
|
const cancel = () => {
|
|
534
625
|
try {
|
|
535
|
-
ws?.send(JSON.stringify({ t: 'cancel' }));
|
|
626
|
+
ws?.send(JSON.stringify({ t: 'cancel', chat: attachedChatId || undefined }));
|
|
536
627
|
} catch {
|
|
537
628
|
/* socket already gone */
|
|
538
629
|
}
|
|
@@ -2240,10 +2240,19 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2240
2240
|
|
|
2241
2241
|
/* identity rail — sidebar footer */
|
|
2242
2242
|
.gi-rail { position: relative; flex: 0 0 auto; margin-top: auto; padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border-default); display: flex; flex-direction: column; gap: var(--space-2); }
|
|
2243
|
-
|
|
2243
|
+
/* Truncation the code already assumed: CloudBar.jsx documents `title` as "the
|
|
2244
|
+
full sentence on hover (the rail truncates)" — and this rule did not exist,
|
|
2245
|
+
so a long hub-supplied project name wrapped and grew, pushing the rail into
|
|
2246
|
+
adjacent chrome. Its sibling .gi-rail-err has carried the same three
|
|
2247
|
+
properties all along. `anywhere` so a single unbreakable 60-char token
|
|
2248
|
+
cannot overflow horizontally either. */
|
|
2249
|
+
.gi-rail-hint { font-size: var(--type-xs); color: var(--fg-2); padding: var(--space-1) var(--space-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: anywhere; }
|
|
2244
2250
|
.gi-rail-signin { width: 100%; justify-content: center; }
|
|
2245
2251
|
.gi-rail-confirm { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; padding: var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); }
|
|
2246
|
-
|
|
2252
|
+
/* …but the deep-link confirm strip is a paragraph that MUST wrap — it explains
|
|
2253
|
+
both sides of a consent decision, and one ellipsised line would hide half of
|
|
2254
|
+
what the person is agreeing to. */
|
|
2255
|
+
.gi-rail-confirm .gi-rail-hint { flex: 1 1 100%; padding: 0; white-space: normal; overflow: visible; }
|
|
2247
2256
|
.gi-rail-err { font-size: var(--type-xs); color: var(--status-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2248
2257
|
.gi-avatar { border-radius: var(--radius-pill); display: grid; place-items: center; font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--accent-fg); background: var(--accent); flex: 0 0 auto; }
|
|
2249
2258
|
.gi-rail-account { display: flex; align-items: center; gap: var(--space-2); width: 100%; padding: var(--space-1) var(--space-2); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--fg-0); cursor: pointer; text-align: left; }
|
|
@@ -880,6 +880,57 @@
|
|
|
880
880
|
gap: var(--space-2);
|
|
881
881
|
}
|
|
882
882
|
|
|
883
|
+
/* feature-acp-write-path-scope Task 4 — the out-of-project WRITE variant.
|
|
884
|
+
Distinguished from an ordinary permission card because the two ask
|
|
885
|
+
materially different questions: the ordinary one is "do you want this
|
|
886
|
+
tool to run", this one is "do you want a file written somewhere your
|
|
887
|
+
project's history stack cannot roll back". The danger tint is the whole
|
|
888
|
+
point — a card that looks identical to the routine one gets clicked
|
|
889
|
+
through at the same rate. */
|
|
890
|
+
.chat-perm--outside {
|
|
891
|
+
border-color: var(--danger, #d9534f);
|
|
892
|
+
}
|
|
893
|
+
.chat-perm--outside .chat-perm-hd b {
|
|
894
|
+
color: var(--danger, #d9534f);
|
|
895
|
+
}
|
|
896
|
+
.chat-perm-outside {
|
|
897
|
+
display: flex;
|
|
898
|
+
flex-direction: column;
|
|
899
|
+
gap: var(--space-1);
|
|
900
|
+
}
|
|
901
|
+
/* The resolved absolute paths. Monospaced + wrapping at any character: these
|
|
902
|
+
are the one string on the card the user actually has to READ, and a path
|
|
903
|
+
that overflows or ellipsizes is a path whose dangerous tail is hidden —
|
|
904
|
+
the same failure the .chat-perm-hd b clamp above exists to prevent, but
|
|
905
|
+
with the opposite fix, because here truncation is what would be unsafe. */
|
|
906
|
+
.chat-perm-paths {
|
|
907
|
+
margin: 0;
|
|
908
|
+
padding: 0;
|
|
909
|
+
list-style: none;
|
|
910
|
+
display: flex;
|
|
911
|
+
flex-direction: column;
|
|
912
|
+
gap: var(--space-1);
|
|
913
|
+
}
|
|
914
|
+
.chat-perm-path {
|
|
915
|
+
padding: var(--space-1) var(--space-2);
|
|
916
|
+
border-radius: var(--radius-xs);
|
|
917
|
+
background: var(--bg-1);
|
|
918
|
+
color: var(--fg-0);
|
|
919
|
+
font-family: var(--font-mono);
|
|
920
|
+
font-size: var(--type-xs);
|
|
921
|
+
overflow-wrap: anywhere;
|
|
922
|
+
}
|
|
923
|
+
.chat-perm-scope,
|
|
924
|
+
.chat-perm-note {
|
|
925
|
+
margin: 0;
|
|
926
|
+
font-size: var(--type-xs);
|
|
927
|
+
color: var(--fg-3);
|
|
928
|
+
}
|
|
929
|
+
.chat-perm-root {
|
|
930
|
+
font-family: var(--font-mono);
|
|
931
|
+
overflow-wrap: anywhere;
|
|
932
|
+
}
|
|
933
|
+
|
|
883
934
|
/* Form-elicitation card (feature-acp-ask-user-question — AskUserQuestion +
|
|
884
935
|
generic MCP form input). Same slot + shape as .chat-perm, mutually
|
|
885
936
|
exclusive with it — see the mount site in ChatPanel.jsx. */
|