@bivy/bivy 0.17.3-staging.9 → 0.18.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.
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import net from "node:net";
4
+ const MAX_ITEMS = 3000;
5
+ const MAX_DEPTH = 4;
6
+ const TIMEOUT_MS = 5_000;
7
+ /** A path of titles, top menu first: "File > Export…" or ["File", "Export…"]. */
8
+ export function readMenuPath(input) {
9
+ const path = typeof input === "string" ? input.split(">").map((part) => part.trim()) : input;
10
+ if (!Array.isArray(path) || !path.length || path.length > 8 || path.some((part) => typeof part !== "string" || !part || part.length > 200)) {
11
+ throw new Error("A menu item is a path of titles, like \"File > Save\".");
12
+ }
13
+ return path;
14
+ }
15
+ /** Keeps what the display says to known fields and bounded sizes: the menu
16
+ * comes from the app, so it is data to show, never markup. */
17
+ function clean(items, depth, budget) {
18
+ if (!Array.isArray(items))
19
+ return [];
20
+ const out = [];
21
+ for (const raw of items) {
22
+ if (budget.left-- <= 0)
23
+ break;
24
+ const item = raw;
25
+ if (item?.separator === true) {
26
+ out.push({ separator: true });
27
+ continue;
28
+ }
29
+ if (typeof item?.title !== "string" || !item.title)
30
+ continue;
31
+ const entry = { title: item.title.slice(0, 200), enabled: item.enabled === true };
32
+ if (typeof item.shortcut === "string" && item.shortcut.length <= 20)
33
+ entry.shortcut = item.shortcut;
34
+ if (item.checked === true)
35
+ entry.checked = true;
36
+ const children = depth < MAX_DEPTH ? clean(item.items, depth + 1, budget) : [];
37
+ if (children.length)
38
+ entry.items = children;
39
+ out.push(entry);
40
+ }
41
+ return out;
42
+ }
43
+ /** One request to a display's control socket, one JSON answer. */
44
+ function ask(control, request) {
45
+ return new Promise((resolve, reject) => {
46
+ const socket = net.connect(control);
47
+ let data = "";
48
+ socket.setTimeout(TIMEOUT_MS, () => socket.destroy(new Error("The app didn't answer in time.")));
49
+ socket.on("data", (chunk) => { data += chunk; if (data.length > 4 * 1024 * 1024)
50
+ socket.destroy(new Error("The app's menu is too large.")); });
51
+ socket.on("error", (error) => reject(error.code ? new Error("The app isn't running.") : error));
52
+ socket.on("end", () => {
53
+ try {
54
+ const answer = JSON.parse(data);
55
+ if (typeof answer.error === "string")
56
+ reject(new Error(answer.error.slice(0, 300)));
57
+ else
58
+ resolve(answer);
59
+ }
60
+ catch {
61
+ reject(new Error("The app's display gave an unreadable answer."));
62
+ }
63
+ });
64
+ socket.end(`${JSON.stringify(request)}\n`);
65
+ });
66
+ }
67
+ /** The app's menu bar, read now, so enabled and checked states are current. */
68
+ export async function readMenus(control) {
69
+ const answer = await ask(control, { menu: true });
70
+ const budget = { left: MAX_ITEMS };
71
+ return (Array.isArray(answer.menus) ? answer.menus : []).flatMap((menu) => typeof menu?.title === "string" && menu.title ? [{ title: menu.title.slice(0, 200), items: clean(menu.items, 1, budget) }] : []);
72
+ }
73
+ /** Chooses the menu item at `path`, as if picked from the menu bar. */
74
+ export async function pressMenu(control, path) {
75
+ await ask(control, { press: path });
76
+ }
@@ -20,8 +20,10 @@ iframe { width:100%; height:100%; border:0; background:var(--bg); }
20
20
  /* The pill floats above the app instead of taking a header row from it. */
21
21
  #dock { position:fixed; left:var(--space-2); right:var(--space-2); bottom:calc(var(--space-2) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); display:flex; flex-direction:column; align-items:center; gap:var(--space-2); pointer-events:none; }
22
22
  #dock > * { pointer-events:auto; }
23
- nav { display:flex; align-items:center; gap:var(--space-1); max-width:100%; padding:var(--space-1); background:var(--surface); border:thin solid var(--line); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
24
- nav .btn { flex-shrink:0; border-radius:var(--radius-full); }
23
+ /* The hint sits over the app; a tap on it is meant for the app underneath. */
24
+ #dock > #pointing { pointer-events:none; }
25
+ nav, #draw-bar { display:flex; align-items:center; gap:var(--space-1); max-width:100%; padding:var(--space-1); background:var(--surface); border:thin solid var(--line); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
26
+ nav .btn, #draw-bar .btn { flex-shrink:0; border-radius:var(--radius-full); }
25
27
  nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accent); }
26
28
  #name { display:flex; flex-direction:column; min-width:0; padding:0 var(--space-2); }
27
29
  #title { font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:14em; }
@@ -39,10 +41,36 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
39
41
  #compare-hint { font-size:var(--text-xs); margin:var(--space-1) 0 0; }
40
42
  #entries { list-style:none; margin:0; padding:0; font-family:var(--font-mono); font-size:var(--text-xs); }
41
43
  #entries li { display:flex; gap:var(--space-2); align-items:baseline; padding:var(--space-1) 0; border-top:thin solid var(--line); overflow-wrap:anywhere; }
