hop2 0.11.15 → 0.11.17
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/README.md +38 -0
- package/hay/apps/web/src/components/ViewsPanel.tsx +203 -77
- package/hay/apps/web/src/styles.css +61 -0
- package/hay/apps/web/src/utils/voiceHold.ts +58 -10
- package/hay/apps/web/test/ViewsPanel.test.tsx +44 -1
- package/hay/apps/web/test/voiceHold.test.ts +197 -0
- package/hay-web/assets/digest-archive.json +745 -661
- package/hay-web/assets/{index-Bcl-2CbE.js → index-DS4O4H5H.js} +8 -7
- package/hay-web/assets/{index-Bcl-2CbE.js.map → index-DS4O4H5H.js.map} +1 -1
- package/hay-web/assets/{index-DKWcSY-E.css → index-De2GknLS.css} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +1062 -1
- package/lib/mailer.js +199 -0
- package/lib/registration.js +337 -0
- package/package.json +1 -1
- package/tests/mailer.unit.test.cjs +160 -0
- package/tests/registration.integration.test.cjs +436 -0
- package/tests/registration.unit.test.cjs +170 -0
package/README.md
CHANGED
|
@@ -320,6 +320,44 @@ hop client ./credentials.json
|
|
|
320
320
|
|
|
321
321
|
First run prompts for password setup and TOTP enrollment, then the user logs in at their assigned URL such as `https://alice.hop.yourdomain.com`.
|
|
322
322
|
|
|
323
|
+
### Self-service signup (optional)
|
|
324
|
+
|
|
325
|
+
Instead of adding every user by hand, the landing host can serve a public
|
|
326
|
+
signup page. Visitors ask for a subdomain, confirm an email address on a
|
|
327
|
+
domain you nominate, and wait for you to approve — approval runs the same
|
|
328
|
+
`hop user add` + `hop user export` provisioning as the manual flow, then
|
|
329
|
+
emails them a one-time link to collect their credentials.
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
hop mail setup # SMTP (a Gmail App Password works)
|
|
333
|
+
hop registration admin jianzhou # reserve your own subdomain
|
|
334
|
+
hop registration domain uchicago.edu
|
|
335
|
+
hop registration enable
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Then review requests as they arrive:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
hop user pending
|
|
342
|
+
hop user approve alice
|
|
343
|
+
hop user reject bob "please use your own name"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Notes:
|
|
347
|
+
|
|
348
|
+
- Off by default. Enabling it makes the bare hostname public; your own
|
|
349
|
+
browser keeps working while its session cookie is valid, and
|
|
350
|
+
`https://<landing-host>/signin` always reaches the login page.
|
|
351
|
+
- Nothing runs on your machine for them. A registrant gets a Cloudflare
|
|
352
|
+
tunnel and a DNS record; hop itself runs on *their* computer, with their
|
|
353
|
+
own password and TOTP.
|
|
354
|
+
- The tunnel lives in **your** Cloudflare account and the subdomain is under
|
|
355
|
+
**your** domain, so an approved user can serve anything they like at
|
|
356
|
+
`their-name.your-domain`. Approve people you would vouch for, and use
|
|
357
|
+
`hop user remove <name>` to revoke (delete the DNS record by hand).
|
|
358
|
+
- Registrations live in `~/.hop2/.registrations.json` (0600). Verification
|
|
359
|
+
and claim links are stored only as hashes.
|
|
360
|
+
|
|
323
361
|
### Security
|
|
324
362
|
|
|
325
363
|
- Password + TOTP is supported; password is required for custom domains
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { MouseEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { relativeTime } from "../utils/switcherModel";
|
|
3
3
|
|
|
4
4
|
// Views: the results an agent hands a human that a terminal cannot draw — a
|
|
5
5
|
// PDF, a rendered write-up, a screenshot, a proxied dev server. The daemon has
|
|
6
6
|
// published them since `hop view` shipped, but the only way to reach one from
|
|
7
7
|
// this client was to catch the printed URL before it scrolled out of the
|
|
8
|
-
// viewport.
|
|
9
|
-
//
|
|
8
|
+
// viewport.
|
|
9
|
+
//
|
|
10
|
+
// The panel is a reading surface, not just an index: on a desk-sized window
|
|
11
|
+
// it is two panes, list and PREVIEW, and a plain click renders the result in
|
|
12
|
+
// place. The iframe is what makes that safe — navigating would tear down the
|
|
13
|
+
// WebSocket attached to the live terminal, and reading a result must never
|
|
14
|
+
// disconnect the session that produced it. Narrow windows keep the old
|
|
15
|
+
// contract (rows are anchors to a new tab), because a preview pane the width
|
|
16
|
+
// of a phone is worse than the browser's own viewer.
|
|
10
17
|
|
|
11
18
|
type ViewItem = {
|
|
12
19
|
// The session's internalName — the same key /api/sessions reports views
|
|
@@ -23,7 +30,7 @@ type ViewItem = {
|
|
|
23
30
|
kind?: "file" | "server";
|
|
24
31
|
// For a server, the proxy itself. The manifest scan can only see the
|
|
25
32
|
// redirect page `hop port` leaves behind; going straight to the proxy skips
|
|
26
|
-
// a visible flash and a round trip
|
|
33
|
+
// a visible flash and a round trip.
|
|
27
34
|
target?: string;
|
|
28
35
|
};
|
|
29
36
|
|
|
@@ -81,6 +88,10 @@ const kindOf = (name: string, kind?: string) => {
|
|
|
81
88
|
return "FILE";
|
|
82
89
|
};
|
|
83
90
|
|
|
91
|
+
const isImage = (name: string) =>
|
|
92
|
+
["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(
|
|
93
|
+
(name.split(".").pop() || "").toLowerCase());
|
|
94
|
+
|
|
84
95
|
const formatBytes = (n?: number) => {
|
|
85
96
|
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return "";
|
|
86
97
|
if (n < 1024) return `${n} B`;
|
|
@@ -88,15 +99,29 @@ const formatBytes = (n?: number) => {
|
|
|
88
99
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
89
100
|
};
|
|
90
101
|
|
|
102
|
+
const hrefFor = (item: ViewItem) =>
|
|
103
|
+
item.kind === "server" && item.target ? item.target : item.path;
|
|
104
|
+
|
|
91
105
|
export const ViewsPanel = ({ session, sessions = [], onClose }: Props) => {
|
|
92
106
|
const [items, setItems] = useState<ViewItem[] | null>(null);
|
|
93
107
|
const [failed, setFailed] = useState(false);
|
|
108
|
+
const [preview, setPreview] = useState<ViewItem | null>(null);
|
|
109
|
+
// Two-step delete, disarmed by leaving the row: a browser confirm() dialog
|
|
110
|
+
// over a floating panel reads as a different app interrupting.
|
|
111
|
+
const [armedDelete, setArmedDelete] = useState<string | null>(null);
|
|
112
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
94
113
|
// Frozen at open. The mark-seen effect below fires the moment the list
|
|
95
114
|
// lands, so reading live storage during render would clear every "new" dot
|
|
96
115
|
// in the same frame that exists to show them.
|
|
97
116
|
const [seenAtOpen] = useState(loadViewsSeen);
|
|
117
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
98
118
|
const now = Date.now();
|
|
99
119
|
|
|
120
|
+
// The preview pane only exists where it has room to be useful. Checked at
|
|
121
|
+
// interaction time, not render time, so a resized window behaves like what
|
|
122
|
+
// it currently is rather than what it was at open.
|
|
123
|
+
const wideEnoughForPreview = () => window.innerWidth >= 760;
|
|
124
|
+
|
|
100
125
|
useEffect(() => {
|
|
101
126
|
let cancelled = false;
|
|
102
127
|
fetch("/api/views", { cache: "no-store", credentials: "same-origin" })
|
|
@@ -108,20 +133,6 @@ export const ViewsPanel = ({ session, sessions = [], onClose }: Props) => {
|
|
|
108
133
|
return () => { cancelled = true; };
|
|
109
134
|
}, []);
|
|
110
135
|
|
|
111
|
-
// Capture phase + stopPropagation: the switcher listens for Escape on window
|
|
112
|
-
// too, so without this one press closed the panel AND dismissed the wall
|
|
113
|
-
// behind it, dumping the reader into a terminal they never asked for.
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
const onKey = (event: KeyboardEvent) => {
|
|
116
|
-
if (event.key !== "Escape") return;
|
|
117
|
-
event.preventDefault();
|
|
118
|
-
event.stopPropagation();
|
|
119
|
-
onClose();
|
|
120
|
-
};
|
|
121
|
-
window.addEventListener("keydown", onKey, true);
|
|
122
|
-
return () => window.removeEventListener("keydown", onKey, true);
|
|
123
|
-
}, [onClose]);
|
|
124
|
-
|
|
125
136
|
const groups = useMemo(() => {
|
|
126
137
|
// The daemon folds case when a name addresses a session; match it here so
|
|
127
138
|
// a scoped open from a differently-cased key still finds its rows.
|
|
@@ -140,6 +151,36 @@ export const ViewsPanel = ({ session, sessions = [], onClose }: Props) => {
|
|
|
140
151
|
return [...by.entries()];
|
|
141
152
|
}, [items, session]);
|
|
142
153
|
|
|
154
|
+
const flatRows = useMemo(() => groups.flatMap(([, rows]) => rows), [groups]);
|
|
155
|
+
|
|
156
|
+
// Escape backs OUT one layer — preview first, then the panel — and arrows
|
|
157
|
+
// read the list like a mail client. Capture phase + stopPropagation: the
|
|
158
|
+
// switcher listens for Escape on window too, and without this one press
|
|
159
|
+
// closed the panel AND dismissed the wall behind it.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
const onKey = (event: KeyboardEvent) => {
|
|
162
|
+
if (event.key === "Escape") {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
event.stopPropagation();
|
|
165
|
+
if (preview) setPreview(null);
|
|
166
|
+
else onClose();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && flatRows.length
|
|
170
|
+
&& wideEnoughForPreview()) {
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
event.stopPropagation();
|
|
173
|
+
const at = preview ? flatRows.findIndex((r) => r.path === preview.path) : -1;
|
|
174
|
+
const next = event.key === "ArrowDown"
|
|
175
|
+
? Math.min(flatRows.length - 1, at + 1)
|
|
176
|
+
: Math.max(0, at < 0 ? 0 : at - 1);
|
|
177
|
+
setPreview(flatRows[next]);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
window.addEventListener("keydown", onKey, true);
|
|
181
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
182
|
+
}, [onClose, preview, flatRows]);
|
|
183
|
+
|
|
143
184
|
// Opening the panel IS the act of seeing what it shows.
|
|
144
185
|
useEffect(() => {
|
|
145
186
|
if (!items) return;
|
|
@@ -167,76 +208,161 @@ export const ViewsPanel = ({ session, sessions = [], onClose }: Props) => {
|
|
|
167
208
|
return hit ? (hit.displayName || hit.name) : internalName;
|
|
168
209
|
};
|
|
169
210
|
|
|
211
|
+
// Rows stay real anchors so ⌘-click, middle-click and "copy link address"
|
|
212
|
+
// keep their browser meaning; only a PLAIN left click is intercepted, and
|
|
213
|
+
// only where the preview pane exists to receive it.
|
|
214
|
+
const onRowClick = (event: MouseEvent, item: ViewItem) => {
|
|
215
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
216
|
+
if (!wideEnoughForPreview()) return; // narrow: the anchor's new tab is right
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
setPreview(item);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const copyLink = (item: ViewItem) => {
|
|
222
|
+
const url = location.origin + hrefFor(item);
|
|
223
|
+
navigator.clipboard?.writeText(url).then(() => {
|
|
224
|
+
setCopied(item.path);
|
|
225
|
+
window.setTimeout(() => setCopied((c) => (c === item.path ? null : c)), 1400);
|
|
226
|
+
}).catch(() => { /* clipboard denied — the ↗ anchor still carries the URL */ });
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const deleteItem = (item: ViewItem) => {
|
|
230
|
+
if (armedDelete !== item.path) { setArmedDelete(item.path); return; }
|
|
231
|
+
setArmedDelete(null);
|
|
232
|
+
fetch("/api/views", {
|
|
233
|
+
method: "DELETE",
|
|
234
|
+
credentials: "same-origin",
|
|
235
|
+
headers: { "Content-Type": "application/json" },
|
|
236
|
+
body: JSON.stringify({ session: item.session, name: item.name })
|
|
237
|
+
}).then((r) => {
|
|
238
|
+
if (!r.ok) return;
|
|
239
|
+
setItems((cur) => (cur || []).filter((i) => i.path !== item.path));
|
|
240
|
+
setPreview((p) => (p?.path === item.path ? null : p));
|
|
241
|
+
}).catch(() => { /* row stays; nothing lied about being deleted */ });
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const showPreview = preview !== null;
|
|
245
|
+
|
|
170
246
|
return (
|
|
171
247
|
<>
|
|
172
248
|
<div className="views-backdrop" onClick={onClose} />
|
|
173
|
-
<div className="views-panel"
|
|
249
|
+
<div className={"views-panel" + (showPreview ? " with-preview" : "")}
|
|
250
|
+
role="dialog" aria-label="Published views">
|
|
174
251
|
<div className="views-head">
|
|
175
252
|
<span className="views-badge">◧ Views</span>
|
|
176
253
|
{session && <span className="views-scope">{label(session)}</span>}
|
|
177
254
|
<span className="views-head-spacer" />
|
|
178
255
|
<button type="button" className="views-close" aria-label="Close views" onClick={onClose}>×</button>
|
|
179
256
|
</div>
|
|
180
|
-
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
{groups.map(([key, rows]) => (
|
|
193
|
-
<div className="views-group" key={key}>
|
|
194
|
-
<div className="views-dateline">{label(key)}</div>
|
|
195
|
-
{rows.map((item) => {
|
|
196
|
-
const fresh = (item.mtime || 0) > (seenAtOpen[key] || 0);
|
|
197
|
-
const isServer = item.kind === "server";
|
|
198
|
-
// A server has no meaningful size and its filename is an
|
|
199
|
-
// implementation detail (the redirect page) — it is a door,
|
|
200
|
-
// not a document, so say what it is instead.
|
|
201
|
-
const meta = isServer
|
|
202
|
-
? "live server · opens the running app"
|
|
203
|
-
: [
|
|
204
|
-
// The filename is only worth a line of its own when
|
|
205
|
-
// the title isn't already showing it.
|
|
206
|
-
item.title ? item.name : "",
|
|
207
|
-
relativeTime((item.mtime || 0) * 1000, now),
|
|
208
|
-
formatBytes(item.bytes)
|
|
209
|
-
].filter(Boolean).join(" · ");
|
|
210
|
-
return (
|
|
211
|
-
// A real anchor to a new tab, not an in-app overlay or a
|
|
212
|
-
// button: these are PDFs, videos and whole HTML pages, and
|
|
213
|
-
// the browser's own viewers already do zoom/scrub/print far
|
|
214
|
-
// better than an iframe would. Decisively, navigating in
|
|
215
|
-
// place would tear down the WebSocket attached to the live
|
|
216
|
-
// terminal — reading a result must not disconnect the
|
|
217
|
-
// session that produced it. The tunnel_session cookie is
|
|
218
|
-
// same-origin, so the new tab authenticates itself, and
|
|
219
|
-
// being an anchor keeps ⌘-click and "copy link" working.
|
|
220
|
-
<a
|
|
221
|
-
key={item.path}
|
|
222
|
-
className={"views-row" + (fresh ? " fresh" : "") + (isServer ? " server" : "")}
|
|
223
|
-
href={isServer && item.target ? item.target : item.path}
|
|
224
|
-
target="_blank"
|
|
225
|
-
rel="noopener"
|
|
226
|
-
>
|
|
227
|
-
<span className="views-kind" aria-hidden="true">{kindOf(item.name, item.kind)}</span>
|
|
228
|
-
<span className="views-row-text">
|
|
229
|
-
<span className="views-title">{item.title || item.name}</span>
|
|
230
|
-
{meta && <span className="views-meta">{meta}</span>}
|
|
231
|
-
</span>
|
|
232
|
-
{fresh && <span className="views-dot" aria-label="New" />}
|
|
233
|
-
</a>
|
|
234
|
-
);
|
|
235
|
-
})}
|
|
257
|
+
<div className="views-body">
|
|
258
|
+
<div className="views-list" ref={listRef}>
|
|
259
|
+
{failed ? (
|
|
260
|
+
<p className="views-empty">Couldn't reach the view index.</p>
|
|
261
|
+
) : !items ? (
|
|
262
|
+
<p className="views-empty">Loading…</p>
|
|
263
|
+
) : groups.length === 0 ? (
|
|
264
|
+
<div className="views-empty">
|
|
265
|
+
<p>Nothing published yet. Agents hand over results a terminal
|
|
266
|
+
can't draw — plots, PDFs, rendered write-ups, live dev servers —
|
|
267
|
+
and they land here, grouped by the session that produced them.</p>
|
|
268
|
+
<pre className="views-empty-cmds">{"hop view --title \"ROC curve\" plot.png\nhop port 5173"}</pre>
|
|
236
269
|
</div>
|
|
237
|
-
)
|
|
270
|
+
) : (
|
|
271
|
+
groups.map(([key, rows]) => (
|
|
272
|
+
<div className="views-group" key={key}>
|
|
273
|
+
<div className="views-dateline">{label(key)}</div>
|
|
274
|
+
{rows.map((item) => {
|
|
275
|
+
const fresh = (item.mtime || 0) > (seenAtOpen[key] || 0);
|
|
276
|
+
const isServer = item.kind === "server";
|
|
277
|
+
const selected = preview?.path === item.path;
|
|
278
|
+
// A server has no meaningful size and its filename is an
|
|
279
|
+
// implementation detail (the redirect page) — it is a
|
|
280
|
+
// door, not a document, so say what it is instead.
|
|
281
|
+
const meta = isServer
|
|
282
|
+
? "live server"
|
|
283
|
+
: [
|
|
284
|
+
// The filename is only worth a line of its own when
|
|
285
|
+
// the title isn't already showing it.
|
|
286
|
+
item.title ? item.name : "",
|
|
287
|
+
relativeTime((item.mtime || 0) * 1000, now),
|
|
288
|
+
formatBytes(item.bytes)
|
|
289
|
+
].filter(Boolean).join(" · ");
|
|
290
|
+
return (
|
|
291
|
+
<a
|
|
292
|
+
key={item.path}
|
|
293
|
+
className={"views-row" + (fresh ? " fresh" : "")
|
|
294
|
+
+ (isServer ? " server" : "") + (selected ? " selected" : "")}
|
|
295
|
+
href={hrefFor(item)}
|
|
296
|
+
target="_blank"
|
|
297
|
+
rel="noopener"
|
|
298
|
+
onClick={(e) => onRowClick(e, item)}
|
|
299
|
+
onMouseLeave={() => setArmedDelete((a) => (a === item.path ? null : a))}
|
|
300
|
+
>
|
|
301
|
+
{isImage(item.name)
|
|
302
|
+
// The file is its own thumbnail — one lazy request,
|
|
303
|
+
// and a list of plots becomes scannable at a glance.
|
|
304
|
+
? <img className="views-thumb" src={item.path} loading="lazy" alt="" />
|
|
305
|
+
: <span className="views-kind" aria-hidden="true">{kindOf(item.name, item.kind)}</span>}
|
|
306
|
+
<span className="views-row-text">
|
|
307
|
+
<span className="views-title">{item.title || item.name}</span>
|
|
308
|
+
{meta && <span className="views-meta">{meta}</span>}
|
|
309
|
+
</span>
|
|
310
|
+
{fresh && <span className="views-dot" aria-label="New" />}
|
|
311
|
+
<span className="views-acts" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
312
|
+
{/* A button, not an <a>: anchors cannot nest, and
|
|
313
|
+
the row itself is already the real link. */}
|
|
314
|
+
<button type="button" className="views-act"
|
|
315
|
+
title="Open in a new tab" aria-label="Open in a new tab"
|
|
316
|
+
onClick={() => window.open(hrefFor(item), "_blank", "noopener")}>↗</button>
|
|
317
|
+
<button type="button" className="views-act"
|
|
318
|
+
title={copied === item.path ? "Copied" : "Copy link"}
|
|
319
|
+
aria-label="Copy link"
|
|
320
|
+
onClick={() => copyLink(item)}>{copied === item.path ? "✓" : "⧉"}</button>
|
|
321
|
+
{!isServer && (
|
|
322
|
+
<button type="button"
|
|
323
|
+
className={"views-act danger" + (armedDelete === item.path ? " armed" : "")}
|
|
324
|
+
title={armedDelete === item.path ? "Click again to delete" : "Delete"}
|
|
325
|
+
aria-label={armedDelete === item.path ? "Click again to delete" : "Delete"}
|
|
326
|
+
onClick={() => deleteItem(item)}>
|
|
327
|
+
{armedDelete === item.path ? "sure?" : "🗑"}
|
|
328
|
+
</button>
|
|
329
|
+
)}
|
|
330
|
+
</span>
|
|
331
|
+
</a>
|
|
332
|
+
);
|
|
333
|
+
})}
|
|
334
|
+
</div>
|
|
335
|
+
))
|
|
336
|
+
)}
|
|
238
337
|
</div>
|
|
239
|
-
|
|
338
|
+
{showPreview && (
|
|
339
|
+
<div className="views-preview">
|
|
340
|
+
<div className="views-preview-head">
|
|
341
|
+
<span className="views-preview-title" title={preview.title || preview.name}>
|
|
342
|
+
{preview.title || preview.name}
|
|
343
|
+
</span>
|
|
344
|
+
<span className="views-head-spacer" />
|
|
345
|
+
<a className="views-act" href={hrefFor(preview)} target="_blank" rel="noopener"
|
|
346
|
+
title="Open in a new tab" aria-label="Open preview in a new tab">↗</a>
|
|
347
|
+
<button type="button" className="views-act" aria-label="Close preview"
|
|
348
|
+
onClick={() => setPreview(null)}>×</button>
|
|
349
|
+
</div>
|
|
350
|
+
{/* Sandboxed only for LIVE servers: an arbitrary dev app must not
|
|
351
|
+
be able to navigate the wall out from under its own terminal
|
|
352
|
+
(frame-busting). Our own files skip the sandbox, because a
|
|
353
|
+
sandboxed iframe disables the browser's PDF viewer. */}
|
|
354
|
+
<iframe
|
|
355
|
+
className="views-frame"
|
|
356
|
+
key={preview.path}
|
|
357
|
+
src={hrefFor(preview)}
|
|
358
|
+
title={preview.title || preview.name}
|
|
359
|
+
sandbox={preview.kind === "server"
|
|
360
|
+
? "allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
|
|
361
|
+
: undefined}
|
|
362
|
+
/>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
240
366
|
</div>
|
|
241
367
|
</>
|
|
242
368
|
);
|
|
@@ -4048,6 +4048,35 @@ button.primary {
|
|
|
4048
4048
|
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
|
4049
4049
|
padding: 14px 16px;
|
|
4050
4050
|
}
|
|
4051
|
+
/* With a preview open the dialog becomes a reading surface: list + pane,
|
|
4052
|
+
sized like a mail client rather than a popover. The transition is not
|
|
4053
|
+
animated — a width tween on an iframe re-lays the document per frame. */
|
|
4054
|
+
.views-panel.with-preview {
|
|
4055
|
+
width: min(1080px, 94vw);
|
|
4056
|
+
max-height: min(84vh, 820px);
|
|
4057
|
+
height: min(84vh, 820px);
|
|
4058
|
+
}
|
|
4059
|
+
.views-body { display: flex; min-height: 0; flex: 1; gap: 0; }
|
|
4060
|
+
.views-panel.with-preview .views-list {
|
|
4061
|
+
flex: 0 0 300px;
|
|
4062
|
+
border-right: 1px solid var(--line);
|
|
4063
|
+
padding-right: 10px;
|
|
4064
|
+
margin-right: 12px;
|
|
4065
|
+
}
|
|
4066
|
+
.views-preview { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
4067
|
+
.views-preview-head {
|
|
4068
|
+
display: flex; align-items: center; gap: 6px;
|
|
4069
|
+
padding: 0 0 8px 2px;
|
|
4070
|
+
}
|
|
4071
|
+
.views-preview-title {
|
|
4072
|
+
font-size: 12.5px; font-weight: 600; color: var(--ink);
|
|
4073
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
4074
|
+
}
|
|
4075
|
+
.views-frame {
|
|
4076
|
+
flex: 1; width: 100%; border: 1px solid var(--line); border-radius: 8px;
|
|
4077
|
+
background: #fff; /* PDFs and pages assume a paper ground before they load */
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4051
4080
|
.views-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
4052
4081
|
.views-head-spacer { flex: 1; }
|
|
4053
4082
|
.views-badge {
|
|
@@ -4106,6 +4135,38 @@ button.primary {
|
|
|
4106
4135
|
width: 6px; height: 6px; border-radius: 3px;
|
|
4107
4136
|
background: var(--accent, #7c5cff);
|
|
4108
4137
|
}
|
|
4138
|
+
.views-row.selected {
|
|
4139
|
+
background: color-mix(in srgb, var(--accent, #7c5cff) 12%, transparent);
|
|
4140
|
+
}
|
|
4141
|
+
/* The image IS its own thumbnail — one lazy request per visible row. */
|
|
4142
|
+
.views-thumb {
|
|
4143
|
+
flex: 0 0 34px; width: 34px; height: 26px;
|
|
4144
|
+
object-fit: cover; border-radius: 4px;
|
|
4145
|
+
background: var(--bg-alt);
|
|
4146
|
+
}
|
|
4147
|
+
/* Row actions surface on hover; on touch (no hover) they stay visible,
|
|
4148
|
+
because an affordance you cannot discover does not exist. */
|
|
4149
|
+
.views-acts { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
|
4150
|
+
@media (hover: hover) {
|
|
4151
|
+
.views-row .views-acts { opacity: 0; }
|
|
4152
|
+
.views-row:hover .views-acts, .views-row.selected .views-acts { opacity: 1; }
|
|
4153
|
+
}
|
|
4154
|
+
.views-act {
|
|
4155
|
+
border: none; background: none; cursor: pointer;
|
|
4156
|
+
color: var(--ink-muted); text-decoration: none;
|
|
4157
|
+
font-size: 12px; line-height: 1; padding: 4px 5px; border-radius: 5px;
|
|
4158
|
+
font-family: inherit;
|
|
4159
|
+
}
|
|
4160
|
+
.views-act:hover { background: color-mix(in srgb, var(--ink) 10%, transparent); color: var(--ink); }
|
|
4161
|
+
.views-act.danger.armed {
|
|
4162
|
+
color: #ff6b6b; font-size: 10.5px; font-weight: 700;
|
|
4163
|
+
}
|
|
4164
|
+
.views-empty-cmds {
|
|
4165
|
+
margin: 8px 0 2px; padding: 8px 10px;
|
|
4166
|
+
font-family: var(--font-mono); font-size: 11px; line-height: 1.6;
|
|
4167
|
+
color: var(--ink); background: var(--bg-alt);
|
|
4168
|
+
border-radius: 8px; white-space: pre;
|
|
4169
|
+
}
|
|
4109
4170
|
|
|
4110
4171
|
/* The affordances that open it: a topbar/switcher button and a card chip,
|
|
4111
4172
|
each carrying the same unseen dot. */
|
|
@@ -11,6 +11,14 @@
|
|
|
11
11
|
// - releasing stops; the transcript is TYPED, never submitted
|
|
12
12
|
// - auto-repeat spaces are swallowed; any other key cancels a pending hold
|
|
13
13
|
// - while listening, every space is eaten
|
|
14
|
+
//
|
|
15
|
+
// Swallowing a key here means handling THREE event types, not one. xterm
|
|
16
|
+
// consults this handler from _keyDown AND from _keyPress, and when we refuse
|
|
17
|
+
// a keydown it returns before setting its own _keyDownHandled flag — so the
|
|
18
|
+
// browser still fires keypress, xterm still asks us, and an `undefined`
|
|
19
|
+
// answer there let every auto-repeat land a real space in the composer. That
|
|
20
|
+
// was "holding space keeps typing actual spaces". preventDefault goes with
|
|
21
|
+
// it, so the character never reaches the hidden textarea either.
|
|
14
22
|
|
|
15
23
|
type RecognitionLike = {
|
|
16
24
|
lang: string;
|
|
@@ -41,6 +49,8 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
|
|
|
41
49
|
const threshold = opts.thresholdMs ?? 550;
|
|
42
50
|
let timer = 0;
|
|
43
51
|
let active = false;
|
|
52
|
+
/** A hold has begun (space is down) but dictation has not started yet. */
|
|
53
|
+
let pending = false;
|
|
44
54
|
let spacesTyped = 0;
|
|
45
55
|
let finalText = "";
|
|
46
56
|
let interimText = "";
|
|
@@ -58,11 +68,14 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
|
|
|
58
68
|
};
|
|
59
69
|
|
|
60
70
|
const start = () => {
|
|
71
|
+
pending = false;
|
|
61
72
|
if (active) return;
|
|
62
73
|
const Ctor = speechRecognitionCtor();
|
|
63
|
-
|
|
74
|
+
// Silence here read as "voice randomly does nothing": the space vanished
|
|
75
|
+
// (or didn't) and no overlay ever appeared, with no reason given.
|
|
76
|
+
if (!Ctor) { opts.notify("Voice input is not supported in this browser"); return; }
|
|
64
77
|
let r: RecognitionLike;
|
|
65
|
-
try { r = new Ctor(); } catch { return; }
|
|
78
|
+
try { r = new Ctor(); } catch { opts.notify("Voice input could not start"); return; }
|
|
66
79
|
active = true;
|
|
67
80
|
finalText = "";
|
|
68
81
|
interimText = "";
|
|
@@ -101,7 +114,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
|
|
|
101
114
|
r.onend = () => finish(true);
|
|
102
115
|
rec = r;
|
|
103
116
|
opts.setOverlay("");
|
|
104
|
-
try { r.start(); } catch { finish(false); }
|
|
117
|
+
try { r.start(); } catch { opts.notify("Voice input could not start"); finish(false); }
|
|
105
118
|
};
|
|
106
119
|
|
|
107
120
|
const stop = () => {
|
|
@@ -115,29 +128,63 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
|
|
|
115
128
|
* false — consumed (swallow), true — let the terminal process it,
|
|
116
129
|
* undefined — not a voice-relevant event; continue with other branches.
|
|
117
130
|
*/
|
|
118
|
-
const handleKey = (ev: {
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
const handleKey = (ev: {
|
|
132
|
+
type: string; code: string; key: string; repeat?: boolean;
|
|
133
|
+
metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; shiftKey?: boolean;
|
|
134
|
+
preventDefault?: () => void;
|
|
135
|
+
}): boolean | undefined => {
|
|
136
|
+
/** Refuse the key AND stop the browser acting on it. */
|
|
137
|
+
const swallow = () => {
|
|
138
|
+
try { ev.preventDefault?.(); } catch { /* synthetic event in a test */ }
|
|
139
|
+
return false;
|
|
140
|
+
};
|
|
141
|
+
// Return, mid-dictation, ENDS it and keeps the words (Jian). Without
|
|
142
|
+
// this the Enter fell through to the terminal while the recogniser was
|
|
143
|
+
// still running: a bare newline hit the composer, and the transcript
|
|
144
|
+
// landed after it — the one key you would reach for to finish a thought
|
|
145
|
+
// was the one that split it in half. Swallowed rather than passed on, so
|
|
146
|
+
// this inserts and does not also submit; a second Return sends it.
|
|
147
|
+
if (active && ev.type === "keydown" && (ev.key === "Enter" || ev.code === "Enter"
|
|
148
|
+
|| ev.code === "NumpadEnter")) {
|
|
149
|
+
stop();
|
|
150
|
+
return swallow();
|
|
151
|
+
}
|
|
152
|
+
// A keypress carries no `code` in some engines; fall back to the key.
|
|
153
|
+
const isSpace = ev.code === "Space" || (ev.type === "keypress" && ev.key === " ");
|
|
154
|
+
if (isSpace && !ev.metaKey && !ev.ctrlKey && !ev.altKey && !ev.shiftKey
|
|
155
|
+
&& !!speechRecognitionCtor() && (active || pending || opts.eligible())) {
|
|
121
156
|
if (ev.type === "keydown") {
|
|
122
|
-
if (active) return
|
|
157
|
+
if (active) return swallow(); // listening: eat every space
|
|
123
158
|
if (!ev.repeat) {
|
|
124
159
|
window.clearTimeout(timer);
|
|
160
|
+
pending = true;
|
|
125
161
|
spacesTyped = 1; // this one goes through, for zero latency
|
|
126
162
|
timer = window.setTimeout(start, threshold);
|
|
127
163
|
return true;
|
|
128
164
|
}
|
|
129
|
-
return
|
|
165
|
+
return swallow(); // swallow auto-repeat while held
|
|
166
|
+
}
|
|
167
|
+
// The one that was missing. xterm asks again here, and its own
|
|
168
|
+
// _keyDownHandled guard is not set for a keydown we refused — so an
|
|
169
|
+
// `undefined` answer types a space per auto-repeat. The first space is
|
|
170
|
+
// already emitted by the keydown xterm DID process, so nothing is lost.
|
|
171
|
+
if (ev.type === "keypress") {
|
|
172
|
+
return (active || pending) ? swallow() : undefined;
|
|
130
173
|
}
|
|
131
174
|
if (ev.type === "keyup") {
|
|
132
175
|
window.clearTimeout(timer);
|
|
176
|
+
pending = false;
|
|
133
177
|
spacesTyped = 0;
|
|
134
|
-
if (active) { stop(); return
|
|
178
|
+
if (active) { stop(); return swallow(); }
|
|
135
179
|
return true;
|
|
136
180
|
}
|
|
137
181
|
return undefined;
|
|
138
182
|
}
|
|
139
183
|
// Any other keydown cancels a pending (not yet active) hold.
|
|
140
|
-
if (ev.type === "keydown" && ev.code !== "Space")
|
|
184
|
+
if (ev.type === "keydown" && ev.code !== "Space") {
|
|
185
|
+
window.clearTimeout(timer);
|
|
186
|
+
pending = false;
|
|
187
|
+
}
|
|
141
188
|
return undefined;
|
|
142
189
|
};
|
|
143
190
|
|
|
@@ -145,6 +192,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
|
|
|
145
192
|
window.clearTimeout(timer);
|
|
146
193
|
if (active) { try { rec?.stop(); } catch { /* gone */ } }
|
|
147
194
|
active = false;
|
|
195
|
+
pending = false;
|
|
148
196
|
rec = null;
|
|
149
197
|
opts.setOverlay(null);
|
|
150
198
|
};
|
|
@@ -28,6 +28,45 @@ beforeEach(() => {
|
|
|
28
28
|
afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); });
|
|
29
29
|
|
|
30
30
|
describe("ViewsPanel", () => {
|
|
31
|
+
it("a plain click previews IN PLACE; the row stays a real link for modified clicks", async () => {
|
|
32
|
+
render(<ViewsPanel onClose={() => {}} />);
|
|
33
|
+
await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
|
|
34
|
+
const row = screen.getByText("Views end-to-end").closest("a") as HTMLAnchorElement;
|
|
35
|
+
// jsdom's window is 1024 wide — the desk case, where the pane exists.
|
|
36
|
+
fireEvent.click(row);
|
|
37
|
+
const frame = document.querySelector("iframe.views-frame") as HTMLIFrameElement;
|
|
38
|
+
expect(frame).toBeTruthy();
|
|
39
|
+
expect(frame.getAttribute("src")).toBe("/view/Orion/agent-result.md/inline");
|
|
40
|
+
// Our own files must NOT be sandboxed — a sandboxed iframe disables the
|
|
41
|
+
// browser's PDF viewer, which is half of what the pane is for.
|
|
42
|
+
expect(frame.hasAttribute("sandbox")).toBe(false);
|
|
43
|
+
// The row keeps its browser meaning: href + target survive for ⌘-click.
|
|
44
|
+
expect(row.getAttribute("target")).toBe("_blank");
|
|
45
|
+
// Escape backs out ONE layer — the preview goes, the panel stays.
|
|
46
|
+
fireEvent.keyDown(window, { key: "Escape" });
|
|
47
|
+
expect(document.querySelector("iframe.views-frame")).toBeNull();
|
|
48
|
+
expect(screen.getByText("Views end-to-end")).toBeTruthy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("delete is two-step, and only the second click calls the API", async () => {
|
|
52
|
+
render(<ViewsPanel onClose={() => {}} />);
|
|
53
|
+
await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
|
|
54
|
+
const del = screen.getAllByLabelText("Delete")[0];
|
|
55
|
+
fireEvent.click(del);
|
|
56
|
+
// Armed, not deleted: nothing left the browser yet.
|
|
57
|
+
expect((fetch as ReturnType<typeof vi.fn>).mock.calls.filter(
|
|
58
|
+
(c) => c[1]?.method === "DELETE").length).toBe(0);
|
|
59
|
+
fireEvent.click(screen.getAllByLabelText("Click again to delete")[0]);
|
|
60
|
+
await waitFor(() => {
|
|
61
|
+
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls.filter(
|
|
62
|
+
(c) => c[1]?.method === "DELETE");
|
|
63
|
+
expect(calls.length).toBe(1);
|
|
64
|
+
expect(JSON.parse(calls[0][1].body)).toEqual({ session: "Orion", name: "agent-result.md" });
|
|
65
|
+
});
|
|
66
|
+
// The row leaves the list only because the server said ok.
|
|
67
|
+
await waitFor(() => expect(screen.queryByText("Views end-to-end")).toBeNull());
|
|
68
|
+
});
|
|
69
|
+
|
|
31
70
|
it("groups by session, leads with the title, falls back to the filename", async () => {
|
|
32
71
|
render(<ViewsPanel sessions={[{ name: "Orion", displayName: "orion-worker", internalName: "Orion" }]} onClose={() => {}} />);
|
|
33
72
|
await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
|
|
@@ -36,7 +75,11 @@ describe("ViewsPanel", () => {
|
|
|
36
75
|
expect(screen.getByText("Nebula")).toBeTruthy();
|
|
37
76
|
expect(screen.getByText("views-test.html")).toBeTruthy();
|
|
38
77
|
expect(screen.getByText("MD")).toBeTruthy();
|
|
39
|
-
|
|
78
|
+
// Images render as their own thumbnail, not a type tag — the file is the
|
|
79
|
+
// most honest icon it could have.
|
|
80
|
+
const thumb = document.querySelector("img.views-thumb") as HTMLImageElement;
|
|
81
|
+
expect(thumb).toBeTruthy();
|
|
82
|
+
expect(thumb.getAttribute("loading")).toBe("lazy");
|
|
40
83
|
|
|
41
84
|
const link = screen.getByText("Views end-to-end").closest("a") as HTMLAnchorElement;
|
|
42
85
|
expect(link.getAttribute("href")).toBe("/view/Orion/agent-result.md/inline");
|