hop2 0.11.1 → 0.11.2

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.
@@ -2895,52 +2895,24 @@ button.primary {
2895
2895
  }
2896
2896
 
2897
2897
  /* Tap-a-URL prompt (mobile: remote apps can't open the phone's browser) */
2898
- .link-prompt {
2899
- position: fixed;
2900
- left: 12px;
2901
- right: 12px;
2902
- bottom: calc(env(safe-area-inset-bottom, 0px) + var(--mobile-keyboard-height, 0px) + 14px);
2903
- z-index: 650;
2904
- display: flex;
2905
- align-items: center;
2906
- gap: 8px;
2907
- padding: 10px 12px;
2908
- background: var(--panel);
2909
- border: 1px solid var(--accent);
2910
- border-radius: 12px;
2911
- box-shadow: var(--shadow);
2898
+ /* Link menu: the terminal's tap-a-URL menu, on the shared context-menu look.
2899
+ Anchored at the link like every other menu, not docked to the viewport. */
2900
+ .context-menu.link-menu {
2901
+ max-width: min(340px, calc(100vw - 16px));
2912
2902
  }
2913
-
2914
- .link-prompt-url {
2915
- flex: 1;
2916
- min-width: 0;
2903
+ .context-menu-title {
2904
+ display: block;
2905
+ padding: 6px 10px 7px;
2906
+ font-family: var(--font-mono);
2907
+ font-size: 11.5px;
2908
+ color: var(--ink-muted);
2917
2909
  overflow: hidden;
2918
2910
  text-overflow: ellipsis;
2919
2911
  white-space: nowrap;
2920
- font-family: var(--font-mono);
2921
- font-size: 12px;
2922
- color: var(--ink);
2923
- }
2924
-
2925
- .link-prompt button {
2926
- flex-shrink: 0;
2927
- padding: 7px 12px;
2928
- font-size: 12.5px;
2929
- }
2930
-
2931
- .link-prompt .link-prompt-open {
2932
- background: var(--accent);
2933
- color: var(--accent-fg);
2912
+ border-bottom: 1px solid var(--line);
2913
+ margin-bottom: 3px;
2934
2914
  }
2935
2915
 
2936
- @media (min-width: 769px) {
2937
- .link-prompt {
2938
- left: auto;
2939
- right: 20px;
2940
- bottom: 44px;
2941
- max-width: 520px;
2942
- }
2943
- }
2944
2916
 
2945
2917
  /* ── Tile-zoom control (− level +): bigger tiles show more terminal. The
2946
2918
  ladder itself (widths, heights, font sizes, how many levels this viewport
@@ -3691,9 +3663,17 @@ button.primary {
3691
3663
  scrollbar wide), and entering full screen (which has no such reservation)
3692
3664
  visibly re-flowed. Hidden bar → width-true fits on both surfaces. */
3693
3665
  .switcher-live-tile .xterm-viewport {
3694
- overflow-y: auto !important;
3666
+ /* A WATCH tile is a snapshot of the live screen — there is nothing to
3667
+ scroll through, and letting it scroll produced a bar that moved the view
3668
+ off the newest output with no way to tell you were adrift. Only the tile
3669
+ you have clicked into (is-live) scrolls, because only that one has a
3670
+ real session's scrollback behind it. */
3671
+ overflow: hidden !important;
3695
3672
  scrollbar-width: none;
3696
3673
  }
3674
+ .switcher-live-tile.is-live .xterm-viewport {
3675
+ overflow-y: auto !important;
3676
+ }
3697
3677
  .switcher-live-tile .xterm-viewport::-webkit-scrollbar {
3698
3678
  width: 0;
3699
3679
  height: 0;
@@ -3715,3 +3695,200 @@ button.primary {
3715
3695
  opacity: 0.72;
3716
3696
  border-style: dashed;
3717
3697
  }
3698
+
3699
+ /* Manual-mode folders: user-authored grouping, distinct from Project mode's
3700
+ automatic directory sections. */
3701
+ .switcher-folder-new {
3702
+ margin-left: 10px;
3703
+ padding: 1px 8px;
3704
+ border-radius: 999px;
3705
+ border: 1px dashed var(--line);
3706
+ background: transparent;
3707
+ color: var(--ink-muted);
3708
+ font-family: var(--font-mono);
3709
+ font-size: 11px;
3710
+ cursor: pointer;
3711
+ }
3712
+ .switcher-folder-new:hover {
3713
+ border-color: var(--accent);
3714
+ color: var(--ink);
3715
+ }
3716
+ .switcher-folder {
3717
+ border: 1px solid transparent;
3718
+ border-radius: 12px;
3719
+ padding: 2px 6px 6px;
3720
+ transition: border-color 0.12s ease, background 0.12s ease;
3721
+ }
3722
+ .switcher-folder.drop-target,
3723
+ .switcher-unfiled.drop-target {
3724
+ border-color: var(--accent);
3725
+ background: color-mix(in srgb, var(--accent) 7%, transparent);
3726
+ }
3727
+ .switcher-folder-count {
3728
+ margin-left: 8px;
3729
+ font-size: 10.5px;
3730
+ color: var(--ink-muted);
3731
+ }
3732
+ .switcher-folder .switcher-group-label button,
3733
+ .switcher-unfiled button {
3734
+ margin-left: 2px;
3735
+ border: none;
3736
+ background: transparent;
3737
+ color: var(--ink-muted);
3738
+ cursor: pointer;
3739
+ font-size: 10.5px;
3740
+ line-height: 1;
3741
+ padding: 2px 4px;
3742
+ border-radius: 4px;
3743
+ opacity: 0;
3744
+ transition: opacity 0.12s ease;
3745
+ }
3746
+ .switcher-folder .switcher-group-label button:hover {
3747
+ color: var(--ink);
3748
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
3749
+ }
3750
+ /* The + CREATES — primary affordance, always visible; edit/delete are
3751
+ maintenance and stay hover-revealed. */
3752
+ .switcher-folder .switcher-group-label .switcher-folder-add {
3753
+ opacity: 1;
3754
+ font-size: 12.5px;
3755
+ margin-left: 6px;
3756
+ }
3757
+ .switcher-folder:hover .switcher-group-label button {
3758
+ opacity: 1;
3759
+ }
3760
+ .switcher-folder-empty {
3761
+ margin: 4px 0 8px;
3762
+ font-family: var(--font-mono);
3763
+ font-size: 11.5px;
3764
+ color: var(--ink-muted);
3765
+ }
3766
+ .switcher-unfiled {
3767
+ border: 1px solid transparent;
3768
+ border-radius: 10px;
3769
+ padding: 2px 6px;
3770
+ }
3771
+
3772
+ /* Context menus. One look everywhere: same panel, line, and radius as the
3773
+ session sheet, so a right-click never feels like a different application. */
3774
+ .context-menu {
3775
+ position: fixed;
3776
+ z-index: 120;
3777
+ min-width: 190px;
3778
+ padding: 5px;
3779
+ border-radius: 11px;
3780
+ border: 1px solid var(--line);
3781
+ background: var(--panel);
3782
+ box-shadow: 0 10px 34px rgba(0, 0, 0, 0.3);
3783
+ display: flex;
3784
+ flex-direction: column;
3785
+ gap: 1px;
3786
+ }
3787
+ .context-menu-item {
3788
+ display: flex;
3789
+ align-items: center;
3790
+ justify-content: space-between;
3791
+ gap: 12px;
3792
+ width: 100%;
3793
+ padding: 7px 10px;
3794
+ border: none;
3795
+ border-radius: 7px;
3796
+ background: transparent;
3797
+ color: var(--ink);
3798
+ font-family: var(--font-ui, inherit);
3799
+ font-size: 13px;
3800
+ text-align: left;
3801
+ cursor: pointer;
3802
+ }
3803
+ .context-menu-item.active:not(:disabled) {
3804
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
3805
+ }
3806
+ .context-menu-item:disabled {
3807
+ color: var(--ink-muted);
3808
+ cursor: default;
3809
+ }
3810
+ .context-menu-item.danger {
3811
+ color: #e5484d;
3812
+ }
3813
+ .context-menu-item.danger.active {
3814
+ background: color-mix(in srgb, #e5484d 14%, transparent);
3815
+ }
3816
+ .context-menu-item kbd {
3817
+ font-family: var(--font-mono);
3818
+ font-size: 11px;
3819
+ color: var(--ink-muted);
3820
+ }
3821
+ .context-menu-separator {
3822
+ height: 1px;
3823
+ margin: 4px 6px;
3824
+ background: var(--line);
3825
+ }
3826
+
3827
+
3828
+ /* ── Mobile switcher: a phone toolbar, not a desktop toolbar squeezed. The
3829
+ top bar had NO narrow-screen rules — ten header items and seven tab
3830
+ buttons on two nowrap rows shoved the right-edge actions (keyboard!)
3831
+ against the bezel and overflowed the tab row. ── */
3832
+ @media (max-width: 767px) {
3833
+ .switcher-header {
3834
+ gap: 6px;
3835
+ padding: calc(env(safe-area-inset-top, 0px) + 8px)
3836
+ calc(env(safe-area-inset-right, 0px) + 10px)
3837
+ 8px
3838
+ calc(env(safe-area-inset-left, 0px) + 10px);
3839
+ }
3840
+ .switcher-header h2 {
3841
+ font-size: 15px;
3842
+ }
3843
+ /* + alone — the word is desktop chrome. */
3844
+ .switcher-new-label {
3845
+ display: none;
3846
+ }
3847
+ /* Explained by a tooltip, and touch has no tooltips: the chip is noise
3848
+ here. Interactivity is discovered by tapping a tile, which is the
3849
+ gesture anyway. */
3850
+ .switcher-chip.live {
3851
+ display: none;
3852
+ }
3853
+ .switcher-zoom-readout {
3854
+ display: none;
3855
+ }
3856
+ .switcher-tilesize button {
3857
+ padding: 5px 10px; /* finger-sized, was mouse-sized */
3858
+ }
3859
+ /* Real tap targets for the header actions, clear of the screen edge
3860
+ (the ✕ keeps them off the bezel on notched phones via header padding). */
3861
+ .switcher-action,
3862
+ .switcher-close {
3863
+ width: 38px;
3864
+ height: 38px;
3865
+ }
3866
+ /* Tabs wrap onto tidy lines; the filter takes a full row of its own —
3867
+ a text input sharing a squeezed line with seven buttons helped nobody. */
3868
+ .switcher-filter-row {
3869
+ flex-wrap: wrap;
3870
+ row-gap: 8px;
3871
+ padding: 0 calc(env(safe-area-inset-right, 0px) + 10px) 10px
3872
+ calc(env(safe-area-inset-left, 0px) + 10px);
3873
+ }
3874
+ .switcher-filter,
3875
+ .switcher-filter.compact {
3876
+ flex: 1 1 100%;
3877
+ order: 2;
3878
+ opacity: 1;
3879
+ min-height: 38px;
3880
+ }
3881
+ .switcher-origin button {
3882
+ padding: 6px 10px;
3883
+ font-size: 11px;
3884
+ }
3885
+ }
3886
+
3887
+ /* Touch devices: hover-revealed controls do not exist. Folder rename/delete
3888
+ were literally unreachable on a phone. */
3889
+ @media (hover: none) {
3890
+ .switcher-folder .switcher-group-label button,
3891
+ .switcher-unfiled button {
3892
+ opacity: 1;
3893
+ }
3894
+ }
@@ -35,6 +35,8 @@ export type SwitcherSession = {
35
35
  parked?: boolean;
36
36
  // Archived: process stopped, but reopening resumes the conversation.
37
37
  archived?: boolean;
38
+ // Folder the user filed this session under (Manual mode). Null = loose.
39
+ folderId?: string | null;
38
40
  // A hop CLI is attached in a real terminal window on the host. The wall
39
41
  // must never resize such a session — that window's size is physical truth.
40
42
  hasLocalCli?: boolean;
@@ -53,11 +55,13 @@ export type SwitcherGroup = {
53
55
  rows: SwitcherSession[];
54
56
  };
55
57
 
58
+ export type SwitcherFolder = { id: string; name: string };
59
+
56
60
  export type SwitcherModel =
57
61
  | { mode: "tiers"; hero: SwitcherSession[]; groups: SwitcherGroup[]; currentInHero: boolean }
58
62
  | { mode: "filter"; rows: SwitcherSession[] }
59
63
  | { mode: "project"; groups: SwitcherGroup[] }
60
- | { mode: "manual"; rows: SwitcherSession[] };
64
+ | { mode: "manual"; rows: SwitcherSession[]; folders: Array<{ folder: SwitcherFolder; rows: SwitcherSession[] }> };
61
65
 
62
66
  // Minimum hero-card count (including the current session). All attention
63
67
  // sessions are always hero cards — attention must never be buried in the tail.
@@ -188,12 +192,41 @@ const orderManually = (sessions: SwitcherSession[], manualOrder: string[]): Swit
188
192
  });
189
193
  };
190
194
 
195
+ /**
196
+ * Split sessions into the user's folders plus everything still loose.
197
+ * Folders belong to MANUAL mode only: Recent is ordered by the machine and
198
+ * Project by the filesystem, so a hand-authored grouping would contradict
199
+ * both. Empty folders are kept — a folder you made is a place you intend to
200
+ * put things, and silently hiding it would make new folders look broken.
201
+ */
202
+ const splitIntoFolders = (
203
+ sessions: SwitcherSession[],
204
+ folders: SwitcherFolder[],
205
+ manualOrder: string[]
206
+ ) => {
207
+ const byId = new Map(folders.map((f) => [f.id, [] as SwitcherSession[]]));
208
+ const loose: SwitcherSession[] = [];
209
+ for (const s of sessions) {
210
+ const bucket = s.folderId ? byId.get(s.folderId) : undefined;
211
+ if (bucket) bucket.push(s);
212
+ else loose.push(s);
213
+ }
214
+ return {
215
+ folders: folders.map((folder) => ({
216
+ folder,
217
+ rows: orderManually(byId.get(folder.id) || [], manualOrder)
218
+ })),
219
+ loose: orderManually(loose, manualOrder)
220
+ };
221
+ };
222
+
191
223
  export const buildSwitcherModel = (
192
224
  sessions: SwitcherSession[],
193
225
  currentRoom: string | null,
194
226
  filter: string,
195
227
  sortMode: SwitcherSortMode = "recent",
196
- manualOrder: string[] = []
228
+ manualOrder: string[] = [],
229
+ folders: SwitcherFolder[] = []
197
230
  ): SwitcherModel => {
198
231
  const query = filter.trim();
199
232
  if (query) {
@@ -207,7 +240,10 @@ export const buildSwitcherModel = (
207
240
  // are hidden. Score-ranking here would reorder the wall under the user's
208
241
  // hands at the exact moment they were trying to arrange it.
209
242
  if (sortMode === "manual") {
210
- return { mode: "manual", rows: orderManually(matches, manualOrder) };
243
+ // Filtering narrows within folders too, so a search never pretends a
244
+ // session left the folder you filed it in.
245
+ const split = splitIntoFolders(matches, folders, manualOrder);
246
+ return { mode: "manual", rows: split.loose, folders: split.folders };
211
247
  }
212
248
  const rows = matches
213
249
  .map((s) => ({ s, score: filterScore(s, q) }))
@@ -221,7 +257,8 @@ export const buildSwitcherModel = (
221
257
  return { mode: "project", groups: groupByProject(sessions, true) };
222
258
  }
223
259
  if (sortMode === "manual") {
224
- return { mode: "manual", rows: orderManually(sessions, manualOrder) };
260
+ const split = splitIntoFolders(sessions, folders, manualOrder);
261
+ return { mode: "manual", rows: split.loose, folders: split.folders };
225
262
  }
226
263
 
227
264
  const current = sessions.find((s) => isCurrent(s, currentRoom)) ?? null;
@@ -271,3 +271,54 @@ describe("focus rules", () => {
271
271
  expect(document.querySelectorAll(".switcher-live-tile.is-live").length).toBe(0);
272
272
  });
273
273
  });
274
+
275
+ describe("context menus", () => {
276
+ afterEach(() => { document.body.innerHTML = ""; });
277
+
278
+ it("right-clicking the wall offers create + view actions, not session actions", () => {
279
+ render(<SessionSwitcher {...props} open />);
280
+ const scroll = document.querySelector(".switcher-scroll")!;
281
+ fireEvent.contextMenu(scroll, { clientX: 40, clientY: 40 });
282
+ const labels = Array.from(document.querySelectorAll(".context-menu-item")).map((b) => b.textContent);
283
+ expect(labels.some((l) => l?.includes("New session"))).toBe(true);
284
+ expect(labels.some((l) => l?.includes("Group by project"))).toBe(true);
285
+ // Session-specific verbs belong to a card's menu, never the background.
286
+ expect(labels.some((l) => l?.includes("Rename"))).toBe(false);
287
+ });
288
+
289
+ it("a right-click on a card does not open the wall menu", () => {
290
+ render(<SessionSwitcher {...props} open />);
291
+ const card = document.querySelector(".switcher-card")!;
292
+ fireEvent.contextMenu(card, { clientX: 10, clientY: 10 });
293
+ const labels = Array.from(document.querySelectorAll(".context-menu-item")).map((b) => b.textContent);
294
+ expect(labels.some((l) => l?.includes("New session"))).toBe(false);
295
+ });
296
+
297
+ it("Escape closes the menu", () => {
298
+ render(<SessionSwitcher {...props} open />);
299
+ fireEvent.contextMenu(document.querySelector(".switcher-scroll")!, { clientX: 30, clientY: 30 });
300
+ expect(document.querySelector(".context-menu")).toBeTruthy();
301
+ fireEvent.keyDown(window, { key: "Escape" });
302
+ expect(document.querySelector(".context-menu")).toBeNull();
303
+ });
304
+ });
305
+
306
+ describe("manual folders in the wall", () => {
307
+ afterEach(() => { document.body.innerHTML = ""; });
308
+ const folders = [{ id: "f1", name: "Filed" }];
309
+ const withFolder: SwitcherSession[] = [
310
+ { name: "inside", displayName: "inside", internalName: "inside", active: true, starting: false, createdBy: "user", folderId: "f1" },
311
+ { name: "outside", displayName: "outside", internalName: "outside", active: true, starting: false, createdBy: "user" }
312
+ ];
313
+
314
+ it("a filter matching only a foldered session shows it, not an empty wall", () => {
315
+ render(<SessionSwitcher {...props} sessions={withFolder} folders={folders} open />);
316
+ fireEvent.click(screen.getByRole("button", { name: "Manual" }));
317
+ const input = document.querySelector(".switcher-top input") as HTMLInputElement
318
+ || document.querySelector("input") as HTMLInputElement;
319
+ fireEvent.change(input, { target: { value: "insid" } });
320
+ expect(screen.queryByText("No matches")).toBeNull();
321
+ expect(screen.getByText("inside")).toBeTruthy();
322
+ expect(screen.getByText("Filed")).toBeTruthy();
323
+ });
324
+ });
@@ -310,3 +310,68 @@ describe("manual mode stability", () => {
310
310
  expect(after.rows.map((r) => r.name)).toEqual(["c", "a", "b"]);
311
311
  });
312
312
  });
313
+
314
+ describe("manual mode folders", () => {
315
+ const folders = [{ id: "f1", name: "Experiments" }, { id: "f2", name: "Infra" }];
316
+
317
+ it("files sessions into their folders and leaves the rest loose", () => {
318
+ const sessions = [
319
+ mk({ name: "alpha", folderId: "f1" }),
320
+ mk({ name: "beta" }),
321
+ mk({ name: "gamma", folderId: "f2" })
322
+ ];
323
+ const model = buildSwitcherModel(sessions, null, "", "manual", [], folders);
324
+ if (model.mode !== "manual") throw new Error("expected manual");
325
+ expect(model.folders.map((f) => f.folder.name)).toEqual(["Experiments", "Infra"]);
326
+ expect(model.folders[0].rows.map((r) => r.name)).toEqual(["alpha"]);
327
+ expect(model.rows.map((r) => r.name)).toEqual(["beta"]);
328
+ });
329
+
330
+ it("keeps an empty folder visible — a folder is a place you intend to fill", () => {
331
+ const model = buildSwitcherModel([mk({ name: "solo" })], null, "", "manual", [], folders);
332
+ if (model.mode !== "manual") throw new Error("expected manual");
333
+ expect(model.folders.map((f) => f.rows.length)).toEqual([0, 0]);
334
+ expect(model.rows.map((r) => r.name)).toEqual(["solo"]);
335
+ });
336
+
337
+ it("a session in a deleted folder falls back to loose rather than vanishing", () => {
338
+ const sessions = [mk({ name: "orphan", folderId: "f_gone" })];
339
+ const model = buildSwitcherModel(sessions, null, "", "manual", [], folders);
340
+ if (model.mode !== "manual") throw new Error("expected manual");
341
+ expect(model.rows.map((r) => r.name)).toEqual(["orphan"]);
342
+ });
343
+
344
+ it("filtering narrows inside folders instead of pulling sessions out of them", () => {
345
+ const sessions = [
346
+ mk({ name: "alpha", folderId: "f1" }),
347
+ mk({ name: "alpine", folderId: "f1" }),
348
+ mk({ name: "beta" })
349
+ ];
350
+ const model = buildSwitcherModel(sessions, null, "alp", "manual", [], folders);
351
+ if (model.mode !== "manual") throw new Error("expected manual");
352
+ expect(model.folders[0].rows.map((r) => r.name)).toEqual(["alpha", "alpine"]);
353
+ expect(model.rows).toEqual([]);
354
+ });
355
+
356
+ it("folders do not leak into Recent or Project", () => {
357
+ const sessions = [mk({ name: "alpha", folderId: "f1" }), mk({ name: "beta" })];
358
+ const recent = buildSwitcherModel(sessions, null, "", "recent", [], folders);
359
+ const project = buildSwitcherModel(sessions, null, "", "project", [], folders);
360
+ expect(recent.mode).toBe("tiers");
361
+ expect(project.mode).toBe("project");
362
+ if (project.mode !== "project") throw new Error("expected project");
363
+ expect(project.groups.some((g) => g.label === "Experiments")).toBe(false);
364
+ });
365
+ });
366
+
367
+ describe("manual mode folders under filter", () => {
368
+ const folders = [{ id: "f1", name: "Experiments" }];
369
+
370
+ it("matches inside folders are found and stay filed", () => {
371
+ const sessions = [mk({ name: "alpha", folderId: "f1" }), mk({ name: "beta" })];
372
+ const model = buildSwitcherModel(sessions, null, "alph", "manual", [], folders);
373
+ if (model.mode !== "manual") throw new Error("expected manual");
374
+ expect(model.folders[0].rows.map((r) => r.name)).toEqual(["alpha"]);
375
+ expect(model.rows).toEqual([]); // loose list empty ≠ no results
376
+ });
377
+ });