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.
- package/hay/apps/web/index.html +2 -1
- package/hay/apps/web/src/App.tsx +24 -6
- package/hay/apps/web/src/components/CwdField.tsx +128 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +227 -35
- package/hay/apps/web/src/styles.css +78 -1
- package/hay/apps/web/src/utils/tabTitle.ts +15 -0
- package/hay/apps/web/src/utils/voiceHold.ts +53 -12
- package/hay/apps/web/test/SessionSwitcher.test.tsx +138 -0
- package/hay/apps/web/test/tabTitle.test.ts +16 -0
- package/hay/apps/web/test/voiceHold.test.ts +46 -0
- package/hop +182 -16
- package/package.json +1 -1
- package/scripts/trajectory-extract.js +234 -0
- package/tests/trajectory-extract.unit.test.cjs +72 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
165
|
+
r.onend = ended;
|
|
124
166
|
rec = r;
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|