hop2 0.11.11 → 0.11.13
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/CHANGELOG.md +2 -1
- package/hay/apps/server/src/index.ts +2 -0
- package/hay/apps/server/src/lib.ts +2 -0
- package/hay/apps/server/src/rooms.ts +127 -76
- package/hay/apps/server/src/screenGrid.ts +30 -2
- package/hay/apps/server/test/rooms.test.ts +183 -79
- package/hay/apps/web/scripts/run-e2e.mjs +40 -0
- package/hay/apps/web/src/App.tsx +231 -97
- package/hay/apps/web/src/components/SessionSwitcher.tsx +158 -17
- package/hay/apps/web/src/styles.css +32 -0
- package/hay/apps/web/src/utils/switcherModel.ts +52 -13
- package/hay/apps/web/test/SessionSwitcher.test.tsx +12 -0
- package/hay/apps/web/test/switcherModel.test.ts +61 -0
- package/hay-web/assets/digest-archive.json +604 -0
- package/hay-web/assets/index--8sYRyoP.js +133 -0
- package/hay-web/assets/index--8sYRyoP.js.map +1 -0
- package/hay-web/assets/index-DHotLq-D.css +1 -0
- package/hay-web/assets/index-DnSA-VR6.js +133 -0
- package/hay-web/assets/{index-DQ3VWtnf.js.map → index-DnSA-VR6.js.map} +1 -1
- package/hay-web/assets/{index-DQ3VWtnf.js → index-DzlPn2In.js} +16 -16
- package/hay-web/assets/index-DzlPn2In.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +607 -109
- package/package.json +1 -1
- package/scripts/hay-host.js +25 -0
- package/skills/hop-session/SKILL.md +29 -2
- package/tests/terminal-api.integration.test.cjs +52 -2
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
type CSSProperties,
|
|
8
8
|
type FormEvent,
|
|
9
9
|
type MouseEvent as ReactMouseEvent,
|
|
10
|
-
type PointerEvent as ReactPointerEvent
|
|
10
|
+
type PointerEvent as ReactPointerEvent,
|
|
11
|
+
type ReactNode
|
|
11
12
|
} from "react";
|
|
12
13
|
import { Terminal } from "@xterm/xterm";
|
|
13
14
|
import { FitAddon } from "@xterm/addon-fit";
|
|
@@ -38,6 +39,17 @@ const LONG_PRESS_MS = 450;
|
|
|
38
39
|
const PREVIEW_REFRESH_MS = 5000;
|
|
39
40
|
const FILTER_THRESHOLD = 10;
|
|
40
41
|
|
|
42
|
+
// Mark the query terms inside a content-match snippet so the eye lands on WHY
|
|
43
|
+
// the line matched instead of re-scanning it. Terms mirror the server's
|
|
44
|
+
// AND-term split.
|
|
45
|
+
const highlightSnippet = (snippet: string, query: string): ReactNode => {
|
|
46
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
47
|
+
if (!terms.length) return snippet;
|
|
48
|
+
const pattern = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
49
|
+
const parts = snippet.split(new RegExp(`(${pattern})`, "ig"));
|
|
50
|
+
return parts.map((part, i) => (i % 2 === 1 ? <mark key={i}>{part}</mark> : part));
|
|
51
|
+
};
|
|
52
|
+
|
|
41
53
|
// Zoom ladder: one +/− press per step. Each level sets the grid's minimum
|
|
42
54
|
// tile width, the preview window height, and the preview font size (the
|
|
43
55
|
// focused terminal inherits the same metrics so preview → terminal stays a
|
|
@@ -343,6 +355,11 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
343
355
|
// Wired by the live effect; watch mode leaves them null so keys go nowhere.
|
|
344
356
|
const sendInputRef = useRef<((data: string) => void) | null>(null);
|
|
345
357
|
const kbdEnhancedRef = useRef(false);
|
|
358
|
+
// Throttle gate for typing-driven size ownership (below) — same 500ms
|
|
359
|
+
// budget as the full-screen terminal's fit-on-type, and for the same
|
|
360
|
+
// reason: proposeDimensions() forces a layout read, and doing that on
|
|
361
|
+
// every keystroke is what stalled touch input on phones there.
|
|
362
|
+
const lastTypeFitAtRef = useRef(0);
|
|
346
363
|
// null = watching; "live"/"down" = connected state for the chrome.
|
|
347
364
|
const [conn, setConn] = useState<"live" | "down" | null>(null);
|
|
348
365
|
// Last bytes from the session — paces the app-scroll coast (flywheel).
|
|
@@ -724,7 +741,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
724
741
|
const sock = new WebSocket(
|
|
725
742
|
wsBase + sep + "room=" + encodeURIComponent(room)
|
|
726
743
|
+ "&name=" + encodeURIComponent((userName || "user") + " (wall)")
|
|
727
|
-
+ "&replay=0&cols=" + dims.cols + "&rows=" + dims.rows
|
|
744
|
+
+ "&source=monitor&replay=0&cols=" + dims.cols + "&rows=" + dims.rows
|
|
728
745
|
);
|
|
729
746
|
sock.onopen = () => {
|
|
730
747
|
try {
|
|
@@ -927,6 +944,30 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
927
944
|
};
|
|
928
945
|
connect();
|
|
929
946
|
sendInputRef.current = (data: string) => {
|
|
947
|
+
// A keystroke INTO this tile is a genuine user action on it — per the
|
|
948
|
+
// size-ownership model, it should take the size and fit the tile it
|
|
949
|
+
// was typed into. Until now the tile only ever declared the SESSION's
|
|
950
|
+
// mirrored size (from wsUrl's cols/rows, unchanged since connect), so
|
|
951
|
+
// the server's keystroke ownership-transfer had nothing to apply: the
|
|
952
|
+
// tile "won" but never actually resized to itself. Declare the tile's
|
|
953
|
+
// own box fit as an ordinary resize (no claim, no monitor tag) before
|
|
954
|
+
// the input — same pattern as SecondaryPane's fitFocused(), applied on
|
|
955
|
+
// typing here since a wall tile has no separate focus/blur moment.
|
|
956
|
+
const nowFit = performance.now();
|
|
957
|
+
if (ws && ws.readyState === WebSocket.OPEN && nowFit - lastTypeFitAtRef.current > 500) {
|
|
958
|
+
lastTypeFitAtRef.current = nowFit;
|
|
959
|
+
const fitAddon = fitRef.current;
|
|
960
|
+
const boxEl = boxRef.current;
|
|
961
|
+
const t = termRef.current;
|
|
962
|
+
if (fitAddon && boxEl && t && boxEl.clientWidth >= 60 && boxEl.clientHeight >= 40) {
|
|
963
|
+
const dims = fitAddon.proposeDimensions();
|
|
964
|
+
if (dims?.cols && dims?.rows && dims.cols >= 20 && dims.rows >= 5
|
|
965
|
+
&& (t.cols !== dims.cols || t.rows !== dims.rows)) {
|
|
966
|
+
t.resize(dims.cols, dims.rows);
|
|
967
|
+
ws.send(JSON.stringify({ type: "resize", cols: dims.cols, rows: dims.rows }));
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
930
971
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
931
972
|
pendingInput.push({ data, at: Date.now() });
|
|
932
973
|
if (pendingInput.length > 200) pendingInput.shift();
|
|
@@ -1513,10 +1554,14 @@ export const SessionSwitcher = ({
|
|
|
1513
1554
|
// Content-aware matches: debounced grep over each session's recent screen
|
|
1514
1555
|
// text (daemon-side, over already-retained output — no index, no polling).
|
|
1515
1556
|
const [contentMatches, setContentMatches] = useState<Array<{ session: SwitcherSession; snippet: string }>>([]);
|
|
1557
|
+
// The server caps how many sessions it greps per keystroke; when the fleet
|
|
1558
|
+
// exceeds the cap, say so — silence would read as "searched everything".
|
|
1559
|
+
const [contentTruncated, setContentTruncated] = useState(false);
|
|
1516
1560
|
useEffect(() => {
|
|
1517
1561
|
const q = filter.trim();
|
|
1518
1562
|
if (!open || q.length < 3) {
|
|
1519
1563
|
setContentMatches([]);
|
|
1564
|
+
setContentTruncated(false);
|
|
1520
1565
|
return;
|
|
1521
1566
|
}
|
|
1522
1567
|
let cancelled = false;
|
|
@@ -1534,8 +1579,12 @@ export const SessionSwitcher = ({
|
|
|
1534
1579
|
})
|
|
1535
1580
|
.filter(Boolean) as Array<{ session: SwitcherSession; snippet: string }>
|
|
1536
1581
|
);
|
|
1582
|
+
setContentTruncated(data?.truncated === true);
|
|
1537
1583
|
} catch {
|
|
1538
|
-
if (!cancelled)
|
|
1584
|
+
if (!cancelled) {
|
|
1585
|
+
setContentMatches([]);
|
|
1586
|
+
setContentTruncated(false);
|
|
1587
|
+
}
|
|
1539
1588
|
}
|
|
1540
1589
|
}, 250);
|
|
1541
1590
|
return () => {
|
|
@@ -1544,6 +1593,37 @@ export const SessionSwitcher = ({
|
|
|
1544
1593
|
};
|
|
1545
1594
|
}, [open, filter, visibleSessions]);
|
|
1546
1595
|
|
|
1596
|
+
// Full-history search: greps ENTIRE transcripts server-side (ripgrep), so
|
|
1597
|
+
// it runs only when the user explicitly asks — never per keystroke. Results
|
|
1598
|
+
// are pinned to the query they answered; editing the filter discards them.
|
|
1599
|
+
const [deepSearch, setDeepSearch] = useState<{
|
|
1600
|
+
q: string;
|
|
1601
|
+
status: "loading" | "done" | "unavailable";
|
|
1602
|
+
rows: Array<{ session: SwitcherSession; snippet: string }>;
|
|
1603
|
+
} | null>(null);
|
|
1604
|
+
useEffect(() => {
|
|
1605
|
+
if (deepSearch && deepSearch.q !== filter.trim()) setDeepSearch(null);
|
|
1606
|
+
}, [filter, deepSearch]);
|
|
1607
|
+
const runDeepSearch = async () => {
|
|
1608
|
+
const q = filter.trim();
|
|
1609
|
+
if (q.length < 3) return;
|
|
1610
|
+
setDeepSearch({ q, status: "loading", rows: [] });
|
|
1611
|
+
try {
|
|
1612
|
+
const res = await fetch(`/api/sessions/search?q=${encodeURIComponent(q)}&deep=1`);
|
|
1613
|
+
const data = await res.json();
|
|
1614
|
+
const byKey = new Map(visibleSessions.map((s) => [s.internalName || s.name, s]));
|
|
1615
|
+
const rows = (Array.isArray(data?.matches) ? data.matches : [])
|
|
1616
|
+
.map((m: { internalName?: string; name?: string; snippet?: string }) => {
|
|
1617
|
+
const session = byKey.get(m.internalName || "") || byKey.get(m.name || "");
|
|
1618
|
+
return session ? { session, snippet: m.snippet || "" } : null;
|
|
1619
|
+
})
|
|
1620
|
+
.filter(Boolean) as Array<{ session: SwitcherSession; snippet: string }>;
|
|
1621
|
+
setDeepSearch({ q, status: data?.deepUnavailable ? "unavailable" : "done", rows });
|
|
1622
|
+
} catch {
|
|
1623
|
+
setDeepSearch({ q, status: "unavailable", rows: [] });
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1626
|
+
|
|
1547
1627
|
// Content matches the name filter didn't already surface.
|
|
1548
1628
|
const extraContentMatches = useMemo(() => {
|
|
1549
1629
|
// Content hits surface in EVERY mode under a query. They were rendered
|
|
@@ -1565,18 +1645,39 @@ export const SessionSwitcher = ({
|
|
|
1565
1645
|
return contentMatches.filter((m) => !seen.has(sessionKey(m.session)));
|
|
1566
1646
|
}, [model, contentMatches, filter]);
|
|
1567
1647
|
|
|
1648
|
+
// History hits the shallower tiers already showed are noise here — only
|
|
1649
|
+
// sessions the user hasn't been offered yet earn a history card.
|
|
1650
|
+
const extraDeepMatches = useMemo(() => {
|
|
1651
|
+
if (!deepSearch || deepSearch.status !== "done" || deepSearch.q !== filter.trim()) return [];
|
|
1652
|
+
const seen = new Set<string>();
|
|
1653
|
+
if (model.mode === "filter") model.rows.forEach((r) => seen.add(sessionKey(r)));
|
|
1654
|
+
else if (model.mode === "manual") {
|
|
1655
|
+
model.rows.forEach((r) => seen.add(sessionKey(r)));
|
|
1656
|
+
model.folders.forEach((f) => f.rows.forEach((r) => seen.add(sessionKey(r))));
|
|
1657
|
+
}
|
|
1658
|
+
extraContentMatches.forEach((m) => seen.add(sessionKey(m.session)));
|
|
1659
|
+
const out: Array<{ session: SwitcherSession; snippet: string }> = [];
|
|
1660
|
+
for (const m of deepSearch.rows) {
|
|
1661
|
+
const key = sessionKey(m.session);
|
|
1662
|
+
if (seen.has(key)) continue;
|
|
1663
|
+
seen.add(key);
|
|
1664
|
+
out.push(m);
|
|
1665
|
+
}
|
|
1666
|
+
return out;
|
|
1667
|
+
}, [deepSearch, model, extraContentMatches, filter]);
|
|
1668
|
+
|
|
1568
1669
|
// Flat navigation order = exactly the visual order: heroes, then group rows
|
|
1569
1670
|
// (and under a filter: name matches, then on-screen content matches).
|
|
1570
1671
|
const flatNav = useMemo<SwitcherSession[]>(
|
|
1571
1672
|
() => {
|
|
1572
|
-
if (model.mode === "filter") return [...model.rows, ...extraContentMatches.map((m) => m.session)];
|
|
1673
|
+
if (model.mode === "filter") return [...model.rows, ...extraContentMatches.map((m) => m.session), ...extraDeepMatches.map((m) => m.session)];
|
|
1573
1674
|
// Folder rows come first because that is their visual order; keyboard
|
|
1574
1675
|
// ↑/↓ and Enter must traverse what the eye sees, foldered included.
|
|
1575
|
-
if (model.mode === "manual") return [...model.folders.flatMap((f) => f.rows), ...model.rows, ...extraContentMatches.map((m) => m.session)];
|
|
1676
|
+
if (model.mode === "manual") return [...model.folders.flatMap((f) => f.rows), ...model.rows, ...extraContentMatches.map((m) => m.session), ...extraDeepMatches.map((m) => m.session)];
|
|
1576
1677
|
if (model.mode === "project") return model.groups.flatMap((g) => g.rows);
|
|
1577
1678
|
return [...model.hero, ...model.groups.flatMap((g) => g.rows)];
|
|
1578
1679
|
},
|
|
1579
|
-
[model, extraContentMatches]
|
|
1680
|
+
[model, extraContentMatches, extraDeepMatches]
|
|
1580
1681
|
);
|
|
1581
1682
|
const navIndexByKey = useMemo(() => {
|
|
1582
1683
|
const m = new Map<string, number>();
|
|
@@ -2434,7 +2535,7 @@ export const SessionSwitcher = ({
|
|
|
2434
2535
|
: (preview?.text || " ")}
|
|
2435
2536
|
</pre>
|
|
2436
2537
|
)}
|
|
2437
|
-
{snippet && <div className="switcher-card-snippet" title={snippet}>{snippet}</div>}
|
|
2538
|
+
{snippet && <div className="switcher-card-snippet" title={snippet}>{highlightSnippet(snippet, filter.trim())}</div>}
|
|
2438
2539
|
<div className="switcher-card-meta">
|
|
2439
2540
|
<span className="switcher-card-dir" title={s.cwd || undefined}>{dirPath(s) ? `\u200E${dirPath(s)}\u200E` : "\u00a0"}</span>
|
|
2440
2541
|
<span className="switcher-card-when">{meta(s)}</span>
|
|
@@ -2482,6 +2583,50 @@ export const SessionSwitcher = ({
|
|
|
2482
2583
|
const sheetSession = sheet?.session;
|
|
2483
2584
|
const sheetAgentPermitted = sheetSession?.agentPermitted === true;
|
|
2484
2585
|
|
|
2586
|
+
// Content/history hits plus the full-history row, rendered below whatever
|
|
2587
|
+
// the mode showed. Manual mode keeps its own branch under a query (filtering
|
|
2588
|
+
// narrows the user's order in place), so this tail must ride along there
|
|
2589
|
+
// too — search reaches past the name tier in every mode, not just Recent.
|
|
2590
|
+
const searchTail = filter.trim() ? (
|
|
2591
|
+
<>
|
|
2592
|
+
{extraContentMatches.length > 0 && (
|
|
2593
|
+
<section className="switcher-group">
|
|
2594
|
+
<h3 className="switcher-group-label">found in terminal output{contentTruncated ? " · most recent sessions only" : ""}</h3>
|
|
2595
|
+
<div className="switcher-grid">
|
|
2596
|
+
{extraContentMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
|
|
2597
|
+
</div>
|
|
2598
|
+
</section>
|
|
2599
|
+
)}
|
|
2600
|
+
{extraDeepMatches.length > 0 && (
|
|
2601
|
+
<section className="switcher-group">
|
|
2602
|
+
<h3 className="switcher-group-label">found in full history</h3>
|
|
2603
|
+
<div className="switcher-grid">
|
|
2604
|
+
{extraDeepMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
|
|
2605
|
+
</div>
|
|
2606
|
+
</section>
|
|
2607
|
+
)}
|
|
2608
|
+
{filter.trim().length >= 3 && (
|
|
2609
|
+
<div className="switcher-deep-row">
|
|
2610
|
+
{!deepSearch ? (
|
|
2611
|
+
<button type="button" className="switcher-deep-btn" onClick={runDeepSearch}>
|
|
2612
|
+
Search full history for “{filter.trim()}”
|
|
2613
|
+
</button>
|
|
2614
|
+
) : deepSearch.status === "loading" ? (
|
|
2615
|
+
<span className="switcher-deep-note">searching full history…</span>
|
|
2616
|
+
) : deepSearch.status === "unavailable" ? (
|
|
2617
|
+
<span className="switcher-deep-note">full-history search unavailable on this host</span>
|
|
2618
|
+
) : (
|
|
2619
|
+
<span className="switcher-deep-note">
|
|
2620
|
+
{extraDeepMatches.length > 0
|
|
2621
|
+
? `${extraDeepMatches.length} more in full history`
|
|
2622
|
+
: "nothing further back matches"}
|
|
2623
|
+
</span>
|
|
2624
|
+
)}
|
|
2625
|
+
</div>
|
|
2626
|
+
)}
|
|
2627
|
+
</>
|
|
2628
|
+
) : null;
|
|
2629
|
+
|
|
2485
2630
|
return (
|
|
2486
2631
|
<div
|
|
2487
2632
|
// tile-dense: too narrow for inline actions. tile-micro: too short for
|
|
@@ -2669,19 +2814,12 @@ export const SessionSwitcher = ({
|
|
|
2669
2814
|
// cards at the current zoom, same as the wall — the screen content
|
|
2670
2815
|
// is usually WHY you're looking for the session.
|
|
2671
2816
|
<>
|
|
2672
|
-
{model.rows.length === 0 && extraContentMatches.length === 0 ? (
|
|
2817
|
+
{model.rows.length === 0 && extraContentMatches.length === 0 && extraDeepMatches.length === 0 ? (
|
|
2673
2818
|
<div className="switcher-empty">No sessions match “{filter.trim()}”</div>
|
|
2674
2819
|
) : (
|
|
2675
2820
|
<div className="switcher-grid">{model.rows.map((s) => renderCard(s))}</div>
|
|
2676
2821
|
)}
|
|
2677
|
-
{
|
|
2678
|
-
<section className="switcher-group">
|
|
2679
|
-
<h3 className="switcher-group-label">found in terminal output</h3>
|
|
2680
|
-
<div className="switcher-grid">
|
|
2681
|
-
{extraContentMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
|
|
2682
|
-
</div>
|
|
2683
|
-
</section>
|
|
2684
|
-
)}
|
|
2822
|
+
{searchTail}
|
|
2685
2823
|
</>
|
|
2686
2824
|
) : model.mode === "project" ? (
|
|
2687
2825
|
<>
|
|
@@ -2707,7 +2845,9 @@ export const SessionSwitcher = ({
|
|
|
2707
2845
|
) : model.mode === "manual" ? (
|
|
2708
2846
|
<>
|
|
2709
2847
|
{model.rows.length === 0 && model.folders.every((f) => f.rows.length === 0) ? (
|
|
2710
|
-
|
|
2848
|
+
extraContentMatches.length === 0 && extraDeepMatches.length === 0 && (
|
|
2849
|
+
<div className="switcher-empty">{filter.trim() ? "No matches" : "No sessions"}</div>
|
|
2850
|
+
)
|
|
2711
2851
|
) : (
|
|
2712
2852
|
<>
|
|
2713
2853
|
<p className="switcher-hint">
|
|
@@ -2784,6 +2924,7 @@ export const SessionSwitcher = ({
|
|
|
2784
2924
|
<div className="switcher-grid">{model.rows.map((s) => renderCard(s))}</div>
|
|
2785
2925
|
</>
|
|
2786
2926
|
)}
|
|
2927
|
+
{searchTail}
|
|
2787
2928
|
</>
|
|
2788
2929
|
) : (
|
|
2789
2930
|
<>
|
|
@@ -2893,6 +2893,38 @@ button.primary {
|
|
|
2893
2893
|
text-overflow: ellipsis;
|
|
2894
2894
|
white-space: nowrap;
|
|
2895
2895
|
}
|
|
2896
|
+
.switcher-card-snippet mark {
|
|
2897
|
+
background: none;
|
|
2898
|
+
color: var(--accent);
|
|
2899
|
+
font-weight: 600;
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
/* Full-history search: an explicit act, so it's a button under the results,
|
|
2903
|
+
never something a keystroke triggers. */
|
|
2904
|
+
.switcher-deep-row {
|
|
2905
|
+
display: flex;
|
|
2906
|
+
justify-content: center;
|
|
2907
|
+
padding: 10px 0 14px;
|
|
2908
|
+
}
|
|
2909
|
+
.switcher-deep-btn {
|
|
2910
|
+
font-family: var(--font-mono);
|
|
2911
|
+
font-size: 11.5px;
|
|
2912
|
+
color: var(--ink-muted);
|
|
2913
|
+
background: var(--bg-alt);
|
|
2914
|
+
border: 1px solid var(--line);
|
|
2915
|
+
border-radius: 7px;
|
|
2916
|
+
padding: 6px 12px;
|
|
2917
|
+
cursor: pointer;
|
|
2918
|
+
}
|
|
2919
|
+
.switcher-deep-btn:hover {
|
|
2920
|
+
color: var(--ink);
|
|
2921
|
+
border-color: var(--ink-muted);
|
|
2922
|
+
}
|
|
2923
|
+
.switcher-deep-note {
|
|
2924
|
+
font-family: var(--font-mono);
|
|
2925
|
+
font-size: 11.5px;
|
|
2926
|
+
color: var(--ink-muted);
|
|
2927
|
+
}
|
|
2896
2928
|
|
|
2897
2929
|
/* Tap-a-URL prompt (mobile: remote apps can't open the phone's browser) */
|
|
2898
2930
|
/* Link menu: the terminal's tap-a-URL menu, on the shared context-menu look.
|
|
@@ -107,28 +107,63 @@ export const projectKey = (cwd?: string) => {
|
|
|
107
107
|
return (lead ? lead + "/" : "/") + kept.join("/");
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
/** Characters of `needle` appear in `hay` in order (VS Code-style fuzzy). */
|
|
111
|
+
const isSubsequence = (needle: string, hay: string): boolean => {
|
|
112
|
+
let i = 0;
|
|
113
|
+
for (let j = 0; j < hay.length && i < needle.length; j++) {
|
|
114
|
+
if (hay[j] === needle[i]) i++;
|
|
115
|
+
}
|
|
116
|
+
return i === needle.length;
|
|
117
|
+
};
|
|
118
|
+
|
|
110
119
|
/**
|
|
111
|
-
* Ranked
|
|
120
|
+
* Ranked matching for ONE term: exact beats prefix beats substring, and the
|
|
112
121
|
* home-shortened cwd the UI displays is first-class ("~" must rank sessions
|
|
113
|
-
* whose workdir IS ~ above everything that merely lives under it).
|
|
122
|
+
* whose workdir IS ~ above everything that merely lives under it). Below the
|
|
123
|
+
* name/cwd tiers come the user's own descriptions — the tagline (`hop ai
|
|
124
|
+
* tagline`) and the folder the session is filed in — then the foreground
|
|
125
|
+
* process, then a typo-tolerant subsequence tier ("hubl" still finds Hubble;
|
|
126
|
+
* 3+ chars so one letter can't fuzzy-match half the wall).
|
|
114
127
|
*/
|
|
115
|
-
|
|
128
|
+
const termScore = (s: SwitcherSession, t: string, folderName: string): number => {
|
|
116
129
|
const name = (s.displayName || s.name).toLowerCase();
|
|
117
130
|
const cwdRaw = (s.cwd || "").toLowerCase();
|
|
118
131
|
const cwdShort = s.cwd ? shortenForGroup(s.cwd).toLowerCase() : "";
|
|
119
132
|
const proc = (s.foregroundProcess || "").toLowerCase();
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
if (
|
|
133
|
+
const tagline = (s.tagline || "").toLowerCase();
|
|
134
|
+
if (name === t) return 100;
|
|
135
|
+
if (cwdShort === t) return 90; // exact workdir ("~", "~/code/hop2")
|
|
136
|
+
if (name.startsWith(t)) return 80;
|
|
123
137
|
const cwdBase = cwdShort.split("/").filter(Boolean).pop() || "";
|
|
124
|
-
if (cwdBase ===
|
|
125
|
-
if (name.includes(
|
|
126
|
-
if (cwdShort.startsWith(
|
|
127
|
-
if (
|
|
128
|
-
if (
|
|
138
|
+
if (cwdBase === t) return 70; // project dir name typed exactly
|
|
139
|
+
if (name.includes(t)) return 60;
|
|
140
|
+
if (cwdShort.startsWith(t)) return 55; // workdir prefix ("~/code")
|
|
141
|
+
if (tagline.includes(t)) return 50; // the user's own words for the session
|
|
142
|
+
if (folderName.includes(t)) return 45; // the folder they filed it in
|
|
143
|
+
if (cwdShort.includes(t) || cwdRaw.includes(t)) return 40;
|
|
144
|
+
if (proc.includes(t)) return 30;
|
|
145
|
+
if (t.length >= 3 && isSubsequence(t, name)) return 20;
|
|
129
146
|
return 0;
|
|
130
147
|
};
|
|
131
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Multi-word queries are AND-terms, each free to hit a different field —
|
|
151
|
+
* "hop web" matches a session named "web" in ~/Code/hop2. The score is the
|
|
152
|
+
* weakest term's (every word must genuinely match), so single-term queries
|
|
153
|
+
* rank exactly as before.
|
|
154
|
+
*/
|
|
155
|
+
export const filterScore = (s: SwitcherSession, q: string, folderName = ""): number => {
|
|
156
|
+
const terms = q.split(/\s+/).filter(Boolean);
|
|
157
|
+
if (terms.length === 0) return 0;
|
|
158
|
+
let weakest = Infinity;
|
|
159
|
+
for (const t of terms) {
|
|
160
|
+
const score = termScore(s, t, folderName);
|
|
161
|
+
if (score === 0) return 0;
|
|
162
|
+
if (score < weakest) weakest = score;
|
|
163
|
+
}
|
|
164
|
+
return weakest;
|
|
165
|
+
};
|
|
166
|
+
|
|
132
167
|
export const filterSessionsByOrigin = (
|
|
133
168
|
sessions: SwitcherSession[],
|
|
134
169
|
scope: SessionOriginScope
|
|
@@ -231,7 +266,11 @@ export const buildSwitcherModel = (
|
|
|
231
266
|
const query = filter.trim();
|
|
232
267
|
if (query) {
|
|
233
268
|
const q = query.toLowerCase();
|
|
234
|
-
|
|
269
|
+
// Typing a folder's name surfaces the sessions filed in it.
|
|
270
|
+
const folderNameById = new Map(folders.map((f) => [f.id, f.name.toLowerCase()]));
|
|
271
|
+
const scoreOf = (s: SwitcherSession) =>
|
|
272
|
+
filterScore(s, q, (s.folderId && folderNameById.get(s.folderId)) || "");
|
|
273
|
+
const matches = sessions.filter((s) => scoreOf(s) > 0);
|
|
235
274
|
// Manual mode: filtering NARROWS the wall, it never re-ranks it. Keeping
|
|
236
275
|
// the user's own order in the filtered view is what makes "find it, then
|
|
237
276
|
// drag it" work — you can see where a card sits relative to the other
|
|
@@ -246,7 +285,7 @@ export const buildSwitcherModel = (
|
|
|
246
285
|
return { mode: "manual", rows: split.loose, folders: split.folders };
|
|
247
286
|
}
|
|
248
287
|
const rows = matches
|
|
249
|
-
.map((s) => ({ s, score:
|
|
288
|
+
.map((s) => ({ s, score: scoreOf(s) }))
|
|
250
289
|
.sort((a, b) => b.score - a.score || byAttentionThenRecency(a.s, b.s))
|
|
251
290
|
.map((x) => x.s);
|
|
252
291
|
return { mode: "filter", rows };
|
|
@@ -325,4 +325,16 @@ describe("manual folders in the wall", () => {
|
|
|
325
325
|
expect(screen.getByText("inside")).toBeTruthy();
|
|
326
326
|
expect(screen.getByText("Filed")).toBeTruthy();
|
|
327
327
|
});
|
|
328
|
+
|
|
329
|
+
it("a query in manual mode still offers the full-history search", () => {
|
|
330
|
+
// Manual keeps its own render branch under a query (narrow-in-place), so
|
|
331
|
+
// the search tail — content hits + the deep button — must ride along or
|
|
332
|
+
// manual-mode users never see anything past the name tier.
|
|
333
|
+
render(<SessionSwitcher {...props} sessions={withFolder} folders={folders} open />);
|
|
334
|
+
fireEvent.click(screen.getByRole("button", { name: "Manual" }));
|
|
335
|
+
const input = document.querySelector(".switcher-top input") as HTMLInputElement
|
|
336
|
+
|| document.querySelector("input") as HTMLInputElement;
|
|
337
|
+
fireEvent.change(input, { target: { value: "outsid" } });
|
|
338
|
+
expect(screen.getByRole("button", { name: /Search full history for/ })).toBeTruthy();
|
|
339
|
+
});
|
|
328
340
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
buildSwitcherModel,
|
|
4
|
+
filterScore,
|
|
4
5
|
filterSessionsByOrigin,
|
|
5
6
|
projectKey,
|
|
6
7
|
relativeTime,
|
|
@@ -375,3 +376,63 @@ describe("manual mode folders under filter", () => {
|
|
|
375
376
|
expect(model.rows).toEqual([]); // loose list empty ≠ no results
|
|
376
377
|
});
|
|
377
378
|
});
|
|
379
|
+
|
|
380
|
+
describe("filterScore tiers", () => {
|
|
381
|
+
it("searches taglines — the user's own words for a session", () => {
|
|
382
|
+
const s = mk({ name: "exp42", tagline: "protein folding sweep" });
|
|
383
|
+
expect(filterScore(s, "folding")).toBe(50);
|
|
384
|
+
expect(filterScore(mk({ name: "exp43" }), "folding")).toBe(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("ranks a name substring above a tagline hit", () => {
|
|
388
|
+
const byName = mk({ name: "folding-ui" });
|
|
389
|
+
const byTagline = mk({ name: "exp42", tagline: "protein folding sweep" });
|
|
390
|
+
expect(filterScore(byName, "folding")).toBeGreaterThan(filterScore(byTagline, "folding"));
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("multi-word terms AND across different fields", () => {
|
|
394
|
+
const s = mk({ name: "web", cwd: "/Users/x/Code/hop2" });
|
|
395
|
+
expect(filterScore(s, "hop2 web")).toBeGreaterThan(0);
|
|
396
|
+
expect(filterScore(s, "hop2 nothere")).toBe(0);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("multi-word score is the weakest term's — every word must earn the rank", () => {
|
|
400
|
+
const s = mk({ name: "web", cwd: "/Users/x/Code/hop2" });
|
|
401
|
+
// "web" hits the name exactly (100); "hop2" only the cwd (≤70).
|
|
402
|
+
expect(filterScore(s, "web hop2")).toBeLessThan(filterScore(s, "web"));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("single-term ranking is unchanged: exact > prefix > substring", () => {
|
|
406
|
+
expect(filterScore(mk({ name: "hub" }), "hub")).toBe(100);
|
|
407
|
+
expect(filterScore(mk({ name: "hubble" }), "hub")).toBe(80);
|
|
408
|
+
expect(filterScore(mk({ name: "the-hub-x" }), "hub")).toBe(60);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("tolerates typos via subsequence, ranked below everything else", () => {
|
|
412
|
+
const s = mk({ name: "hubble" });
|
|
413
|
+
expect(filterScore(s, "hbl")).toBe(20);
|
|
414
|
+
expect(filterScore(s, "hlb")).toBe(0); // order matters
|
|
415
|
+
expect(filterScore(mk({ name: "ab" }), "ab")).toBe(100);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("subsequence needs 3+ chars so short queries stay literal", () => {
|
|
419
|
+
expect(filterScore(mk({ name: "hubble" }), "he")).toBe(0);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("matches the folder a session is filed in, passed by the model", () => {
|
|
423
|
+
const folders = [{ id: "f1", name: "Experiments" }];
|
|
424
|
+
const sessions = [mk({ name: "alpha", folderId: "f1" }), mk({ name: "beta" })];
|
|
425
|
+
const model = buildSwitcherModel(sessions, null, "experi", "recent", [], folders);
|
|
426
|
+
if (model.mode !== "filter") throw new Error("expected filter");
|
|
427
|
+
expect(model.rows.map((r) => r.name)).toEqual(["alpha"]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("folder-name queries narrow manual mode to that folder's sessions", () => {
|
|
431
|
+
const folders = [{ id: "f1", name: "Experiments" }];
|
|
432
|
+
const sessions = [mk({ name: "alpha", folderId: "f1" }), mk({ name: "beta" })];
|
|
433
|
+
const model = buildSwitcherModel(sessions, null, "experiments", "manual", [], folders);
|
|
434
|
+
if (model.mode !== "manual") throw new Error("expected manual");
|
|
435
|
+
expect(model.folders[0].rows.map((r) => r.name)).toEqual(["alpha"]);
|
|
436
|
+
expect(model.rows).toEqual([]);
|
|
437
|
+
});
|
|
438
|
+
});
|