hop2 0.11.39 → 0.11.40

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.
@@ -2612,6 +2612,26 @@ button.primary {
2612
2612
  background: color-mix(in srgb, var(--err) 12%, transparent);
2613
2613
  }
2614
2614
 
2615
+ /* A file held over a card: this session is where it will land. */
2616
+ .switcher-card.file-drop {
2617
+ outline: 2px dashed var(--accent);
2618
+ outline-offset: -2px;
2619
+ }
2620
+
2621
+ /* Folder picker: the session's current folder is marked, not highlighted —
2622
+ a filled row in a list of plain rows reads as hover state. */
2623
+ .switcher-sheet > button.on {
2624
+ font-weight: 600;
2625
+ }
2626
+ .switcher-sheet > button.on::before {
2627
+ content: "✓";
2628
+ margin-right: 6px;
2629
+ color: var(--accent);
2630
+ }
2631
+ .switcher-sheet > button.back {
2632
+ color: var(--ink-muted);
2633
+ }
2634
+
2615
2635
  /* Touch: same menu, roomier targets. */
2616
2636
  @media (pointer: coarse) {
2617
2637
  .switcher-sheet {
@@ -3521,9 +3541,60 @@ button.primary {
3521
3541
 
3522
3542
  .switcher-create-row {
3523
3543
  display: flex;
3544
+ flex-wrap: wrap;
3524
3545
  gap: 6px;
3525
3546
  padding: 0 16px 10px;
3526
3547
  }
3548
+ /* Working directory: its own full-width line under name + type, with the
3549
+ completion list floating below it. */
3550
+ .switcher-create-cwd {
3551
+ position: relative;
3552
+ flex-basis: 100%;
3553
+ min-width: 0;
3554
+ }
3555
+ .switcher-create-cwd input {
3556
+ width: 100%;
3557
+ box-sizing: border-box;
3558
+ }
3559
+ .cwd-suggest {
3560
+ position: absolute;
3561
+ left: 0;
3562
+ right: 0;
3563
+ top: calc(100% + 4px);
3564
+ z-index: 30;
3565
+ max-height: 240px;
3566
+ overflow-y: auto;
3567
+ padding: 4px;
3568
+ border-radius: 8px;
3569
+ background: var(--panel);
3570
+ border: 1px solid var(--line);
3571
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 12px 32px rgba(0, 0, 0, 0.22);
3572
+ }
3573
+ .cwd-suggest-label {
3574
+ padding: 4px 8px 2px;
3575
+ font-size: 10.5px;
3576
+ letter-spacing: 0.04em;
3577
+ text-transform: uppercase;
3578
+ color: var(--ink-muted);
3579
+ }
3580
+ .switcher-create-row .cwd-suggest button {
3581
+ display: block;
3582
+ width: 100%;
3583
+ padding: 6px 8px;
3584
+ border: none;
3585
+ border-radius: 6px;
3586
+ background: none;
3587
+ color: var(--ink);
3588
+ text-align: left;
3589
+ white-space: nowrap;
3590
+ overflow: hidden;
3591
+ text-overflow: ellipsis;
3592
+ cursor: pointer;
3593
+ }
3594
+ .switcher-create-row .cwd-suggest button.on,
3595
+ .switcher-create-row .cwd-suggest button:hover {
3596
+ background: var(--bg-alt);
3597
+ }
3527
3598
  /* New-session launch-type picker: Terminal / Claude / Codex, a small
3528
3599
  segmented control in the create row. Maps to the create API's startup. */
3529
3600
  .switcher-create-type {
@@ -3731,13 +3802,19 @@ button.primary {
3731
3802
  0%, 100% { opacity: 1; transform: scale(1); }
3732
3803
  50% { opacity: 0.45; transform: scale(0.75); }
3733
3804
  }
3805
+ /* A live caption: what matters is the words arriving NOW. nowrap + ellipsis
3806
+ pinned the START of the transcript and hid everything after the first
3807
+ line, so a long dictation looked frozen. Right-aligned in a clipping flex
3808
+ box, the text overflows off the LEFT edge and the tail stays in view. */
3734
3809
  .voice-text {
3735
3810
  font-family: var(--font-mono);
3736
3811
  font-size: 12.5px;
3737
3812
  color: var(--ink, #ddd);
3738
3813
  white-space: nowrap;
3739
3814
  overflow: hidden;
3740
- text-overflow: ellipsis;
3815
+ display: flex;
3816
+ justify-content: flex-end;
3817
+ min-width: 0;
3741
3818
  }
3742
3819
  .voice-text.muted { color: var(--ink-muted, #888); }
3743
3820
 
@@ -0,0 +1,15 @@
1
+ // The browser tab's title.
2
+ //
3
+ // A session tab reads by session: its name first, the app second, so a row
4
+ // of hop tabs is scannable and still says what it is. The hub — no session
5
+ // open — is just the app. Before this, the hub showed the placeholder room id
6
+ // minted for a would-be new session ("room-k3x9q"), and the pre-hydration
7
+ // title was the web package's internal name ("Hay"): neither named anything
8
+ // the user had.
9
+ export const APP_TITLE = "hop";
10
+
11
+ export const tabTitle = (sessionLabel: string | null | undefined, alert: boolean): string => {
12
+ const label = (sessionLabel || "").trim();
13
+ const base = label ? `${label} · ${APP_TITLE}` : APP_TITLE;
14
+ return `${alert ? "● " : ""}${base}`;
15
+ };
@@ -61,16 +61,25 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
61
61
  let finalText = "";
62
62
  let interimText = "";
63
63
  let rec: RecognitionLike | null = null;
64
+ /** stop() was called: the next onend is ours, not the recogniser giving up. */
65
+ let stopping = false;
66
+ /** Consecutive recogniser restarts that produced nothing — a loop guard. */
67
+ let emptyRestarts = 0;
64
68
 
65
69
  const finish = (sendText: boolean) => {
66
70
  if (!active) return;
67
71
  active = false;
68
72
  pending = false;
73
+ stopping = false;
74
+ emptyRestarts = 0;
69
75
  rec = null;
70
76
  window.clearTimeout(watchdog);
71
77
  window.removeEventListener("blur", onWindowBlur);
72
78
  opts.setOverlay(null);
73
- const text = (finalText || interimText).trim();
79
+ // Final AND interim: a recogniser stopped by us often ends before it
80
+ // finalises the last phrase, and `final || interim` dropped that phrase
81
+ // whenever anything earlier had already been finalised.
82
+ const text = (finalText + interimText).trim();
74
83
  finalText = "";
75
84
  interimText = "";
76
85
  if (sendText && text) opts.send(text);
@@ -83,18 +92,37 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
83
92
  // Silence here read as "voice randomly does nothing": the space vanished
84
93
  // (or didn't) and no overlay ever appeared, with no reason given.
85
94
  if (!Ctor) { opts.notify("Voice input is not supported in this browser"); return; }
86
- let r: RecognitionLike;
87
- try { r = new Ctor(); } catch { opts.notify("Voice input could not start"); return; }
88
95
  active = true;
96
+ stopping = false;
97
+ emptyRestarts = 0;
89
98
  finalText = "";
90
99
  interimText = "";
91
100
  // Erase every space that reached the app during this hold.
92
101
  for (let i = 0; i < Math.max(1, spacesTyped); i++) opts.send("\x7f");
93
102
  spacesTyped = 0;
103
+ armWatchdog();
104
+ window.addEventListener("blur", onWindowBlur);
105
+ opts.setOverlay("");
106
+ if (!listen(Ctor)) { opts.notify("Voice input could not start"); finish(false); }
107
+ };
108
+
109
+ // One recogniser session. The hold outlives it: browser recognisers end
110
+ // on their own schedule — Safari after a pause in speech, Chrome after a
111
+ // few seconds of silence ("no-speech") — and `continuous` does not change
112
+ // that in practice. Ending the hold with them meant dictation cut out
113
+ // while Space was still down and whatever came next was lost. So a
114
+ // recogniser that ends while the key is held is simply replaced; the words
115
+ // already final are kept, and only OUR stop() finishes the hold.
116
+ const listen = (Ctor: new () => RecognitionLike): boolean => {
117
+ let r: RecognitionLike;
118
+ try { r = new Ctor(); } catch { return false; }
119
+ let heard = false;
94
120
  r.lang = navigator.language || "en-US";
95
121
  r.continuous = true;
96
122
  r.interimResults = true;
97
123
  r.onresult = (ev) => {
124
+ heard = true;
125
+ emptyRestarts = 0;
98
126
  let interim = "";
99
127
  for (let i = ev.resultIndex; i < ev.results.length; i++) {
100
128
  const res = ev.results[i];
@@ -104,11 +132,25 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
104
132
  interimText = interim;
105
133
  opts.setOverlay((finalText + interim).trim());
106
134
  };
135
+ const ended = () => {
136
+ if (!active || rec !== r) return; // an old recogniser, or already finished
137
+ if (stopping) { finish(true); return; } // released: type what we have
138
+ // A finished phrase is folded in; the interim is gone with the session.
139
+ interimText = "";
140
+ // Three recognisers in a row that heard nothing is the service refusing
141
+ // us, not silence — give the space bar back rather than spin forever.
142
+ if (!heard && ++emptyRestarts >= 3) { finish(true); return; }
143
+ opts.setOverlay(finalText.trim());
144
+ if (!listen(Ctor)) finish(true);
145
+ };
107
146
  r.onerror = (ev) => {
108
- // EVERY failure explains itself. Only mic-denial was surfaced before;
109
- // Chrome's cloud recognizer routinely fails with "network", and that
110
- // silent death read as "voice randomly doesn't work".
111
147
  const code = ev?.error || "unknown";
148
+ // Quiet or interrupted while still held: not a failure, just this
149
+ // recogniser's session ending — the onend that follows restarts it.
150
+ if (active && !stopping && (code === "no-speech" || code === "aborted")) return;
151
+ // EVERY real failure explains itself. Only mic-denial was surfaced
152
+ // before; Chrome's cloud recogniser routinely fails with "network",
153
+ // and that silent death read as "voice randomly doesn't work".
112
154
  if (code === "not-allowed" || code === "service-not-allowed") {
113
155
  opts.notify("Microphone permission needed for voice input");
114
156
  } else if (code === "network") {
@@ -120,18 +162,17 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
120
162
  }
121
163
  finish(false);
122
164
  };
123
- r.onend = () => finish(true);
165
+ r.onend = ended;
124
166
  rec = r;
125
- armWatchdog();
126
- window.addEventListener("blur", onWindowBlur);
127
- opts.setOverlay("");
128
- try { r.start(); } catch { opts.notify("Voice input could not start"); finish(false); }
167
+ try { r.start(); } catch { rec = null; return false; }
168
+ return true;
129
169
  };
130
170
 
131
171
  const stop = () => {
132
172
  const r = rec;
133
- rec = null;
173
+ stopping = true;
134
174
  try { r?.stop(); } catch { /* already gone */ }
175
+ rec = null;
135
176
  // Finish on OUR schedule rather than waiting for the recogniser's onend.
136
177
  // A recogniser that never fires one (it happens: the service drops, the
137
178
  // tab backgrounds, permission is revoked mid-hold) left `active` stuck
@@ -371,6 +371,47 @@ describe("manual folders in the wall", () => {
371
371
  }
372
372
  });
373
373
 
374
+ it("files a session into a folder from its menu, no drag needed", async () => {
375
+ const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
376
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
377
+ const u = String(url);
378
+ calls.push({ url: u, body: init?.body ? JSON.parse(String(init.body)) : null });
379
+ if (u.includes("digest")) return { ok: false, json: async () => null };
380
+ if (u === "/api/folders") return { ok: true, json: async () => ({ ok: true, folder: { id: "f2", name: "Fresh" } }) };
381
+ return { ok: true, json: async () => ({ ok: true }) };
382
+ }));
383
+ try {
384
+ render(<SessionSwitcher {...props} sessions={withFolder} folders={folders} open />);
385
+ // Into an existing folder.
386
+ fireEvent.click(screen.getByRole("button", { name: "More actions for outside" }));
387
+ fireEvent.click(screen.getByRole("button", { name: "Move to folder…" }));
388
+ fireEvent.click(screen.getByRole("button", { name: "Filed" }));
389
+ await vi.waitFor(() => expect(calls.some((c) => c.url === "/api/sessions/move")).toBe(true));
390
+ expect(calls.find((c) => c.url === "/api/sessions/move")!.body).toEqual({ internalName: "outside", folderId: "f1" });
391
+
392
+ // Out of its folder: the current one is marked, "No folder" clears it.
393
+ calls.length = 0;
394
+ fireEvent.click(screen.getByRole("button", { name: "More actions for inside" }));
395
+ fireEvent.click(screen.getByRole("button", { name: "Move to folder…" }));
396
+ expect(screen.getByRole("button", { name: "Filed" }).className).toContain("on");
397
+ fireEvent.click(screen.getByRole("button", { name: "No folder" }));
398
+ await vi.waitFor(() => expect(calls.some((c) => c.url === "/api/sessions/move")).toBe(true));
399
+ expect(calls.find((c) => c.url === "/api/sessions/move")!.body).toEqual({ internalName: "inside", folderId: null });
400
+
401
+ // A brand-new folder, created and filed into in one gesture.
402
+ calls.length = 0;
403
+ vi.stubGlobal("prompt", vi.fn(() => "Fresh"));
404
+ fireEvent.click(screen.getByRole("button", { name: "More actions for outside" }));
405
+ fireEvent.click(screen.getByRole("button", { name: "Move to folder…" }));
406
+ fireEvent.click(screen.getByRole("button", { name: "New folder…" }));
407
+ await vi.waitFor(() => expect(calls.some((c) => c.url === "/api/sessions/move")).toBe(true));
408
+ expect(calls.find((c) => c.url === "/api/folders")!.body).toEqual({ name: "Fresh" });
409
+ expect(calls.find((c) => c.url === "/api/sessions/move")!.body).toEqual({ internalName: "outside", folderId: "f2" });
410
+ } finally {
411
+ vi.unstubAllGlobals();
412
+ }
413
+ });
414
+
374
415
  it("a query in manual mode still offers the full-history search", () => {
375
416
  // Manual keeps its own render branch under a query (narrow-in-place), so
376
417
  // the search tail — content hits + the deep button — must ride along or
@@ -384,6 +425,103 @@ describe("manual folders in the wall", () => {
384
425
  });
385
426
  });
386
427
 
428
+ describe("handoff to the other tool", () => {
429
+ afterEach(() => { vi.unstubAllGlobals(); document.body.innerHTML = ""; });
430
+
431
+ it("asks the daemon for a codex target and reports the extract", async () => {
432
+ const bodies: Array<Record<string, unknown>> = [];
433
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
434
+ const u = String(url);
435
+ if (u.includes("digest")) return { ok: false, json: async () => null };
436
+ if (u === "/api/sessions/fork") {
437
+ bodies.push(JSON.parse(String(init?.body)));
438
+ return { ok: true, json: async () => ({ ok: true, name: "alpha-codex", kind: "handoff", target: "codex", extract: { turns: 12, bytes: 40960 } }) };
439
+ }
440
+ return { ok: true, json: async () => ({ ok: true }) };
441
+ }));
442
+ render(<SessionSwitcher {...props} open />);
443
+ fireEvent.click(screen.getAllByRole("button", { name: /More actions for/ })[0]);
444
+ fireEvent.click(screen.getByRole("button", { name: "Continue in Codex…" }));
445
+ await vi.waitFor(() => expect(bodies.length).toBe(1));
446
+ expect(bodies[0]).toMatchObject({ target: "codex" });
447
+ expect(typeof bodies[0].internalName).toBe("string");
448
+ await vi.waitFor(() => expect(props.onNotice).toHaveBeenCalledWith("alpha-codex — 12 turns handed off (40 KB); it reads them first"));
449
+ });
450
+ });
451
+
452
+ describe("file drop on a wall card", () => {
453
+ afterEach(() => { vi.unstubAllGlobals(); document.body.innerHTML = ""; });
454
+
455
+ it("uploads to that session instead of being swallowed by the reorder handlers", async () => {
456
+ const calls: string[] = [];
457
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
458
+ const u = String(url);
459
+ calls.push(u);
460
+ if (u.includes("digest")) return { ok: false, json: async () => null };
461
+ return { ok: true, json: async () => ({ path: "/Users/me/.hop2/uploads/x/notes.txt" }) };
462
+ }));
463
+ render(<SessionSwitcher {...props} open />);
464
+ const card = document.querySelector(".switcher-card[data-session-key]") as HTMLElement;
465
+ const key = card.getAttribute("data-session-key")!;
466
+ const file = new File(["hi"], "notes.txt", { type: "text/plain" });
467
+ const dataTransfer = { types: ["Files"], files: [file], items: [], dropEffect: "none" };
468
+ fireEvent.dragOver(card, { dataTransfer });
469
+ expect(card.className).toContain("file-drop");
470
+ fireEvent.drop(card, { dataTransfer });
471
+ await vi.waitFor(() => expect(calls.some((u) => u.startsWith(`/api/sessions/upload?name=${encodeURIComponent(key)}&filename=notes.txt`))).toBe(true));
472
+ await vi.waitFor(() => expect(props.onNotice).toHaveBeenCalledWith(expect.stringContaining("/Users/me/.hop2/uploads/x/notes.txt")));
473
+ });
474
+ });
475
+
476
+ describe("create form working directory", () => {
477
+ afterEach(() => { vi.unstubAllGlobals(); document.body.innerHTML = ""; try { localStorage.setItem("hay_create_cwd", ""); } catch { /* stubbed storage */ } });
478
+
479
+ it("completes directories from the daemon and sends the chosen one with the create", async () => {
480
+ const calls: Array<{ url: string; body: string | null }> = [];
481
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
482
+ const u = String(url);
483
+ calls.push({ url: u, body: init?.body ? String(init.body) : null });
484
+ if (u.startsWith("/api/fs/complete")) {
485
+ return { ok: true, json: async () => ({ home: "/Users/me", dir: "/Users/me/Code", entries: [{ name: "hop2", path: "/Users/me/Code/hop2" }] }) };
486
+ }
487
+ if (u.includes("digest")) return { ok: false, json: async () => null };
488
+ return { ok: true, json: async () => ({ ok: true, name: "fresh", internalName: "s_fresh" }) };
489
+ }));
490
+ render(<SessionSwitcher {...props} open />);
491
+ fireEvent.click(screen.getAllByRole("button", { name: "New session" })[0]);
492
+ fireEvent.change(screen.getByLabelText("New session name"), { target: { value: "fresh" } });
493
+ const cwd = screen.getByLabelText("Working directory") as HTMLInputElement;
494
+ fireEvent.change(cwd, { target: { value: "~/Code/ho" } });
495
+ // Suggestions are shortened to ~ and completing a directory descends into it.
496
+ const option = await screen.findByRole("option", { name: "~/Code/hop2" });
497
+ expect(calls.some((c) => c.url === "/api/fs/complete?q=" + encodeURIComponent("~/Code/ho"))).toBe(true);
498
+ fireEvent.mouseDown(option);
499
+ expect(cwd.value).toBe("~/Code/hop2/");
500
+ fireEvent.submit(cwd.closest("form")!);
501
+ await vi.waitFor(() => expect(calls.some((c) => c.url === "/api/sessions" && c.body)).toBe(true));
502
+ const create = JSON.parse(calls.find((c) => c.url === "/api/sessions" && c.body)!.body!);
503
+ expect(create).toMatchObject({ name: "fresh", cwd: "~/Code/hop2/" });
504
+ // Remembered for the next create — written once the create succeeds.
505
+ await vi.waitFor(() => expect(localStorage.getItem("hay_create_cwd")).toBe("~/Code/hop2/"));
506
+ });
507
+
508
+ it("offers the directories of recent sessions before anything is typed", () => {
509
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, json: async () => null })));
510
+ const withCwd: SwitcherSession[] = [
511
+ { name: "a", displayName: "a", internalName: "a", active: true, starting: false, createdBy: "user", cwd: "/Users/me/Code/one", lastActivityAt: 2 },
512
+ { name: "b", displayName: "b", internalName: "b", active: true, starting: false, createdBy: "user", cwd: "/Users/me/Code/two", lastActivityAt: 1 }
513
+ ];
514
+ render(<SessionSwitcher {...props} sessions={withCwd} open />);
515
+ fireEvent.click(screen.getAllByRole("button", { name: "New session" })[0]);
516
+ const cwd = screen.getByLabelText("Working directory") as HTMLInputElement;
517
+ fireEvent.focus(cwd);
518
+ const options = screen.getAllByRole("option").map((o) => o.textContent);
519
+ expect(options).toEqual(["~/Code/one", "~/Code/two"]);
520
+ fireEvent.mouseDown(screen.getByRole("option", { name: "~/Code/one" }));
521
+ expect(cwd.value).toBe("~/Code/one");
522
+ });
523
+ });
524
+
387
525
  describe("create colliding with a hidden session", () => {
388
526
  afterEach(() => { vi.unstubAllGlobals(); });
389
527
 
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { tabTitle } from "../src/utils/tabTitle";
3
+
4
+ describe("tab title", () => {
5
+ it("names the session first and the app second", () => {
6
+ expect(tabTitle("angler", false)).toBe("angler · hop");
7
+ });
8
+ it("is just the app on the hub, never a placeholder room id", () => {
9
+ expect(tabTitle(null, false)).toBe("hop");
10
+ expect(tabTitle("", false)).toBe("hop");
11
+ });
12
+ it("marks attention with a leading dot", () => {
13
+ expect(tabTitle("angler", true)).toBe("● angler · hop");
14
+ expect(tabTitle(null, true)).toBe("● hop");
15
+ });
16
+ });
@@ -227,6 +227,52 @@ describe("hold-space dictation", () => {
227
227
  expect(overlays[overlays.length - 1]).toBe(null);
228
228
  });
229
229
 
230
+ it("a recogniser that ends on its own mid-hold is replaced, and the words survive", () => {
231
+ // Safari ends a session at the first pause in speech; Chrome after a few
232
+ // silent seconds. Neither is the user letting go of Space.
233
+ const hold = makeHold();
234
+ hold.handleKey(keydown());
235
+ vi.advanceTimersByTime(500);
236
+ const first = FakeRecognition.last!;
237
+ first.onresult?.({ resultIndex: 0, results: [{ isFinal: true, 0: { transcript: "hello " } }] });
238
+ first.onend?.();
239
+
240
+ expect(hold.isActive()).toBe(true);
241
+ const second = FakeRecognition.last!;
242
+ expect(second).not.toBe(first);
243
+ expect(second.started).toBe(true);
244
+ second.onresult?.({ resultIndex: 0, results: [{ isFinal: false, 0: { transcript: "world" } }] });
245
+ expect(overlays[overlays.length - 1]).toBe("hello world");
246
+
247
+ hold.handleKey(keyup());
248
+ expect(hold.isActive()).toBe(false);
249
+ expect(sent[sent.length - 1]).toBe("hello world");
250
+ });
251
+
252
+ it("Chrome's no-speech mid-hold is a pause, not a failure", () => {
253
+ const hold = makeHold();
254
+ hold.handleKey(keydown());
255
+ vi.advanceTimersByTime(500);
256
+ const first = FakeRecognition.last!;
257
+ first.onerror?.({ error: "no-speech" });
258
+ first.onend?.();
259
+ expect(hold.isActive()).toBe(true);
260
+ expect(notices).toEqual([]);
261
+ expect(FakeRecognition.last).not.toBe(first);
262
+ });
263
+
264
+ it("gives the space bar back when recognisers keep ending without hearing anything", () => {
265
+ const hold = makeHold();
266
+ hold.handleKey(keydown());
267
+ vi.advanceTimersByTime(500);
268
+ FakeRecognition.last!.onend?.();
269
+ FakeRecognition.last!.onend?.();
270
+ expect(hold.isActive()).toBe(true);
271
+ FakeRecognition.last!.onend?.();
272
+ expect(hold.isActive()).toBe(false);
273
+ expect(overlays[overlays.length - 1]).toBe(null);
274
+ });
275
+
230
276
  it("a recogniser that never ends cannot kill the space bar", () => {
231
277
  // The regression that bit Jian: dictation started, the recogniser never
232
278
  // fired onend, `active` stuck true — and because every space is eaten