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.
@@ -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 (!room) return false;
568
- const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
569
- const proc = (s?.foregroundProcess || "").trim();
570
- return proc === "claude" || /^\d+\.\d+\.\d+$/.test(proc);
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
- // Browser SpeechRecognition (Chrome + Safari; where absent, space behaves
957
- // normally). The first press types its space instantly — no latency on the
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 speechRecognitionCtor = () =>
969
- (window as unknown as { SpeechRecognition?: unknown; webkitSpeechRecognition?: unknown }).SpeechRecognition
970
- || (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition;
971
- const finishVoiceInput = (send: boolean) => {
972
- if (!voiceActiveRef.current) return;
973
- voiceActiveRef.current = false;
974
- voiceRecRef.current = null;
975
- setVoiceOverlay(null);
976
- // Engines usually finalize pending speech on stop(); if only an interim
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
- // A snapshot near the requested bound was almost certainly cut —
1456
- // that's when the "load full history" pill is worth offering.
1457
- snapshotWasCappedRef.current = typeof message.data === "string" && message.data.length >= 350000
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
- t.write(`\x1bc${message.data}`, () => {
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 (Claude sessions, when the browser has
1776
- // SpeechRecognition). First press types its space with zero latency;
1777
- // holding past the threshold erases it and dictates until release.
1778
- if (event.code === 'Space' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey
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 measurePreviewCharW = () => {
166
- if (previewCharW) return previewCharW;
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:${PREVIEW_BASE_FS}px;line-height:1`;
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
- previewCharW = el.getBoundingClientRect().width / 100 || PREVIEW_BASE_FS * 0.6;
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
- term.write("\x1bc" + m.data, () => {
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 before switching: reuses the move endpoint, which is the
1844
- // one place membership is persisted durably.
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
- onSwitch({ name: data.name, displayName: data.name, active: false, starting: true, internalName: data.name });
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={createInFolder ? "new session in " + createInFolder.name : "session-name"}
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
+ }