hop2 0.11.2 → 0.11.4
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 +7 -1
- package/hay/apps/server/package.json +3 -1
- package/hay/apps/server/src/rooms.ts +142 -33
- package/hay/apps/server/src/screenGrid.ts +145 -0
- package/hay/apps/server/test/rooms.test.ts +104 -0
- package/hay/apps/web/src/App.tsx +70 -105
- package/hay/apps/web/src/components/SessionSwitcher.tsx +126 -15
- package/hay/apps/web/src/styles.css +13 -0
- package/hay/apps/web/src/utils/voiceHold.ts +170 -0
- package/hay/package-lock.json +17 -0
- package/hay/packages/shared/src/protocol.ts +6 -1
- package/hay-web/assets/{index-BohAWpcL.css → index-BRpBf8QE.css} +1 -1
- package/hay-web/assets/index-isX6Rkr2.js +132 -0
- package/hay-web/assets/index-isX6Rkr2.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +539 -150
- package/hopa +6 -1
- package/lib/hay-host-watchdog.js +97 -0
- package/mcp/hop-mcp.js +6 -1
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +24 -3
- package/scripts/hay-host.js +14 -1
- package/scripts/salvage-and-restart-host.js +116 -0
- package/tests/hay-host-watchdog.unit.test.cjs +109 -0
- package/tests/terminal-api.integration.test.cjs +100 -0
- package/hay-web/assets/index-DfGT3DuO.js +0 -130
- package/hay-web/assets/index-DfGT3DuO.js.map +0 -1
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { activityLabel, sortPresence } from "./utils/presence";
|
|
|
13
13
|
import { createOptimisticEcho } from "./utils/optimisticEcho";
|
|
14
14
|
import { attachScrollFlywheel } from "./utils/scrollFlywheel";
|
|
15
15
|
import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
|
|
16
|
+
import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
|
|
16
17
|
import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
17
18
|
import { originalPathHint } from "./utils/fileDrop";
|
|
18
19
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
@@ -518,6 +519,10 @@ const App = () => {
|
|
|
518
519
|
const shouldReconnectRef = useRef(true);
|
|
519
520
|
const connectNonceRef = useRef(0);
|
|
520
521
|
const userScrolledUpRef = useRef(false);
|
|
522
|
+
// Late-bound hooks for the voice controller (created before these exist).
|
|
523
|
+
const handleUserInputRef = useRef<((data: string) => void) | null>(null);
|
|
524
|
+
const pushNoticeRef = useRef<((m: string) => void) | null>(null);
|
|
525
|
+
const foregroundIsClaudeRef = useRef<(() => boolean) | null>(null);
|
|
521
526
|
// Custom overlay scrollbar (native overlay bars can't be styled, and styled
|
|
522
527
|
// webkit bars reserve layout width): a floating semi-transparent thumb.
|
|
523
528
|
const termScrollbarRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -562,13 +567,44 @@ const App = () => {
|
|
|
562
567
|
// Claude Code titles its process with a bare version number; hop's session
|
|
563
568
|
// poll surfaces it as foregroundProcess. Used to unlock claude-specific key
|
|
564
569
|
// encodings (Shift+Enter) without protocol negotiation.
|
|
570
|
+
// Is Claude Code the thing on screen? The foreground PROCESS cannot answer
|
|
571
|
+
// alone: a session relaunched by `hop restore` runs
|
|
572
|
+
// `shell -lc "claude ...; exec shell -l"`, so the room reports the WRAPPER
|
|
573
|
+
// SHELL for claude's whole life. Everything gated on this — Shift+Enter as
|
|
574
|
+
// a newline, hold-space voice — silently stopped working in exactly the
|
|
575
|
+
// sessions a restore brought back, which is why voice "does not always
|
|
576
|
+
// work". The screen is the honest witness, so fall back to claude's own
|
|
577
|
+
// chrome in the buffer (cached ~1s, since key handlers ask per keystroke).
|
|
578
|
+
const claudeScreenRef = useRef<{ at: number; value: boolean }>({ at: 0, value: false });
|
|
579
|
+
const screenLooksLikeClaude = () => {
|
|
580
|
+
const now = performance.now();
|
|
581
|
+
if (now - claudeScreenRef.current.at < 1000) return claudeScreenRef.current.value;
|
|
582
|
+
let value = false;
|
|
583
|
+
const t = termRef.current;
|
|
584
|
+
if (t) {
|
|
585
|
+
try {
|
|
586
|
+
const buf = t.buffer.active;
|
|
587
|
+
const end = buf.baseY + t.rows;
|
|
588
|
+
let text = "";
|
|
589
|
+
for (let i = Math.max(0, end - 30); i < end; i++) {
|
|
590
|
+
text += (buf.getLine(i)?.translateToString(true) ?? "") + "\n";
|
|
591
|
+
}
|
|
592
|
+
value = /bypass permissions on|shift\+tab to cycle|esc to interrupt|\? for shortcuts/i.test(text);
|
|
593
|
+
} catch { /* disposed mid-read */ }
|
|
594
|
+
}
|
|
595
|
+
claudeScreenRef.current = { at: now, value };
|
|
596
|
+
return value;
|
|
597
|
+
};
|
|
565
598
|
const foregroundIsClaude = () => {
|
|
566
599
|
const room = activeSessionRoomRef.current;
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
600
|
+
if (room) {
|
|
601
|
+
const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
|
|
602
|
+
const proc = (s?.foregroundProcess || "").trim();
|
|
603
|
+
if (proc === "claude" || /^\d+\.\d+\.\d+$/.test(proc)) return true;
|
|
604
|
+
}
|
|
605
|
+
return screenLooksLikeClaude();
|
|
571
606
|
};
|
|
607
|
+
foregroundIsClaudeRef.current = foregroundIsClaude;
|
|
572
608
|
const drawerOpenRef = useRef(drawerOpen);
|
|
573
609
|
drawerOpenRef.current = drawerOpen;
|
|
574
610
|
const shortcutHelpRef = useRef(shortcutHelpOpen);
|
|
@@ -723,6 +759,7 @@ const App = () => {
|
|
|
723
759
|
}, 3000);
|
|
724
760
|
};
|
|
725
761
|
|
|
762
|
+
pushNoticeRef.current = pushNotice;
|
|
726
763
|
const showToast = (message: string, durationMs = 2000) => {
|
|
727
764
|
setToast(message);
|
|
728
765
|
if (toastTimeout.current) {
|
|
@@ -953,78 +990,18 @@ const App = () => {
|
|
|
953
990
|
};
|
|
954
991
|
|
|
955
992
|
// ── Voice input: hold SPACE in a Claude session to dictate. ──
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
// most common key; crossing the hold threshold erases that space and
|
|
959
|
-
// listens until release. The transcript is TYPED, not submitted: you can
|
|
960
|
-
// edit before Enter. Auto-repeat spaces are swallowed while held, which in
|
|
961
|
-
// a Claude composer is noise nobody wants anyway.
|
|
962
|
-
const voiceTimerRef = useRef(0);
|
|
963
|
-
const voiceActiveRef = useRef(false);
|
|
964
|
-
const voiceRecRef = useRef<{ stop: () => void } | null>(null);
|
|
965
|
-
const voiceFinalRef = useRef("");
|
|
966
|
-
const voiceInterimRef = useRef("");
|
|
993
|
+
// Shared controller (utils/voiceHold) — the live wall tiles run the same
|
|
994
|
+
// machine, so dictation exists on every surface a Claude composer does.
|
|
967
995
|
const [voiceOverlay, setVoiceOverlay] = useState<{ interim: string } | null>(null);
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
// result ever arrived, it is still what the user said — use it.
|
|
978
|
-
const text = (voiceFinalRef.current || voiceInterimRef.current).trim();
|
|
979
|
-
voiceFinalRef.current = "";
|
|
980
|
-
voiceInterimRef.current = "";
|
|
981
|
-
if (send && text) handleUserInput(text);
|
|
982
|
-
};
|
|
983
|
-
const startVoiceInput = () => {
|
|
984
|
-
if (voiceActiveRef.current) return;
|
|
985
|
-
const Ctor = speechRecognitionCtor() as (new () => never) | undefined;
|
|
986
|
-
if (!Ctor) return;
|
|
987
|
-
let rec: never;
|
|
988
|
-
try { rec = new Ctor(); } catch { return; }
|
|
989
|
-
const r = rec as {
|
|
990
|
-
lang: string; continuous: boolean; interimResults: boolean;
|
|
991
|
-
onresult: (ev: { resultIndex: number; results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }) => void;
|
|
992
|
-
onerror: (ev: { error?: string }) => void; onend: () => void;
|
|
993
|
-
start: () => void; stop: () => void;
|
|
994
|
-
};
|
|
995
|
-
voiceActiveRef.current = true;
|
|
996
|
-
voiceFinalRef.current = "";
|
|
997
|
-
voiceInterimRef.current = "";
|
|
998
|
-
handleUserInput(""); // erase the space the initial press typed
|
|
999
|
-
r.lang = navigator.language || "en-US";
|
|
1000
|
-
r.continuous = true;
|
|
1001
|
-
r.interimResults = true;
|
|
1002
|
-
r.onresult = (ev) => {
|
|
1003
|
-
let interim = "";
|
|
1004
|
-
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
|
1005
|
-
const res = ev.results[i];
|
|
1006
|
-
if (res.isFinal) voiceFinalRef.current += res[0].transcript;
|
|
1007
|
-
else interim += res[0].transcript;
|
|
1008
|
-
}
|
|
1009
|
-
voiceInterimRef.current = interim;
|
|
1010
|
-
setVoiceOverlay({ interim: (voiceFinalRef.current + interim).trim() });
|
|
1011
|
-
};
|
|
1012
|
-
r.onerror = (ev) => {
|
|
1013
|
-
if (ev?.error === "not-allowed" || ev?.error === "service-not-allowed") {
|
|
1014
|
-
pushNotice("Microphone permission needed for voice input");
|
|
1015
|
-
}
|
|
1016
|
-
finishVoiceInput(false);
|
|
1017
|
-
};
|
|
1018
|
-
r.onend = () => finishVoiceInput(true);
|
|
1019
|
-
voiceRecRef.current = r;
|
|
1020
|
-
setVoiceOverlay({ interim: "" });
|
|
1021
|
-
try { r.start(); } catch { finishVoiceInput(false); }
|
|
1022
|
-
};
|
|
1023
|
-
const stopVoiceInput = () => {
|
|
1024
|
-
const rec = voiceRecRef.current;
|
|
1025
|
-
if (rec) { try { rec.stop(); } catch { finishVoiceInput(true); } }
|
|
1026
|
-
else finishVoiceInput(true);
|
|
1027
|
-
};
|
|
996
|
+
const voiceHoldRef = useRef<ReturnType<typeof createVoiceHold> | null>(null);
|
|
997
|
+
if (!voiceHoldRef.current) {
|
|
998
|
+
voiceHoldRef.current = createVoiceHold({
|
|
999
|
+
send: (data) => handleUserInputRef.current?.(data),
|
|
1000
|
+
notify: (m) => pushNoticeRef.current?.(m),
|
|
1001
|
+
setOverlay: (interim) => setVoiceOverlay(interim === null ? null : { interim }),
|
|
1002
|
+
eligible: () => foregroundIsClaudeRef.current?.() ?? false
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1028
1005
|
|
|
1029
1006
|
const handleUserInput = (data: string) => {
|
|
1030
1007
|
// Typing is "I am at the prompt": any earlier scroll-up turned
|
|
@@ -1105,6 +1082,7 @@ const App = () => {
|
|
|
1105
1082
|
|
|
1106
1083
|
// One-shot: armed when a snapshot loads (fresh session open/switch), consumed
|
|
1107
1084
|
// by the next resize so it goes out as an attach claim.
|
|
1085
|
+
handleUserInputRef.current = handleUserInput;
|
|
1108
1086
|
const attachClaimPendingRef = useRef(false);
|
|
1109
1087
|
// Throttle gate for fit-on-type (one fit per typing burst, not per key).
|
|
1110
1088
|
const lastTypeFitAtRef = useRef(0);
|
|
@@ -1452,9 +1430,12 @@ const App = () => {
|
|
|
1452
1430
|
remoteMouseSgrRef.current = message.mouseSgr === true;
|
|
1453
1431
|
optimisticEchoRef.current.reset();
|
|
1454
1432
|
userScrolledUpRef.current = false;
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
|
|
1433
|
+
// Deeper history exists than this snapshot carries: the server says
|
|
1434
|
+
// so outright for serialized snapshots (which are a few KB no
|
|
1435
|
+
// matter how deep the session is), and for raw tails a payload near
|
|
1436
|
+
// the requested bound was almost certainly cut.
|
|
1437
|
+
snapshotWasCappedRef.current = (message.capped === true
|
|
1438
|
+
|| (typeof message.data === "string" && message.data.length >= 350000))
|
|
1458
1439
|
&& !deepReplayRoomsRef.current.has(activeSessionRoomRef.current || "");
|
|
1459
1440
|
// Reset IN the stream (RIS) rather than as a separate call. A bare
|
|
1460
1441
|
// reset() paints an empty terminal immediately, while the snapshot
|
|
@@ -1470,7 +1451,13 @@ const App = () => {
|
|
|
1470
1451
|
deepRestoreRef.current = null;
|
|
1471
1452
|
if (termRef.current) {
|
|
1472
1453
|
const t = termRef.current;
|
|
1473
|
-
|
|
1454
|
+
// Re-assert remote modes in-band after the snapshot: serialized
|
|
1455
|
+
// snapshots carry the screen but not private modes, and xterm's
|
|
1456
|
+
// own mode state gates scroll ownership and selection behavior
|
|
1457
|
+
// (the refs above cover synthesized reports, not xterm itself).
|
|
1458
|
+
const modeSeq = (message.mouseReporting ? "\x1b[?1002h" + (message.mouseSgr ? "\x1b[?1006h" : "") : "")
|
|
1459
|
+
+ (message.cursorHidden ? "\x1b[?25l" : "");
|
|
1460
|
+
t.write(`\x1bc${message.data}${modeSeq}`, () => {
|
|
1474
1461
|
if (pendingRestore) {
|
|
1475
1462
|
// The user was reading at the top of the OLD buffer; that
|
|
1476
1463
|
// content now sits newLen - oldLen lines down. Land there,
|
|
@@ -1772,32 +1759,10 @@ const App = () => {
|
|
|
1772
1759
|
}
|
|
1773
1760
|
return false;
|
|
1774
1761
|
}
|
|
1775
|
-
// Long-press SPACE = voice input
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
&& foregroundIsClaude() && !!speechRecognitionCtor()) {
|
|
1780
|
-
if (event.type === 'keydown') {
|
|
1781
|
-
if (!event.repeat) {
|
|
1782
|
-
window.clearTimeout(voiceTimerRef.current);
|
|
1783
|
-
voiceTimerRef.current = window.setTimeout(startVoiceInput, 550);
|
|
1784
|
-
return true;
|
|
1785
|
-
}
|
|
1786
|
-
return false; // swallow auto-repeat while held
|
|
1787
|
-
}
|
|
1788
|
-
if (event.type === 'keyup') {
|
|
1789
|
-
window.clearTimeout(voiceTimerRef.current);
|
|
1790
|
-
if (voiceActiveRef.current) {
|
|
1791
|
-
stopVoiceInput();
|
|
1792
|
-
return false;
|
|
1793
|
-
}
|
|
1794
|
-
return true;
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
// Any other keydown cancels a pending (not yet active) voice hold —
|
|
1798
|
-
// normal typing must never trip the mic.
|
|
1799
|
-
if (event.type === 'keydown' && event.code !== 'Space') {
|
|
1800
|
-
window.clearTimeout(voiceTimerRef.current);
|
|
1762
|
+
// Long-press SPACE = voice input — shared controller; see voiceHold.ts.
|
|
1763
|
+
{
|
|
1764
|
+
const verdict = voiceHoldRef.current?.handleKey(event);
|
|
1765
|
+
if (verdict !== undefined) return verdict;
|
|
1801
1766
|
}
|
|
1802
1767
|
// Shift+Enter: xterm.js would emit a plain \r (indistinguishable from
|
|
1803
1768
|
// Enter), which apps like Claude Code treat as "submit". Synthesize the
|
|
@@ -15,6 +15,7 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
|
|
15
15
|
import { attachScrollFlywheel } from "../utils/scrollFlywheel";
|
|
16
16
|
import { ContextMenu, type MenuRequest } from "./ContextMenu";
|
|
17
17
|
import { collectTerminalMatches, selectTerminalMatch } from "../utils/terminalSearch";
|
|
18
|
+
import { bufferLooksLikeClaude, createVoiceHold } from "../utils/voiceHold";
|
|
18
19
|
import {
|
|
19
20
|
buildSwitcherModel,
|
|
20
21
|
filterSessionsByOrigin,
|
|
@@ -159,20 +160,41 @@ const PREVIEW_BASE_LH = PREVIEW_BASE_FS * 1.3;
|
|
|
159
160
|
// Measured advance width of one monospace cell at the base font — resolved
|
|
160
161
|
// lazily because the terminal font stack loads with the page.
|
|
161
162
|
let previewCharW = 0;
|
|
163
|
+
const advanceCache = new Map<number, number>();
|
|
162
164
|
if (typeof document !== "undefined") {
|
|
163
|
-
document.fonts?.ready?.then(() => { previewCharW = 0; }).catch(() => { /* no Font API */ });
|
|
165
|
+
document.fonts?.ready?.then(() => { previewCharW = 0; advanceCache.clear(); }).catch(() => { /* no Font API */ });
|
|
164
166
|
}
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
+
const measureAdvanceAt = (fs: number) => {
|
|
168
|
+
const hit = advanceCache.get(fs);
|
|
169
|
+
if (hit) return hit;
|
|
167
170
|
const el = document.createElement("span");
|
|
168
|
-
el.style.cssText = `position:absolute;visibility:hidden;white-space:pre;font-size:${
|
|
171
|
+
el.style.cssText = `position:absolute;visibility:hidden;white-space:pre;font-size:${fs}px;line-height:1`;
|
|
169
172
|
el.style.fontFamily = "var(--font-terminal)";
|
|
170
173
|
el.textContent = "0".repeat(100);
|
|
171
174
|
document.body.appendChild(el);
|
|
172
|
-
|
|
175
|
+
const adv = el.getBoundingClientRect().width / 100 || fs * 0.6;
|
|
173
176
|
el.remove();
|
|
177
|
+
advanceCache.set(fs, adv);
|
|
178
|
+
return adv;
|
|
179
|
+
};
|
|
180
|
+
const measurePreviewCharW = () => {
|
|
181
|
+
if (previewCharW) return previewCharW;
|
|
182
|
+
previewCharW = measureAdvanceAt(PREVIEW_BASE_FS);
|
|
174
183
|
return previewCharW;
|
|
175
184
|
};
|
|
185
|
+
// The WebGL renderer — what live tiles actually use — FLOORS the cell width
|
|
186
|
+
// to whole device pixels: cell = floor(advance × dpr) / dpr (measured
|
|
187
|
+
// empirically at four font sizes across dpr 1 and 2; the DOM renderer
|
|
188
|
+
// instead rounds to hundredths, which is why matching THAT rule still left
|
|
189
|
+
// a shift). The preview's DOM text flows at the font's true fractional
|
|
190
|
+
// advance, so 76+ columns accumulate a visible right-edge disagreement in
|
|
191
|
+
// the preview → terminal swap. Fractional letter-spacing puts preview
|
|
192
|
+
// glyphs on exactly the grid the WebGL terminal will draw.
|
|
193
|
+
const tileLetterSpacing = (fs: number) => {
|
|
194
|
+
const adv = measureAdvanceAt(fs);
|
|
195
|
+
const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
|
|
196
|
+
return Math.floor(adv * dpr) / dpr - adv;
|
|
197
|
+
};
|
|
176
198
|
|
|
177
199
|
// ONE font for the whole wall at a given zoom, derived from the box width —
|
|
178
200
|
// never from any session's geometry. Both live tiles and static previews use
|
|
@@ -192,6 +214,7 @@ const previewScaleObserver = typeof ResizeObserver !== "undefined"
|
|
|
192
214
|
const fs2 = tileFontFor(box.clientWidth);
|
|
193
215
|
inner.style.fontSize = fs2 + "px";
|
|
194
216
|
inner.style.lineHeight = Math.round(fs2 * 1.3) + "px";
|
|
217
|
+
inner.style.letterSpacing = tileLetterSpacing(fs2) + "px";
|
|
195
218
|
}
|
|
196
219
|
})
|
|
197
220
|
: null;
|
|
@@ -216,7 +239,7 @@ const ScaledScreen = ({ frame }: { frame: PreviewFrame }) => {
|
|
|
216
239
|
<div className="switcher-preview-scalebox" ref={boxRef}>
|
|
217
240
|
<div
|
|
218
241
|
className="switcher-preview-screen"
|
|
219
|
-
style={{ fontSize: PREVIEW_BASE_FS, lineHeight: `${PREVIEW_BASE_LH}px` }}
|
|
242
|
+
style={{ fontSize: PREVIEW_BASE_FS, lineHeight: `${PREVIEW_BASE_LH}px`, letterSpacing: `${tileLetterSpacing(PREVIEW_BASE_FS)}px` }}
|
|
220
243
|
>
|
|
221
244
|
{renderPreviewRuns(padded)}
|
|
222
245
|
</div>
|
|
@@ -270,10 +293,10 @@ const runningApp = (s: SwitcherSession) => {
|
|
|
270
293
|
// responsiveness and whether keystrokes are accepted. The old design — an
|
|
271
294
|
// HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
|
|
272
295
|
const LIVETILE_POLL_MS = 5000;
|
|
273
|
-
const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus }: {
|
|
296
|
+
const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice }: {
|
|
274
297
|
wsBase: string; room: string; userName: string; theme: object | undefined;
|
|
275
298
|
live: boolean; claudeApp: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
|
|
276
|
-
onFullscreen: () => void; onUnfocus: () => void;
|
|
299
|
+
onFullscreen: () => void; onUnfocus: () => void; onNotice: (m: string) => void;
|
|
277
300
|
}) => {
|
|
278
301
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
279
302
|
const termRef = useRef<Terminal | null>(null);
|
|
@@ -284,6 +307,24 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
284
307
|
const kbdEnhancedRef = useRef(false);
|
|
285
308
|
// null = watching; "live"/"down" = connected state for the chrome.
|
|
286
309
|
const [conn, setConn] = useState<"live" | "down" | null>(null);
|
|
310
|
+
// Hold-space voice, same controller as the full screen — the tile is where
|
|
311
|
+
// typing mostly happens now, and dictation not existing here was most of
|
|
312
|
+
// "voice doesn't always work".
|
|
313
|
+
const [voiceInterim, setVoiceInterim] = useState<string | null>(null);
|
|
314
|
+
const voiceHoldRef = useRef<ReturnType<typeof createVoiceHold> | null>(null);
|
|
315
|
+
if (!voiceHoldRef.current) {
|
|
316
|
+
voiceHoldRef.current = createVoiceHold({
|
|
317
|
+
send: (data) => sendInputRef.current?.(data),
|
|
318
|
+
notify: (m) => onNoticeRef.current?.(m),
|
|
319
|
+
setOverlay: setVoiceInterim,
|
|
320
|
+
eligible: () => {
|
|
321
|
+
if (claudeAppRef.current) return true;
|
|
322
|
+
const t = termRef.current;
|
|
323
|
+
return !!t && bufferLooksLikeClaude(t as never);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
287
328
|
// In-tile find (⌘F while the tile is live): same engine as the full-screen
|
|
288
329
|
// find bar. Scope follows focus — ⌘F in a live tile searches THIS session;
|
|
289
330
|
// ⌘F anywhere else is the wall filter, which finds sessions.
|
|
@@ -319,6 +360,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
319
360
|
const myIdRef = useRef<string | null>(null);
|
|
320
361
|
const [viewers, setViewers] = useState<Array<{ name: string }>>([]);
|
|
321
362
|
const [driver, setDriver] = useState<{ name: string; color: string } | null>(null);
|
|
363
|
+
const onNoticeRef = useRef(onNotice);
|
|
364
|
+
onNoticeRef.current = onNotice;
|
|
322
365
|
const claudeAppRef = useRef(claudeApp);
|
|
323
366
|
claudeAppRef.current = claudeApp;
|
|
324
367
|
const liveRef = useRef(live);
|
|
@@ -407,6 +450,10 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
407
450
|
// Keys route through refs so the handler survives mode flips untouched.
|
|
408
451
|
term.attachCustomKeyEventHandler((ev) => {
|
|
409
452
|
if (!liveRef.current) return false; // watching: the terminal takes no keys
|
|
453
|
+
{
|
|
454
|
+
const verdict = voiceHoldRef.current?.handleKey(ev);
|
|
455
|
+
if (verdict !== undefined) return verdict;
|
|
456
|
+
}
|
|
410
457
|
if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key.toLowerCase() === "f") {
|
|
411
458
|
// Scope follows focus: find in THIS terminal. (The wall filter stays
|
|
412
459
|
// one ⌘K away; ⌘. still unfocuses the tile.)
|
|
@@ -661,7 +708,17 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
661
708
|
// In-band RIS: clear and repaint in one parse pass. A separate
|
|
662
709
|
// reset() painted an empty frame before the snapshot landed —
|
|
663
710
|
// the blink that made preview → terminal feel like a switch.
|
|
664
|
-
|
|
711
|
+
//
|
|
712
|
+
// Then re-assert the remote app's modes from the flags: a
|
|
713
|
+
// serialized snapshot carries the SCREEN but not private modes
|
|
714
|
+
// (the raw tail incidentally did), and this terminal's own state
|
|
715
|
+
// is what gates wheel forwarding vs local scrollback and
|
|
716
|
+
// plain-drag vs shift-drag selection — without it every tile
|
|
717
|
+
// guessed "no tracking" and scrolling/selection went wrong
|
|
718
|
+
// per-session.
|
|
719
|
+
const modes = (m.mouseReporting ? "\x1b[?1002h" + (m.mouseSgr ? "\x1b[?1006h" : "") : "")
|
|
720
|
+
+ (m.cursorHidden ? "\x1b[?25l" : "");
|
|
721
|
+
term.write("\x1bc" + m.data + modes, () => {
|
|
665
722
|
term.scrollToBottom();
|
|
666
723
|
term.refresh(0, term.rows - 1);
|
|
667
724
|
rescaleRef.current();
|
|
@@ -752,6 +809,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
752
809
|
setConn(null);
|
|
753
810
|
setViewers([]);
|
|
754
811
|
setDriver(null);
|
|
812
|
+
voiceHoldRef.current?.dispose();
|
|
755
813
|
setFind(null);
|
|
756
814
|
findIndexRef.current = -1;
|
|
757
815
|
term.blur();
|
|
@@ -770,6 +828,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
770
828
|
onPointerDown={live ? (e) => e.stopPropagation() : undefined}
|
|
771
829
|
>
|
|
772
830
|
<div className="switcher-focus-term" ref={boxRef} />
|
|
831
|
+
{voiceInterim !== null && (
|
|
832
|
+
<div className="voice-overlay" aria-hidden="true">
|
|
833
|
+
<span className="voice-dot" />
|
|
834
|
+
<span className={voiceInterim ? "voice-text" : "voice-text muted"}>
|
|
835
|
+
{voiceInterim || "Listening… release Space to insert"}
|
|
836
|
+
</span>
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
773
839
|
{live && (
|
|
774
840
|
<div className="switcher-live-chrome" onClick={(e) => e.stopPropagation()}>
|
|
775
841
|
<span className={"switcher-focus-label" + (conn === "down" ? " down" : "")}>
|
|
@@ -1495,6 +1561,30 @@ export const SessionSwitcher = ({
|
|
|
1495
1561
|
body: JSON.stringify({ internalName: sessionKey(session), parked: false })
|
|
1496
1562
|
}).catch(() => { /* daemon will still show it parked; harmless */ });
|
|
1497
1563
|
}
|
|
1564
|
+
// A DEAD terminal session starts IN PLACE instead of falling through to
|
|
1565
|
+
// full screen — the last accidental door into the mode. Attaching is what
|
|
1566
|
+
// starts a known session, so a claim-free throwaway socket kicks it
|
|
1567
|
+
// alive; the tile is pre-focused and goes live the moment the session
|
|
1568
|
+
// reports active. Ports still open full screen (they are web pages), and
|
|
1569
|
+
// without live tiles (no wsBase / small zoom) the old switch stands.
|
|
1570
|
+
if (session.type !== "port" && !session.active && interactiveTiles && tileWsBase) {
|
|
1571
|
+
const key = sessionKey(session);
|
|
1572
|
+
try {
|
|
1573
|
+
const sep = tileWsBase.includes("?") ? "&" : "?";
|
|
1574
|
+
const kick = new WebSocket(
|
|
1575
|
+
tileWsBase + sep + "room=" + encodeURIComponent(key)
|
|
1576
|
+
+ "&name=" + encodeURIComponent((userName || "user") + " (start)")
|
|
1577
|
+
+ "&replay=0&nudge=0&cols=80&rows=24"
|
|
1578
|
+
);
|
|
1579
|
+
kick.onopen = () => { window.setTimeout(() => { try { kick.close(); } catch { /* done */ } }, 400); };
|
|
1580
|
+
kick.onerror = () => { try { kick.close(); } catch { /* refused */ } };
|
|
1581
|
+
} catch { /* daemon will surface it as starting on the next poll */ }
|
|
1582
|
+
onNotice("Starting " + (session.displayName || session.name) + "…");
|
|
1583
|
+
setFocusedKey(key);
|
|
1584
|
+
onFocusSession?.(session);
|
|
1585
|
+
onRefresh();
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1498
1588
|
onSwitch(session);
|
|
1499
1589
|
};
|
|
1500
1590
|
|
|
@@ -1840,8 +1930,8 @@ export const SessionSwitcher = ({
|
|
|
1840
1930
|
setCreating(false);
|
|
1841
1931
|
setCreateDraft("");
|
|
1842
1932
|
if (createInFolder) {
|
|
1843
|
-
// File it
|
|
1844
|
-
//
|
|
1933
|
+
// File it first: the move endpoint is the one place membership is
|
|
1934
|
+
// persisted durably.
|
|
1845
1935
|
await fetch("/api/sessions/move", {
|
|
1846
1936
|
method: "POST",
|
|
1847
1937
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1849,7 +1939,13 @@ export const SessionSwitcher = ({
|
|
|
1849
1939
|
}).catch(() => { /* filing is best-effort; the session exists */ });
|
|
1850
1940
|
setCreateInFolder(null);
|
|
1851
1941
|
}
|
|
1852
|
-
|
|
1942
|
+
// Stay on the wall. Creating used to jump straight to full screen —
|
|
1943
|
+
// the exact accidental mode-entry the hotkey inversion removed from
|
|
1944
|
+
// clicks. The new tile becomes current and goes live in place as soon
|
|
1945
|
+
// as the session reports active.
|
|
1946
|
+
onFocusSession?.({ name: data.name, displayName: data.name, active: false, starting: true, internalName: data.name });
|
|
1947
|
+
setFocusedKey(data.name);
|
|
1948
|
+
onRefresh();
|
|
1853
1949
|
} catch {
|
|
1854
1950
|
onNotice("Failed to create session");
|
|
1855
1951
|
}
|
|
@@ -2016,6 +2112,7 @@ export const SessionSwitcher = ({
|
|
|
2016
2112
|
activeRows={preview?.rows}
|
|
2017
2113
|
onFullscreen={() => onSwitch(s)}
|
|
2018
2114
|
onUnfocus={() => setFocusedKey(null)}
|
|
2115
|
+
onNotice={onNotice}
|
|
2019
2116
|
/>
|
|
2020
2117
|
</div>
|
|
2021
2118
|
) : (
|
|
@@ -2235,10 +2332,10 @@ export const SessionSwitcher = ({
|
|
|
2235
2332
|
</div>
|
|
2236
2333
|
{/* Create form lives in the top bar so the header + works from EVERY
|
|
2237
2334
|
mode — the dashed grid card only exists in recent mode. */}
|
|
2238
|
-
{creating && (
|
|
2335
|
+
{creating && !createInFolder && (
|
|
2239
2336
|
<form className="switcher-create-row inline-edit" onSubmit={submitCreate}>
|
|
2240
2337
|
<input
|
|
2241
|
-
placeholder=
|
|
2338
|
+
placeholder="session-name"
|
|
2242
2339
|
value={createDraft}
|
|
2243
2340
|
onChange={(e) => setCreateDraft(e.target.value)}
|
|
2244
2341
|
maxLength={64}
|
|
@@ -2331,8 +2428,22 @@ export const SessionSwitcher = ({
|
|
|
2331
2428
|
<button type="button" onClick={() => renameFolder(folder)} title="Rename folder">✎</button>
|
|
2332
2429
|
<button type="button" onClick={() => deleteFolder(folder)} title="Delete folder">🗑</button>
|
|
2333
2430
|
</h3>
|
|
2431
|
+
{creating && createInFolder?.id === folder.id && (
|
|
2432
|
+
<form className="switcher-create-row inline-edit switcher-create-infolder" onSubmit={submitCreate}>
|
|
2433
|
+
<input
|
|
2434
|
+
placeholder={"new session in " + folder.name}
|
|
2435
|
+
value={createDraft}
|
|
2436
|
+
onChange={(e) => setCreateDraft(e.target.value)}
|
|
2437
|
+
maxLength={64}
|
|
2438
|
+
autoFocus
|
|
2439
|
+
aria-label={"New session name in " + folder.name}
|
|
2440
|
+
/>
|
|
2441
|
+
<button type="submit">Create</button>
|
|
2442
|
+
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
|
|
2443
|
+
</form>
|
|
2444
|
+
)}
|
|
2334
2445
|
{rows.length === 0 ? (
|
|
2335
|
-
<p className="switcher-folder-empty">Drag a session here</p>
|
|
2446
|
+
creating && createInFolder?.id === folder.id ? null : <p className="switcher-folder-empty">Drag a session here, or + to create one</p>
|
|
2336
2447
|
) : (
|
|
2337
2448
|
<div className="switcher-grid">{rows.map((s) => renderCard(s))}</div>
|
|
2338
2449
|
)}
|
|
@@ -3892,3 +3892,16 @@ button.primary {
|
|
|
3892
3892
|
opacity: 1;
|
|
3893
3893
|
}
|
|
3894
3894
|
}
|
|
3895
|
+
|
|
3896
|
+
/* Create-in-folder: the input appears INSIDE the folder, where the session
|
|
3897
|
+
will land — not up in the header bar two scrolls away from the + that
|
|
3898
|
+
asked for it. */
|
|
3899
|
+
.switcher-folder .switcher-create-infolder {
|
|
3900
|
+
margin: 4px 0 8px;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
/* Voice overlay inside a live tile: same pill, tile-scaled. */
|
|
3904
|
+
.switcher-live-tile .voice-overlay {
|
|
3905
|
+
bottom: 30px;
|
|
3906
|
+
max-width: 86%;
|
|
3907
|
+
}
|