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.
- package/hay/apps/web/src/App.tsx +34 -4
- package/hay/apps/web/src/components/SecondaryPane.tsx +10 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +74 -12
- package/hay/apps/web/src/components/ViewsPanel.tsx +4 -2
- package/hay/apps/web/src/styles.css +73 -4
- package/hay/apps/web/src/utils/agentPhase.ts +26 -0
- package/hay/apps/web/src/utils/dragSelect.ts +100 -0
- package/hay/apps/web/src/utils/sizeClaim.ts +33 -0
- package/hay/apps/web/src/utils/switcherModel.ts +3 -0
- package/hay/apps/web/src/utils/voiceHold.ts +50 -13
- package/hay/apps/web/test/agentPhase.test.ts +22 -0
- package/hay/apps/web/test/dragSelect.test.ts +87 -0
- package/hay/apps/web/test/sizeClaim.test.ts +24 -1
- package/hay/apps/web/test/voiceHold.test.ts +31 -1
- package/hop +149 -8
- package/lib/agent-phase.js +67 -0
- package/lib/codex-identity.js +48 -0
- package/lib/identity-snapshot.js +74 -0
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +75 -0
- package/scripts/codex-notify-hook.js +34 -9
- package/tests/agent-phase.unit.test.cjs +64 -0
- package/tests/claude-session-hook.unit.test.cjs +11 -0
- package/tests/codex-identity.unit.test.cjs +32 -0
- package/tests/codex-notify-hook.unit.test.cjs +47 -0
- package/tests/identity-snapshot.unit.test.cjs +52 -0
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
// the
|
|
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
|
-
|
|
2253
|
-
|
|
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
|
-
|
|
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
|
+
};
|