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.
@@ -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) setContentMatches([]);
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
- {extraContentMatches.length > 0 && (
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
- <div className="switcher-empty">{filter.trim() ? "No matches" : "No sessions"}</div>
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 filter matching: exact beats prefix beats substring, and the
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
- export const filterScore = (s: SwitcherSession, q: string): number => {
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
- if (name === q) return 100;
121
- if (cwdShort === q) return 90; // exact workdir ("~", "~/code/hop2")
122
- if (name.startsWith(q)) return 80;
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 === q) return 70; // project dir name typed exactly
125
- if (name.includes(q)) return 60;
126
- if (cwdShort.startsWith(q)) return 55; // workdir prefix ("~/code")
127
- if (cwdShort.includes(q) || cwdRaw.includes(q)) return 40;
128
- if (proc.includes(q)) return 30;
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
- const matches = sessions.filter((s) => filterScore(s, q) > 0);
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: filterScore(s, q) }))
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
+ });