hop2 0.11.13 → 0.11.15

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.
@@ -4019,3 +4019,112 @@ button.primary {
4019
4019
  .digest-headline { font-size: 12.5px; font-weight: 600; color: var(--ink); }
4020
4020
  .digest-item.read .digest-headline { color: var(--ink-muted); font-weight: 500; }
4021
4021
  .digest-why { font-size: 11.5px; color: var(--ink-muted); }
4022
+
4023
+ /* ── Views: the index of what agents published with `hop view`. Layered above
4024
+ the switcher (600) and its sheets (620) because it opens FROM them. ── */
4025
+ .views-backdrop {
4026
+ position: fixed;
4027
+ inset: 0;
4028
+ z-index: 720;
4029
+ background: rgba(0, 0, 0, 0.45);
4030
+ backdrop-filter: blur(2px);
4031
+ -webkit-backdrop-filter: blur(2px);
4032
+ }
4033
+ .views-panel {
4034
+ position: fixed;
4035
+ top: 50%;
4036
+ left: 50%;
4037
+ transform: translate(-50%, -50%);
4038
+ z-index: 730;
4039
+ width: min(460px, 92vw);
4040
+ /* Bounded, not sized to content: a fleet that has been publishing for a week
4041
+ otherwise grows a dialog taller than the screen with no way to scroll it. */
4042
+ max-height: min(74vh, 620px);
4043
+ display: flex;
4044
+ flex-direction: column;
4045
+ background: var(--panel);
4046
+ border: 1px solid var(--line);
4047
+ border-radius: 12px;
4048
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
4049
+ padding: 14px 16px;
4050
+ }
4051
+ .views-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
4052
+ .views-head-spacer { flex: 1; }
4053
+ .views-badge {
4054
+ font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
4055
+ color: var(--accent, #7c5cff);
4056
+ }
4057
+ .views-scope {
4058
+ font-family: var(--font-mono);
4059
+ font-size: 10.5px; color: var(--ink-muted);
4060
+ }
4061
+ .views-close {
4062
+ border: none; background: none; cursor: pointer;
4063
+ color: var(--ink-muted); font-size: 16px; line-height: 1; padding: 2px 4px;
4064
+ }
4065
+ .views-empty { margin: 6px 2px 4px; font-size: 12.5px; line-height: 1.5; color: var(--ink-muted); }
4066
+ .views-empty code { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); }
4067
+ .views-list { overflow-y: auto; margin: 0 -6px; padding: 0 6px; }
4068
+ .views-group + .views-group { margin-top: 10px; }
4069
+ .views-dateline {
4070
+ font-family: var(--font-mono);
4071
+ font-size: 10.5px; font-weight: 700; text-transform: uppercase;
4072
+ letter-spacing: 0.05em; color: var(--ink-muted);
4073
+ padding-bottom: 3px;
4074
+ }
4075
+ .views-row {
4076
+ display: flex; align-items: center; gap: 9px;
4077
+ text-decoration: none; color: inherit;
4078
+ padding: 6px; margin: 0 -6px; border-radius: 8px;
4079
+ }
4080
+ .views-row:hover { background: color-mix(in srgb, var(--ink) 6%, transparent); }
4081
+ .views-kind {
4082
+ flex: 0 0 34px;
4083
+ font-family: var(--font-mono);
4084
+ font-size: 8.5px; font-weight: 700; letter-spacing: 0.04em;
4085
+ text-align: center;
4086
+ padding: 3px 0; border-radius: 4px;
4087
+ background: var(--bg-alt); color: var(--ink-muted);
4088
+ }
4089
+ .views-row.fresh .views-kind {
4090
+ background: color-mix(in srgb, var(--accent, #7c5cff) 20%, transparent);
4091
+ color: var(--accent, #7c5cff);
4092
+ }
4093
+ .views-row-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
4094
+ /* Titles are agent-written prose of arbitrary length; without the clamp one
4095
+ sentence reflowed the row to four lines and pushed the rest off-screen. */
4096
+ .views-title {
4097
+ font-size: 12.5px; font-weight: 600; color: var(--ink);
4098
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
4099
+ }
4100
+ .views-meta {
4101
+ font-size: 11px; color: var(--ink-muted);
4102
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
4103
+ }
4104
+ .views-dot {
4105
+ flex-shrink: 0;
4106
+ width: 6px; height: 6px; border-radius: 3px;
4107
+ background: var(--accent, #7c5cff);
4108
+ }
4109
+
4110
+ /* The affordances that open it: a topbar/switcher button and a card chip,
4111
+ each carrying the same unseen dot. */
4112
+ .views-chip-dot {
4113
+ flex-shrink: 0;
4114
+ width: 5px; height: 5px; border-radius: 3px;
4115
+ background: var(--accent, #7c5cff);
4116
+ }
4117
+ /* The switcher's header action is a fixed 32px square with a centred glyph —
4118
+ an inline dot would shove the icon off-centre, so it rides the corner. */
4119
+ .switcher-action { position: relative; }
4120
+ .switcher-action .views-chip-dot { position: absolute; top: 5px; right: 5px; }
4121
+ .switcher-chip.views {
4122
+ border: none;
4123
+ cursor: pointer;
4124
+ font-family: inherit;
4125
+ line-height: 1.2;
4126
+ }
4127
+ .switcher-chip.views.fresh {
4128
+ background: color-mix(in srgb, var(--accent, #7c5cff) 22%, transparent);
4129
+ color: var(--accent, #7c5cff);
4130
+ }
@@ -1,6 +1,22 @@
1
- // Drag-and-drop helper. Browsers deliberately never expose a dropped file's
2
- // real filesystem path, so hop can't paste it — instead the drop is caught
3
- // and the user is pointed at the OS gesture that DOES yield the path.
1
+ // Drag-and-drop helpers. Browsers deliberately never expose a dropped file's
2
+ // real filesystem path — so hop uploads the BYTES to the host instead and
3
+ // pastes the path the file landed on. The path hint below survives as the
4
+ // fallback for when that upload cannot happen (old daemon, offline).
5
+
6
+ /** Quote a path for a shell only when it needs it. A bare path stays bare —
7
+ * most drops land somewhere boring, and gratuitous quotes read as noise in
8
+ * an agent's composer. Single-quote style, with the standard '\'' escape. */
9
+ export const shellQuotePath = (filePath: string): string => {
10
+ if (filePath === "") return "''";
11
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(filePath)) return filePath;
12
+ return `'${filePath.replace(/'/g, `'\\''`)}'`;
13
+ };
14
+
15
+ /** Join uploaded paths into one paste: space-separated, each quoted only if
16
+ * it must be. Reads as a command argument list, which is what it usually
17
+ * becomes. */
18
+ export const pasteableUploadPaths = (paths: string[]): string =>
19
+ paths.map(shellQuotePath).join(" ");
4
20
 
5
21
  /** OS-specific "copy the file's path" hotkey hint. Pass a platform string
6
22
  * (navigator.platform + userAgent); pure so it's unit-testable. */
@@ -6,6 +6,16 @@
6
6
  // long tail becomes compact rows auto-grouped by project directory. A filter
7
7
  // query collapses everything into one flat ranked list.
8
8
 
9
+ // Per-session rollup of what `hop view` / `hop port` published, as
10
+ // /api/sessions reports it. latestAt is epoch SECONDS, matching the manifest.
11
+ export type ViewsSummary = {
12
+ count: number;
13
+ latestAt: number;
14
+ latestName?: string;
15
+ latestTitle?: string;
16
+ latestPath?: string;
17
+ };
18
+
9
19
  export type SwitcherSession = {
10
20
  name: string;
11
21
  displayName: string;
@@ -40,6 +50,8 @@ export type SwitcherSession = {
40
50
  // A hop CLI is attached in a real terminal window on the host. The wall
41
51
  // must never resize such a session — that window's size is physical truth.
42
52
  hasLocalCli?: boolean;
53
+ // Absent unless this session has published something.
54
+ views?: ViewsSummary;
43
55
  };
44
56
 
45
57
  export type SessionOriginScope = "user" | "agent" | "all";
@@ -326,6 +326,25 @@ describe("manual folders in the wall", () => {
326
326
  expect(screen.getByText("Filed")).toBeTruthy();
327
327
  });
328
328
 
329
+ it("Escape clears a typed filter before it dismisses the wall", () => {
330
+ const onClose = vi.fn();
331
+ render(<SessionSwitcher {...props} onClose={onClose} sessions={withFolder} folders={folders} open />);
332
+ const input = document.querySelector(".switcher-top input") as HTMLInputElement
333
+ || document.querySelector("input") as HTMLInputElement;
334
+ fireEvent.change(input, { target: { value: "outsid" } });
335
+ expect(input.value).toBe("outsid");
336
+
337
+ // First Escape: the query is the innermost layer — it goes, the wall stays.
338
+ fireEvent.keyDown(window, { key: "Escape" });
339
+ expect(input.value).toBe("");
340
+ expect(onClose).not.toHaveBeenCalled();
341
+ expect(screen.getByText("inside")).toBeTruthy(); // full wall is back
342
+
343
+ // Second Escape: nothing left to unwind, so the wall dismisses.
344
+ fireEvent.keyDown(window, { key: "Escape" });
345
+ expect(onClose).toHaveBeenCalled();
346
+ });
347
+
329
348
  it("a query in manual mode still offers the full-history search", () => {
330
349
  // Manual keeps its own render branch under a query (narrow-in-place), so
331
350
  // the search tail — content hits + the deep button — must ride along or
@@ -0,0 +1,118 @@
1
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "../src/components/ViewsPanel";
4
+ import { SessionSwitcher } from "../src/components/SessionSwitcher";
5
+ import type { SwitcherSession } from "../src/utils/switcherModel";
6
+
7
+ // A slice of the real /api/views manifest: newest first, one empty title (the
8
+ // common case — `hop view` without --title), two sessions.
9
+ const items = [
10
+ { session: "Orion", name: "agent-result.md", title: "Views end-to-end", path: "/view/Orion/agent-result.md/inline", bytes: 437, mtime: 1786303213 },
11
+ { session: "Orion", name: "views-test.html", title: "", path: "/view/Orion/views-test.html/inline", bytes: 2062, mtime: 1786299563 },
12
+ { session: "Nebula", name: "shot.png", title: "", path: "/view/Nebula/shot.png/inline", bytes: 999999, mtime: 1786299000 }
13
+ ];
14
+
15
+ let store: Record<string, string> = {};
16
+ beforeEach(() => {
17
+ // jsdom's own localStorage is only partially implemented here, and the
18
+ // seen-markers are the whole point of these cases.
19
+ store = {};
20
+ vi.stubGlobal("localStorage", {
21
+ getItem: (key: string) => (key in store ? store[key] : null),
22
+ setItem: (key: string, value: string) => { store[key] = value; },
23
+ removeItem: (key: string) => { delete store[key]; },
24
+ clear: () => { store = {}; }
25
+ });
26
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ items }) })));
27
+ });
28
+ afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); });
29
+
30
+ describe("ViewsPanel", () => {
31
+ it("groups by session, leads with the title, falls back to the filename", async () => {
32
+ render(<ViewsPanel sessions={[{ name: "Orion", displayName: "orion-worker", internalName: "Orion" }]} onClose={() => {}} />);
33
+ await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
34
+ // The manifest only knows internalNames; the dateline shows the rename.
35
+ expect(screen.getByText("orion-worker")).toBeTruthy();
36
+ expect(screen.getByText("Nebula")).toBeTruthy();
37
+ expect(screen.getByText("views-test.html")).toBeTruthy();
38
+ expect(screen.getByText("MD")).toBeTruthy();
39
+ expect(screen.getByText("IMG")).toBeTruthy();
40
+
41
+ const link = screen.getByText("Views end-to-end").closest("a") as HTMLAnchorElement;
42
+ expect(link.getAttribute("href")).toBe("/view/Orion/agent-result.md/inline");
43
+ expect(link.getAttribute("target")).toBe("_blank");
44
+ expect(link.className).toContain("fresh");
45
+
46
+ // Opening IS seeing: every session on screen gets its high-water mark.
47
+ await waitFor(() => expect(loadViewsSeen()).toEqual({ Orion: 1786303213, Nebula: 1786299000 }));
48
+ expect(hasUnseenViews("Orion", 1786303213, loadViewsSeen())).toBe(false);
49
+ expect(hasUnseenViews("Orion", 1786303300, loadViewsSeen())).toBe(true);
50
+ });
51
+
52
+ it("scopes to one session without pruning the others' markers", async () => {
53
+ store["hop_views_seen"] = JSON.stringify({ Nebula: 1 });
54
+ render(<ViewsPanel session="orion" onClose={() => {}} />);
55
+ await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
56
+ expect(screen.queryByText("shot.png")).toBeNull();
57
+ await waitFor(() => expect(loadViewsSeen().Orion).toBe(1786303213));
58
+ expect(loadViewsSeen().Nebula).toBe(1);
59
+ });
60
+
61
+ it("explains the feature when nothing is published", async () => {
62
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ items: [] }) })));
63
+ render(<ViewsPanel onClose={() => {}} />);
64
+ await waitFor(() => expect(screen.getByText(/Nothing published yet/)).toBeTruthy());
65
+ });
66
+
67
+ it("closes on Escape and on the backdrop", async () => {
68
+ const onClose = vi.fn();
69
+ const { container } = render(<ViewsPanel onClose={onClose} />);
70
+ await waitFor(() => expect(screen.getByText("Views end-to-end")).toBeTruthy());
71
+ fireEvent.keyDown(window, { key: "Escape" });
72
+ expect(onClose).toHaveBeenCalled();
73
+ fireEvent.click(container.querySelector(".views-backdrop")!);
74
+ expect(onClose.mock.calls.length).toBe(2);
75
+ });
76
+ });
77
+
78
+ const switcherSessions: SwitcherSession[] = [
79
+ {
80
+ name: "Orion", displayName: "Orion", internalName: "Orion",
81
+ active: true, starting: false, createdBy: "user",
82
+ views: { count: 3, latestAt: 1786303213, latestName: "agent-result.md", latestTitle: "Views end-to-end", latestPath: "/view/Orion/agent-result.md/inline" }
83
+ },
84
+ { name: "Nebula", displayName: "Nebula", internalName: "Nebula", active: true, starting: false, createdBy: "user" }
85
+ ];
86
+
87
+ describe("switcher views affordances", () => {
88
+ const onOpenViews = vi.fn();
89
+ const props = {
90
+ sessions: switcherSessions, currentRoom: null,
91
+ onClose: vi.fn(), onSwitch: vi.fn(), onRefresh: vi.fn(), onNotice: vi.fn(), onOpenViews
92
+ };
93
+
94
+ beforeEach(() => {
95
+ sessionStorage.clear();
96
+ Object.defineProperty(HTMLElement.prototype, "scrollTo", { configurable: true, value: vi.fn() });
97
+ });
98
+
99
+ it("opens scoped from a card chip and fleet-wide from the header", () => {
100
+ render(<SessionSwitcher {...props} open />);
101
+ const chip = screen.getByRole("button", { name: "3 published views in Orion" });
102
+ expect(chip.className).toContain("fresh"); // no marker yet = unseen
103
+ fireEvent.click(chip);
104
+ expect(onOpenViews).toHaveBeenCalledWith("Orion");
105
+
106
+ fireEvent.click(screen.getByRole("button", { name: "Published views" }));
107
+ expect(onOpenViews).toHaveBeenLastCalledWith(); // no argument = the fleet
108
+ expect(screen.queryByRole("button", { name: /published views in Nebula/ })).toBeNull();
109
+ expect(screen.getByLabelText("New views")).toBeTruthy();
110
+ });
111
+
112
+ it("goes quiet once the marker covers the newest view", () => {
113
+ store["hop_views_seen"] = JSON.stringify({ Orion: 1786303213 });
114
+ render(<SessionSwitcher {...props} open />);
115
+ expect(screen.getByRole("button", { name: "3 published views in Orion" }).className).not.toContain("fresh");
116
+ expect(screen.queryByLabelText("New views")).toBeNull();
117
+ });
118
+ });
@@ -1,5 +1,29 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { originalPathHint } from "../src/utils/fileDrop";
2
+ import { originalPathHint, pasteableUploadPaths, shellQuotePath } from "../src/utils/fileDrop";
3
+
4
+ describe("shellQuotePath", () => {
5
+ it("leaves an ordinary path bare", () => {
6
+ expect(shellQuotePath("/Users/me/.hop2/uploads/room/shot.png")).toBe("/Users/me/.hop2/uploads/room/shot.png");
7
+ });
8
+
9
+ it("quotes a path containing spaces", () => {
10
+ expect(shellQuotePath("/tmp/my file.txt")).toBe("'/tmp/my file.txt'");
11
+ });
12
+
13
+ it("survives an apostrophe in the filename", () => {
14
+ // The classic shell-quoting trap: 'it's' must close, escape, reopen.
15
+ expect(shellQuotePath("/tmp/it's here.txt")).toBe(`'/tmp/it'\\''s here.txt'`);
16
+ });
17
+
18
+ it("quotes shell metacharacters that would otherwise execute", () => {
19
+ expect(shellQuotePath("/tmp/$(whoami).txt")).toBe("'/tmp/$(whoami).txt'");
20
+ expect(shellQuotePath("/tmp/a;rm -rf b")).toBe("'/tmp/a;rm -rf b'");
21
+ });
22
+
23
+ it("joins several uploads into one argument list", () => {
24
+ expect(pasteableUploadPaths(["/tmp/a.png", "/tmp/b c.png"])).toBe("/tmp/a.png '/tmp/b c.png'");
25
+ });
26
+ });
3
27
 
4
28
  describe("originalPathHint", () => {
5
29
  it("names ⌘⌥C and Finder on Apple platforms", () => {