@1agh/maude 0.30.0 → 0.31.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 (90) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +781 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,349 @@
1
+ // Phase 29 (epic E4) Task 4 — the persistent project + draft switcher.
2
+ //
3
+ // REDESIGNED to match .design/ui/RepoBranchSwitcher.tsx: not a top header — a
4
+ // compact ONE-LINE dock at the BOTTOM of the sidebar (mounted directly above the
5
+ // IdentityBar avatar, so the two form one bottom dock — the gi-rail/gi-menu
6
+ // anatomy). The trigger reads "📁 <project> · <version> ⌃" and opens ONE popup
7
+ // UPWARD with a Project section (recents + Open another folder…) and a Version
8
+ // section (Shared version / drafts / the fold-back CTA / New draft).
9
+ //
10
+ // Vocabulary contract: a git branch is a "draft", main/master is the "Shared
11
+ // version" — no branch/checkout/main/merge jargon. Project switch → open_local_project
12
+ // (Tauri) reloads the webview; draft switch → POST /_api/git/checkout then reload
13
+ // (the git-lifecycle HEAD-watcher flushes Yjs first — DDR-051, not duplicated here);
14
+ // "Add this draft to the Shared version" → POST /_api/git/fold. Renders nothing
15
+ // until the project is a git repo. CSS (rb-*) in 3-shell-maude.css.
16
+
17
+ import { useEffect, useRef, useState } from 'react';
18
+
19
+ import { appRecentProjects, isNativeApp, openLocalProject, pickDirectory } from '../github.js';
20
+
21
+ const SHARED = new Set(['main', 'master']);
22
+
23
+ function Icon({ name, size = 16, className }) {
24
+ const p = {
25
+ check: <polyline points="3 8.2 6.4 11.5 13 4.2" />,
26
+ 'chevron-up': <polyline points="3.5 10 8 5.5 12.5 10" />,
27
+ 'chevron-down': <polyline points="3.5 6 8 10.5 12.5 6" />,
28
+ 'chevron-right': <polyline points="6 3.5 10.5 8 6 12.5" />,
29
+ folder: <path d="M2 4.5h4l1.3 1.5H14V13H2z" />,
30
+ 'folder-open': (<><path d="M2 4.5h4l1.3 1.5H14" /><path d="M2 6h12.5l-1.4 7H3.4z" /></>),
31
+ share: (<><circle cx="4" cy="8" r="1.6" /><circle cx="12" cy="4" r="1.6" /><circle cx="12" cy="12" r="1.6" /><line x1="5.4" y1="7.2" x2="10.6" y2="4.6" /><line x1="5.4" y1="8.8" x2="10.6" y2="11.4" /></>),
32
+ draft: (<><path d="M3 11.5 11 3.5l1.5 1.5L4.5 13l-2 .5z" /><line x1="9.5" y1="5" x2="11" y2="6.5" /></>),
33
+ // git-branch glyph — only the web read-only badge uses it (git vocab there).
34
+ branch: (<><circle cx="4.5" cy="4" r="1.4" /><circle cx="4.5" cy="12" r="1.4" /><circle cx="11.5" cy="6.5" r="1.4" /><line x1="4.5" y1="5.4" x2="4.5" y2="10.6" /><path d="M4.5 8.6h2.6a3 3 0 0 0 3-1.6" /></>),
35
+ plus: (<><line x1="8" y1="3" x2="8" y2="13" /><line x1="3" y1="8" x2="13" y2="8" /></>),
36
+ // "lift this draft up into the Shared version" — the fold-back action.
37
+ 'arrow-up-to-line': (<><line x1="3.5" y1="3" x2="12.5" y2="3" /><line x1="8" y1="13" x2="8" y2="6" /><polyline points="5 8.5 8 5.5 11 8.5" /></>),
38
+ spinner: <path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8" />,
39
+ }[name];
40
+ return (
41
+ <svg className={className} width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
42
+ {p}
43
+ </svg>
44
+ );
45
+ }
46
+
47
+ function basename(p) {
48
+ return String(p).replace(/[/\\]+$/, '').split(/[/\\]/).pop() || String(p);
49
+ }
50
+ function slugify(s) {
51
+ return s.trim().toLowerCase().replace(/[^a-z0-9._/-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
52
+ }
53
+ async function getJson(url) {
54
+ const r = await fetch(url);
55
+ return r.json();
56
+ }
57
+ async function postJson(url, body) {
58
+ const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
59
+ let json = null;
60
+ try { json = await r.json(); } catch { /* no body */ }
61
+ return { ok: r.ok, status: r.status, json };
62
+ }
63
+
64
+ export default function RepoBranchSwitcher({ project, liveBranch }) {
65
+ const native = isNativeApp();
66
+ const [status, setStatus] = useState(null); // { repo, branch }
67
+ const [branches, setBranches] = useState([]);
68
+ const [recents, setRecents] = useState([]);
69
+ const [open, setOpen] = useState(false);
70
+ const [newDraft, setNewDraft] = useState(false);
71
+ const [draftName, setDraftName] = useState('');
72
+ const [foldConfirm, setFoldConfirm] = useState(false);
73
+ const [folding, setFolding] = useState('');
74
+ const [busy, setBusy] = useState(false);
75
+ const [switching, setSwitching] = useState('');
76
+ const [err, setErr] = useState('');
77
+ const rootRef = useRef(null);
78
+
79
+ useEffect(() => {
80
+ let alive = true;
81
+ (async () => {
82
+ try {
83
+ const s = await getJson('/_api/git/status');
84
+ if (!alive) return;
85
+ setStatus(s);
86
+ // Branches drive the native dock's draft picker only — the web badge is
87
+ // read-only, so skip the extra request there.
88
+ if (s.repo && native) {
89
+ const b = await getJson('/_api/git/branches');
90
+ if (alive) setBranches(b.branches || []);
91
+ }
92
+ } catch { /* not a repo / offline */ }
93
+ })();
94
+ if (native) appRecentProjects().then((r) => alive && setRecents(r || [])).catch(() => {});
95
+ return () => { alive = false; };
96
+ }, [native]);
97
+
98
+ useEffect(() => {
99
+ if (!open && !newDraft) return undefined;
100
+ const onDoc = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) { setOpen(false); setNewDraft(false); } };
101
+ document.addEventListener('mousedown', onDoc);
102
+ return () => document.removeEventListener('mousedown', onDoc);
103
+ }, [open, newDraft]);
104
+
105
+ // Only meaningful for a versioned project (drafts need git).
106
+ if (!status?.repo) return null;
107
+
108
+ // Prefer the live branch from the git-status broadcast (kept current as the
109
+ // dev runs `git checkout` in their terminal) over the one-shot mount fetch.
110
+ const branch = liveBranch || status.branch || 'main';
111
+ const onShared = SHARED.has(branch);
112
+ const sharedName = branches.find((b) => SHARED.has(b.name))?.name || 'main';
113
+ const drafts = branches.filter((b) => !SHARED.has(b.name));
114
+ const otherDrafts = drafts.filter((b) => b.name !== branch);
115
+ const projectName = project || basename(recents[0] || 'Project');
116
+
117
+ // Web studio (browser, CLI-launched inside a repo): the switcher is awareness
118
+ // only. The developer owns the workspace from their terminal — switching repos
119
+ // and branches happens there — so render a compact, read-only "📁 project ·
120
+ // branch: X" badge in git vocab (not "draft"/"Shared version"), kept live by
121
+ // the same git-status broadcast that drives the Changes panel. No actions: a
122
+ // UI checkout would rewrite the dev's working tree under their hands and
123
+ // collide with their editor (DDR-119 — native owns the workspace; web is a
124
+ // repo-bound companion).
125
+ if (!native) {
126
+ return (
127
+ <div className="rb-dock-wrap">
128
+ <div className="rb-dock rb-dock--ro">
129
+ <span
130
+ className="rb-trigger rb-trigger--ro"
131
+ title={`${projectName} · branch: ${branch} — switch branches in your terminal`}
132
+ >
133
+ <span className="rb-trigger-icon"><Icon name="folder" size={14} /></span>
134
+ <span className="rb-trigger-proj">{projectName}</span>
135
+ <span className="rb-trigger-sep" aria-hidden="true">·</span>
136
+ <span className="rb-trigger-ver rb-trigger-ver--ro">
137
+ <Icon name="branch" size={12} />
138
+ <span className="rb-trigger-ver-name">branch: {branch}</span>
139
+ </span>
140
+ </span>
141
+ </div>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ async function switchDraft(name) {
147
+ setOpen(false);
148
+ if (name === branch) return;
149
+ setSwitching(name === sharedName ? 'the shared version' : name);
150
+ setErr('');
151
+ const r = await postJson('/_api/git/checkout', { name });
152
+ if (r.ok && r.json?.ok) window.location.reload();
153
+ else { setErr(r.json?.error || 'Could not switch.'); setSwitching(''); }
154
+ }
155
+
156
+ async function createDraft() {
157
+ const name = slugify(draftName);
158
+ if (!name) return;
159
+ setBusy(true); setErr('');
160
+ const r = await postJson('/_api/git/branch', { name });
161
+ if (r.ok && r.json?.ok) window.location.reload();
162
+ else { setErr(r.json?.error || 'Could not create the draft.'); setBusy(false); }
163
+ }
164
+
165
+ // "Add this draft to the Shared version" (Task 7) — merge + publish, then remove.
166
+ async function foldDraft() {
167
+ setFoldConfirm(false);
168
+ setFolding(branch);
169
+ setErr('');
170
+ const r = await postJson('/_api/git/fold', { name: branch });
171
+ if (r.ok && r.json?.ok) window.location.reload();
172
+ else {
173
+ setErr(r.status === 401 ? 'Sign in with GitHub to publish the Shared version.' : r.json?.error || 'Could not add the draft.');
174
+ setFolding('');
175
+ }
176
+ }
177
+
178
+ async function switchRepo(path) {
179
+ setOpen(false);
180
+ if (!native) return;
181
+ setSwitching(basename(path));
182
+ try { await openLocalProject(path); }
183
+ catch (e) { setErr(String(e?.message || e || 'Could not open that project.')); setSwitching(''); }
184
+ }
185
+
186
+ async function openAnother() {
187
+ setOpen(false);
188
+ if (!native) return;
189
+ try {
190
+ const dir = await pickDirectory();
191
+ if (!dir) return;
192
+ setSwitching(basename(dir));
193
+ await openLocalProject(dir);
194
+ } catch (e) { setErr(String(e?.message || e || 'Could not open that folder.')); setSwitching(''); }
195
+ }
196
+
197
+ const slug = slugify(draftName);
198
+ const currentDraft = onShared ? null : branches.find((b) => b.current);
199
+
200
+ return (
201
+ <div className="rb-dock-wrap">
202
+ <div className="rb-dock" ref={rootRef}>
203
+ {open && (
204
+ <div className="rb-pop rb-pop--up" id="rb-switch-pop" role="menu" aria-label="Switch project or version">
205
+ {/* ── Project ── */}
206
+ <div className="rb-pop-hd">Project</div>
207
+ {native && recents.length > 0 ? (
208
+ recents.map((p, i) => (
209
+ <button type="button" key={p} className={'rb-pop-item' + (i === 0 ? ' is-current' : '')} role="menuitem" onClick={() => switchRepo(p)}>
210
+ <span className="rb-pop-icon"><Icon name="folder" size={14} /></span>
211
+ <span className="rb-pop-tx">
212
+ <span className="rb-pop-name">{basename(p)}</span>
213
+ <span className="rb-pop-sub">{p}</span>
214
+ </span>
215
+ {i === 0 ? <Icon name="check" size={14} className="rb-pop-check" /> : null}
216
+ </button>
217
+ ))
218
+ ) : (
219
+ <div className="rb-pop-sub" style={{ padding: 'var(--space-2) var(--space-3)' }}>{native ? 'No other recent projects.' : 'Open another project from the desktop app.'}</div>
220
+ )}
221
+ {native && (
222
+ <button type="button" className="rb-pop-item rb-pop-item--action" role="menuitem" onClick={openAnother}>
223
+ <span className="rb-pop-icon"><Icon name="folder-open" size={14} /></span>
224
+ <span className="rb-pop-tx"><span className="rb-pop-name">Open another folder…</span></span>
225
+ </button>
226
+ )}
227
+
228
+ <div className="rb-pop-sep" />
229
+ <div className="rb-pop-hd">Version</div>
230
+
231
+ {onShared ? (
232
+ <>
233
+ <button type="button" className="rb-pop-item is-current" role="menuitem" aria-current="true" onClick={() => switchDraft(sharedName)}>
234
+ <span className="rb-pop-icon rb-pop-icon--shared"><Icon name="share" size={14} /></span>
235
+ <span className="rb-pop-tx">
236
+ <span className="rb-pop-name">Shared version</span>
237
+ <span className="rb-pop-sub">what everyone sees</span>
238
+ </span>
239
+ <Icon name="check" size={14} className="rb-pop-check" />
240
+ </button>
241
+ {drafts.length > 0 && <div className="rb-pop-grouplabel">Drafts</div>}
242
+ {drafts.map((b) => (
243
+ <button type="button" key={b.name} className="rb-pop-item" role="menuitem" onClick={() => switchDraft(b.name)}>
244
+ <span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
245
+ <span className="rb-pop-tx"><span className="rb-pop-name">{b.name}</span></span>
246
+ </button>
247
+ ))}
248
+ </>
249
+ ) : (
250
+ <>
251
+ {/* On a draft — it's the current row, and the one strong action is
252
+ folding it back into the Shared version (Task 7). */}
253
+ <button type="button" className="rb-pop-item is-current" role="menuitem" aria-current="true">
254
+ <span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
255
+ <span className="rb-pop-tx">
256
+ <span className="rb-pop-name">{currentDraft?.name || branch}</span>
257
+ <span className="rb-pop-sub">your draft</span>
258
+ </span>
259
+ <Icon name="check" size={14} className="rb-pop-check" />
260
+ </button>
261
+ <button type="button" className="rb-fold" role="menuitem" onClick={() => { setOpen(false); setFoldConfirm(true); }}>
262
+ <span className="rb-fold-icon"><Icon name="arrow-up-to-line" size={15} /></span>
263
+ <span className="rb-fold-tx">
264
+ <span className="rb-fold-title">Add this draft to the Shared version</span>
265
+ <span className="rb-fold-sub">make it the version everyone works from</span>
266
+ </span>
267
+ </button>
268
+ <div className="rb-pop-grouplabel">Switch to</div>
269
+ <button type="button" className="rb-pop-item" role="menuitem" onClick={() => switchDraft(sharedName)}>
270
+ <span className="rb-pop-icon rb-pop-icon--shared"><Icon name="share" size={14} /></span>
271
+ <span className="rb-pop-tx">
272
+ <span className="rb-pop-name">Shared version</span>
273
+ <span className="rb-pop-sub">what everyone sees</span>
274
+ </span>
275
+ </button>
276
+ {otherDrafts.map((b) => (
277
+ <button type="button" key={b.name} className="rb-pop-item" role="menuitem" onClick={() => switchDraft(b.name)}>
278
+ <span className="rb-pop-icon rb-pop-icon--draft"><Icon name="draft" size={14} /></span>
279
+ <span className="rb-pop-tx"><span className="rb-pop-name">{b.name}</span></span>
280
+ </button>
281
+ ))}
282
+ </>
283
+ )}
284
+
285
+ <button type="button" className="rb-pop-item rb-pop-item--action" role="menuitem" onClick={() => { setOpen(false); setNewDraft(true); }}>
286
+ <span className="rb-pop-icon"><Icon name="plus" size={14} /></span>
287
+ <span className="rb-pop-tx">
288
+ <span className="rb-pop-name">New draft</span>
289
+ <span className="rb-pop-sub">a separate line of work, just yours for now</span>
290
+ </span>
291
+ </button>
292
+ </div>
293
+ )}
294
+
295
+ {newDraft && (
296
+ <div className="rb-newdraft rb-newdraft--up">
297
+ <label className="rb-newdraft-field">
298
+ <span className="rb-newdraft-label">Name your draft</span>
299
+ <input className="input rb-newdraft-input" type="text" value={draftName} placeholder="Nav redesign" aria-label="Draft name" autoFocus onChange={(e) => setDraftName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && slug) createDraft(); if (e.key === 'Escape') { setNewDraft(false); setDraftName(''); } }} />
300
+ {slug && <span className="rb-pop-sub">Creates a draft called <b>{slug}</b></span>}
301
+ </label>
302
+ {err && <span className="rb-newdraft-err">{err}</span>}
303
+ <div className="rb-newdraft-actions">
304
+ <button type="button" className="btn btn--ghost btn--sm" onClick={() => { setNewDraft(false); setDraftName(''); setErr(''); }} disabled={busy}>Cancel</button>
305
+ <button type="button" className="btn btn--primary btn--sm" onClick={createDraft} disabled={busy || !slug}><Icon name="draft" size={13} /> {busy ? 'Creating…' : 'Create draft'}</button>
306
+ </div>
307
+ <p className="rb-newdraft-hint">A draft is your own copy to try things in. Add it to the Shared version when you're happy, or throw it away — nothing else changes.</p>
308
+ </div>
309
+ )}
310
+
311
+ {switching ? (
312
+ <div className="rb-switching" role="status" aria-live="polite">
313
+ <Icon name="spinner" size={14} className="rb-spin" />
314
+ <span>{folding ? <>Adding <b>{folding}</b> to the Shared version…</> : <>Opening <b>{switching}</b>…</>}</span>
315
+ </div>
316
+ ) : (
317
+ <button type="button" className={'rb-trigger' + (open ? ' is-open' : '')} aria-expanded={open} aria-haspopup="menu" aria-controls="rb-switch-pop" onClick={() => { setOpen((v) => !v); setNewDraft(false); }} title={`${projectName} · ${onShared ? 'Shared version' : branch}`}>
318
+ <span className="rb-trigger-icon"><Icon name="folder" size={14} /></span>
319
+ <span className="rb-trigger-proj">{projectName}</span>
320
+ <span className="rb-trigger-sep" aria-hidden="true">·</span>
321
+ <span className={'rb-trigger-ver' + (onShared ? '' : ' is-draft')}>
322
+ <Icon name={onShared ? 'share' : 'draft'} size={12} />
323
+ <span className="rb-trigger-ver-name">{onShared ? 'Shared version' : branch}</span>
324
+ </span>
325
+ <Icon name="chevron-up" size={13} className="rb-trigger-caret" />
326
+ </button>
327
+ )}
328
+ {err && !newDraft && !switching && <div className="rb-switcher-err" role="alert">{err}</div>}
329
+ </div>
330
+
331
+ {/* Fold-back confirm — the one modal in this surface. Plain words, no merge UI. */}
332
+ {foldConfirm && (
333
+ <div className="rb-scrim" role="presentation" onClick={() => setFoldConfirm(false)}>
334
+ <div className="rb-sheet" role="dialog" aria-modal="true" aria-labelledby="rb-sheet-title" aria-describedby="rb-sheet-body" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Escape') setFoldConfirm(false); }}>
335
+ <span className="rb-sheet-icon"><Icon name="arrow-up-to-line" size={20} /></span>
336
+ <h2 className="rb-sheet-title" id="rb-sheet-title">Add this draft to the Shared version</h2>
337
+ <p className="rb-sheet-body" id="rb-sheet-body">Everything in <b>“{currentDraft?.name || branch}”</b> will become part of the Shared version everyone sees.</p>
338
+ <p className="rb-sheet-meta">Everyone else picks it up the next time they Pull changes, and this draft is then removed. Nothing else changes.</p>
339
+ {err && <p className="rb-newdraft-err">{err}</p>}
340
+ <div className="rb-sheet-actions">
341
+ <button type="button" className="btn btn--ghost" onClick={() => { setFoldConfirm(false); setErr(''); }}>Cancel</button>
342
+ <button type="button" className="btn btn--primary" onClick={foldDraft}><Icon name="arrow-up-to-line" size={15} /> Add to the Shared version</button>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ )}
347
+ </div>
348
+ );
349
+ }
@@ -0,0 +1,286 @@
1
+ // Phase 31 (DDR-123) — client glue between assistant-ui's local runtime and the
2
+ // dev-server ACP bridge (`/_ws/acp`). One persistent socket per ChatPanel mount;
3
+ // the bridge lazy-spawns the user's `claude` on the first prompt, so just opening
4
+ // the panel costs nothing. A custom `ChatModelAdapter` translates the bridge's
5
+ // JSON frames into the streamed assistant message parts assistant-ui renders.
6
+
7
+ const WS_PATH = '/_ws/acp';
8
+
9
+ /** Connection wrapper around the loopback `/_ws/acp` socket. */
10
+ export function createAcpConnection() {
11
+ let ws = null;
12
+ let openPromise = null;
13
+ let turnHandler = null; // the in-flight run()'s frame sink
14
+ const statusListeners = new Set();
15
+ const status = { available: null, reason: undefined, ready: false };
16
+
17
+ // Live "what's running now" — in-flight tool calls (a tool_call with no
18
+ // completed/failed tool_call_update yet). Surfaced as the activity bar so
19
+ // background work is visible at a glance, not only buried in the text.
20
+ const activityListeners = new Set();
21
+ const activeTools = new Map(); // toolCallId → { title, kind }
22
+
23
+ // Turn-busy signal — true while a prompt turn is in flight. Drives the
24
+ // menubar Assistant badge + the "finished" notification.
25
+ const busyListeners = new Set();
26
+ let busy = false;
27
+
28
+ function emitStatus() {
29
+ for (const fn of statusListeners) fn({ ...status });
30
+ }
31
+
32
+ function setBusy(next) {
33
+ if (busy === next) return;
34
+ busy = next;
35
+ for (const fn of busyListeners) fn(busy);
36
+ }
37
+
38
+ function emitActivity() {
39
+ const snapshot = [...activeTools.values()];
40
+ for (const fn of activityListeners) fn(snapshot);
41
+ }
42
+
43
+ function trackActivity(frame) {
44
+ if (frame.t === 'update') {
45
+ const u = frame.update;
46
+ if (u.sessionUpdate === 'tool_call') {
47
+ activeTools.set(u.toolCallId, { title: u.title || u.kind || 'tool', kind: u.kind });
48
+ emitActivity();
49
+ } else if (
50
+ u.sessionUpdate === 'tool_call_update' &&
51
+ (u.status === 'completed' || u.status === 'failed')
52
+ ) {
53
+ if (activeTools.delete(u.toolCallId)) emitActivity();
54
+ }
55
+ } else if (frame.t === 'turn-end' || frame.t === 'error') {
56
+ if (activeTools.size) {
57
+ activeTools.clear();
58
+ emitActivity();
59
+ }
60
+ }
61
+ }
62
+
63
+ function onFrame(frame) {
64
+ if (frame.t === 'ready') {
65
+ status.ready = true;
66
+ status.available = frame.available;
67
+ status.reason = frame.reason;
68
+ emitStatus();
69
+ return;
70
+ }
71
+ // Everything else belongs to the active prompt turn.
72
+ trackActivity(frame);
73
+ if (turnHandler) turnHandler(frame);
74
+ }
75
+
76
+ function ensureOpen() {
77
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
78
+ return openPromise;
79
+ }
80
+ openPromise = new Promise((resolve, reject) => {
81
+ const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
82
+ const sock = new WebSocket(`${scheme}://${location.host}${WS_PATH}`);
83
+ ws = sock;
84
+ sock.onopen = () => resolve();
85
+ sock.onerror = () => reject(new Error('Could not reach the Claude bridge.'));
86
+ sock.onclose = () => {
87
+ ws = null;
88
+ status.ready = false;
89
+ emitStatus();
90
+ };
91
+ sock.onmessage = (e) => {
92
+ try {
93
+ onFrame(JSON.parse(e.data));
94
+ } catch {
95
+ /* ignore malformed frame */
96
+ }
97
+ };
98
+ });
99
+ return openPromise;
100
+ }
101
+
102
+ return {
103
+ onStatus(fn) {
104
+ statusListeners.add(fn);
105
+ fn({ ...status });
106
+ return () => statusListeners.delete(fn);
107
+ },
108
+
109
+ onActivity(fn) {
110
+ activityListeners.add(fn);
111
+ fn([...activeTools.values()]);
112
+ return () => activityListeners.delete(fn);
113
+ },
114
+
115
+ /**
116
+ * Drive one prompt turn. Async-generates the bridge's `update` frames until
117
+ * `turn-end`; throws on `error`; sends `cancel` when `abortSignal` aborts.
118
+ */
119
+ async *prompt(text, chatId, abortSignal, model, effort) {
120
+ await ensureOpen();
121
+ const queue = [];
122
+ let wake = null;
123
+ let ended = false;
124
+ let failure = null;
125
+ turnHandler = (frame) => {
126
+ if (frame.t === 'turn-end') ended = true;
127
+ else if (frame.t === 'error') failure = frame.message || 'The Claude bridge errored.';
128
+ else queue.push(frame); // update / connected / permission
129
+ if (wake) {
130
+ const w = wake;
131
+ wake = null;
132
+ w();
133
+ }
134
+ };
135
+ const cancel = () => {
136
+ try {
137
+ ws?.send(JSON.stringify({ t: 'cancel' }));
138
+ } catch {
139
+ /* socket already gone */
140
+ }
141
+ };
142
+ abortSignal?.addEventListener('abort', cancel, { once: true });
143
+ setBusy(true);
144
+ try {
145
+ ws.send(
146
+ JSON.stringify({
147
+ t: 'prompt',
148
+ text,
149
+ chat: chatId || undefined,
150
+ model: model || undefined,
151
+ effort: effort || undefined,
152
+ })
153
+ );
154
+ for (;;) {
155
+ while (queue.length) yield queue.shift();
156
+ if (failure) throw new Error(failure);
157
+ if (ended) return;
158
+ await new Promise((r) => {
159
+ wake = r;
160
+ });
161
+ }
162
+ } finally {
163
+ turnHandler = null;
164
+ setBusy(false);
165
+ abortSignal?.removeEventListener('abort', cancel);
166
+ }
167
+ },
168
+
169
+ onBusy(fn) {
170
+ busyListeners.add(fn);
171
+ fn(busy);
172
+ return () => busyListeners.delete(fn);
173
+ },
174
+
175
+ get busy() {
176
+ return busy;
177
+ },
178
+
179
+ close() {
180
+ try {
181
+ ws?.close();
182
+ } catch {
183
+ /* already closed */
184
+ }
185
+ ws = null;
186
+ },
187
+ };
188
+ }
189
+
190
+ function lastUserText(messages) {
191
+ for (let i = messages.length - 1; i >= 0; i--) {
192
+ const m = messages[i];
193
+ if (m.role !== 'user') continue;
194
+ return (m.content || [])
195
+ .filter((p) => p.type === 'text')
196
+ .map((p) => p.text)
197
+ .join('')
198
+ .trim();
199
+ }
200
+ return '';
201
+ }
202
+
203
+ function safeJson(value) {
204
+ try {
205
+ return JSON.stringify(value ?? {});
206
+ } catch {
207
+ return '{}';
208
+ }
209
+ }
210
+
211
+ /**
212
+ * assistant-ui `ChatModelAdapter` over the ACP bridge. Streams text + tool-call
213
+ * parts, preserving the order in which the agent emits them. `available_commands_update`
214
+ * and `usage_update` are intentionally dropped (chrome noise, not chat content).
215
+ */
216
+ export function makeAcpAdapter(conn, getChatId, getModel, getEffort) {
217
+ return {
218
+ async *run({ messages, abortSignal }) {
219
+ const text = lastUserText(messages);
220
+ if (!text) return;
221
+
222
+ const parts = [];
223
+ const toolIndex = new Map();
224
+
225
+ for await (const frame of conn.prompt(
226
+ text,
227
+ getChatId(),
228
+ abortSignal,
229
+ getModel?.(),
230
+ getEffort?.()
231
+ )) {
232
+ if (frame.t !== 'update') continue;
233
+ const u = frame.update;
234
+ switch (u.sessionUpdate) {
235
+ case 'agent_message_chunk': {
236
+ if (u.content?.type !== 'text') break;
237
+ const last = parts[parts.length - 1];
238
+ if (last && last.type === 'text') {
239
+ parts[parts.length - 1] = { ...last, text: last.text + u.content.text };
240
+ } else {
241
+ parts.push({ type: 'text', text: u.content.text });
242
+ }
243
+ break;
244
+ }
245
+ case 'agent_thought_chunk': {
246
+ // Extended-thinking — rendered as a collapsed "Thinking" disclosure
247
+ // (assistant-ui reasoning part) so it's available but not in the way.
248
+ if (u.content?.type !== 'text') break;
249
+ const last = parts[parts.length - 1];
250
+ if (last && last.type === 'reasoning') {
251
+ parts[parts.length - 1] = { ...last, text: last.text + u.content.text };
252
+ } else {
253
+ parts.push({ type: 'reasoning', text: u.content.text });
254
+ }
255
+ break;
256
+ }
257
+ case 'tool_call': {
258
+ toolIndex.set(u.toolCallId, parts.length);
259
+ parts.push({
260
+ type: 'tool-call',
261
+ toolCallId: u.toolCallId,
262
+ toolName: u.title || u.kind || 'tool',
263
+ args: u.rawInput ?? {},
264
+ argsText: safeJson(u.rawInput),
265
+ result: undefined,
266
+ });
267
+ break;
268
+ }
269
+ case 'tool_call_update': {
270
+ const idx = toolIndex.get(u.toolCallId);
271
+ if (idx == null) break;
272
+ parts[idx] = {
273
+ ...parts[idx],
274
+ result: u.rawOutput ?? parts[idx].result,
275
+ isError: u.status === 'failed',
276
+ };
277
+ break;
278
+ }
279
+ default:
280
+ break; // plan / thought / commands / usage — not rendered in v1
281
+ }
282
+ yield { content: parts.slice() };
283
+ }
284
+ },
285
+ };
286
+ }