42
- #draft-text { width:100%; min-height:5em; box-sizing:border-box; font:inherit; font-size:var(--text-sm); resize:vertical; }
44
+ /* Keeps .field's 16px: smaller, and iOS zooms the shell on focus and stays zoomed. */
45
+ #draft-text { width:100%; min-height:5em; box-sizing:border-box; resize:vertical; }
46
+ #draft-row { display:flex; align-items:flex-start; gap:var(--space-2); }
47
+ #draft-row #draft-text { flex:1; min-width:0; }
48
+ /* Hold to talk: a phone-sized target that never selects text or opens a callout. */
49
+ #mic { flex:none; inline-size:44px; block-size:44px; padding:0; border-radius:var(--radius-full); touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
50
+ #mic[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
51
+ #voice-status { font-size:var(--text-xs); margin:var(--space-2) 0 0; }
52
+ #voice-status:empty { display:none; }
53
+ /* The panel scrolls on a phone; its actions stay in reach at the bottom. */
54
+ #draft .panel-actions { position:sticky; bottom:calc(-1 * var(--space-3)); margin:var(--space-2) calc(-1 * var(--space-3)) calc(-1 * var(--space-3)); padding:var(--space-2) var(--space-3) var(--space-3); background:var(--surface); border-top:thin solid var(--line); }
43
55
  #draft-hint { font-size:var(--text-xs); margin:var(--space-2) 0 var(--space-1); }
44
56
  #draft-context { margin:0; padding:var(--space-2); background:var(--surface-2); border-radius:var(--radius-md); font-family:var(--font-mono); font-size:var(--text-xs); white-space:pre-wrap; overflow-wrap:anywhere; color:var(--muted); }
45
57
  #show { position:fixed; right:var(--space-3); bottom:calc(var(--space-3) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
58
+ /* Draw: marks over the frozen app (or Compare's "after" shot). The layer takes
59
+ every touch, so nothing reaches the app; two fingers scroll the page. */
60
+ #ink { position:fixed; z-index:var(--z-sticky); touch-action:none; cursor:crosshair; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
61
+ #ink.frozen { pointer-events:none; cursor:default; }
62
+ #ink .halo { fill:none; stroke:var(--annotate-halo); stroke-width:7; stroke-linecap:round; stroke-linejoin:round; }
63
+ #ink .mark { fill:none; stroke:var(--annotate); stroke-width:4; stroke-linecap:round; stroke-linejoin:round; }
64
+ #dock > #drawing { pointer-events:none; }
65
+ /* Hints float over the app, whose page may be any colour: back them solidly. */
66
+ #dock > .banner.inline[data-tone="accent"] { background:color-mix(in srgb, var(--accent) 14%, var(--surface)); box-shadow:var(--shadow-sm); }
67
+ #draw-bar .btn { min-block-size:44px; }
68
+ #draw-bar .seg-btn { min-block-size:36px; min-inline-size:48px; }
69
+ #draw-bar .segmented { flex-shrink:0; }
70
+ #compare-draw { margin-top:var(--space-2); }
71
+ /* Marking a Compare shot: make it big enough to draw on with a thumb. */
72
+ #compare[data-drawing] #compare-stage img { max-height:min(60vh, 560px); }
73
+ #compare[data-drawing] #compare-slider, #compare[data-drawing] #compare-hint, #compare[data-drawing] #compare-draw { display:none; }
46
74
  .label-narrow { display:none; }
