@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.
- package/bin/bivy.mjs +157 -12
- package/dist/apps/annotate.js +94 -0
- package/dist/apps/display-viewer.js +80 -7
- package/dist/apps/display.js +11 -1
- package/dist/apps/gateway.js +106 -14
- package/dist/apps/input.js +96 -0
- package/dist/apps/inspector.js +84 -29
- package/dist/apps/macos-display.js +170 -0
- package/dist/apps/macos-display.swift +873 -0
- package/dist/apps/menu.js +76 -0
- package/dist/apps/preview-shell.js +232 -16
- package/dist/apps/preview-stream.js +17 -3
- package/dist/apps/registry.js +76 -5
- package/dist/apps/review.js +111 -0
- package/dist/apps/rfb.js +69 -20
- package/dist/apps/screenshot.js +8 -2
- package/dist/apps/service.js +343 -13
- package/dist/apps/styles.css +162 -10
- package/dist/apps/tokens.css +6 -0
- package/dist/apps/types.js +17 -0
- package/dist/attach.js +37 -4
- package/dist/controllers/app-commands.js +16 -4
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +19 -3
- package/dist/server.js +200 -21
- package/dist/session/attachment-store.js +15 -0
- package/dist/session/event-log.js +25 -3
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +113 -69
- package/dist/session/session-new-dedupe.js +1 -1
- package/dist/terminal.js +23 -0
- package/dist/worktree.js +40 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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='';
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|
|
15
|
-
this.
|
|
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) {
|
package/dist/apps/registry.js
CHANGED
|
@@ -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
|
-
|
|
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)
|