@oharness/server 0.7.0 → 0.8.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/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/i18n.js +144 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +400 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +904 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1694 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +46 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +910 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +690 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1275 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +699 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +2793 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +70 -13583
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +511 -25
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser pane: what the agent's browser is looking at, live, and a way to
|
|
3
|
+
* take the mouse off it.
|
|
4
|
+
*
|
|
5
|
+
* A column beside the transcript, not a panel over it. Watching what the model
|
|
6
|
+
* does is only useful next to what it is saying, and the layout already had the
|
|
7
|
+
* shape for that — the diff pane is a sibling of the main column and slides in
|
|
8
|
+
* the same way, so this is built the same way rather than inventing a second
|
|
9
|
+
* kind of side surface.
|
|
10
|
+
*
|
|
11
|
+
* The picture is an img pointed at an MJPEG stream, so the browser decodes the
|
|
12
|
+
* frames and this file never touches one. The element is created once and kept:
|
|
13
|
+
* the pane closes by collapsing to zero width, which leaves the stream running
|
|
14
|
+
* rather than tearing it down and reconnecting on the next toggle.
|
|
15
|
+
*
|
|
16
|
+
* Taking over sends the same input events the tools send, through the same
|
|
17
|
+
* call, so a person and the model reach the page identically.
|
|
18
|
+
*
|
|
19
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
20
|
+
*/
|
|
21
|
+
export const BROWSER = String.raw `
|
|
22
|
+
const browserPane = document.getElementById("browser-pane");
|
|
23
|
+
const browserBody = document.getElementById("browser-body");
|
|
24
|
+
const browserWhere = document.getElementById("browser-where");
|
|
25
|
+
const browserStatus = document.getElementById("browser-status");
|
|
26
|
+
const browserHint = document.getElementById("browser-hint");
|
|
27
|
+
const browserTabs = document.getElementById("browser-tabs");
|
|
28
|
+
const browserResizer = document.getElementById("browser-resizer");
|
|
29
|
+
const browserReloadBtn = document.getElementById("browser-reload");
|
|
30
|
+
const browserStopBtn = document.getElementById("browser-stop");
|
|
31
|
+
const browserTakeoverBtn = document.getElementById("browser-takeover");
|
|
32
|
+
const browserTakeoverLabel = browserTakeoverBtn.querySelector("span");
|
|
33
|
+
const browserImportBtn = document.getElementById("browser-import");
|
|
34
|
+
const browserTitle = document.getElementById("browser-title");
|
|
35
|
+
const browserExt = document.getElementById("browser-ext");
|
|
36
|
+
const browserExtGet = document.getElementById("browser-ext-get");
|
|
37
|
+
/**
|
|
38
|
+
* Whether the pane, once open, is the whole window rather than a column beside
|
|
39
|
+
* the conversation — the stylesheet's breakpoint. There it only ever opens
|
|
40
|
+
* because someone asked: opening itself would take the conversation away.
|
|
41
|
+
*/
|
|
42
|
+
const BROWSER_NARROW = window.matchMedia("(max-width:900px)");
|
|
43
|
+
/** Which browsers the active conversation may use, from its last state. */
|
|
44
|
+
let browserPolicy = null;
|
|
45
|
+
let browserExtConnected = true;
|
|
46
|
+
let browserExtPoll = null;
|
|
47
|
+
const BROWSER_EXT_HIDDEN = "oharness.browserExtHidden";
|
|
48
|
+
const browserSuggest = document.getElementById("browser-suggest");
|
|
49
|
+
const browserSuggestText = document.getElementById("browser-suggest-text");
|
|
50
|
+
let browserSuggestion = null;
|
|
51
|
+
let browserFrame = null;
|
|
52
|
+
let browserStatePoll = null;
|
|
53
|
+
let browserTakeover = false;
|
|
54
|
+
let browserViewport = null;
|
|
55
|
+
let browserCurrentUrl = "";
|
|
56
|
+
let browserActiveTabId = "";
|
|
57
|
+
let browserStreamRetry = null;
|
|
58
|
+
const BROWSER_STREAM_QUALITY = 85;
|
|
59
|
+
const browserNativeBridge = window.oharness && window.oharness.browser;
|
|
60
|
+
let browserNativeSurface = null;
|
|
61
|
+
const BROWSER_WIDTH_KEY = "oharness.browserPaneWidth";
|
|
62
|
+
const BROWSER_WIDTH_MIN = 320;
|
|
63
|
+
const BROWSER_MAIN_MIN = 360;
|
|
64
|
+
let browserPaneWidth = null;
|
|
65
|
+
let browserResizing = false;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const saved = Number(localStorage.getItem(BROWSER_WIDTH_KEY));
|
|
69
|
+
if (Number.isFinite(saved) && saved >= BROWSER_WIDTH_MIN) browserPaneWidth = saved;
|
|
70
|
+
} catch { /* the width can still work for this page without persistence */ }
|
|
71
|
+
|
|
72
|
+
function browserWidthLimits() {
|
|
73
|
+
const split = document.getElementById("split").getBoundingClientRect().width;
|
|
74
|
+
const review = document.getElementById("review").getBoundingClientRect().width;
|
|
75
|
+
const max = Math.max(BROWSER_WIDTH_MIN, split - review - BROWSER_MAIN_MIN);
|
|
76
|
+
return { min: Math.min(BROWSER_WIDTH_MIN, max), max };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function browserSetPaneWidth(px, persist) {
|
|
80
|
+
const limits = browserWidthLimits();
|
|
81
|
+
browserPaneWidth = Math.round(Math.min(Math.max(px, limits.min), limits.max));
|
|
82
|
+
browserPane.style.setProperty("--browser-pane-width", browserPaneWidth + "px");
|
|
83
|
+
if (!persist) return;
|
|
84
|
+
try { localStorage.setItem(BROWSER_WIDTH_KEY, String(browserPaneWidth)); } catch { /* keep the live width */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function browserRestorePaneWidth() {
|
|
88
|
+
if (browserPaneWidth !== null && window.innerWidth > 900) browserSetPaneWidth(browserPaneWidth, false);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
browserRestorePaneWidth();
|
|
92
|
+
browserResizer.addEventListener("pointerdown", (event) => {
|
|
93
|
+
if (event.button !== 0 || window.innerWidth <= 900) return;
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
const startX = event.clientX;
|
|
96
|
+
const startWidth = browserPane.getBoundingClientRect().width;
|
|
97
|
+
try { browserResizer.setPointerCapture(event.pointerId); } catch { /* window listeners keep the drag alive */ }
|
|
98
|
+
document.body.classList.add("browser-resizing");
|
|
99
|
+
// A native page takes the mouse the moment the pointer crosses it, pointer
|
|
100
|
+
// capture or not, and the drag would stop there. Put it away until release.
|
|
101
|
+
browserResizing = true;
|
|
102
|
+
browserPresentNative(null, null);
|
|
103
|
+
|
|
104
|
+
const move = (next) => browserSetPaneWidth(startWidth + startX - next.clientX, false);
|
|
105
|
+
const done = () => {
|
|
106
|
+
window.removeEventListener("pointermove", move);
|
|
107
|
+
window.removeEventListener("pointerup", done);
|
|
108
|
+
window.removeEventListener("pointercancel", done);
|
|
109
|
+
document.body.classList.remove("browser-resizing");
|
|
110
|
+
if (browserPaneWidth !== null) browserSetPaneWidth(browserPaneWidth, true);
|
|
111
|
+
browserResizing = false;
|
|
112
|
+
if (browserNativeSurface) browserPresentNative(activeId, browserNativeSurface);
|
|
113
|
+
};
|
|
114
|
+
window.addEventListener("pointermove", move);
|
|
115
|
+
window.addEventListener("pointerup", done);
|
|
116
|
+
window.addEventListener("pointercancel", done);
|
|
117
|
+
});
|
|
118
|
+
browserResizer.addEventListener("dblclick", () => {
|
|
119
|
+
browserPaneWidth = null;
|
|
120
|
+
browserPane.style.removeProperty("--browser-pane-width");
|
|
121
|
+
try { localStorage.removeItem(BROWSER_WIDTH_KEY); } catch { /* default still applies */ }
|
|
122
|
+
});
|
|
123
|
+
browserResizer.addEventListener("keydown", (event) => {
|
|
124
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
const current = browserPane.getBoundingClientRect().width;
|
|
127
|
+
browserSetPaneWidth(current + (event.key === "ArrowLeft" ? 16 : -16), true);
|
|
128
|
+
});
|
|
129
|
+
window.addEventListener("resize", browserRestorePaneWidth);
|
|
130
|
+
|
|
131
|
+
function browserPresentNative(session, surface) {
|
|
132
|
+
if (!browserNativeBridge) return;
|
|
133
|
+
// A WebContentsView is a native sibling above the renderer, so no CSS
|
|
134
|
+
// z-index can put a DOM modal over it. Detach for every modal and restore
|
|
135
|
+
// after its open attribute goes away.
|
|
136
|
+
if (!session || !surface || browserResizing || !browserPane.classList.contains("open") || document.querySelector("dialog[open]")) {
|
|
137
|
+
browserNativeBridge.present(null, null);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const rect = surface.getBoundingClientRect();
|
|
141
|
+
// The resize handle is a DOM strip on the pane's left edge; the native view
|
|
142
|
+
// sits above all DOM, so it has to stop short of the handle to leave it
|
|
143
|
+
// grabbable.
|
|
144
|
+
const inset = Math.max(0, browserResizer.getBoundingClientRect().right - rect.left);
|
|
145
|
+
browserNativeBridge.present(session, {
|
|
146
|
+
x: rect.left + inset, y: rect.top, width: Math.max(1, rect.width - inset), height: rect.height,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const browserNativeObserver = browserNativeBridge ? new ResizeObserver(() => {
|
|
151
|
+
if (browserNativeSurface) browserPresentNative(activeId, browserNativeSurface);
|
|
152
|
+
}) : null;
|
|
153
|
+
const browserDialogObserver = browserNativeBridge ? new MutationObserver(() => {
|
|
154
|
+
if (browserNativeSurface) browserPresentNative(activeId, browserNativeSurface);
|
|
155
|
+
}) : null;
|
|
156
|
+
if (browserDialogObserver) {
|
|
157
|
+
for (const dialog of document.querySelectorAll("dialog")) {
|
|
158
|
+
browserDialogObserver.observe(dialog, { attributes: true, attributeFilter: ["open"] });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function browserStreamUrl(id) {
|
|
163
|
+
return q("/browser/stream?session=" + encodeURIComponent(id || "") + "&quality=" + BROWSER_STREAM_QUALITY);
|
|
164
|
+
}
|
|
165
|
+
const browserViews = new Map();
|
|
166
|
+
|
|
167
|
+
function browserView(id) {
|
|
168
|
+
let view = browserViews.get(id);
|
|
169
|
+
if (!view) {
|
|
170
|
+
view = { open: false, running: false };
|
|
171
|
+
browserViews.set(id, view);
|
|
172
|
+
}
|
|
173
|
+
return view;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The session-owned restore button drawn in each conversation tab. */
|
|
177
|
+
function browserTabButton(id) {
|
|
178
|
+
const button = el("button", "tab-browser");
|
|
179
|
+
button.type = "button";
|
|
180
|
+
button.title = t("browser.open");
|
|
181
|
+
button.setAttribute("aria-label", t("browser.open"));
|
|
182
|
+
button.classList.toggle("on", id === activeId && browserPane.classList.contains("open"));
|
|
183
|
+
button.classList.toggle("running", Boolean(browserViews.get(id)?.running));
|
|
184
|
+
button.innerHTML = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.5" width="12.5" height="11" rx="2"/><path d="M1.75 6h12.5"/><circle cx="4.25" cy="4.25" r=".55" fill="currentColor" stroke="none"/></svg><i></i>';
|
|
185
|
+
button.addEventListener("click", async (event) => {
|
|
186
|
+
event.stopPropagation();
|
|
187
|
+
if (id !== activeId) await focusTab(id);
|
|
188
|
+
const open = !browserPane.classList.contains("open");
|
|
189
|
+
track("panel_opened", { panel: "session-browser" });
|
|
190
|
+
browserOpen(open);
|
|
191
|
+
});
|
|
192
|
+
return button;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Where a point in the drawn image lands in the page's own coordinates.
|
|
197
|
+
*
|
|
198
|
+
* Two conversions, not one. The first is the obvious one, from however large
|
|
199
|
+
* the pane happens to draw the picture to the picture's own pixels. The second
|
|
200
|
+
* is the one that is easy to miss: Chrome scales a screencast frame down, so
|
|
201
|
+
* those pixels are not the page's either. Skipping it puts every click a
|
|
202
|
+
* consistent fraction short of where it was aimed, which looks like a flaky
|
|
203
|
+
* browser rather than a unit error.
|
|
204
|
+
*/
|
|
205
|
+
function browserPoint(event) {
|
|
206
|
+
const rect = browserFrame.getBoundingClientRect();
|
|
207
|
+
if (!rect.width || !rect.height) return { x: 0, y: 0 };
|
|
208
|
+
// Straight from where it was clicked in the drawn rectangle to the same
|
|
209
|
+
// fraction of the page. The frame's own pixel size is deliberately not in
|
|
210
|
+
// this: it is a scaled copy of the page rather than the page, and it reads
|
|
211
|
+
// as zero for an instant while a new part of the stream arrives — which
|
|
212
|
+
// would put a click at the origin, or nowhere at all.
|
|
213
|
+
const width = (browserViewport && browserViewport.width) || browserFrame.naturalWidth || rect.width;
|
|
214
|
+
const height = (browserViewport && browserViewport.height) || browserFrame.naturalHeight || rect.height;
|
|
215
|
+
return {
|
|
216
|
+
x: Math.round(((event.clientX - rect.left) / rect.width) * width),
|
|
217
|
+
y: Math.round(((event.clientY - rect.top) / rect.height) * height),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function browserModifiers(event) {
|
|
222
|
+
return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function browserSend(event) {
|
|
226
|
+
if (!activeId) return;
|
|
227
|
+
await fetch(q("/browser/input"), {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "content-type": "application/json" },
|
|
230
|
+
body: JSON.stringify({ session: activeId, event }),
|
|
231
|
+
}).catch(() => {});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Hand the pane the keyboard as well as the mouse.
|
|
236
|
+
*
|
|
237
|
+
* On the document rather than the image, because an image cannot hold focus,
|
|
238
|
+
* and removed the moment takeover ends: a listener left behind would quietly
|
|
239
|
+
* steal typing from the composer.
|
|
240
|
+
*/
|
|
241
|
+
function browserKeyHandler(event) {
|
|
242
|
+
if (!browserTakeover) return;
|
|
243
|
+
if (event.key === "Escape") return void browserSetTakeover(false);
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
const modifiers = browserModifiers(event);
|
|
246
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
|
|
247
|
+
void browserSend({ type: "text", text: event.key });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
void browserSend({ type: "key", action: "down", key: event.key, code: event.code, modifiers });
|
|
251
|
+
void browserSend({ type: "key", action: "up", key: event.key, code: event.code, modifiers });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function browserSetTakeover(on) {
|
|
255
|
+
browserTakeover = on && Boolean(browserFrame);
|
|
256
|
+
browserTakeoverBtn.classList.toggle("on", browserTakeover);
|
|
257
|
+
browserTakeoverBtn.setAttribute("aria-pressed", String(browserTakeover));
|
|
258
|
+
browserTakeoverLabel.textContent = t(browserTakeover ? "browser.release" : "browser.takeover");
|
|
259
|
+
browserHint.hidden = !browserTakeover;
|
|
260
|
+
browserPane.classList.toggle("live", browserTakeover);
|
|
261
|
+
if (browserTakeover) document.addEventListener("keydown", browserKeyHandler);
|
|
262
|
+
else document.removeEventListener("keydown", browserKeyHandler);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Build the image once; later state changes only swap what the body shows. */
|
|
266
|
+
function browserEnsureFrame() {
|
|
267
|
+
if (browserFrame) return browserFrame;
|
|
268
|
+
const img = el("img");
|
|
269
|
+
img.id = "browser-frame";
|
|
270
|
+
img.alt = t("nav.browser");
|
|
271
|
+
img.draggable = false;
|
|
272
|
+
img.addEventListener("error", () => {
|
|
273
|
+
if (browserStreamRetry || !activeId || !browserPane.classList.contains("open")) return;
|
|
274
|
+
browserStreamRetry = setTimeout(() => {
|
|
275
|
+
browserStreamRetry = null;
|
|
276
|
+
if (!browserFrame || !browserView(activeId).running) return;
|
|
277
|
+
browserFrame.src = browserStreamUrl(activeId) + "&retry=" + Date.now();
|
|
278
|
+
}, 500);
|
|
279
|
+
});
|
|
280
|
+
img.addEventListener("mousedown", (event) => {
|
|
281
|
+
if (!browserTakeover) return;
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
const point = browserPoint(event);
|
|
284
|
+
const button = event.button === 2 ? "right" : event.button === 1 ? "middle" : "left";
|
|
285
|
+
const modifiers = browserModifiers(event);
|
|
286
|
+
void browserSend({ type: "mouse", action: "move", x: point.x, y: point.y, modifiers });
|
|
287
|
+
void browserSend({ type: "mouse", action: "down", x: point.x, y: point.y, button, clickCount: event.detail || 1, modifiers });
|
|
288
|
+
});
|
|
289
|
+
img.addEventListener("mouseup", (event) => {
|
|
290
|
+
if (!browserTakeover) return;
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
const point = browserPoint(event);
|
|
293
|
+
const button = event.button === 2 ? "right" : event.button === 1 ? "middle" : "left";
|
|
294
|
+
void browserSend({ type: "mouse", action: "up", x: point.x, y: point.y, button, clickCount: event.detail || 1, modifiers: browserModifiers(event) });
|
|
295
|
+
});
|
|
296
|
+
img.addEventListener("contextmenu", (event) => { if (browserTakeover) event.preventDefault(); });
|
|
297
|
+
img.addEventListener("wheel", (event) => {
|
|
298
|
+
if (!browserTakeover) return;
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
const point = browserPoint(event);
|
|
301
|
+
void browserSend({ type: "mouse", action: "wheel", x: point.x, y: point.y, deltaX: -event.deltaX, deltaY: -event.deltaY, modifiers: browserModifiers(event) });
|
|
302
|
+
}, { passive: false });
|
|
303
|
+
browserFrame = img;
|
|
304
|
+
return img;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function browserShowIdle() {
|
|
308
|
+
browserPresentNative(null, null);
|
|
309
|
+
browserNativeObserver?.disconnect();
|
|
310
|
+
browserNativeSurface = null;
|
|
311
|
+
browserSetTakeover(false);
|
|
312
|
+
if (browserFrame) { browserFrame.src = ""; browserFrame = null; }
|
|
313
|
+
browserViewport = null;
|
|
314
|
+
browserTakeoverBtn.hidden = true;
|
|
315
|
+
browserReloadBtn.disabled = true;
|
|
316
|
+
browserStopBtn.disabled = true;
|
|
317
|
+
browserStatus.hidden = true;
|
|
318
|
+
browserHint.hidden = true;
|
|
319
|
+
browserCurrentUrl = "";
|
|
320
|
+
browserActiveTabId = "";
|
|
321
|
+
browserTabs.hidden = true;
|
|
322
|
+
browserTabs.replaceChildren();
|
|
323
|
+
if (document.activeElement !== browserWhere) browserWhere.value = "";
|
|
324
|
+
const idle = el("div", "browser-idle");
|
|
325
|
+
idle.appendChild(el("p", "muted", t("browser.idle")));
|
|
326
|
+
idle.appendChild(el("p", "muted small", t("browser.idleHow")));
|
|
327
|
+
browserBody.replaceChildren(idle);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function browserTabLabel(tab, index) {
|
|
331
|
+
if (tab.title) return tab.title;
|
|
332
|
+
try {
|
|
333
|
+
const parsed = new URL(tab.url);
|
|
334
|
+
return parsed.hostname || parsed.href;
|
|
335
|
+
} catch {
|
|
336
|
+
return tab.url || "Tab " + (index + 1);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function browserPaintPageTabs(tabs) {
|
|
341
|
+
const list = Array.isArray(tabs) ? tabs : [];
|
|
342
|
+
browserTabs.hidden = list.length === 0;
|
|
343
|
+
browserTabs.replaceChildren(...list.map((tab, index) => {
|
|
344
|
+
const item = el("div", "browser-page-tab" + (tab.active ? " active" : ""));
|
|
345
|
+
item.dataset.id = tab.id;
|
|
346
|
+
item.setAttribute("role", "tab");
|
|
347
|
+
item.setAttribute("aria-selected", String(Boolean(tab.active)));
|
|
348
|
+
|
|
349
|
+
const select = el("button", "browser-page-tab-select");
|
|
350
|
+
select.type = "button";
|
|
351
|
+
select.title = tab.title ? tab.title + "\n" + tab.url : tab.url;
|
|
352
|
+
select.appendChild(el("i"));
|
|
353
|
+
select.appendChild(el("span", "", browserTabLabel(tab, index)));
|
|
354
|
+
select.addEventListener("click", () => void browserSelectTab(tab.id));
|
|
355
|
+
|
|
356
|
+
const close = el("button", "browser-page-tab-close");
|
|
357
|
+
close.type = "button";
|
|
358
|
+
// A tab the person had open before the agent was pointed at it is only let
|
|
359
|
+
// go of, never closed, so its button says that instead.
|
|
360
|
+
const closeLabel = t(tab.mine === false ? "browser.releaseTab" : "browser.closeTab");
|
|
361
|
+
close.title = closeLabel;
|
|
362
|
+
close.setAttribute("aria-label", closeLabel);
|
|
363
|
+
close.textContent = "×";
|
|
364
|
+
close.addEventListener("click", () => void browserCloseTab(tab.id));
|
|
365
|
+
item.append(select, close);
|
|
366
|
+
return item;
|
|
367
|
+
}));
|
|
368
|
+
const active = browserTabs.querySelector(".browser-page-tab.active");
|
|
369
|
+
if (active && active.dataset.id !== browserTabs.dataset.shown) {
|
|
370
|
+
browserTabs.dataset.shown = active.dataset.id;
|
|
371
|
+
browserTabsReveal();
|
|
372
|
+
}
|
|
373
|
+
browserTabsEdges();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Scroll the active tab clear of the edge fades, touching nothing if it already is. */
|
|
377
|
+
function browserTabsReveal() {
|
|
378
|
+
const active = browserTabs.querySelector(".browser-page-tab.active");
|
|
379
|
+
if (!active) return;
|
|
380
|
+
const fade = 28;
|
|
381
|
+
const max = browserTabs.scrollWidth - browserTabs.clientWidth;
|
|
382
|
+
const left = active.offsetLeft - fade;
|
|
383
|
+
const right = active.offsetLeft + active.offsetWidth + fade;
|
|
384
|
+
let next = browserTabs.scrollLeft;
|
|
385
|
+
if (left < next) next = left;
|
|
386
|
+
else if (right > next + browserTabs.clientWidth) next = right - browserTabs.clientWidth;
|
|
387
|
+
browserTabs.scrollLeft = Math.max(0, Math.min(max, next));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Mark which sides have tabs scrolled out of sight, for the edge fades. */
|
|
391
|
+
function browserTabsEdges() {
|
|
392
|
+
const max = browserTabs.scrollWidth - browserTabs.clientWidth;
|
|
393
|
+
browserTabs.classList.toggle("more-left", browserTabs.scrollLeft > 1);
|
|
394
|
+
browserTabs.classList.toggle("more-right", max - browserTabs.scrollLeft > 1);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
browserTabs.addEventListener("scroll", browserTabsEdges, { passive: true });
|
|
398
|
+
// A mouse wheel only scrolls vertically, and this strip only scrolls sideways.
|
|
399
|
+
browserTabs.addEventListener("wheel", (event) => {
|
|
400
|
+
if (browserTabs.scrollWidth <= browserTabs.clientWidth) return;
|
|
401
|
+
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
browserTabs.scrollLeft += event.deltaY;
|
|
404
|
+
}, { passive: false });
|
|
405
|
+
// Resizes include the pane's opening animation, when the strip is briefly a
|
|
406
|
+
// few pixels wide: reveal against the settled width, not that one.
|
|
407
|
+
new ResizeObserver(() => { browserTabsReveal(); browserTabsEdges(); }).observe(browserTabs);
|
|
408
|
+
|
|
409
|
+
async function browserSelectTab(id) {
|
|
410
|
+
if (!activeId || !id || id === browserActiveTabId) return;
|
|
411
|
+
const result = await api("/browser/tab", { session: activeId, id });
|
|
412
|
+
if (result.error) return void addNotice(result.error, "err");
|
|
413
|
+
// An MJPEG response is bound to the page that was active when it started.
|
|
414
|
+
// Reconnect it after a switch; native presentation is remounted by its host.
|
|
415
|
+
if (browserFrame) { browserFrame.src = ""; browserFrame = null; }
|
|
416
|
+
browserActiveTabId = result.id || id;
|
|
417
|
+
browserCurrentUrl = result.url || "";
|
|
418
|
+
void browserRefresh(activeId);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function browserCloseTab(id) {
|
|
422
|
+
if (!activeId || !id) return;
|
|
423
|
+
const result = await api("/browser/tab", { session: activeId, id, action: "close" });
|
|
424
|
+
if (result.error) return void addNotice(result.error, "err");
|
|
425
|
+
if (!result.running) {
|
|
426
|
+
browserView(activeId).running = false;
|
|
427
|
+
paintTabs();
|
|
428
|
+
browserShowIdle();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (browserFrame) { browserFrame.src = ""; browserFrame = null; }
|
|
432
|
+
browserActiveTabId = result.active || "";
|
|
433
|
+
void browserRefresh(activeId);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function browserShowLive(origin, viewport, presentation) {
|
|
437
|
+
browserViewport = viewport || null;
|
|
438
|
+
browserCurrentUrl = origin || "";
|
|
439
|
+
if (document.activeElement !== browserWhere) browserWhere.value = browserCurrentUrl;
|
|
440
|
+
browserWhere.title = origin || "";
|
|
441
|
+
browserStatus.hidden = false;
|
|
442
|
+
const native = presentation === "native" && Boolean(browserNativeBridge);
|
|
443
|
+
browserTakeoverBtn.hidden = native;
|
|
444
|
+
browserReloadBtn.disabled = false;
|
|
445
|
+
browserStopBtn.disabled = false;
|
|
446
|
+
if (native) {
|
|
447
|
+
browserSetTakeover(false);
|
|
448
|
+
if (browserFrame) { browserFrame.src = ""; browserFrame = null; }
|
|
449
|
+
if (!browserNativeSurface) {
|
|
450
|
+
browserNativeSurface = el("div", "browser-native-surface");
|
|
451
|
+
browserNativeObserver?.observe(browserNativeSurface);
|
|
452
|
+
}
|
|
453
|
+
if (browserNativeSurface.parentElement !== browserBody) browserBody.replaceChildren(browserNativeSurface);
|
|
454
|
+
requestAnimationFrame(() => browserPresentNative(activeId, browserNativeSurface));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
browserPresentNative(null, null);
|
|
458
|
+
browserNativeObserver?.disconnect();
|
|
459
|
+
browserNativeSurface = null;
|
|
460
|
+
const img = browserEnsureFrame();
|
|
461
|
+
if (!img.src) img.src = browserStreamUrl(activeId);
|
|
462
|
+
if (img.parentElement !== browserBody) {
|
|
463
|
+
browserBody.replaceChildren(img);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function browserRefresh(id = activeId) {
|
|
468
|
+
if (!id) return;
|
|
469
|
+
const state = await getJson("/browser/state?session=" + encodeURIComponent(id));
|
|
470
|
+
if (state.error) return;
|
|
471
|
+
const view = browserView(id);
|
|
472
|
+
const changed = view.running !== Boolean(state.running);
|
|
473
|
+
view.running = Boolean(state.running);
|
|
474
|
+
if (changed) paintTabs();
|
|
475
|
+
if (id !== activeId || !browserPane.classList.contains("open")) return;
|
|
476
|
+
const nextActiveTabId = Array.isArray(state.tabs) ? (state.tabs.find((tab) => tab.active)?.id || "") : "";
|
|
477
|
+
const tabChanged = Boolean(browserActiveTabId && nextActiveTabId && browserActiveTabId !== nextActiveTabId);
|
|
478
|
+
if (tabChanged && browserFrame) { browserFrame.src = ""; browserFrame = null; }
|
|
479
|
+
browserActiveTabId = nextActiveTabId;
|
|
480
|
+
browserImportBtn.hidden = !state.mode;
|
|
481
|
+
// With two browsers to work in, the pane says which one this is.
|
|
482
|
+
browserTitle.textContent = t(
|
|
483
|
+
state.policy !== "auto" ? "nav.browser" : state.mode === "extension" ? "browser.titleYours" : "browser.titleBuiltin",
|
|
484
|
+
);
|
|
485
|
+
browserPolicy = state.policy || null;
|
|
486
|
+
browserPaintExt();
|
|
487
|
+
browserPaintSuggestion(state.importSuggestion);
|
|
488
|
+
browserPaintPageTabs(state.tabs);
|
|
489
|
+
if (state.running) browserShowLive(state.url || state.origin, state.viewport, state.presentation);
|
|
490
|
+
else browserShowIdle();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function browserOpen(on) {
|
|
494
|
+
// In the extension's side panel the person's own browser is right there.
|
|
495
|
+
if (!activeId || IS_EMBED) on = false;
|
|
496
|
+
if (activeId) browserView(activeId).open = on;
|
|
497
|
+
browserPane.classList.toggle("open", on);
|
|
498
|
+
browserScrim.hidden = !(on && BROWSER_NARROW.matches);
|
|
499
|
+
if (!on) browserPresentNative(null, null);
|
|
500
|
+
paintTabs();
|
|
501
|
+
if (browserStatePoll) { clearInterval(browserStatePoll); browserStatePoll = null; }
|
|
502
|
+
if (!on) {
|
|
503
|
+
if (browserExtPoll) { clearInterval(browserExtPoll); browserExtPoll = null; }
|
|
504
|
+
return void browserSetTakeover(false);
|
|
505
|
+
}
|
|
506
|
+
void browserRefresh(activeId);
|
|
507
|
+
// Polled rather than pushed: the answer is session state the server already
|
|
508
|
+
// holds, and asking must never be what starts a browser.
|
|
509
|
+
browserStatePoll = setInterval(() => void browserRefresh(activeId), 2000);
|
|
510
|
+
// Whether the extension is there changes rarely, and asking opens a socket,
|
|
511
|
+
// so it is checked when the pane opens and then now and again.
|
|
512
|
+
void browserCheckExt();
|
|
513
|
+
if (!browserExtPoll) browserExtPoll = setInterval(() => void browserCheckExt(), 30000);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Follow the active tab without carrying another session's stream with it. */
|
|
517
|
+
function browserActivateSession() {
|
|
518
|
+
browserPresentNative(null, null);
|
|
519
|
+
browserSetTakeover(false);
|
|
520
|
+
if (browserStreamRetry) { clearTimeout(browserStreamRetry); browserStreamRetry = null; }
|
|
521
|
+
if (browserFrame) {
|
|
522
|
+
browserFrame.src = "";
|
|
523
|
+
browserFrame = null;
|
|
524
|
+
}
|
|
525
|
+
browserViewport = null;
|
|
526
|
+
browserNativeObserver?.disconnect();
|
|
527
|
+
browserNativeSurface = null;
|
|
528
|
+
browserPaintSuggestion(null);
|
|
529
|
+
browserOpen(Boolean(activeId && browserView(activeId).open && !BROWSER_NARROW.matches));
|
|
530
|
+
if (activeId) void browserRefresh(activeId);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** A browser tool is the primary entry: reveal its session automatically. */
|
|
534
|
+
function browserToolStarted(session, toolName) {
|
|
535
|
+
if (!session || typeof toolName !== "string" || !toolName.startsWith("browser_")) return;
|
|
536
|
+
const view = browserView(session);
|
|
537
|
+
view.running = true;
|
|
538
|
+
// On a narrow screen the tab's browser button lights up instead; the
|
|
539
|
+
// conversation stays in view and the page is one tap away.
|
|
540
|
+
if (BROWSER_NARROW.matches) return void paintTabs();
|
|
541
|
+
view.open = true;
|
|
542
|
+
if (session === activeId) browserOpen(true);
|
|
543
|
+
else paintTabs();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function browserForgetSession(id) {
|
|
547
|
+
browserViews.delete(id);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function browserNavigateFromAddress() {
|
|
551
|
+
const url = browserWhere.value.trim();
|
|
552
|
+
if (!activeId || !url) return;
|
|
553
|
+
const result = await api("/browser/navigate", { session: activeId, url });
|
|
554
|
+
if (result.error) {
|
|
555
|
+
addNotice(result.error, "err");
|
|
556
|
+
browserWhere.value = browserCurrentUrl;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
browserCurrentUrl = result.url || url;
|
|
560
|
+
browserWhere.value = browserCurrentUrl;
|
|
561
|
+
browserView(activeId).running = true;
|
|
562
|
+
paintTabs();
|
|
563
|
+
void browserRefresh(activeId);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function browserReload() {
|
|
567
|
+
if (!activeId || !browserView(activeId).running) return;
|
|
568
|
+
browserReloadBtn.disabled = true;
|
|
569
|
+
const result = await api("/browser/navigate", { session: activeId, action: "reload" });
|
|
570
|
+
browserReloadBtn.disabled = false;
|
|
571
|
+
if (result.error) return void addNotice(result.error, "err");
|
|
572
|
+
browserCurrentUrl = result.url || browserCurrentUrl;
|
|
573
|
+
if (document.activeElement !== browserWhere) browserWhere.value = browserCurrentUrl;
|
|
574
|
+
void browserRefresh(activeId);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function browserStop() {
|
|
578
|
+
if (!activeId || !browserView(activeId).running) return;
|
|
579
|
+
if (!confirm(t("browser.stopConfirm"))) return;
|
|
580
|
+
browserStopBtn.disabled = true;
|
|
581
|
+
const result = await api("/browser/close", { session: activeId });
|
|
582
|
+
if (result.error) {
|
|
583
|
+
browserStopBtn.disabled = false;
|
|
584
|
+
return void addNotice(result.error, "err");
|
|
585
|
+
}
|
|
586
|
+
browserView(activeId).running = false;
|
|
587
|
+
paintTabs();
|
|
588
|
+
browserShowIdle();
|
|
589
|
+
addNotice(t("browser.stopped"));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Importing a sign-in from the person's own browser.
|
|
594
|
+
*
|
|
595
|
+
* Two ways in, one way through. The header button is always there; the bar
|
|
596
|
+
* under the tabs appears when the built-in browser reaches a site the person
|
|
597
|
+
* has open in their own browser. Both only open the dialog — the dialog's
|
|
598
|
+
* Import button is the one thing that reads anything out of that browser.
|
|
599
|
+
*/
|
|
600
|
+
/**
|
|
601
|
+
* What a detected site does besides showing the bar, chosen in the dialog:
|
|
602
|
+
* "bar" waits to be clicked, "idle" opens the dialog once the agent's turn is
|
|
603
|
+
* over, "now" opens it straight away. Per viewer, like the pane's width.
|
|
604
|
+
*/
|
|
605
|
+
const BROWSER_IMPORT_PROMPT_KEY = "oharness.browserImportPrompt";
|
|
606
|
+
const browserAutoPrompted = new Set();
|
|
607
|
+
|
|
608
|
+
function browserImportPrompt() {
|
|
609
|
+
try {
|
|
610
|
+
const value = localStorage.getItem(BROWSER_IMPORT_PROMPT_KEY);
|
|
611
|
+
return value === "bar" || value === "now" ? value : "idle";
|
|
612
|
+
} catch {
|
|
613
|
+
return "idle";
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function browserPaintSuggestion(suggestion) {
|
|
618
|
+
browserSuggestion = suggestion && suggestion.site ? suggestion : null;
|
|
619
|
+
browserSuggest.hidden = !browserSuggestion;
|
|
620
|
+
if (!browserSuggestion) return;
|
|
621
|
+
browserSuggestText.textContent = t("browser.importSuggest", { site: browserSuggestion.site, browser: browserSuggestion.browser });
|
|
622
|
+
|
|
623
|
+
// Once per site per conversation: Cancel is an answer, and the bar stays for
|
|
624
|
+
// anyone who changes their mind.
|
|
625
|
+
const mode = browserImportPrompt();
|
|
626
|
+
const key = activeId + "|" + browserSuggestion.site;
|
|
627
|
+
if (mode === "bar" || !activeId || browserAutoPrompted.has(key)) return;
|
|
628
|
+
// Waiting is only a skipped poll: the first one after the turn ends opens it.
|
|
629
|
+
if (mode === "idle" && P && P.busy) return;
|
|
630
|
+
if (document.querySelector("dialog[open]")) return;
|
|
631
|
+
browserAutoPrompted.add(key);
|
|
632
|
+
void browserOpenImport(browserSuggestion.site);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* How to get the browser extension: its page, and — where this app ships its
|
|
637
|
+
* files — the folder to load it from and the three steps that takes. Shared by
|
|
638
|
+
* the settings row, the import dialog and the fallback bar's setup.
|
|
639
|
+
*/
|
|
640
|
+
function extensionHelp(info) {
|
|
641
|
+
const box = el("div", "ext-help");
|
|
642
|
+
const actions = el("div", "row-actions");
|
|
643
|
+
if (info.folder && !info.connected) {
|
|
644
|
+
const show = el("button", "ghost", t("settings.extensionShowFolder"));
|
|
645
|
+
show.type = "button";
|
|
646
|
+
show.addEventListener("click", () => void api("/browser/extension/reveal", {}));
|
|
647
|
+
actions.appendChild(show);
|
|
648
|
+
}
|
|
649
|
+
if (info.installUrl) {
|
|
650
|
+
const get = el("a", "ghost ext-get", t("settings.extensionGet"));
|
|
651
|
+
get.prepend(chromeLogo());
|
|
652
|
+
get.href = info.installUrl;
|
|
653
|
+
get.target = "_blank";
|
|
654
|
+
get.rel = "noopener";
|
|
655
|
+
actions.appendChild(get);
|
|
656
|
+
}
|
|
657
|
+
box.appendChild(actions);
|
|
658
|
+
if (info.folder && !info.connected) {
|
|
659
|
+
const steps = el("ol");
|
|
660
|
+
const one = el("li");
|
|
661
|
+
one.append(t("settings.extensionStep1a") + " ", el("code", null, "chrome://extensions"), " " + t("settings.extensionStep1b"));
|
|
662
|
+
steps.append(one, el("li", null, t("settings.extensionStep2")));
|
|
663
|
+
// The desktop app registers the host itself; a server run from npm needs
|
|
664
|
+
// the one command that does it.
|
|
665
|
+
if (!IS_DESKTOP) {
|
|
666
|
+
const install = el("li");
|
|
667
|
+
install.append(t("settings.extensionStepCli") + " ", el("code", null, "oharness-browser-host install"));
|
|
668
|
+
steps.appendChild(install);
|
|
669
|
+
}
|
|
670
|
+
steps.appendChild(el("li", null, t("settings.extensionStep3")));
|
|
671
|
+
box.appendChild(steps);
|
|
672
|
+
}
|
|
673
|
+
return box;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* The pane's way to the extension: shown while it is missing and the agent
|
|
678
|
+
* may use the person's browser, until the person closes it. Once closed, or
|
|
679
|
+
* where the agent keeps to the built-in browser, a plain icon stays in its
|
|
680
|
+
* place — importing sign-ins still needs the extension.
|
|
681
|
+
*/
|
|
682
|
+
function browserPaintExt() {
|
|
683
|
+
let hidden = false;
|
|
684
|
+
try { hidden = localStorage.getItem(BROWSER_EXT_HIDDEN) === "1"; } catch {}
|
|
685
|
+
browserExt.hidden = hidden || browserExtConnected || !browserPolicy || browserPolicy === "builtin";
|
|
686
|
+
browserExtGet.hidden = browserExtConnected || !browserExt.hidden;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function browserCheckExt() {
|
|
690
|
+
const info = await getJson("/browser/extension");
|
|
691
|
+
if (info.error) return;
|
|
692
|
+
browserExtConnected = Boolean(info.connected);
|
|
693
|
+
browserPaintExt();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Chrome's own mark, in its colours: on the button that gets the Chrome
|
|
698
|
+
* extension it is the thing a person looks for, so it is not redrawn in the
|
|
699
|
+
* icons' grey line. Three segments, each the same shape turned a third of the
|
|
700
|
+
* way round, bounded by lines tangent to the hub, around a ringed blue hub.
|
|
701
|
+
*/
|
|
702
|
+
function chromeLogo() {
|
|
703
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
704
|
+
const svg = document.createElementNS(NS, "svg");
|
|
705
|
+
svg.setAttribute("viewBox", "0 0 48 48");
|
|
706
|
+
svg.setAttribute("aria-hidden", "true");
|
|
707
|
+
const segment = "M6.68 14A20 20 0 0 1 41.32 14L24 14A10 10 0 0 0 15.34 29Z";
|
|
708
|
+
for (const [fill, turn] of [["#ea4335", 0], ["#fbbc04", 120], ["#34a853", 240]]) {
|
|
709
|
+
const path = document.createElementNS(NS, "path");
|
|
710
|
+
path.setAttribute("d", segment);
|
|
711
|
+
path.setAttribute("fill", fill);
|
|
712
|
+
path.setAttribute("transform", "rotate(" + turn + " 24 24)");
|
|
713
|
+
svg.appendChild(path);
|
|
714
|
+
}
|
|
715
|
+
for (const [r, fill] of [[10, "#fff"], [8, "#4285f4"]]) {
|
|
716
|
+
const circle = document.createElementNS(NS, "circle");
|
|
717
|
+
circle.setAttribute("cx", "24");
|
|
718
|
+
circle.setAttribute("cy", "24");
|
|
719
|
+
circle.setAttribute("r", String(r));
|
|
720
|
+
circle.setAttribute("fill", fill);
|
|
721
|
+
svg.appendChild(circle);
|
|
722
|
+
}
|
|
723
|
+
return svg;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Settings, opened on the browser row: where getting the extension lives. */
|
|
727
|
+
async function openBrowserSettings() {
|
|
728
|
+
document.getElementById("nav-settings").click();
|
|
729
|
+
for (let tries = 0; tries < 40; tries++) {
|
|
730
|
+
const row = document.querySelector(".setting-seg")?.closest("details");
|
|
731
|
+
if (row) {
|
|
732
|
+
row.open = true;
|
|
733
|
+
row.scrollIntoView({ block: "center" });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function browserOpenImport(preselect) {
|
|
741
|
+
if (!activeId) return;
|
|
742
|
+
const session = activeId;
|
|
743
|
+
const dialog = document.getElementById("browser-import-dialog");
|
|
744
|
+
const desc = document.getElementById("bi-desc");
|
|
745
|
+
const body = document.getElementById("bi-body");
|
|
746
|
+
const confirmBtn = document.getElementById("bi-confirm");
|
|
747
|
+
const cancelBtn = document.getElementById("bi-cancel");
|
|
748
|
+
desc.textContent = "";
|
|
749
|
+
body.replaceChildren(el("p", "muted small", t("browser.importLoading")));
|
|
750
|
+
confirmBtn.disabled = true;
|
|
751
|
+
confirmBtn.textContent = t("browser.importAction");
|
|
752
|
+
cancelBtn.onclick = () => dialog.close();
|
|
753
|
+
if (!dialog.open) dialog.showModal();
|
|
754
|
+
|
|
755
|
+
const [sources, state] = await Promise.all([
|
|
756
|
+
getJson("/browser/import?session=" + encodeURIComponent(session)),
|
|
757
|
+
getJson("/browser/state?session=" + encodeURIComponent(session)),
|
|
758
|
+
]);
|
|
759
|
+
if (!dialog.open || session !== activeId) return;
|
|
760
|
+
if (sources.error) {
|
|
761
|
+
body.replaceChildren(el("p", "bi-note", t("browser.importNoExtension")), el("p", "muted small mono", sources.error));
|
|
762
|
+
// Without the extension there is nothing to import from, so the way to
|
|
763
|
+
// get it is the useful thing this dialog can offer.
|
|
764
|
+
void getJson("/browser/extension").then((info) => {
|
|
765
|
+
if (dialog.open && !info.error) body.appendChild(extensionHelp(info));
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const browser = sources.browser || "Chrome";
|
|
771
|
+
desc.textContent = t("browser.importFrom", { browser });
|
|
772
|
+
const wanted = new Set([preselect, sources.current].filter(Boolean));
|
|
773
|
+
const sites = Array.isArray(sources.sites) ? sources.sites : [];
|
|
774
|
+
const boxes = [];
|
|
775
|
+
const list = el("div", "bi-sites");
|
|
776
|
+
for (const site of sites) {
|
|
777
|
+
const row = el("label", "bi-site");
|
|
778
|
+
const box = el("input");
|
|
779
|
+
box.type = "checkbox";
|
|
780
|
+
box.value = site.site;
|
|
781
|
+
box.checked = [...wanted].some((want) => want === site.site || want.endsWith("." + site.site) || site.site.endsWith("." + want));
|
|
782
|
+
row.classList.toggle("on", box.checked);
|
|
783
|
+
box.addEventListener("change", () => row.classList.toggle("on", box.checked));
|
|
784
|
+
const text = el("span", "bi-site-text");
|
|
785
|
+
text.appendChild(el("span", "bi-site-name", site.site));
|
|
786
|
+
if (site.title) text.appendChild(el("span", "bi-site-title", site.title));
|
|
787
|
+
row.append(box, text);
|
|
788
|
+
if (site.tabs > 1) row.appendChild(el("span", "bi-site-tabs", t("browser.importTabs", { n: site.tabs })));
|
|
789
|
+
boxes.push(box);
|
|
790
|
+
list.appendChild(row);
|
|
791
|
+
}
|
|
792
|
+
const other = el("input");
|
|
793
|
+
other.type = "text";
|
|
794
|
+
other.autocomplete = "off";
|
|
795
|
+
other.spellcheck = false;
|
|
796
|
+
other.placeholder = t("browser.importOther");
|
|
797
|
+
// A suggested site with no tab of its own still deserves to be pre-filled.
|
|
798
|
+
const unlisted = [...wanted].find((want) => !boxes.some((box) => box.checked && (want === box.value || want.endsWith("." + box.value))));
|
|
799
|
+
if (unlisted && !boxes.some((box) => box.checked)) other.value = unlisted;
|
|
800
|
+
|
|
801
|
+
const parts = [el("div", "bi-label", t(sites.length ? "browser.importSites" : "browser.importNoSites", { browser }))];
|
|
802
|
+
if (sites.length) parts.push(list);
|
|
803
|
+
parts.push(other, el("p", "bi-note", t("browser.importWhat")), el("p", "bi-note bi-warn", t("browser.importWarn", { browser })));
|
|
804
|
+
if (state.mode === "extension") parts.push(el("p", "bi-note", t("browser.importMigrate", { browser })));
|
|
805
|
+
parts.push(browserImportPromptPicker());
|
|
806
|
+
body.replaceChildren(...parts);
|
|
807
|
+
confirmBtn.disabled = false;
|
|
808
|
+
|
|
809
|
+
confirmBtn.onclick = async () => {
|
|
810
|
+
const chosen = boxes.filter((box) => box.checked).map((box) => box.value);
|
|
811
|
+
for (const typed of other.value.split(/[\s,]+/)) if (typed.trim()) chosen.push(typed.trim());
|
|
812
|
+
if (!chosen.length) return void addNotice(t("browser.importPick"), "err");
|
|
813
|
+
// The import moves the page the agent is working on; the server refuses
|
|
814
|
+
// it mid-turn, so say so here rather than relay its 409.
|
|
815
|
+
if (session === activeId && P && P.busy) return void addNotice(t("browser.importWaitTurn"), "err");
|
|
816
|
+
confirmBtn.disabled = true;
|
|
817
|
+
confirmBtn.textContent = t("browser.importBusy");
|
|
818
|
+
// Reading several sites and loading each one can take a while.
|
|
819
|
+
const result = await api("/browser/import", { session, action: "import", sites: chosen }, 120000);
|
|
820
|
+
confirmBtn.disabled = false;
|
|
821
|
+
confirmBtn.textContent = t("browser.importAction");
|
|
822
|
+
if (result.error) return void addNotice(result.error, "err");
|
|
823
|
+
dialog.close();
|
|
824
|
+
for (const entry of result.imported || []) {
|
|
825
|
+
addNotice(t("browser.importDone", { site: entry.site, browser: result.browser || browser, cookies: entry.cookies, storage: entry.localStorage }));
|
|
826
|
+
}
|
|
827
|
+
browserPaintSuggestion(null);
|
|
828
|
+
browserView(session).running = true;
|
|
829
|
+
if (session === activeId) browserOpen(true);
|
|
830
|
+
paintTabs();
|
|
831
|
+
};
|
|
832
|
+
other.addEventListener("keydown", (event) => {
|
|
833
|
+
if (event.key === "Enter") { event.preventDefault(); confirmBtn.click(); }
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function browserImportPromptPicker() {
|
|
838
|
+
const row = el("label", "bi-prompt");
|
|
839
|
+
row.appendChild(el("span", "", t("browser.importPrompt")));
|
|
840
|
+
const select = el("select");
|
|
841
|
+
for (const value of ["bar", "idle", "now"]) {
|
|
842
|
+
const option = el("option", "", t("browser.importPrompt." + value));
|
|
843
|
+
option.value = value;
|
|
844
|
+
select.appendChild(option);
|
|
845
|
+
}
|
|
846
|
+
select.value = browserImportPrompt();
|
|
847
|
+
select.addEventListener("change", () => {
|
|
848
|
+
try { localStorage.setItem(BROWSER_IMPORT_PROMPT_KEY, select.value); } catch { /* this page keeps the default */ }
|
|
849
|
+
});
|
|
850
|
+
row.appendChild(select);
|
|
851
|
+
return row;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
browserImportBtn.addEventListener("click", () => void browserOpenImport(browserSuggestion && browserSuggestion.site));
|
|
855
|
+
document.getElementById("browser-ext-open").addEventListener("click", () => void openBrowserSettings());
|
|
856
|
+
// The Chrome mark is what someone looking for the extension recognises.
|
|
857
|
+
browserExtGet.appendChild(chromeLogo());
|
|
858
|
+
browserExtGet.addEventListener("click", () => void openBrowserSettings());
|
|
859
|
+
document.getElementById("browser-ext-hide").addEventListener("click", () => {
|
|
860
|
+
try { localStorage.setItem(BROWSER_EXT_HIDDEN, "1"); } catch {}
|
|
861
|
+
browserPaintExt();
|
|
862
|
+
});
|
|
863
|
+
document.getElementById("browser-suggest-import").addEventListener("click", () => void browserOpenImport(browserSuggestion && browserSuggestion.site));
|
|
864
|
+
document.getElementById("browser-suggest-dismiss").addEventListener("click", async () => {
|
|
865
|
+
if (!activeId || !browserSuggestion) return;
|
|
866
|
+
const site = browserSuggestion.site;
|
|
867
|
+
browserPaintSuggestion(null);
|
|
868
|
+
const result = await api("/browser/import", { session: activeId, action: "dismiss", site });
|
|
869
|
+
if (result.error) addNotice(result.error, "err");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
document.getElementById("browser-hide").addEventListener("click", () => browserOpen(false));
|
|
873
|
+
const browserScrim = document.getElementById("browser-scrim");
|
|
874
|
+
browserScrim.addEventListener("click", () => browserOpen(false));
|
|
875
|
+
// A window widened past the breakpoint turns the drawer back into a column,
|
|
876
|
+
// which has no scrim.
|
|
877
|
+
BROWSER_NARROW.addEventListener("change", () => {
|
|
878
|
+
browserScrim.hidden = !(browserPane.classList.contains("open") && BROWSER_NARROW.matches);
|
|
879
|
+
});
|
|
880
|
+
// The drawer slides with a transform, which moves the pane without resizing
|
|
881
|
+
// it; the desktop app's native page follows the pane's box, so it is placed
|
|
882
|
+
// again once the slide has finished.
|
|
883
|
+
browserPane.addEventListener("transitionend", (event) => {
|
|
884
|
+
if (event.target === browserPane && browserNativeSurface) browserPresentNative(activeId, browserNativeSurface);
|
|
885
|
+
});
|
|
886
|
+
browserStopBtn.addEventListener("click", () => void browserStop());
|
|
887
|
+
browserTakeoverBtn.addEventListener("click", () => browserSetTakeover(!browserTakeover));
|
|
888
|
+
browserReloadBtn.addEventListener("click", () => void browserReload());
|
|
889
|
+
browserWhere.addEventListener("keydown", (event) => {
|
|
890
|
+
if (event.key === "Enter") {
|
|
891
|
+
event.preventDefault();
|
|
892
|
+
void browserNavigateFromAddress();
|
|
893
|
+
} else if (event.key === "Escape") {
|
|
894
|
+
event.preventDefault();
|
|
895
|
+
browserWhere.value = browserCurrentUrl;
|
|
896
|
+
browserWhere.blur();
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
browserSetTakeover(false);
|
|
900
|
+
browserTakeoverBtn.hidden = true;
|
|
901
|
+
browserReloadBtn.disabled = true;
|
|
902
|
+
browserStopBtn.disabled = true;
|
|
903
|
+
`;
|
|
904
|
+
//# sourceMappingURL=browser.js.map
|