47
75
  @media (max-width: 699px) { #lens { display:none; } #name { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } }
48
76
  [hidden] { display:none !important; }
@@ -50,6 +78,7 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
50
78
  <div class="banner" data-tone="warn" id="down" hidden><span class="banner-text" id="down-text" role="status"></span><span class="banner-actions"><button class="btn sm" id="ask" hidden>Ask agent to fix</button></span></div>
51
79
  <p id="status" role="status">Opening app…</p>
52
80
  <div id="stage" data-lens="full"><iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe></div>
81
+ <svg id="ink" hidden aria-hidden="true"></svg>
53
82
  <div id="dock">
54
83
  <section class="panel" id="console" hidden aria-labelledby="console-title">
55
84
  <h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
@@ -61,31 +90,44 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
61
90
  <h2 id="compare-title">Before and after the agent’s last change</h2>
62
91
  <div id="compare-stage"><img id="compare-after" alt="After the agent’s last change"><img id="compare-before" alt="Before the agent’s last change"><input type="range" id="compare-slider" min="0" max="100" value="50" aria-label="Show more of before or after"></div>
63
92
  <p class="muted" id="compare-hint">Left of the handle: before. Right: now. Screenshots at phone width.</p>
93
+ <button class="btn sm" id="compare-draw" type="button" hidden>Draw on “now”</button>
64
94
  </section>
65
95
  <section class="panel" id="draft" hidden aria-labelledby="draft-title">
66
96
  <h2 id="draft-title">Draft for the agent</h2>
67
- <textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
97
+ <div id="draft-row"><textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
98
+ <button class="btn" id="mic" type="button" hidden aria-pressed="false" aria-label="Speak" title="Hold to speak, or tap to start and stop"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5a3 3 0 003-3V6a3 3 0 10-6 0v6.5a3 3 0 003 3z"/><path d="M6 11.5v1a6 6 0 0012 0v-1M12 18.5V21M9 21h6"/></svg></button></div>
99
+ <p class="muted" id="voice-status" role="status"></p>
68
100
  <p class="muted" id="draft-hint">Sent with this context from the preview. Nothing is sent until you send it in the chat.</p>
69
101
  <pre id="draft-context"></pre>
70
102
  <div class="panel-actions"><button class="btn sm ghost" id="draft-cancel">Cancel</button><button class="btn sm primary" id="draft-add">Add to chat</button></div>
71
103
  </section>
72
- <p class="banner inline" data-tone="accent" id="pointing" role="status" hidden>Tap anything in the app to point at it. Press Escape or Point again to stop.</p>
104
+ <p class="banner inline" data-tone="accent" id="pointing" role="status" hidden>Tap anything in the app to point at it. <span id="pointing-voice" hidden>Hold to point and speak. </span>Press Escape or Point again to stop.</p>
105
+ <p class="banner inline" data-tone="accent" id="drawing" role="status" hidden>Circle or box what’s wrong, then Done. Two fingers scroll the page.</p>
106
+ <div id="draw-bar" role="toolbar" aria-label="Drawing tools" hidden>
107
+ <div class="segmented" role="radiogroup" aria-label="Mark with"><button class="seg-btn" type="button" role="radio" aria-checked="true" data-tool="pen">Pen</button><button class="seg-btn" type="button" role="radio" aria-checked="false" data-tool="box">Box</button></div>
108
+ <button class="btn sm ghost" id="draw-undo" type="button" disabled>Undo</button>
109
+ <button class="btn sm ghost" id="draw-clear" type="button" disabled>Clear</button>
110
+ <button class="btn sm ghost" id="draw-cancel" type="button" aria-label="Stop drawing">✕</button>
111
+ <button class="btn sm primary" id="draw-done" type="button" disabled>Done</button>
112
+ </div>
73
113
  <nav aria-label="Bivy preview controls">
74
114
  <button class="btn sm ghost" id="back" aria-label="Back to chat">‹ <span class="label-wide">Back to chat</span><span class="label-narrow">Chat</span></button>
75
115
  <span id="name"><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span></span>
76
116
  <button class="btn sm ghost" id="hide" aria-label="Hide Bivy controls" title="Hide controls">⌄</button>
77
117
  <button class="btn sm ghost" id="point" aria-pressed="false" disabled>Point</button>
118
+ <button class="btn sm ghost" id="draw" type="button" hidden>Draw</button>
78
119
  <button class="btn sm ghost" id="compare-btn" aria-pressed="false" hidden>Compare</button>
79
120
  <button class="btn sm ghost" id="errors" aria-pressed="false" aria-label="Console" disabled>Console <span class="badge" data-variant="solid" data-tone="danger" id="error-count" hidden></span></button>
80
121
  <div class="segmented" id="lens" role="radiogroup" aria-label="Preview width"><button class="seg-btn" role="radio" aria-selected="true" data-lens="full">Full</button><button class="seg-btn" role="radio" aria-selected="false" data-lens="tablet">Tablet</button><button class="seg-btn" role="radio" aria-selected="false" data-lens="phone">Phone</button></div>
81
- <button class="btn sm ghost" id="reload" disabled>Reload</button>
122
+ <button class="btn sm ghost" id="reload" aria-label="Reload" disabled><span class="label-wide">Reload</span><span class="label-narrow" aria-hidden="true">↻</span></button>
82
123
  </nav>
83
124
  </div>
84
125
  <button class="btn sm" id="show" hidden aria-label="Show Bivy controls">Bivy</button>
85
126
  <script nonce="${nonce}">
86
127
  const $=id=>document.getElementById(id);
87
128
  const frame=$('app'),status=$('status'),back=$('back'),reload=$('reload'),stage=$('stage');
88
- const ticket=location.hash.slice(1);history.replaceState(null,'',location.pathname);
129
+ // "#ticket~page": a review card opens the preview on the page it shows.
130
+ const [ticket,startPage='']=location.hash.slice(1).split('~');history.replaceState(null,'',location.pathname);
89
131
  let metadata,currentPath='/';
90
132
  // Peek: framed by a Bivy client, which owns closing and the composer.
91
133
  const embedded=parent!==window;
@@ -106,11 +148,13 @@ function show(data,launch){
106
148
  // (Set before navigating: the policy is fixed when the frame loads.)
107
149
  for(const b of [$('point'),$('errors')])b.hidden=data.inspect===false;
108
150
  if(data.inspect===false)frame.allow='clipboard-read; clipboard-write';
109
- frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch:'/');
151
+ frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch+(startPage?'~'+startPage:''):'/');
110
152
  back.hidden=embedded&&Boolean(data.returnTo);
111
153
  for(const b of [reload,$('point'),$('errors')])b.disabled=false;
112
154
  status.textContent='Loading app…';
113
155
  frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
156
+ // A Bivy client framing the shell says whether it can take dictation.
157
+ if(embedded&&data.returnTo)toBivy({type:'hello'});
114
158
  // Store navigation metadata only, never tickets or cookies.
115
159
  try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
116
160
  }
@@ -139,16 +183,53 @@ function toChat(text){
139
183
  const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
140
184
  location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
141
185
  }
