hop2 0.11.16 → 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.
@@ -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. This panel is the index that was missing: one fetch of the
9
- // manifest the daemon already maintains, grouped by session, newest first.
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 on a phone link.
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" role="dialog" aria-label="Published views">
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
- {failed ? (
181
- <p className="views-empty">Couldn't reach the view index.</p>
182
- ) : !items ? (
183
- <p className="views-empty">Loading…</p>
184
- ) : groups.length === 0 ? (
185
- <p className="views-empty">
186
- Nothing published yet — agents publish results with <code>hop view &lt;file&gt;</code>.
187
- PDFs, images, HTML and markdown write-ups land here instead of scrolling
188
- past as text.
189
- </p>
190
- ) : (
191
- <div className="views-list">
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
- if (!Ctor) return;
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,7 +128,16 @@ 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: { type: string; code: string; key: string; repeat?: boolean; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; shiftKey?: boolean }): boolean | undefined => {
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
+ };
119
141
  // Return, mid-dictation, ENDS it and keeps the words (Jian). Without
120
142
  // this the Enter fell through to the terminal while the recogniser was
121
143
  // still running: a bare newline hit the composer, and the transcript
@@ -125,30 +147,44 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
125
147
  if (active && ev.type === "keydown" && (ev.key === "Enter" || ev.code === "Enter"
126
148
  || ev.code === "NumpadEnter")) {
127
149
  stop();
128
- return false;
150
+ return swallow();
129
151
  }
130
- if (ev.code === "Space" && !ev.metaKey && !ev.ctrlKey && !ev.altKey && !ev.shiftKey
131
- && !!speechRecognitionCtor() && (active || opts.eligible())) {
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())) {
132
156
  if (ev.type === "keydown") {
133
- if (active) return false; // listening: eat every space
157
+ if (active) return swallow(); // listening: eat every space
134
158
  if (!ev.repeat) {
135
159
  window.clearTimeout(timer);
160
+ pending = true;
136
161
  spacesTyped = 1; // this one goes through, for zero latency
137
162
  timer = window.setTimeout(start, threshold);
138
163
  return true;
139
164
  }
140
- return false; // swallow auto-repeat while held
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;
141
173
  }
142
174
  if (ev.type === "keyup") {
143
175
  window.clearTimeout(timer);
176
+ pending = false;
144
177
  spacesTyped = 0;
145
- if (active) { stop(); return false; }
178
+ if (active) { stop(); return swallow(); }
146
179
  return true;
147
180
  }
148
181
  return undefined;
149
182
  }
150
183
  // Any other keydown cancels a pending (not yet active) hold.
151
- if (ev.type === "keydown" && ev.code !== "Space") window.clearTimeout(timer);
184
+ if (ev.type === "keydown" && ev.code !== "Space") {
185
+ window.clearTimeout(timer);
186
+ pending = false;
187
+ }
152
188
  return undefined;
153
189
  };
154
190
 
@@ -156,6 +192,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
156
192
  window.clearTimeout(timer);
157
193
  if (active) { try { rec?.stop(); } catch { /* gone */ } }
158
194
  active = false;
195
+ pending = false;
159
196
  rec = null;
160
197
  opts.setOverlay(null);
161
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
- expect(screen.getByText("IMG")).toBeTruthy();
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");