Skip to content

Commit 6b2d49b

Browse files
authored
Merge d45a855 into 3ac7729
2 parents 3ac7729 + d45a855 commit 6b2d49b

70 files changed

Lines changed: 1008 additions & 514 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ui/src/components/app-sidebar.ts

Lines changed: 83 additions & 233 deletions
Large diffs are not rendered by default.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/* @vitest-environment jsdom */
2+
3+
import { html, render } from "lit";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import "./session-menu.ts";
6+
import type { SessionMenuAction, SessionMenuData } from "./session-menu.ts";
7+
8+
type SessionMenuElement = HTMLElement & { updateComplete: Promise<boolean> };
9+
10+
const containers: HTMLElement[] = [];
11+
12+
afterEach(() => {
13+
for (const container of containers.splice(0)) {
14+
container.remove();
15+
}
16+
});
17+
18+
async function mountMenu(
19+
options: {
20+
session?: Partial<SessionMenuData>;
21+
canOpenChat?: boolean;
22+
workboard?: { captured: boolean; busy: boolean } | null;
23+
archiveAllowed?: boolean;
24+
groups?: readonly string[];
25+
trigger?: HTMLElement | null;
26+
onAction?: (action: SessionMenuAction) => void;
27+
onClose?: () => void;
28+
} = {},
29+
): Promise<SessionMenuElement> {
30+
const container = document.createElement("div");
31+
containers.push(container);
32+
document.body.append(container);
33+
const session: SessionMenuData = {
34+
key: "agent:main:test",
35+
label: "Test session",
36+
pinned: false,
37+
unread: false,
38+
archived: false,
39+
category: null,
40+
...options.session,
41+
};
42+
render(
43+
html`<openclaw-session-menu
44+
.session=${session}
45+
.x=${100}
46+
.y=${100}
47+
.trigger=${options.trigger ?? null}
48+
.disabled=${false}
49+
.forkDisabled=${false}
50+
.archiveAllowed=${options.archiveAllowed ?? true}
51+
.groups=${options.groups ?? []}
52+
.canOpenChat=${options.canOpenChat ?? true}
53+
.workboard=${options.workboard === undefined
54+
? { captured: false, busy: false }
55+
: options.workboard}
56+
.onAction=${options.onAction ?? (() => {})}
57+
.onClose=${options.onClose ?? (() => {})}
58+
></openclaw-session-menu>`,
59+
container,
60+
);
61+
const element = container.querySelector("openclaw-session-menu") as SessionMenuElement | null;
62+
if (!element) {
63+
throw new Error("Expected session menu");
64+
}
65+
await element.updateComplete;
66+
return element;
67+
}
68+
69+
function menuItemLabels(menu: ParentNode): string[] {
70+
return Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]')).map(
71+
(item) => item.textContent?.trim() ?? "",
72+
);
73+
}
74+
75+
function menuItem(menu: ParentNode, label: string): HTMLButtonElement {
76+
const item = Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).find(
77+
(candidate) => candidate.textContent?.trim() === label,
78+
);
79+
if (!item) {
80+
throw new Error(`Expected menu item: ${label}`);
81+
}
82+
return item;
83+
}
84+
85+
describe("session menu", () => {
86+
it("renders the full plain-session item set in order", async () => {
87+
const menu = await mountMenu();
88+
89+
expect(menuItemLabels(menu)).toEqual([
90+
"Open chat",
91+
"Pin session",
92+
"Mark as unread",
93+
"Rename…",
94+
"Fork",
95+
"Add to Workboard",
96+
"Move to group",
97+
"Archive session",
98+
"Delete…",
99+
]);
100+
});
101+
102+
it("omits Open chat and Workboard when unavailable", async () => {
103+
const menu = await mountMenu({ canOpenChat: false, workboard: null });
104+
105+
expect(menuItemLabels(menu)).not.toContain("Open chat");
106+
expect(menuItemLabels(menu)).not.toContain("Add to Workboard");
107+
});
108+
109+
it("restores archived sessions while keeping delete enabled and pin disabled", async () => {
110+
const menu = await mountMenu({
111+
archiveAllowed: false,
112+
session: { archived: true },
113+
});
114+
115+
expect(menuItem(menu, "Restore session").disabled).toBe(false);
116+
expect(menuItem(menu, "Delete…").disabled).toBe(false);
117+
expect(menuItem(menu, "Pin session").disabled).toBe(true);
118+
});
119+
120+
it("disables archive and delete when an active session cannot be archived", async () => {
121+
const menu = await mountMenu({ archiveAllowed: false });
122+
123+
expect(menuItem(menu, "Archive session").disabled).toBe(true);
124+
expect(menuItem(menu, "Delete…").disabled).toBe(true);
125+
});
126+
127+
it("closes before dispatching Pin", async () => {
128+
const calls: string[] = [];
129+
const menu = await mountMenu({
130+
onClose: () => calls.push("close"),
131+
onAction: (action) => calls.push(action.kind),
132+
});
133+
134+
menuItem(menu, "Pin session").click();
135+
136+
expect(calls).toEqual(["close", "toggle-pin"]);
137+
});
138+
139+
it("opens group actions and dispatches group, removal, and creation choices", async () => {
140+
const onAction = vi.fn<(action: SessionMenuAction) => void>();
141+
const menu = await mountMenu({
142+
session: { category: "Research" },
143+
groups: ["Research", "Projects"],
144+
onAction,
145+
});
146+
147+
menuItem(menu, "Move to group").click();
148+
await menu.updateComplete;
149+
150+
expect(menuItemLabels(menu)).toContain("Research");
151+
expect(menuItemLabels(menu)).toContain("Projects");
152+
expect(menuItem(menu, "Research").querySelector(".session-menu__check svg")).not.toBeNull();
153+
154+
menuItem(menu, "Projects").click();
155+
expect(onAction).toHaveBeenCalledWith({ kind: "move-to-group", category: "Projects" });
156+
157+
menuItem(menu, "Remove from group").click();
158+
expect(onAction).toHaveBeenCalledWith({ kind: "move-to-group", category: null });
159+
160+
menuItem(menu, "New group…").click();
161+
expect(onAction).toHaveBeenCalledWith({ kind: "new-group" });
162+
});
163+
164+
it("omits Remove from group when the session has no category", async () => {
165+
const menu = await mountMenu({ groups: ["Research"] });
166+
167+
menuItem(menu, "Move to group").click();
168+
await menu.updateComplete;
169+
170+
expect(menuItemLabels(menu)).not.toContain("Remove from group");
171+
});
172+
173+
it("closes on Escape and outside pointerdown but ignores its trigger", async () => {
174+
const trigger = document.createElement("button");
175+
document.body.append(trigger);
176+
containers.push(trigger);
177+
const onClose = vi.fn();
178+
await mountMenu({ trigger, onClose });
179+
180+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
181+
expect(onClose).toHaveBeenCalledTimes(1);
182+
183+
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
184+
expect(onClose).toHaveBeenCalledTimes(2);
185+
186+
trigger.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
187+
expect(onClose).toHaveBeenCalledTimes(2);
188+
});
189+
});

0 commit comments

Comments
 (0)