142
- function draft(context){
186
+ function draft(context,listen){
143
187
  panels(null);
144
188
  $('draft').hidden=false;
145
189
  $('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
146
190
  $('draft-context').textContent=context;
147
- const box=$('draft-text');box.value='';box.focus();
191
+ const box=$('draft-text');box.value='';
192
+ // Speaking right away: don't raise the keyboard over the draft.
193
+ if(listen)startListening();else box.focus();
148
194
  }
149
- $('draft-cancel').onclick=()=>{$('draft').hidden=true;};
195
+ // Point and speak. The Bivy client framing this shell does the listening:
196
+ // audio never reaches this origin, and only the transcript comes back, into
197
+ // the draft box, where it stays editable. Hold to talk, or tap to start and
198
+ // tap again to stop.
199
+ const mic=$('mic'),voiceStatus=$('voice-status');
200
+ let voice=false,listening=false,heldAt=0;
201
+ const sayVoice=text=>{voiceStatus.textContent=text;};
202
+ function setListening(on){listening=on;mic.setAttribute('aria-pressed',String(on));mic.setAttribute('aria-label',on?'Stop and add what you said':'Speak');}
203
+ function startListening(){if(!voice||listening)return;setListening(true);sayVoice('Listening… let go, or tap the mic again, to add what you said.');toBivy({type:'listen',state:'start'});}
204
+ function stopListening(){if(!listening)return;sayVoice('Turning speech into text…');toBivy({type:'listen',state:'stop'});}
205
+ function cancelListening(){if(listening)toBivy({type:'listen',state:'cancel'});setListening(false);sayVoice('');}
206
+ function addSpoken(text){
207
+ const box=$('draft-text'),said=String(text||'').trim().slice(0,4000);
208
+ if(!said)return;
209
+ box.value=box.value.trim()?box.value.trimEnd()+' '+said:said;
210
+ sayVoice('Added what you said. Edit it, or Add to chat.');
211
+ }
212
+ mic.onpointerdown=e=>{
213
+ e.preventDefault();
214
+ if(listening){stopListening();heldAt=0;return;}
215
+ heldAt=Date.now();try{mic.setPointerCapture(e.pointerId);}catch{}
216
+ startListening();
217
+ };
218
+ // A short tap keeps listening until the next tap; a hold stops on release.
219
+ mic.onpointerup=mic.onpointercancel=()=>{if(heldAt&&Date.now()-heldAt>400)stopListening();heldAt=0;};
220
+ mic.onclick=e=>{if(e.detail===0){if(listening)stopListening();else startListening();}};
221
+ function fromBivy(d){
222
+ if(d.type==='draw'){drawOk=d.available===true;$('draw').hidden=!drawOk;$('compare-draw').hidden=!drawOk;}
223
+ else if(d.type==='voice'){voice=d.available===true;mic.hidden=!voice;$('pointing-voice').hidden=!voice;if(!voice)cancelListening();}
224
+ else if(d.type==='transcript'){setListening(false);if(typeof d.error==='string')sayVoice(d.error.slice(0,200));else addSpoken(d.text);}
225
+ else if(d.type==='listening'&&d.on===false&&listening){setListening(false);if(voiceStatus.textContent.startsWith('Listening'))sayVoice('');}
226
+ }
227
+ $('draft-cancel').onclick=()=>{cancelListening();$('draft').hidden=true;endDraw();};
150
228
  $('draft-add').onclick=async()=>{
229
+ cancelListening();
151
230
  const note=$('draft-text').value.trim(),text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
231
+ // Marks go to the Bivy client, which adds a picture made on the machine.
232
+ if(marks){const mark=marks;endDraw();return toBivy({type:'annotation',text,mark});}
152
233
  if(metadata.returnTo)return toChat(text);
153
234
  try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
154
235
  };
@@ -167,31 +248,160 @@ $('clear').onclick=resetConsole;
167
248
  $('send-errors').onclick=()=>draft('Console output in the app preview "'+metadata.name+'" (page '+currentPath+'):\\n'+entries.slice(-20).map(e=>'- '+e.level+': '+e.text).join('\\n'));
168
249
  // Point and tell
169
250
  const point=$('point');
170
- function pointing(on){point.setAttribute('aria-pressed',String(on));$('pointing').hidden=!on;frame.contentWindow?.postMessage({type:'bivy:point',on},metadata.origin);}
251
+ // Pointing again starts a new draft: panels over the app step aside.
252
+ function pointing(on){if(on)panels(null);point.setAttribute('aria-pressed',String(on));$('pointing').hidden=!on;frame.contentWindow?.postMessage({type:'bivy:point',on},metadata.origin);}
171
253
  point.onclick=()=>pointing(point.getAttribute('aria-pressed')!=='true');
172
254
  function picked(d){
173
255
  pointing(false);
174
256
  if(d.cancelled)return;
257
+ // A long press: the draft opens listening, and letting go stops.
258
+ if(d.hold&&voice)heldAt=Date.now();
175
259
  const r=d.rect||{},v=d.viewport||{};
176
260
  const errors=entries.filter(e=>e.level==='error').slice(-5);
177
261
  draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
178
262
  +'Element: '+String(d.selector).slice(0,300)+(d.text?' ("'+String(d.text).slice(0,200)+'")':'')+', '+r.width+'×'+r.height+' at '+r.x+','+r.y
179
- +(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''));
263
+ +(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''),d.hold===true&&voice);
264
+ }
265
+ // Draw: freeze the app and mark what's wrong. Marks are kept in page
266
+ // coordinates (so they stay on the content while two fingers scroll), and
267
+ // the app reports what's under each one. On Done they go into the draft box
268
+ // with that context; Add to chat hands them to the Bivy client, which gets a
269
+ // picture from the machine. Nothing about them goes through this origin's
270
+ // network. Compare's "after" shot can be marked the same way.
271
+ const ink=$('ink'),SVG='http://www.w3.org/2000/svg';
272
+ let draw=null,marks=null,drawOk=false,nextStroke=0;
273
+ const pos=v=>({x:Number(v?.x)||0,y:Number(v?.y)||0});
274
+ function startDraw(target){
275
+ endDraw();if(point.getAttribute('aria-pressed')==='true')pointing(false);
276
+ if(target==='frame')panels(null);
277
+ if(target==='compare'){$('compare-slider').value='0';split();$('compare').dataset.drawing='';}
278
+ // Tools first: they change the layout the marks are measured against.
279
+ $('drawing').hidden=false;$('draw-bar').hidden=false;document.querySelector('nav').hidden=true;
280
+ const r=placeInk(target);
281
+ draw={target,rect:r,scroll:{x:0,y:0},strokes:[],elements:{},tool:'pen',current:null,pointers:new Map(),pan:null,done:false,
282
+ state:{viewport:{width:Math.round(r.width),height:Math.round(r.height)},dpr:devicePixelRatio,path:currentPath,signals:{}}};
283
+ ink.style.zIndex=target==='compare'?'calc(var(--z-sticky) + 1)':'';
284
+ ink.classList.remove('frozen');ink.toggleAttribute('hidden',false);
285
+ // Where the page is and what state it holds. Without an inspector (a
286
+ // desktop app, or a page whose policy kept it out) state is unknown.
287
+ if(target==='frame'&&metadata.inspect!==false){
288
+ const current=draw;
289
+ current.state.signals={unknown:true};
290
+ current.waiting=d=>{current.waiting=null;current.scroll=pos(d.scroll);current.state={viewport:{width:Math.round(Number(d.viewport?.width))||current.state.viewport.width,height:Math.round(Number(d.viewport?.height))||current.state.viewport.height},dpr:Number(d.dpr)||devicePixelRatio,theme:d.theme==='dark'?'dark':'light',path:safePath(d.path),signals:Object.fromEntries(Object.entries(d.signals||{}).map(([k,v])=>[k,v===true]))};renderInk();};
291
+ frame.contentWindow?.postMessage({type:'bivy:draw'},metadata.origin);
292
+ }
293
+ $('draw-bar').querySelector('[aria-checked="true"]').focus();
294
+ updateDrawBar();renderInk();
295
+ }
296
+ /** Lays the marking layer over what's marked; returns where that is. */
297
+ function placeInk(target){
298
+ const r=(target==='compare'?$('compare-after'):frame).getBoundingClientRect();
299
+ Object.assign(ink.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
300
+ ink.setAttribute('viewBox','0 0 '+r.width+' '+r.height);
301
+ return {left:r.left,top:r.top,width:r.width,height:r.height};
302
+ }
303
+ // A rotation or a keyboard moves things: keep the layer on them. (Marks on
304
+ // the frame are in page pixels, so they follow; the size in use is kept.)
305
+ addEventListener('resize',()=>{if(draw&&!draw.done)draw.rect=Object.assign(placeInk(draw.target),{});});
306
+ function endDraw(){
307
+ if(!draw)return;
308
+ delete $('compare').dataset.drawing;
309
+ draw=null;marks=null;ink.toggleAttribute('hidden',true);ink.replaceChildren();
310
+ $('drawing').hidden=true;$('draw-bar').hidden=true;document.querySelector('nav').hidden=false;
311
+ }
312
+ function updateDrawBar(){const has=Boolean(draw?.strokes.length);for(const id of ['draw-undo','draw-clear','draw-done'])$(id).disabled=!has;}
313
+ const onScreen=([x,y])=>draw.target==='frame'?[x-draw.scroll.x,y-draw.scroll.y]:[x,y];
314
+ function shape(stroke){
315
+ const pts=stroke.points.map(onScreen);
316
+ if(stroke.tool==='box'){const [a,b]=[pts[0],pts[pts.length-1]];const el=document.createElementNS(SVG,'rect');el.setAttribute('x',Math.min(a[0],b[0]));el.setAttribute('y',Math.min(a[1],b[1]));el.setAttribute('width',Math.abs(b[0]-a[0]));el.setAttribute('height',Math.abs(b[1]-a[1]));el.setAttribute('rx','3');return el;}
317
+ const el=document.createElementNS(SVG,'polyline');el.setAttribute('points',pts.map(p=>p.join(',')).join(' '));return el;
180
318
  }
319
+ function renderInk(){
320
+ if(!draw)return;
321
+ const all=[...draw.strokes,...(draw.current?[draw.current]:[])];
322
+ ink.replaceChildren(...all.flatMap(s=>['halo','mark'].map(c=>{const el=shape(s);el.setAttribute('class',c);return el;})));
323
+ }
324
+ function local(e){const x=e.clientX-draw.rect.left,y=e.clientY-draw.rect.top;return draw.target==='frame'?[Math.round(x+draw.scroll.x),Math.round(y+draw.scroll.y)]:[Math.round(x),Math.round(y)];}
325
+ let scrollAsk=null;
326
+ function scrollPage(dx,dy){
327
+ if(draw?.target!=='frame'||metadata.inspect===false)return;
328
+ scrollAsk=scrollAsk?{dx:scrollAsk.dx+dx,dy:scrollAsk.dy+dy}:{dx,dy};
329
+ requestAnimationFrame(()=>{if(!scrollAsk)return;frame.contentWindow?.postMessage(Object.assign({type:'bivy:scroll'},scrollAsk),metadata.origin);scrollAsk=null;});
330
+ }
331
+ const middle=()=>{const p=[...draw.pointers.values()];return {x:(p[0].x+p[1].x)/2,y:(p[0].y+p[1].y)/2};};
332
+ ink.onpointerdown=e=>{
333
+ if(!draw||draw.done)return;e.preventDefault();try{ink.setPointerCapture(e.pointerId);}catch{}
334
+ draw.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
335
+ // A second finger scrolls instead: the stroke it interrupted is dropped.
336
+ if(draw.pointers.size>=2){draw.current=null;draw.pan=middle();renderInk();return;}
337
+ draw.current={id:nextStroke++,tool:draw.tool,points:[local(e)]};renderInk();
338
+ };
339
+ ink.onpointermove=e=>{
340
+ if(!draw||!draw.pointers.has(e.pointerId))return;
341
+ draw.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
342
+ if(draw.pan&&draw.pointers.size>=2){const m=middle();scrollPage(draw.pan.x-m.x,draw.pan.y-m.y);draw.pan=m;return;}
343
+ const s=draw.current;if(!s)return;const p=local(e);
344
+ if(s.tool==='box')s.points=[s.points[0],p];
345
+ else{const last=s.points[s.points.length-1];if(Math.hypot(p[0]-last[0],p[1]-last[1])<2||s.points.length>=2000)return;s.points.push(p);}
346
+ renderInk();
347
+ };
348
+ ink.onpointerup=ink.onpointercancel=e=>{
349
+ if(!draw)return;draw.pointers.delete(e.pointerId);if(draw.pointers.size<2)draw.pan=null;
350
+ const s=draw.current;draw.current=null;
351
+ if(s&&e.type==='pointerup'){
352
+ const xs=s.points.map(p=>p[0]),ys=s.points.map(p=>p[1]),w=Math.max(...xs)-Math.min(...xs),h=Math.max(...ys)-Math.min(...ys);
353
+ if(w>=4||h>=4){
354
+ draw.strokes.push(s);
355
+ // Ask what's under it while it's on screen (viewport coordinates).
356
+ if(draw.target==='frame'&&metadata.inspect!==false){const [x,y]=onScreen([Math.min(...xs),Math.min(...ys)]);frame.contentWindow?.postMessage({type:'bivy:marks',id:s.id,rect:{x,y,width:w,height:h}},metadata.origin);}
357
+ }
358
+ }
359
+ updateDrawBar();renderInk();
360
+ };
361
+ ink.onwheel=e=>{if(!draw||draw.done)return;e.preventDefault();scrollPage(e.deltaX,e.deltaY);};
362
+ for(const b of $('draw-bar').querySelectorAll('[data-tool]'))b.onclick=()=>{if(!draw)return;draw.tool=b.dataset.tool;for(const o of $('draw-bar').querySelectorAll('[data-tool]'))o.setAttribute('aria-checked',String(o===b));};
363
+ $('draw-undo').onclick=()=>{if(!draw)return;const s=draw.strokes.pop();if(s)delete draw.elements[s.id];updateDrawBar();renderInk();};
364
+ $('draw-clear').onclick=()=>{if(!draw)return;draw.strokes=[];draw.elements={};updateDrawBar();renderInk();};
365
+ $('draw-cancel').onclick=()=>endDraw();
366
+ $('draw').onclick=()=>startDraw('frame');
367
+ $('compare-draw').onclick=()=>startDraw('compare');
368
+ $('draw-done').onclick=()=>{
369
+ if(!draw||!draw.strokes.length)return;
370
+ const d=draw,st=d.state,seen=new Set(),els=[];
371
+ for(const s of d.strokes)for(const el of d.elements[s.id]||[]){const key=String(el.selector);if(!seen.has(key)){seen.add(key);els.push(el);}}
372
+ const bounds=s=>{const xs=s.points.map(p=>p[0]),ys=s.points.map(p=>p[1]);return s.tool+' '+Math.min(...xs)+','+Math.min(...ys)+'–'+Math.max(...xs)+','+Math.max(...ys);};
373
+ const where=d.target==='compare'?'On the Compare screenshot after the agent’s last change in "'+metadata.name+'"':metadata.inspect===false?'On the desktop app "'+metadata.name+'"':'In the app preview "'+metadata.name+'"';
374
+ const scrolled=d.target==='frame'&&(d.scroll.x||d.scroll.y)?', scrolled to '+d.scroll.x+','+d.scroll.y:'';
375
+ const context=where+' (page '+st.path+', viewport '+st.viewport.width+'×'+st.viewport.height+scrolled+'), marked:\\n'
376
+ +(els.length?els.map(el=>'- '+String(el.selector).slice(0,300)+(el.text?' ("'+String(el.text).slice(0,120)+'")':'')).join('\\n')+'\\n':'')
377
+ +'Marks ('+(d.target==='compare'?'screenshot':'page')+' px): '+d.strokes.map(bounds).join('; ');
378
+ const mark=Object.assign({path:st.path,viewport:st.viewport,dpr:st.dpr,strokes:d.strokes.map(s=>({tool:s.tool,points:s.points})),signals:st.signals},
379
+ d.target==='frame'?{scroll:d.scroll,theme:st.theme}:{compare:compareAfter});
380
+ // The marks stay on screen, frozen, while you add your words.
381
+ d.done=true;draw=null;
382
+ draft(context,false);
383
+ draw=d;marks=mark;ink.classList.add('frozen');
384
+ // Compare closed for the draft box: its marks go with it (they're in the context).
385
+ if(d.target==='compare')ink.toggleAttribute('hidden',true);$('drawing').hidden=true;$('draw-bar').hidden=true;document.querySelector('nav').hidden=false;
386
+ };
181
387
  // Compare: screenshots around the agent's last change (agent screenshots on).
182
- const compareBtn=$('compare-btn');let shots=[],urls=[];
388
+ const compareBtn=$('compare-btn');let shots=[],urls=[],compareAfter=-1;
183
389
  async function loadCompare(){
184
390
  try{const r=await fetch(metadata.origin+'/__bivy/compare',{credentials:'include',cache:'no-store'});if(!r.ok)return;shots=(await r.json()).shots||[];compareBtn.hidden=shots.length<2;}catch{}
185
391
  }
186
- function panels(open){for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-pressed',String(id===open));}$('draft').hidden=true;}
392
+ function panels(open){if(listening)cancelListening();if(draw&&!(open==='compare'&&draw.target==='compare'))endDraw();for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-pressed',String(id===open));}$('draft').hidden=true;}
187
393
  compareBtn.onclick=async()=>{
188
394
  if(!$('compare').hidden)return panels(null);
189
395
  panels('compare');
190
396
  for(const u of urls)URL.revokeObjectURL(u);
191
397
  const pick=[shots[shots.length-2],shots[shots.length-1]];
398
+ compareAfter=pick[1].index;
192
399
  urls=await Promise.all(pick.map(async s=>URL.createObjectURL(await (await fetch(metadata.origin+'/__bivy/compare/'+s.index,{credentials:'include'})).blob())));
193
400
  const before=$('compare-before'),after=$('compare-after');
194
401
  before.src=urls[0];after.src=urls[1];
402
+ // Drawing on "now" measures the picture, so wait until it's there.
403
+ $('compare-draw').disabled=true;
404
+ void Promise.all([before.decode(),after.decode()]).catch(()=>{}).then(()=>{$('compare-draw').disabled=false;});
195
405
  split();
196
406
  };
197
407
  const split=()=>{$('compare-before').style.clipPath='inset(0 '+(100-Number($('compare-slider').value))+'% 0 0)';};
@@ -208,6 +418,8 @@ for(const b of $('lens').querySelectorAll('button'))b.onclick=()=>{
208
418
  const down=$('down'),downText=$('down-text'),ask=$('ask');
209
419
  let downPort=0;
210
420
  addEventListener('message',e=>{
421
+ // The Bivy client framing this shell (Peek): voice only.
422
+ if(embedded&&metadata?.returnTo&&e.source===parent&&e.origin===new URL(metadata.returnTo).origin){if(e.data?.source==='bivy')fromBivy(e.data);return;}
211
423
  if(!metadata||e.origin!==metadata.origin||e.source!==frame.contentWindow)return;
212
424
  const d=e.data||{};
213
425
  if(d.type==='bivy:access'&&d.state==='blocked'&&embedded&&metadata.returnTo)toBivy({type:'blocked'});
@@ -220,10 +432,14 @@ addEventListener('message',e=>{
220
432
  if(d.type==='route')currentPath=safePath(d.path);
221
433
  else if(d.type==='console'&&(d.level==='error'||d.level==='warn')){entries.push({level:d.level,text:String(d.text).slice(0,500)});if(entries.length>50)entries.shift();renderConsole();}
222
434
  else if(d.type==='picked')picked(d);
435
+ else if(d.type==='release'&&heldAt){heldAt=0;stopListening();}
436
+ else if(d.type==='draw-state'&&draw&&draw.waiting){draw.waiting(d);}
437
+ else if(d.type==='scrolled'&&draw&&draw.target==='frame'){draw.scroll=pos(d.scroll);renderInk();}
438
+ else if(d.type==='marked'&&draw){draw.elements[Number(d.id)]=Array.isArray(d.elements)?d.elements.slice(0,8):[];}
223
439
  }
224
440
  });
225
441
  ask.onclick=()=>toChat('The app preview "'+metadata.name+'" isn’t loading: nothing is answering on port '+downPort+'. Please find out why the server stopped, restart it, and tell me when it’s back.');
226
- addEventListener('keydown',e=>{if(e.key==='Escape'){if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
442
+ addEventListener('keydown',e=>{if(e.key==='Escape'){if(listening)cancelListening();else if(draw&&!draw.done)endDraw();else if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
227
443
  back.onclick=()=>{if(metadata?.returnTo){window.close();setTimeout(()=>location.replace(metadata.returnTo),100);}else{window.close();status.hidden=false;status.textContent='You can close this tab to return to Bivy.';}};
228
444
  reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
229
445
  (async()=>{
@@ -5,20 +5,29 @@ import { createWebSocketStream } from "ws";
5
5
  /** A bounded HTTP-compatible socket over an outbound WebSocket. Each write is
6
6
  * split into small frames; backpressure propagates in both directions. */
7
7
  export class PreviewStream extends Duplex {
8
+ bytes;
8
9
  wire;
9
10
  timeoutMs = 0;
10
11
  timer;
11
- constructor(ws) {
12
+ constructor(ws, bytes) {
12
13
  super({ highWaterMark: 64 * 1024 });
14
+ this.bytes = bytes;
13
15
  this.wire = createWebSocketStream(ws, { highWaterMark: 64 * 1024 });
14
- this.wire.on("data", (chunk) => { this.touch(); if (!this.push(chunk))
15
- this.wire.pause(); });
16
+ this.wire.on("data", (chunk) => {
17
+ this.touch();
18
+ if (this.bytes)
19
+ this.bytes.fromNode += chunk.length;
20
+ if (!this.push(chunk))
21
+ this.wire.pause();
22
+ });
16
23
  this.wire.once("end", () => this.push(null));
17
24
  this.wire.once("error", (error) => this.destroy(error));
18
25
  this.wire.once("close", () => this.destroy());
19
26
  }
20
27
  _read() { this.wire.resume(); }
21
28
  _write(chunk, encoding, callback) {
29
+ if (this.bytes)
30
+ this.bytes.toNode += chunk.length;
22
31
  let offset = 0;
23
32
  const next = (error) => {
24
33
  if (error || offset >= chunk.length) {
@@ -39,6 +48,8 @@ export class PreviewStream extends Duplex {
39
48
  this.wire.destroy();
40
49
  callback(error);
41
50
  }
51
+ /** Read by http.Agent when it parks a kept-alive socket. */
52
+ get timeout() { return this.timeoutMs; }
42
53
  setTimeout(ms, callback) {
43
54
  this.timeoutMs = ms;
44
55
  this.touch();
@@ -48,6 +59,9 @@ export class PreviewStream extends Duplex {
48
59
  }
49
60
  setNoDelay() { return this; }
50
61
  setKeepAlive() { return this; }
62
+ // http.Agent refs a pooled socket while in use; timers here are already unref'd.
63
+ ref() { return this; }
64
+ unref() { return this; }
51
65
  touch() {
52
66
  clearTimeout(this.timer);
53
67
  if (this.timeoutMs) {
@@ -4,9 +4,19 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { EventEmitter } from "node:events";
7
+ import { REVIEW_CARD_MODES } from "./types.js";
7
8
  const MAX_BYTES = 25 * 1024 * 1024;
9
+ /** Reviewer notes kept per view; the oldest drop first. */
10
+ export const MAX_NOTES = 50;
8
11
  const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
9
12
  const MAX_FILES = 2000;
13
+ /** Stored notes are re-read from disk: keep only well-formed ones, bounded as when they arrived. */
14
+ const isNote = (value) => {
15
+ const note = value;
16
+ return !!note && typeof note.id === "string" && typeof note.at === "number" && typeof note.note === "string" && note.note.length <= 1000
17
+ && typeof note.selector === "string" && typeof note.text === "string" && typeof note.path === "string"
18
+ && typeof note.viewport?.width === "number" && typeof note.viewport?.height === "number";
19
+ };
10
20
  const sameFiles = (a, b) => a.size === b.size && [...a].every(([key, data]) => b.get(key)?.equals(data));
11
21
  /** Snapshot, not a live filesystem server: symlinks and hidden files are never published. */
12
22
  function snapshot(workspace, directory) {
@@ -116,7 +126,7 @@ export class AppRegistry extends EventEmitter {
116
126
  continue;
117
127
  // A workspace that moved or a build that vanished drops that app, not the rest.
118
128
  try {
119
- this.publish(record.sessionId, record.workspace, { ...record.manifest, views: keep.map((k) => k.spec) }, { id: record.id, viewIds: keep.map((k) => k.id), createdAt: record.createdAt });
129
+ this.publish(record.sessionId, record.workspace, { ...record.manifest, views: keep.map((k) => k.spec) }, { id: record.id, viewIds: keep.map((k) => k.id), createdAt: record.createdAt, reviewMode: record.reviewMode, agentNotes: record.agentNotes, notes: record.notes });
120
130
  }
121
131
  catch { /* skipped */ }
122
132
  }
@@ -148,7 +158,7 @@ export class AppRegistry extends EventEmitter {
148
158
  if (this.apps.size >= 50)
149
159
  throw new Error("Remove an app before publishing more (limit: 50).");
150
160
  const id = (i) => (i === undefined ? restore?.id : restore?.viewIds[i]) ?? randomBytes(16).toString("hex");
151
- const app = { id: id(), sessionId, name: name(input.name), views: [], createdAt: restore?.createdAt ?? Date.now() };
161
+ const app = { id: id(), sessionId, name: name(input.name), views: [], createdAt: restore?.createdAt ?? Date.now(), ...(restore?.reviewMode && REVIEW_CARD_MODES.includes(restore.reviewMode) ? { reviewMode: restore.reviewMode } : {}), ...(restore?.agentNotes === true ? { agentNotes: true } : {}) };
152
162
  const entries = [];
153
163
  let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
154
164
  for (const [index, spec] of input.views.entries()) {
@@ -186,19 +196,22 @@ export class AppRegistry extends EventEmitter {
186
196
  else
187
197
  throw new Error("Unsupported app view. Supported views: web, terminal and display.");
188
198
  app.views.push(view);
189
- entries.push({ app, view, target, revision: 0, source });
199
+ const stored = restore?.notes?.[view.id];
200
+ const notes = view.kind === "web" && Array.isArray(stored) ? stored.filter(isNote).slice(-MAX_NOTES) : [];
201
+ entries.push({ app, view, target, revision: 0, source, ...(notes.length ? { notes } : {}) });
190
202
  }
191
203
  // Commit all views together: a bad later view cannot leave a partial app.
192
204
  this.apps.set(app.id, app);
193
205
  for (const entry of entries)
194
206
  this.views.set(entry.view.id, entry);
195
- this.persisted.set(app.id, { sessionId, workspace, manifest: structuredClone(input), id: app.id, viewIds: app.views.map((v) => v.id), createdAt: app.createdAt });
207
+ this.persisted.set(app.id, { sessionId, workspace, manifest: structuredClone(input), id: app.id, viewIds: app.views.map((v) => v.id), createdAt: app.createdAt, ...(app.reviewMode ? { reviewMode: app.reviewMode } : {}), ...(app.agentNotes ? { agentNotes: true } : {}) });
208
+ this.persistNotes(app.id);
196
209
  if (!restore)
197
210
  this.save();
198
211
  return structuredClone(app);
199
212
  }
200
213
  list(sessionId) {
201
- return structuredClone([...this.apps.values()].filter((app) => app.sessionId === sessionId));
214
+ return structuredClone([...this.apps.values()].filter((app) => sessionId === undefined || app.sessionId === sessionId));
202
215
  }
203
216
  getView(id) { return this.views.get(id); }
204
217
  /** An agent turn changed files: re-take changed static snapshots and bump
@@ -253,6 +266,64 @@ export class AppRegistry extends EventEmitter {
253
266
  throw new Error("View not found in this app.");
254
267
  return view;
255
268
  }
269
+ /** Review cards for this app: remembered across sessions' restarts. */
270
+ setReviewMode(sessionId, id, mode) {
271
+ if (!REVIEW_CARD_MODES.includes(mode))
272
+ throw new Error("Preview cards are ready, every or off.");
273
+ const app = this.require(sessionId, id);
274
+ app.reviewMode = mode;
275
+ const record = this.persisted.get(id);
276
+ if (record)
277
+ record.reviewMode = mode;
278
+ this.save();
279
+ }
280
+ reviewMode(id) { return this.apps.get(id)?.reviewMode ?? "ready"; }
281
+ /** Whether agents may read this app's reviewer notes. Only the owner's app sets it. */
282
+ setAgentNotes(sessionId, id, enabled) {
283
+ const app = this.require(sessionId, id);
284
+ if (enabled)
285
+ app.agentNotes = true;
286
+ else
287
+ delete app.agentNotes;
288
+ const record = this.persisted.get(id);
289
+ if (record) {
290
+ if (enabled)
291
+ record.agentNotes = true;
292
+ else
293
+ delete record.agentNotes;
294
+ }
295
+ this.save();
296
+ }
297
+ agentNotes(id) { return this.apps.get(id)?.agentNotes === true; }
298
+ /** A reviewer's note: kept bounded, saved with the app, announced (`notes`). */
299
+ addNote(viewId, note) {
300
+ const entry = this.views.get(viewId);
301
+ if (!entry || entry.view.kind !== "web")
302
+ return;
303
+ entry.notes = [...(entry.notes ?? []), note].slice(-MAX_NOTES);
304
+ this.persistNotes(entry.app.id);
305
+ this.save();
306
+ this.emit("notes", viewId);
307
+ }
308
+ clearNotes(viewId) {
309
+ const entry = this.views.get(viewId);
310
+ if (!entry?.notes)
311
+ return;
312
+ delete entry.notes;
313
+ this.persistNotes(entry.app.id);
314
+ this.save();
315
+ }
316
+ persistNotes(appId) {
317
+ const record = this.persisted.get(appId);
318
+ const app = this.apps.get(appId);
319
+ if (!record || !app)
320
+ return;
321
+ const notes = Object.fromEntries(app.views.map((view) => [view.id, this.views.get(view.id)?.notes]).filter(([, list]) => Array.isArray(list) && list.length));
322
+ if (Object.keys(notes).length)
323
+ record.notes = notes;
324
+ else
325
+ delete record.notes;
326
+ }
256
327
  remove(sessionId, id) {
257
328
  const app = this.require(sessionId, id);
258
329
  for (const view of app.views)