hop2 0.11.63 → 0.11.65

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.
@@ -22,6 +22,7 @@ import { getMathTip } from "./utils/mathTooltip";
22
22
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
23
23
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
24
24
  import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
25
+ import { installDragSelect } from "./utils/dragSelect";
25
26
  import { MobileKeyboard } from "./components/MobileKeyboard";
26
27
  import { SessionSwitcher } from "./components/SessionSwitcher";
27
28
  import { SecondaryPane } from "./components/SecondaryPane";
@@ -261,6 +262,10 @@ type SessionInfo = {
261
262
  createdBy?: "user" | "agent";
262
263
  createdVia?: string | null;
263
264
  agent?: "claude" | "codex" | null;
265
+ // Server-decided: a turn in flight, or the last one finished — and whether
266
+ // a human has opened the session since it finished (see lib/agent-phase.js).
267
+ agentPhase?: "working" | "done" | null;
268
+ turnSeen?: boolean;
264
269
  attentionReason?: string;
265
270
  attentionNote?: string;
266
271
  cols?: number;
@@ -739,6 +744,14 @@ const App = () => {
739
744
  return screenLooksLikeClaude();
740
745
  };
741
746
  foregroundIsClaudeRef.current = foregroundIsClaude;
747
+ // Which agent the current session runs, by the daemon's hook records.
748
+ const currentSessionAgentRef = useRef<(() => "claude" | "codex" | null) | null>(null);
749
+ currentSessionAgentRef.current = () => {
750
+ const room = activeSessionRoomRef.current;
751
+ if (!room) return null;
752
+ const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
753
+ return s?.agent || null;
754
+ };
742
755
  const drawerOpenRef = useRef(drawerOpen);
743
756
  drawerOpenRef.current = drawerOpen;
744
757
  const shortcutHelpRef = useRef(shortcutHelpOpen);
@@ -2094,6 +2107,9 @@ const App = () => {
2094
2107
  fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font-terminal").trim()
2095
2108
  || "Menlo, Monaco, 'Courier New', monospace",
2096
2109
  fontSize,
2110
+ // ⌥-drag always selects, iTerm's escape hatch for apps that track the
2111
+ // mouse. (Plain drag selects too in agent sessions — utils/dragSelect.)
2112
+ macOptionClickForcesSelection: true,
2097
2113
  lineHeight: 1.3,
2098
2114
  cursorBlink: true,
2099
2115
  scrollback: 50000,
@@ -2132,6 +2148,14 @@ const App = () => {
2132
2148
  // Unicode, the browser around the terminal draws properly.
2133
2149
  enableMathHover(terminal, getMathTip());
2134
2150
  terminal.open(containerRef.current);
2151
+ // Plain drag selects in Claude/Codex sessions even though they track the
2152
+ // mouse; the wheel still reaches them. See utils/dragSelect.ts.
2153
+ // Hooked on the SCROLL wrapper, not xterm's host: a press in the gutter
2154
+ // lands on the wrapper (which still starts a selection when tracking is
2155
+ // off), and a capture listener only sees presses on its own subtree.
2156
+ (terminal as any).__dragSelectCleanup = installDragSelect(terminal as never,
2157
+ (containerRef.current.closest(".terminal-scroll") as HTMLElement | null) || containerRef.current, () =>
2158
+ foregroundIsClaudeRef.current?.() === true || currentSessionAgentRef.current?.() === "codex");
2135
2159
 
2136
2160
  // GPU-accelerated rendering (same renderer VS Code uses). Must load after
2137
2161
  // open(). If WebGL isn't available (old GPU, blocklisted driver) the
@@ -3028,6 +3052,10 @@ const App = () => {
3028
3052
  (terminal as any).__overlayScrollbarCleanup();
3029
3053
  (terminal as any).__overlayScrollbarCleanup = null;
3030
3054
  }
3055
+ if ((terminal as any).__dragSelectCleanup) {
3056
+ (terminal as any).__dragSelectCleanup();
3057
+ (terminal as any).__dragSelectCleanup = null;
3058
+ }
3031
3059
  terminal.dispose();
3032
3060
  termRef.current = null;
3033
3061
  fitRef.current = null;
@@ -3205,6 +3233,8 @@ const App = () => {
3205
3233
  createdBy: s.createdBy === "agent" ? "agent" : "user",
3206
3234
  createdVia: typeof s.createdVia === "string" ? s.createdVia : null,
3207
3235
  agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
3236
+ agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
3237
+ turnSeen: s.turnSeen !== false,
3208
3238
  cols: Number.isInteger(s.cols) ? s.cols : undefined,
3209
3239
  rows: Number.isInteger(s.rows) ? s.rows : undefined,
3210
3240
  // These three were silently dropped here, so the switcher never saw
@@ -3966,7 +3996,7 @@ const App = () => {
3966
3996
  grab();
3967
3997
  setViewsOpen((v) => v ? null
3968
3998
  : (activeSessionRoomRef.current
3969
- ? { session: activeSessionRoomRef.current, dock: true }
3999
+ ? { session: activeSessionRoomRef.current }
3970
4000
  : {}));
3971
4001
  return;
3972
4002
  }
@@ -4160,7 +4190,7 @@ const App = () => {
4160
4190
  // DOCKED and scoped: in full-screen mode you almost always want
4161
4191
  // THIS session's results, read beside the live terminal rather
4162
4192
  // than over it. The panel's own toggle widens to the fleet.
4163
- onClick={() => setViewsOpen({ session: session.room, dock: true })}
4193
+ onClick={() => setViewsOpen({ session: session.room })}
4164
4194
  >
4165
4195
  Views
4166
4196
  {(() => {
@@ -4214,7 +4244,7 @@ const App = () => {
4214
4244
  onRefresh={() => fetchSessions({ showLoading: false })}
4215
4245
  onNotice={showToast}
4216
4246
  tileWsBase={resolveWsUrl()}
4217
- onOpenViews={(scope) => setViewsOpen({ session: scope, dock: true })}
4247
+ onOpenViews={(scope) => setViewsOpen({ session: scope })}
4218
4248
  userName={name}
4219
4249
  terminalTheme={resolveTerminalTheme(themeMode)}
4220
4250
  />
@@ -4878,7 +4908,7 @@ const App = () => {
4878
4908
  tileWsBase={resolveWsUrl()}
4879
4909
  onFocusSession={focusSessionInPlace}
4880
4910
  folders={folders}
4881
- onOpenViews={(scope) => setViewsOpen({ session: scope, dock: true })}
4911
+ onOpenViews={(scope) => setViewsOpen({ session: scope })}
4882
4912
  userName={name}
4883
4913
  terminalTheme={resolveTerminalTheme(themeMode)}
4884
4914
  onOpenSettings={() => {
@@ -1,4 +1,6 @@
1
1
  import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
+ import { installDragSelect } from "../utils/dragSelect";
3
+ import { bufferLooksLikeClaude } from "../utils/voiceHold";
2
4
  import { Terminal } from "@xterm/xterm";
3
5
  import { FitAddon } from "@xterm/addon-fit";
4
6
  import { safeParseServerMessage } from "hay-shared";
@@ -58,6 +60,8 @@ export const SecondaryPane = ({
58
60
  onSplit,
59
61
  onZoom
60
62
  }: Props) => {
63
+ const procLabelRef = useRef(procLabel);
64
+ procLabelRef.current = procLabel;
61
65
  const hostRef = useRef<HTMLDivElement>(null);
62
66
  const boxRef = useRef<HTMLDivElement>(null);
63
67
  const termRef = useRef<Terminal | null>(null);
@@ -77,6 +81,7 @@ export const SecondaryPane = ({
77
81
  cols: sizeRef.current.cols,
78
82
  rows: sizeRef.current.rows,
79
83
  fontSize,
84
+ macOptionClickForcesSelection: true,
80
85
  fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
81
86
  theme: theme as never,
82
87
  scrollback: 2000,
@@ -86,6 +91,10 @@ export const SecondaryPane = ({
86
91
  term.loadAddon(fitAddon);
87
92
  enableMathHover(term, getMathTip());
88
93
  term.open(hostRef.current);
94
+ // Plain drag selects in Claude/Codex panes even though they track the
95
+ // mouse; the wheel still reaches them. See utils/dragSelect.ts.
96
+ const uninstallDragSelect = installDragSelect(term as never, hostRef.current, () =>
97
+ /^(claude|codex)$/i.test(procLabelRef.current || "") || bufferLooksLikeClaude(term as never));
89
98
  termRef.current = term;
90
99
 
91
100
  let shouldReconnect = true;
@@ -222,6 +231,7 @@ export const SecondaryPane = ({
222
231
  ro.disconnect();
223
232
  dataSub.dispose();
224
233
  try { wsRef.current?.close(); } catch { /* ignore */ }
234
+ uninstallDragSelect();
225
235
  term.dispose();
226
236
  termRef.current = null;
227
237
  };
@@ -15,6 +15,9 @@ import { FitAddon } from "@xterm/addon-fit";
15
15
  import { WebLinksAddon } from "@xterm/addon-web-links";
16
16
  import { enableMathHover } from "../utils/mathLinkProvider";
17
17
  import { getMathTip } from "../utils/mathTooltip";
18
+ import { claimOnTileFocus, sizeIsForeign } from "../utils/sizeClaim";
19
+ import { cardPhase, phaseLabel, phaseTitle } from "../utils/agentPhase";
20
+ import { installDragSelect } from "../utils/dragSelect";
18
21
  import { urlAtCell, cellAtPoint } from "../utils/urlAtCell";
19
22
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
20
23
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
@@ -354,11 +357,17 @@ const runningApp = (s: SwitcherSession) => {
354
357
  // responsiveness and whether keystrokes are accepted. The old design — an
355
358
  // HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
356
359
  const LIVETILE_POLL_MS = 5000;
357
- const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
360
+ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp, codexApp = false, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
358
361
  /** The wall keeps a registry of live tiles' input senders (file drops). */
359
362
  onSender?: (send: ((data: string) => void) | null) => void;
360
363
  wsBase: string; room: string; userName: string; theme: object | undefined;
361
- live: boolean; claudeApp: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
364
+ /**
365
+ * `live` says the tile is the focused one; `deliberate` says the human
366
+ * put it there (click, Enter, start, create) rather than the wall
367
+ * pre-focusing the current session as it opened. Only a deliberate focus
368
+ * takes the session's size — see claimOnTileFocus.
369
+ */
370
+ live: boolean; deliberate: boolean; claudeApp: boolean; codexApp?: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
362
371
  onFullscreen: () => void; onUnfocus: () => void; onNotice: (m: string) => void;
363
372
  }) => {
364
373
  const boxRef = useRef<HTMLDivElement | null>(null);
@@ -367,6 +376,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
367
376
  const rescaleRef = useRef<() => void>(() => {});
368
377
  // Wired by the live effect; watch mode leaves them null so keys go nowhere.
369
378
  const sendInputRef = useRef<((data: string) => void) | null>(null);
379
+ const deliberateRef = useRef(deliberate);
380
+ deliberateRef.current = deliberate;
370
381
  // When the link addon last opened something from this tile — so the click
371
382
  // that follows its mouseup does not open the same URL a second time.
372
383
  const lastLinkOpenAtRef = useRef(0);
@@ -417,6 +428,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
417
428
  // every session that came back through `hop restore`. The buffer's own
418
429
  // chrome is the honest witness; both callers share it.
419
430
  const looksLikeClaude = () => isClaudeSurface(claudeAppRef.current, termRef.current as never);
431
+ const codexAppRef = useRef(codexApp);
432
+ codexAppRef.current = codexApp;
420
433
  if (!voiceHoldRef.current) {
421
434
  voiceHoldRef.current = createVoiceHold({
422
435
  send: (data) => sendInputRef.current?.(data),
@@ -491,6 +504,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
491
504
  cols: activeCols && activeCols > 1 ? activeCols : 80,
492
505
  rows: activeRows && activeRows > 1 ? activeRows : 24,
493
506
  scrollback: 2000,
507
+ macOptionClickForcesSelection: true,
494
508
  // Same scroll feel as the full-screen terminal: one wheel notch moves
495
509
  // 4 lines (xterm's default of 1 is the "tiles feel sluggish" report),
496
510
  // Shift+wheel blasts. Momentum is attached below.
@@ -522,6 +536,9 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
522
536
  }));
523
537
  enableMathHover(term, getMathTip()); // rendered math on hover, here too
524
538
  term.open(box);
539
+ // Plain drag selects in Claude/Codex tiles even though they track the
540
+ // mouse; the wheel still reaches them. See utils/dragSelect.ts.
541
+ const uninstallDragSelect = installDragSelect(term as never, box, () => looksLikeClaude() || codexAppRef.current);
525
542
  termRef.current = term;
526
543
  fitRef.current = fit;
527
544
  const inner = box.querySelector(".xterm") as HTMLElement | null;
@@ -741,6 +758,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
741
758
  detachFlywheel();
742
759
  ro?.disconnect();
743
760
  sub.dispose();
761
+ uninstallDragSelect();
744
762
  term.dispose();
745
763
  termRef.current = null;
746
764
  fitRef.current = null;
@@ -782,13 +800,9 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
782
800
  // for sizes that are genuinely foreign (full-screen leftovers), not for
783
801
  // disagreements about rounding.
784
802
  const cur = termRef.current;
785
- if (cur) {
786
- const closeCols = Math.abs(cur.cols - dims.cols) <= Math.max(3, Math.round(dims.cols * 0.15));
787
- const closeRows = Math.abs(cur.rows - dims.rows) <= Math.max(2, Math.round(dims.rows * 0.15));
788
- if (closeCols && closeRows) {
789
- lastClaimRef.current = { cols: dims.cols, rows: dims.rows, at: performance.now() };
790
- return;
791
- }
803
+ if (cur && !sizeIsForeign({ cols: cur.cols, rows: cur.rows }, { cols: dims.cols, rows: dims.rows })) {
804
+ lastClaimRef.current = { cols: dims.cols, rows: dims.rows, at: performance.now() };
805
+ return;
792
806
  }
793
807
  lastClaimRef.current = { cols: dims.cols, rows: dims.rows, at: performance.now() };
794
808
  const sep = wsBase.includes("?") ? "&" : "?";
@@ -931,6 +945,31 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
931
945
  let reconnectTimer = 0;
932
946
  let reconnectAttempt = 0;
933
947
  const pendingInput: Array<{ data: string; at: number }> = [];
948
+ // Engaging a tile is an act on it, the same as opening the session full
949
+ // screen, and takes the size the same way — through THIS socket, as an
950
+ // attach claim, so ownership moves here and the election's active_size
951
+ // confirms the fit (the handler below resizes the tile to it). Until
952
+ // now a clicked tile only ever attached at the session's current size
953
+ // and fitted itself on the first keystroke; a session wearing a
954
+ // full-screen size stayed a wall of tiny text when clicked ("clicking
955
+ // the tile does not enter the terminal, but full screen does"). Once
956
+ // per focus: a reconnect is the same socket healing, not a new act.
957
+ let focusClaimDone = false;
958
+ const claimFitOnFocus = (sock: WebSocket) => {
959
+ if (focusClaimDone) return;
960
+ focusClaimDone = true;
961
+ const fitAddon = fitRef.current;
962
+ const boxEl = boxRef.current;
963
+ const t = termRef.current;
964
+ if (!fitAddon || !boxEl || !t || boxEl.clientWidth < 60 || boxEl.clientHeight < 40) return;
965
+ const dims = fitAddon.proposeDimensions();
966
+ const natural = dims?.cols && dims?.rows ? { cols: dims.cols, rows: dims.rows } : null;
967
+ if (!natural || !claimOnTileFocus({ deliberate: deliberateRef.current, natural, current: { cols: t.cols, rows: t.rows } })) return;
968
+ lastClaimRef.current = { cols: natural.cols, rows: natural.rows, at: performance.now() };
969
+ try {
970
+ sock.send(JSON.stringify({ type: "resize", cols: natural.cols, rows: natural.rows, claim: "attach", user: true }));
971
+ } catch { /* closing; the next act re-claims */ }
972
+ };
934
973
  // Sub-second drops stay invisible: the "reconnecting…" chrome waits out
935
974
  // a grace window, and the first retry goes immediately — a tunnel
936
975
  // restart or a waking laptop usually reconnects before either fires.
@@ -963,6 +1002,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
963
1002
  if (disposed || ws !== sock) return;
964
1003
  reconnectAttempt = 0;
965
1004
  markLive();
1005
+ claimFitOnFocus(sock);
966
1006
  window.setTimeout(() => rescaleRef.current(), 30);
967
1007
  const cutoff = Date.now() - 15000;
968
1008
  for (const pend of pendingInput) {
@@ -1614,7 +1654,11 @@ export const SessionSwitcher = ({
1614
1654
  else sessionStorage.removeItem("hay_switcher_focus");
1615
1655
  } catch { /* private mode */ }
1616
1656
  }, [focusedKey]);
1617
- useEffect(() => { if (!open) setFocusedKey(null); }, [open]);
1657
+ // The key the HUMAN focused (click, Enter, start, create), as opposed to
1658
+ // the wall pre-focusing the current session as it opens. The live tile
1659
+ // takes the session's size only for the former.
1660
+ const deliberateFocusRef = useRef<string | null>(null);
1661
+ useEffect(() => { if (!open) { setFocusedKey(null); deliberateFocusRef.current = null; } }, [open]);
1618
1662
  useEffect(() => { if (!interactiveTiles) setFocusedKey(null); }, [interactiveTiles]);
1619
1663
  const [renameDraft, setRenameDraft] = useState("");
1620
1664
  const [creating, setCreating] = useState(false);
@@ -2002,6 +2046,7 @@ export const SessionSwitcher = ({
2002
2046
  if (!current) return;
2003
2047
  initialFocusDoneRef.current = true;
2004
2048
  const key = sessionKey(current);
2049
+ deliberateFocusRef.current = null; // the wall's own pre-focus, not an act
2005
2050
  setFocusedKey(key);
2006
2051
  const idx = navIndexByKey.get(key);
2007
2052
  if (typeof idx === "number") {
@@ -2242,6 +2287,7 @@ export const SessionSwitcher = ({
2242
2287
  const focusInPlace = (session: SwitcherSession) => {
2243
2288
  const key = sessionKey(session);
2244
2289
  filterInputRef.current?.blur();
2290
+ deliberateFocusRef.current = key;
2245
2291
  setFocusedKey(key);
2246
2292
  onFocusSession?.(session);
2247
2293
  const idx = flatNav.findIndex((x) => sessionKey(x) === key);
@@ -2291,6 +2337,7 @@ export const SessionSwitcher = ({
2291
2337
  kick.onerror = () => { try { kick.close(); } catch { /* refused */ } };
2292
2338
  } catch { /* daemon will surface it as starting on the next poll */ }
2293
2339
  onNotice("Starting " + (session.displayName || session.name) + "…");
2340
+ deliberateFocusRef.current = key;
2294
2341
  setFocusedKey(key);
2295
2342
  onFocusSession?.(session);
2296
2343
  onRefresh();
@@ -2703,6 +2750,7 @@ export const SessionSwitcher = ({
2703
2750
  if (hiddenByOrigin) setOriginScope("all");
2704
2751
  if (taken.parked) setParkedOpen(true);
2705
2752
  const key = sessionKey(taken);
2753
+ deliberateFocusRef.current = key;
2706
2754
  setFocusedKey(key);
2707
2755
  if (visible) {
2708
2756
  const idx = navIndexByKey.get(key);
@@ -2746,6 +2794,7 @@ export const SessionSwitcher = ({
2746
2794
  // The id is minted server-side now; the display name is what the user
2747
2795
  // typed. Keys ride on the id, labels on the name.
2748
2796
  onFocusSession?.({ name: data.displayName || data.name, displayName: data.displayName || data.name, active: false, starting: true, internalName: data.internalName || data.name });
2797
+ deliberateFocusRef.current = data.internalName || data.name;
2749
2798
  setFocusedKey(data.internalName || data.name);
2750
2799
  onRefresh();
2751
2800
  } catch {
@@ -2831,7 +2880,13 @@ export const SessionSwitcher = ({
2831
2880
  // producing output RIGHT NOW glows instead of jumping to the top.
2832
2881
  // Window comfortably exceeds the 10s relative-time tick so a busy
2833
2882
  // session's glow holds steady rather than flickering between polls.
2834
- const activeNow = !current && now - (s.lastActivityAt || 0) < 12000;
2883
+ // Agent sessions wear their PHASE (working / done / read) instead of the
2884
+ // generic "output right now" ring: the daemon's verdict is the sharper
2885
+ // signal, and two rings on one card were unreadable.
2886
+ const phase = cardPhase(s);
2887
+ const activeNow = !current && !phase && now - (s.lastActivityAt || 0) < 12000;
2888
+ // A question waiting on you is the card's colour too (amber), over the phase.
2889
+ const needsYou = s.attentionReason === "ask" || waitingOnUser(preview);
2835
2890
  // Freshness: recency the eye can read WITHOUT the row moving. A left-edge
2836
2891
  // bar whose intensity decays from full (just active) to nothing over
2837
2892
  // ~10 minutes — bright means "just now", faint means "a while ago",
@@ -2852,7 +2907,7 @@ export const SessionSwitcher = ({
2852
2907
  tabIndex={0}
2853
2908
  data-nav-index={navIndexByKey.get(key)}
2854
2909
  data-session-key={key}
2855
- className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}${fileDropKey === key ? " file-drop" : ""}`}
2910
+ className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${phase ? ` phase-${phase}` : ""}${needsYou ? " needs-you" : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}${fileDropKey === key ? " file-drop" : ""}`}
2856
2911
  draggable={draggable}
2857
2912
  onDragStart={draggable ? (e) => { setDragKey(key); e.dataTransfer.effectAllowed = "move"; } : undefined}
2858
2913
  onDragEnter={draggable ? () => { if (dragKey && dragKey !== key) moveManual(dragKey, key); } : undefined}
@@ -2941,6 +2996,11 @@ export const SessionSwitcher = ({
2941
2996
  </span>
2942
2997
  )}
2943
2998
  {!current && s.starting && !s.active && <span className="switcher-chip starting">STARTING</span>}
2999
+ {phase && (
3000
+ <span className={`switcher-chip phase ${phase}`} title={phaseTitle(phase)}>
3001
+ <span className="phase-dot" aria-hidden="true" />{phaseLabel(phase)}
3002
+ </span>
3003
+ )}
2944
3004
  {/* A result waiting on this session is worth as much as its status:
2945
3005
  a published PDF or write-up was otherwise invisible from the wall.
2946
3006
  stopPropagation on pointerdown keeps the long-press sheet out of
@@ -2976,7 +3036,9 @@ export const SessionSwitcher = ({
2976
3036
  userName={userName || "user"}
2977
3037
  theme={terminalTheme}
2978
3038
  live={focusedKey === key}
3039
+ deliberate={deliberateFocusRef.current === key}
2979
3040
  claudeApp={appLabel(s) === "claude"}
3041
+ codexApp={appLabel(s) === "codex"}
2980
3042
  claimSize={s.hasLocalCli !== true}
2981
3043
  activeCols={preview?.cols}
2982
3044
  activeRows={preview?.rows}
@@ -60,8 +60,10 @@ type Props = {
60
60
  // Only used to translate internalName into the name the user renamed it to;
61
61
  // the manifest has no idea a session was ever renamed.
62
62
  sessions?: { name: string; displayName?: string; internalName?: string }[];
63
- // Right-side dock beside a live terminal (full-screen mode). Falls back to
64
- // the modal below ~1100px, where a dock would leave no terminal to see.
63
+ // Right-side dock beside a live terminal. Off by default everywhere now:
64
+ // the reader opens as the CENTERED window, index beside page — a dock at
65
+ // the screen's edge was farther from where the eye already is. The dock
66
+ // stays available for a caller that wants it.
65
67
  dock?: boolean;
66
68
  onClose: () => void;
67
69
  };
@@ -2249,8 +2249,18 @@ button.primary {
2249
2249
  .switcher-card.active-now .switcher-card-fresh { animation: none; }
2250
2250
  }
2251
2251
 
2252
- .switcher-card.current {
2253
- border-color: var(--accent);
2252
+ /* CURRENT and FOCUSED used to paint the whole frame purple, which made
2253
+ purple the loudest colour on the wall — and purple says nothing about
2254
+ the session. The frame and the wash now belong to STATUS (see the
2255
+ phase/needs-you rules below); current is a purple bar along the top
2256
+ edge plus its chip, focused a thin purple outline. */
2257
+ .switcher-card.current::before {
2258
+ content: "";
2259
+ position: absolute;
2260
+ top: 0; left: 0; right: 0;
2261
+ height: 3px;
2262
+ background: var(--accent);
2263
+ pointer-events: none;
2254
2264
  }
2255
2265
 
2256
2266
  /* The wall's order freezes while open — a session producing output RIGHT NOW
@@ -2971,7 +2981,6 @@ button.primary {
2971
2981
  .switcher-row.kbd-selected {
2972
2982
  outline: 2px solid var(--accent);
2973
2983
  outline-offset: -2px;
2974
- background: color-mix(in srgb, var(--accent) 7%, var(--panel));
2975
2984
  }
2976
2985
 
2977
2986
  /* AI tagline (hop ai tagline): what this session is working on, in words the
@@ -3185,7 +3194,8 @@ button.primary {
3185
3194
  }
3186
3195
 
3187
3196
  .switcher-card.focused {
3188
- border-color: var(--accent);
3197
+ outline: 1px solid var(--accent);
3198
+ outline-offset: -1px;
3189
3199
  }
3190
3200
 
3191
3201
  @media (min-width: 769px) {
@@ -3795,6 +3805,65 @@ body.pane-dragging { user-select: none; }
3795
3805
  color: #1a1200;
3796
3806
  }
3797
3807
 
3808
+ /* The agent's phase, the wall's three colours (utils/agentPhase.ts):
3809
+ WORKING blue, DONE (unread) green, READ quiet. Purple stays "current /
3810
+ focused", amber stays "asking you", so no two signals share a hue. */
3811
+ .switcher-chip.phase {
3812
+ display: inline-flex;
3813
+ align-items: center;
3814
+ gap: 4px;
3815
+ }
3816
+ .switcher-chip.phase .phase-dot {
3817
+ width: 6px;
3818
+ height: 6px;
3819
+ border-radius: 50%;
3820
+ background: currentColor;
3821
+ }
3822
+ .switcher-chip.phase.working {
3823
+ background: color-mix(in srgb, #3b82f6 22%, var(--bg-alt));
3824
+ color: #3b82f6;
3825
+ }
3826
+ .switcher-chip.phase.working .phase-dot {
3827
+ animation: phase-working-pulse 1.2s ease-in-out infinite;
3828
+ }
3829
+ @keyframes phase-working-pulse {
3830
+ 0%, 100% { opacity: 1; transform: scale(1); }
3831
+ 50% { opacity: 0.35; transform: scale(0.7); }
3832
+ }
3833
+ .switcher-chip.phase.done-unread {
3834
+ background: #22a06b;
3835
+ color: #04170d;
3836
+ }
3837
+ .switcher-chip.phase.done-read {
3838
+ background: var(--bg-alt);
3839
+ color: var(--ink-muted);
3840
+ }
3841
+ @media (prefers-reduced-motion: reduce) {
3842
+ .switcher-chip.phase.working .phase-dot { animation: none; }
3843
+ }
3844
+ /* STATUS owns the card's colour: the frame, a wash over the whole card and
3845
+ a soft ring, all in one hue — blue working, green finished-and-unread,
3846
+ amber asking you (wins over the phase: a question is the more urgent
3847
+ fact). A read session goes quiet. Purple is reserved for "you are here". */
3848
+ .switcher-card.phase-working { --status: #3b82f6; }
3849
+ .switcher-card.phase-done-unread { --status: #22a06b; }
3850
+ .switcher-card.needs-you { --status: var(--warn); }
3851
+ .switcher-card.phase-working,
3852
+ .switcher-card.phase-done-unread,
3853
+ .switcher-card.needs-you {
3854
+ border-color: var(--status);
3855
+ background: color-mix(in srgb, var(--status) 9%, var(--panel));
3856
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--status) 35%, transparent), 0 0 14px -3px color-mix(in srgb, var(--status) 45%, transparent);
3857
+ }
3858
+ .switcher-card.phase-done-read {
3859
+ opacity: 0.88;
3860
+ }
3861
+ .switcher-card.phase-done-read.current,
3862
+ .switcher-card.phase-done-read.focused,
3863
+ .switcher-card.phase-done-read:hover {
3864
+ opacity: 1;
3865
+ }
3866
+
3798
3867
  /* Colour-preview rows. Inline spans paint their background only over the
3799
3868
  glyph box, and at line-height 1.3 the half-leading above and below stayed
3800
3869
  the tile's background — code blocks rendered as horizontal stripes with a
@@ -0,0 +1,26 @@
1
+ // The wall's three agent colours, from the daemon's verdict. The daemon
2
+ // decides working / done and whether a human has opened the session since
3
+ // the turn ended (its seen witness counts every device), so the web wall and
4
+ // the phone paint the same session the same colour.
5
+ //
6
+ // working blue a turn is in flight
7
+ // done-unread green finished; nobody has opened it since
8
+ // done-read quiet finished, and looked at
9
+ //
10
+ // A question the agent is asking (attentionReason "ask") keeps its own amber
11
+ // signal on top; it is not folded in here.
12
+ export type CardPhase = "working" | "done-unread" | "done-read" | null;
13
+
14
+ export const cardPhase = (s: { agentPhase?: "working" | "done" | null; turnSeen?: boolean }): CardPhase => {
15
+ if (s.agentPhase === "working") return "working";
16
+ if (s.agentPhase === "done") return s.turnSeen === false ? "done-unread" : "done-read";
17
+ return null;
18
+ };
19
+
20
+ export const phaseLabel = (p: CardPhase): string =>
21
+ p === "working" ? "WORKING" : p === "done-unread" ? "DONE" : p === "done-read" ? "READ" : "";
22
+
23
+ export const phaseTitle = (p: CardPhase): string =>
24
+ p === "working" ? "The agent is working on a turn"
25
+ : p === "done-unread" ? "The agent finished — you have not opened this session since"
26
+ : p === "done-read" ? "The agent finished, and you have looked" : "";
@@ -0,0 +1,100 @@
1
+ // Drag to select in an AGENT session, even while the app has mouse tracking on.
2
+ //
3
+ // Claude Code and Codex both enable mouse tracking (for the wheel), and
4
+ // xterm then hands every drag to the app instead of selecting. On a Mac
5
+ // xterm offers no rescue gesture by default: Shift is ignored there, and
6
+ // ⌥-drag needs an option nobody sets. So "I selected text and ⌘C copied
7
+ // nothing" was really "nothing was ever selected" — the drag went to Claude,
8
+ // which does nothing with it.
9
+ //
10
+ // Neither agent uses clicks or drags, only the wheel. So while the pointer
11
+ // is down for a plain left drag on an agent surface, tracking is switched
12
+ // off LOCALLY (xterm's protocol state is local anyway — the app is never
13
+ // told), the drag selects, and the protocol is put back on release. The
14
+ // wheel still reaches the app between drags. Other TUIs (vim, htop, tmux
15
+ // with the mouse on) keep their drags: the gate is the agent surface, not
16
+ // the tracking mode.
17
+
18
+ export type TrackingTerminal = {
19
+ modes: { mouseTrackingMode: string };
20
+ hasSelection: () => boolean;
21
+ onSelectionChange: (cb: () => void) => { dispose: () => void };
22
+ _core?: { coreMouseService?: { activeProtocol: string } };
23
+ };
24
+
25
+ /** A plain left press on an agent surface while the app tracks the mouse. */
26
+ export const shouldSelectLocally = (o: {
27
+ button: number;
28
+ metaKey: boolean;
29
+ ctrlKey: boolean;
30
+ trackingMode: string;
31
+ agentSurface: boolean;
32
+ }): boolean =>
33
+ o.button === 0 && !o.metaKey && !o.ctrlKey && o.trackingMode !== "none" && o.agentSurface;
34
+
35
+ /**
36
+ * Installs the capture-phase mousedown that pauses tracking for the drag.
37
+ * Tracking stays paused for as long as the selection it produced exists:
38
+ * xterm DISABLES its selection service when a protocol comes back on, and
39
+ * disabling clears the selection — restoring on release wiped the very
40
+ * text the drag had just selected (so ⌘C copied nothing). The selection
41
+ * clears on the next keypress or a click elsewhere, and tracking returns
42
+ * then. Returns the uninstaller. `agentSurface` is consulted at press time,
43
+ * so a session that stops being an agent session (shell after `exit`) gets
44
+ * its drags forwarded again.
45
+ */
46
+ export const installDragSelect = (
47
+ terminal: TrackingTerminal,
48
+ element: HTMLElement,
49
+ agentSurface: () => boolean
50
+ ): (() => void) => {
51
+ let paused: { prev: string; sub: { dispose: () => void } | null } | null = null;
52
+ const mouse = () => terminal._core?.coreMouseService;
53
+ const resume = () => {
54
+ if (!paused) return;
55
+ const { prev, sub } = paused;
56
+ paused = null;
57
+ sub?.dispose();
58
+ const m = mouse();
59
+ // Put it back only if nothing else changed it meanwhile (the app
60
+ // switching tracking off during the pause is its own decision).
61
+ try { if (m && m.activeProtocol === "NONE") m.activeProtocol = prev; } catch { /* gone */ }
62
+ };
63
+ const onUp = () => {
64
+ if (!paused) return;
65
+ let has = false;
66
+ try { has = terminal.hasSelection(); } catch { has = false; }
67
+ if (!has) { resume(); return; }
68
+ if (!paused.sub) {
69
+ paused.sub = terminal.onSelectionChange(() => {
70
+ let still = false;
71
+ try { still = terminal.hasSelection(); } catch { still = false; }
72
+ if (!still) resume();
73
+ });
74
+ }
75
+ };
76
+ const onDown = (ev: MouseEvent) => {
77
+ const m = mouse();
78
+ if (!m) return;
79
+ let trackingMode = "none";
80
+ try { trackingMode = terminal.modes.mouseTrackingMode; } catch { return; }
81
+ const local = shouldSelectLocally({ button: ev.button, metaKey: ev.metaKey, ctrlKey: ev.ctrlKey, trackingMode, agentSurface: agentSurface() });
82
+ if (paused) {
83
+ // Already paused for a standing selection: another selecting press
84
+ // just replaces it; any other press hands the mouse back first.
85
+ if (!local) resume();
86
+ return;
87
+ }
88
+ if (!local) return;
89
+ const prev = m.activeProtocol;
90
+ try { m.activeProtocol = "NONE"; } catch { return; }
91
+ paused = { prev, sub: null };
92
+ };
93
+ element.addEventListener("mousedown", onDown, true);
94
+ window.addEventListener("mouseup", onUp, true);
95
+ return () => {
96
+ element.removeEventListener("mousedown", onDown, true);
97
+ window.removeEventListener("mouseup", onUp, true);
98
+ resume();
99
+ };
100
+ };
@@ -56,3 +56,36 @@ export const isPlainClick = (
56
56
  if (!down) return true;
57
57
  return Math.hypot(up.x - down.x, up.y - down.y) <= slopPx;
58
58
  };
59
+
60
+ /**
61
+ * CLOSE ENOUGH is the right size. A tile that renders the session scaled
62
+ * by ~0.9 is visually free; a claim is for sizes that are genuinely
63
+ * foreign (a full-screen leftover on a wall tile), not for two walls
64
+ * disagreeing about rounding — that disagreement, claimed, was two
65
+ * browsers re-sizing every session back and forth on every open.
66
+ */
67
+ export const sizeIsForeign = (current: Size, natural: Size): boolean => {
68
+ const closeCols = Math.abs(current.cols - natural.cols) <= Math.max(3, Math.round(natural.cols * 0.15));
69
+ const closeRows = Math.abs(current.rows - natural.rows) <= Math.max(2, Math.round(natural.rows * 0.15));
70
+ return !(closeCols && closeRows);
71
+ };
72
+
73
+ /**
74
+ * A wall tile going live because the human ENGAGED it — a click, Enter,
75
+ * starting or creating a session there — is the same act as opening a
76
+ * session full screen, and takes the size the same way: only when what the
77
+ * session is wearing is foreign to the tile. The wall pre-focusing the
78
+ * current session as it opens is not an act (nobody clicked), and a tile
79
+ * that already fits has nothing to claim — a claim there would be a resize
80
+ * on every wall open, the fight the size-ownership rules exist to end.
81
+ */
82
+ export const claimOnTileFocus = (o: {
83
+ deliberate: boolean;
84
+ natural: Size | null;
85
+ current: Size | null;
86
+ }): boolean => {
87
+ if (!o.deliberate || !o.natural) return false;
88
+ if (o.natural.cols < 20 || o.natural.rows < 5) return false;
89
+ if (!o.current) return true;
90
+ return sizeIsForeign(o.current, o.natural);
91
+ };