improve(ui): keep sidebar sessions inline and draggable (#102558)

* feat(ui): add drag-managed sidebar session groups

* chore: keep release notes in PR body
This commit is contained in:
Peter Steinberger 2026-07-09 09:40:53 +01:00 committed by GitHub
parent 51b4e78c33
commit ecc2cffad8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 692 additions and 197 deletions

View file

@ -34,10 +34,18 @@ import { resolveSessionDisplayName } from "../lib/session-display.ts";
import {
dissolveSessionGroup,
loadStoredSessionCustomGroups,
reorderSessionCustomGroups,
renameSessionGroup,
saveStoredSessionCustomGroups,
} from "../lib/sessions/custom-groups.ts";
import { writeSessionDragData } from "../lib/sessions/drag.ts";
import {
readSessionDragData,
readSessionGroupDragData,
sessionDragActive,
sessionGroupDragActive,
writeSessionDragData,
writeSessionGroupDragData,
} from "../lib/sessions/drag.ts";
import {
groupSidebarSessionRows,
normalizeSidebarSessionsGrouping,
@ -92,8 +100,14 @@ type SidebarSessionGroupMenuState = {
};
type SidebarSessionSortMode = "created" | "updated";
type SidebarSessionGroupDropTarget = {
group: string;
position: "before" | "after";
};
const SIDEBAR_SESSION_GROUPING_STORAGE_KEY = "openclaw:sidebar:sessions:grouping";
const SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY =
"openclaw:sidebar:sessions:collapsed-sections";
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
? "⌘K"
@ -105,6 +119,20 @@ function loadStoredSidebarSessionsGrouping(): SidebarSessionsGrouping {
);
}
function loadStoredCollapsedSessionSections(): ReadonlySet<string> {
try {
const raw = getSafeLocalStorage()?.getItem(SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
return new Set(
Array.isArray(parsed)
? parsed.flatMap((value) => (typeof value === "string" && value ? [value] : []))
: [],
);
} catch {
return new Set();
}
}
const SIDEBAR_SESSION_SORT_OPTIONS = [
{ mode: "created", labelKey: "chat.sidebar.sortCreated" },
{ mode: "updated", labelKey: "chat.sidebar.sortUpdated" },
@ -165,6 +193,10 @@ class AppSidebar extends LitElement {
@state() private sessionGroupSubmenuOpen = false;
@state() private sessionGroupMenu: SidebarSessionGroupMenuState | null = null;
@state() private draggingSessionKey: string | null = null;
@state() private draggingSessionGroup: string | null = null;
@state() private sessionDropTarget: string | null = null;
@state() private sessionGroupDropTarget: SidebarSessionGroupDropTarget | null = null;
@state() private collapsedSessionSections = loadStoredCollapsedSessionSections();
@state() private sessionSortMode: SidebarSessionSortMode = "created";
@state() private sessionsGrouping: SidebarSessionsGrouping = loadStoredSidebarSessionsGrouping();
@state() private sessionSortMenuPosition: { x: number; y: number } | null = null;
@ -376,13 +408,13 @@ class AppSidebar extends LitElement {
category: normalizeOptionalString(row.category),
unread: row.unread === true,
});
const recentSessions = navigation.recentSessions.map(toSidebarSession);
const visibleSessions = navigation.visibleSessions.map(toSidebarSession);
const newSessionDisabled =
!this.connected || this.sessionsLoading || Boolean(navigation.selectedSession?.hasActiveRun);
return {
routeSessionKey: navigation.currentSessionKey,
selectedAgentId: navigation.selectedAgentId,
recentSessions,
visibleSessions,
newSessionDisabled,
newSessionTitle: !this.connected
? "Connect to create a new session"
@ -649,12 +681,13 @@ class AppSidebar extends LitElement {
}
private knownSessionGroups(): string[] {
const loaded = (this.sessionsResult?.sessions ?? [])
const stored = loadStoredSessionCustomGroups();
const storedSet = new Set(stored);
const discovered = (this.sessionsResult?.sessions ?? [])
.map((row) => normalizeOptionalString(row.category))
.filter((name): name is string => Boolean(name));
return [...new Set([...loadStoredSessionCustomGroups(), ...loaded])].toSorted((a, b) =>
a.localeCompare(b),
);
.filter((name): name is string => typeof name === "string" && !storedSet.has(name))
.toSorted((a, b) => a.localeCompare(b));
return [...stored, ...new Set(discovered)];
}
private rememberSessionGroup(name: string) {
@ -695,7 +728,19 @@ class AppSidebar extends LitElement {
if (!next || next === group) {
return;
}
void renameSessionGroup(context.sessions, group, next).finally(() => this.requestUpdate());
// Seed browser-local order with server-discovered groups so rename replaces
// the existing slot instead of promoting the renamed group to the front.
saveStoredSessionCustomGroups(this.knownSessionGroups());
void renameSessionGroup(context.sessions, group, next).finally(() => {
const from = `category:${group}`;
if (this.collapsedSessionSections.has(from)) {
const collapsed = new Set(this.collapsedSessionSections);
collapsed.delete(from);
collapsed.add(`category:${next}`);
this.saveCollapsedSessionSections(collapsed);
}
this.requestUpdate();
});
}
private deleteSessionGroupFromMenu(group: string) {
@ -706,7 +751,113 @@ class AppSidebar extends LitElement {
if (!window.confirm(t("sessionsView.deleteGroupConfirm", { group }))) {
return;
}
void dissolveSessionGroup(context.sessions, group).finally(() => this.requestUpdate());
void dissolveSessionGroup(context.sessions, group).finally(() => {
const collapsed = new Set(this.collapsedSessionSections);
collapsed.delete(`category:${group}`);
this.saveCollapsedSessionSections(collapsed);
this.requestUpdate();
});
}
private saveCollapsedSessionSections(sections: ReadonlySet<string>) {
this.collapsedSessionSections = new Set(sections);
try {
getSafeLocalStorage()?.setItem(
SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY,
JSON.stringify([...sections]),
);
} catch {
// Group membership and ordering remain usable without local persistence.
}
}
private toggleSessionSection(sectionId: string) {
const collapsed = new Set(this.collapsedSessionSections);
if (collapsed.has(sectionId)) {
collapsed.delete(sectionId);
} else {
collapsed.add(sectionId);
}
this.saveCollapsedSessionSections(collapsed);
}
private reorderSessionGroup(source: string, target: string, position: "before" | "after") {
const groups = reorderSessionCustomGroups(this.knownSessionGroups(), source, target, position);
saveStoredSessionCustomGroups(groups);
this.requestUpdate();
}
private handleSessionSectionDragOver(event: DragEvent, sectionId: string, category?: string) {
const dataTransfer = event.dataTransfer;
if (
category &&
sessionGroupDragActive(dataTransfer) &&
this.draggingSessionGroup !== category
) {
event.preventDefault();
if (dataTransfer) {
dataTransfer.dropEffect = "move";
}
const target = event.currentTarget as HTMLElement;
const bounds = target.getBoundingClientRect();
const position = event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
this.sessionGroupDropTarget = { group: category, position };
this.sessionDropTarget = null;
return;
}
if (!sessionDragActive(dataTransfer) || sectionId === "pinned") {
return;
}
event.preventDefault();
if (dataTransfer) {
dataTransfer.dropEffect = "move";
}
this.sessionDropTarget = sectionId;
this.sessionGroupDropTarget = null;
}
private handleSessionSectionDragLeave(event: DragEvent, sectionId: string, category?: string) {
const current = event.currentTarget as HTMLElement;
if (event.relatedTarget instanceof Node && current.contains(event.relatedTarget)) {
return;
}
if (this.sessionDropTarget === sectionId) {
this.sessionDropTarget = null;
}
if (category && this.sessionGroupDropTarget?.group === category) {
this.sessionGroupDropTarget = null;
}
}
private handleSessionSectionDrop(event: DragEvent, category?: string) {
event.preventDefault();
const sourceGroup = readSessionGroupDragData(event.dataTransfer);
if (sourceGroup && category && sourceGroup !== category) {
const position =
this.sessionGroupDropTarget?.group === category
? this.sessionGroupDropTarget.position
: "before";
this.reorderSessionGroup(sourceGroup, category, position);
} else {
const sessionKey = readSessionDragData(event.dataTransfer);
const session = this.getSessionNavigationState().visibleSessions.find(
(candidate) => candidate.key === sessionKey,
);
const nextCategory = category ?? null;
if (session && (session.category !== nextCategory || session.pinned)) {
if (category) {
this.rememberSessionGroup(category);
}
void this.patchSession(session, {
category: nextCategory,
...(session.pinned ? { pinned: false } : {}),
});
}
}
this.draggingSessionKey = null;
this.draggingSessionGroup = null;
this.sessionDropTarget = null;
this.sessionGroupDropTarget = null;
}
private setSessionsGrouping(grouping: SidebarSessionsGrouping) {
@ -1282,6 +1433,7 @@ class AppSidebar extends LitElement {
}}
@dragend=${() => {
this.draggingSessionKey = null;
this.sessionDropTarget = null;
}}
@contextmenu=${(event: MouseEvent) => {
event.preventDefault();
@ -1364,12 +1516,129 @@ class AppSidebar extends LitElement {
return keyed(session.key, row);
}
private renderSessionSection(
section: {
id: string;
category?: string;
rows: SidebarRecentSession[];
},
showFallback = false,
) {
const group = section.category;
const isPinned = section.id === "pinned";
const showHeader = isPinned || this.sessionsGrouping === "category";
const collapsed = showHeader && this.collapsedSessionSections.has(section.id);
const label = isPinned ? t("sessionsView.pinned") : group ? group : t("sessionsView.ungrouped");
const acceptsSessions = !isPinned && this.sessionsGrouping === "category";
const sectionClass = [
"sidebar-recent-sessions__group",
collapsed ? "sidebar-recent-sessions__group--collapsed" : "",
this.sessionDropTarget === section.id ? "sidebar-recent-sessions__group--session-drop" : "",
group && this.sessionGroupDropTarget?.group === group
? `sidebar-recent-sessions__group--group-drop-${this.sessionGroupDropTarget.position}`
: "",
]
.filter(Boolean)
.join(" ");
return html`
<div
class=${sectionClass}
data-session-section=${section.id}
@dragover=${acceptsSessions || group
? (event: DragEvent) => this.handleSessionSectionDragOver(event, section.id, group)
: nothing}
@dragleave=${acceptsSessions || group
? (event: DragEvent) => this.handleSessionSectionDragLeave(event, section.id, group)
: nothing}
@drop=${acceptsSessions || group
? (event: DragEvent) => this.handleSessionSectionDrop(event, group)
: nothing}
>
${showHeader
? html`
<div
class="sidebar-recent-sessions__head"
@contextmenu=${group
? (event: MouseEvent) => {
event.preventDefault();
this.openSessionGroupMenu(group, event.clientX, event.clientY, null);
}
: nothing}
>
${group
? html`
<span
class="sidebar-session-group-drag-handle"
draggable="true"
aria-hidden="true"
@dragstart=${(event: DragEvent) => {
if (event.dataTransfer) {
writeSessionGroupDragData(event.dataTransfer, group);
this.draggingSessionGroup = group;
}
}}
@dragend=${() => {
this.draggingSessionGroup = null;
this.sessionGroupDropTarget = null;
}}
></span>
`
: nothing}
<button
type="button"
class="sidebar-session-group-toggle"
aria-expanded=${String(!collapsed)}
aria-label=${label}
@click=${() => this.toggleSessionSection(section.id)}
>
<span class="sidebar-session-group-toggle__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
<span class="sidebar-recent-sessions__label-text">${label}</span>
<span class="sidebar-session-group-count">${section.rows.length}</span>
</button>
${group
? html`
<button
type="button"
class="sidebar-session-group-actions"
title=${t("sessionsView.groupMenu", { group })}
aria-label=${t("sessionsView.groupMenu", { group })}
aria-haspopup="menu"
aria-expanded=${String(this.sessionGroupMenu?.group === group)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
this.openSessionGroupMenu(group, rect.right, rect.bottom + 4, trigger);
}}
>
${icons.moreHorizontal}
</button>
`
: nothing}
</div>
`
: nothing}
${collapsed
? nothing
: html`
<div class="sidebar-recent-sessions__list">
${showFallback
? this.renderChatFallback()
: section.rows.map((session) => this.renderRecentSession(session))}
</div>
`}
</div>
`;
}
private renderSessions() {
const context = this.context;
const {
routeSessionKey,
selectedAgentId,
recentSessions,
visibleSessions,
newSessionDisabled,
newSessionTitle,
} = this.getSessionNavigationState();
@ -1412,13 +1681,12 @@ class AppSidebar extends LitElement {
: newSessionButton;
// Stable navigation ordering carries through each pinned/category bucket;
// selecting a visible row only moves the active highlight.
const sections = groupSidebarSessionRows(recentSessions, {
const sections = groupSidebarSessionRows(visibleSessions, {
grouping: this.sessionsGrouping,
// Stored-but-empty groups stay visible as sections so a freshly created
// group is usable as a move target before its first session arrives.
knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : undefined,
});
const hasCategorySections = sections.some((section) => section.category !== undefined);
return html`
<section class="sidebar-sessions ${this.collapsed ? "sidebar-sessions--collapsed" : ""}">
${this.collapsed
@ -1430,110 +1698,46 @@ class AppSidebar extends LitElement {
? nothing
: html`
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
${sections.map((section) => {
if (section.id === "pinned" || section.category !== undefined) {
const group = section.category;
return html`
<div class="sidebar-recent-sessions__group">
<div
class="sidebar-recent-sessions__head"
@contextmenu=${group
? (event: MouseEvent) => {
event.preventDefault();
this.openSessionGroupMenu(
group,
event.clientX,
event.clientY,
null,
);
}
: nothing}
>
<span class="sidebar-recent-sessions__label-text"
>${section.id === "pinned"
? t("sessionsView.pinned")
: section.category}</span
>
${group
? html`
<button
type="button"
class="sidebar-session-group-actions"
title=${t("sessionsView.groupMenu", { group })}
aria-label=${t("sessionsView.groupMenu", { group })}
aria-haspopup="menu"
aria-expanded=${String(this.sessionGroupMenu?.group === group)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
this.openSessionGroupMenu(
group,
rect.right,
rect.bottom + 4,
trigger,
);
}}
>
${icons.moreHorizontal}
</button>
`
: nothing}
</div>
<div class="sidebar-recent-sessions__list">
${section.rows.map((session) => this.renderRecentSession(session))}
</div>
</div>
`;
}
return html`
<div class="sidebar-recent-sessions__group">
<div class="sidebar-recent-sessions__head">
<span class="sidebar-recent-sessions__label-text"
>${hasCategorySections && section.rows.length > 0
? t("sessionsView.ungrouped")
: t("sessionsView.title")}</span
>
${this.renderAgentScope(routeSessionKey, selectedAgentId)}
<div class="sidebar-recent-sessions__head sidebar-recent-sessions__head--root">
<span class="sidebar-recent-sessions__label-text"
>${t("sessionsView.title")}</span
>
${this.renderAgentScope(routeSessionKey, selectedAgentId)}
${this.sessionsGrouping === "category"
? html`
<button
type="button"
class="sidebar-session-sort"
title=${t("chat.sidebar.sortSessions")}
aria-label=${t("chat.sidebar.sortSessions")}
aria-haspopup="menu"
aria-expanded=${String(this.sessionSortMenuPosition !== null)}
@click=${(event: MouseEvent) => {
const trigger = event.currentTarget as HTMLElement;
this.toggleSessionSortMenu(trigger);
}}
title=${t("sessionsView.newGroup")}
aria-label=${t("sessionsView.newGroup")}
?disabled=${!this.connected}
@click=${() => this.createSessionGroup()}
>
${icons.listFilter}
${icons.plus}
</button>
</div>
<div class="sidebar-recent-sessions__list">
${recentSessions.length === 0
? this.renderChatFallback()
: section.rows.map((session) => this.renderRecentSession(session))}
</div>
<a
href=${pathForRoute("sessions", this.basePath)}
class="sidebar-recent-sessions__all"
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.("sessions");
}}
>
<span>${t("chat.sidebar.allSessions")}</span>
<span class="sidebar-recent-sessions__all-icon" aria-hidden="true"
>${icons.chevronRight}</span
>
</a>
</div>
`;
})}
`
: nothing}
<button
type="button"
class="sidebar-session-sort"
title=${t("chat.sidebar.sortSessions")}
aria-label=${t("chat.sidebar.sortSessions")}
aria-haspopup="menu"
aria-expanded=${String(this.sessionSortMenuPosition !== null)}
@click=${(event: MouseEvent) => {
const trigger = event.currentTarget as HTMLElement;
this.toggleSessionSortMenu(trigger);
}}
>
${icons.listFilter}
</button>
</div>
${sections.map((section) =>
this.renderSessionSection(
section,
visibleSessions.length === 0 && section.id === "ungrouped",
),
)}
</div>
`}
</section>

View file

@ -146,6 +146,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
it("manages sessions through the sidebar groups and command palette", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
@ -241,7 +242,7 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
// Chats keep recency order with the open session highlighted in place —
// selecting a row must not reshuffle the list.
const chatRows = groups.nth(1).locator(".sidebar-recent-session");
const chatRows = page.locator('[data-session-section="ungrouped"] .sidebar-recent-session');
const rowNames = () =>
chatRows.evaluateAll((rows) =>
rows.map((row) => row.querySelector(".sidebar-recent-session__name")?.textContent ?? ""),
@ -302,7 +303,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
const researchLink = sidebarResearch.locator("a").first();
await researchLink.click();
await expect.poll(() => page.url()).toContain("session=agent%3Amain%3Aresearch");
await expect.poll(rowNames).toEqual(["Main", "Data migration", "Research notes"]);
await expect
.poll(rowNames)
.toEqual(["Main", "Release planning", "Data migration", "Research notes"]);
await expect
.poll(() =>
chatRows
@ -386,6 +389,9 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
match: {},
response: sessionsListResponse([
sessionRow("agent:main:main", "Main", baseTime),
sessionRow("agent:main:apps", "Apps", baseTime - 30_000, {
category: "Apps",
}),
sessionRow("agent:main:paper-a", "Paper A", baseTime - 60_000, {
category: "Research",
}),
@ -429,11 +435,25 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
);
expect(requireRecord(patch.params)).toMatchObject({ category: "Projects", key });
}
await expect
.poll(() =>
page
.locator('[data-session-section^="category:"]')
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-session-section")),
),
)
.toEqual(["category:Apps", "category:Projects"]);
// Delete group: sessions survive and move back to Ungrouped.
await researchGroup.locator(".sidebar-recent-sessions__head").hover();
const projectsGroup = groups.filter({ hasText: "Projects" });
await projectsGroup.waitFor({ state: "visible" });
const projectsMenuButton = projectsGroup.getByRole("button", {
name: "Group options for Projects",
});
await projectsGroup.locator(".sidebar-recent-sessions__head").hover();
page.once("dialog", (dialog) => void dialog.accept());
await groupMenuButton.click();
await projectsMenuButton.click();
await page.getByRole("menuitem", { name: "Delete group…" }).click();
const dissolvePatch = await waitForPatch(
gateway,
@ -457,7 +477,152 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
await sortSessionsButton.click();
await page.getByRole("menuitemradio", { name: "None" }).click();
await expect.poll(() => groups.count()).toBe(1);
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(3);
await expect.poll(() => groups.first().locator(".sidebar-recent-session").count()).toBe(4);
} finally {
await context.close();
}
});
it("shows every sidebar session and supports complete drag-managed groups", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const sessions = Array.from({ length: 12 }, (_, index) =>
sessionRow(`agent:main:session-${index}`, `Session ${index}`, baseTime - index * 60_000, {
...(index === 0 ? { category: "Alpha" } : {}),
...(index === 1 ? { category: "Beta" } : {}),
}),
);
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse(sessions),
"sessions.patch": {},
},
sessionKey: "agent:main:session-0",
});
try {
await page.goto(`${server.baseUrl}chat`);
const sidebarRows = page.locator(".sidebar-recent-session");
await expect.poll(() => sidebarRows.count()).toBe(12);
await expect.poll(() => page.getByText("All sessions", { exact: true }).count()).toBe(0);
await captureUiProof(page, "sidebar-all-sessions.png");
page.once("dialog", (dialog) => void dialog.accept("Gamma"));
await page.getByRole("button", { name: "New group…" }).click();
const gamma = page.locator('[data-session-section="category:Gamma"]');
await gamma.waitFor({ state: "visible" });
const sessionEleven = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-11"]',
);
await sessionEleven.dragTo(gamma);
const groupedPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:session-11" && params.category === "Gamma",
);
expect(requireRecord(groupedPatch.params)).toMatchObject({
category: "Gamma",
key: "agent:main:session-11",
});
await expect
.poll(() => gamma.locator(".sidebar-recent-session").count(), { timeout: 10_000 })
.toBe(1);
await captureUiProof(page, "sidebar-session-dropped-into-group.png");
const ungrouped = page.locator('[data-session-section="ungrouped"]');
await gamma.locator(".sidebar-recent-session").dragTo(ungrouped);
const ungroupedPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:session-11" && params.category === null,
);
expect(requireRecord(ungroupedPatch.params)).toMatchObject({
category: null,
key: "agent:main:session-11",
});
await expect
.poll(() => ungrouped.locator(".sidebar-recent-session").count(), { timeout: 10_000 })
.toBe(10);
const alpha = page.locator('[data-session-section="category:Alpha"]');
const alphaToggle = alpha.getByRole("button", { name: "Alpha", exact: true });
await alphaToggle.click();
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0);
await captureUiProof(page, "sidebar-session-group-collapsed.png");
await alphaToggle.click();
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(1);
await alphaToggle.click();
await expect.poll(() => alpha.locator(".sidebar-recent-session").count()).toBe(0);
await gamma.locator(".sidebar-session-group-drag-handle").dragTo(alpha, {
targetPosition: { x: 4, y: 2 },
});
const customGroupOrder = () =>
page
.locator('[data-session-section^="category:"]')
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-session-section")),
);
await expect
.poll(customGroupOrder)
.toEqual(["category:Gamma", "category:Alpha", "category:Beta"]);
await captureUiProof(page, "sidebar-session-groups-reordered.png");
await page.reload();
await expect
.poll(customGroupOrder)
.toEqual(["category:Gamma", "category:Alpha", "category:Beta"]);
await expect
.poll(() =>
page
.locator('[data-session-section="category:Alpha"] .sidebar-session-group-toggle')
.getAttribute("aria-expanded"),
)
.toBe("false");
await expect.poll(() => page.locator(".sidebar-recent-session").count()).toBe(11);
const patchCountBeforeFlatDrag = (await gateway.getRequests("sessions.patch")).length;
const sortSessionsButton = page.getByRole("button", { name: "Sort sessions" });
await sortSessionsButton.click();
await page.getByRole("menuitemradio", { name: "None" }).click();
const flatSection = page.locator('[data-session-section="ungrouped"]');
await flatSection
.locator('.sidebar-recent-session[data-session-key="agent:main:session-1"]')
.dragTo(flatSection);
expect((await gateway.getRequests("sessions.patch")).length).toBe(patchCountBeforeFlatDrag);
} finally {
await context.close();
}
});
it("keeps a new empty group visible before the first saved session", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([]),
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${server.baseUrl}chat`);
page.once("dialog", (dialog) => void dialog.accept("First group"));
await page.getByRole("button", { name: "New group…" }).click();
await page.locator('[data-session-section="category:First group"]').waitFor({
state: "visible",
});
await page.locator('[data-session-section="ungrouped"] .sidebar-recent-session').waitFor({
state: "visible",
});
} finally {
await context.close();
}
@ -470,13 +635,14 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:pinned", "Pinned only", Date.parse("2026-07-01T16:00:00.000Z"), {
pinned: true,
}),
]),
"sessions.patch": {},
},
sessionKey: "agent:main:pinned",
});
@ -484,14 +650,26 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
try {
await page.goto(`${server.baseUrl}chat`);
const sessionGroups = page.locator(".sidebar-recent-sessions__group");
const pinnedGroup = sessionGroups.filter({ hasText: "Pinned" });
const chatsGroup = sessionGroups.filter({ hasText: "Sessions" });
const pinnedGroup = page.locator('[data-session-section="pinned"]');
const chatsGroup = page.locator('[data-session-section="ungrouped"]');
await expect
.poll(() => trimmedTextContents(pinnedGroup.locator(".sidebar-recent-session__name")))
.toEqual(["Pinned only"]);
await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(0);
await expect.poll(() => page.locator(".sidebar-recent-session--active").count()).toBe(1);
await pinnedGroup.locator(".sidebar-recent-session").dragTo(chatsGroup);
const unpinPatch = await waitForPatch(
gateway,
(params) =>
params.key === "agent:main:pinned" && params.category === null && params.pinned === false,
);
expect(requireRecord(unpinPatch.params)).toMatchObject({
category: null,
key: "agent:main:pinned",
pinned: false,
});
await expect.poll(() => pinnedGroup.locator(".sidebar-recent-session").count()).toBe(0);
await expect.poll(() => chatsGroup.locator(".sidebar-recent-session").count()).toBe(1);
} finally {
await context.close();
}

View file

@ -4,6 +4,7 @@ import { createStorageMock } from "../../test-helpers/storage.ts";
import {
dissolveSessionGroup,
loadStoredSessionCustomGroups,
reorderSessionCustomGroups,
renameSessionGroup,
saveStoredSessionCustomGroups,
} from "./custom-groups.ts";
@ -132,6 +133,24 @@ describe("renameSessionGroup", () => {
});
});
describe("reorderSessionCustomGroups", () => {
it("moves a group before the drop target and keeps the rest stable", () => {
expect(reorderSessionCustomGroups(["Alpha", "Beta", "Gamma"], "Gamma", "Alpha")).toEqual([
"Gamma",
"Alpha",
"Beta",
]);
expect(reorderSessionCustomGroups(["Alpha", "Beta", "Gamma"], "Alpha", "Gamma")).toEqual([
"Beta",
"Alpha",
"Gamma",
]);
expect(
reorderSessionCustomGroups(["Alpha", "Beta", "Gamma"], "Alpha", "Gamma", "after"),
).toEqual(["Beta", "Gamma", "Alpha"]);
});
});
describe("dissolveSessionGroup", () => {
it("drops the stored group and moves member sessions back to ungrouped", async () => {
saveStoredSessionCustomGroups(["Research", "Apps"]);

View file

@ -27,12 +27,32 @@ export function loadStoredSessionCustomGroups(): string[] {
export function saveStoredSessionCustomGroups(groups: readonly string[]) {
try {
getSafeLocalStorage()?.setItem(SESSION_CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify(groups));
const normalized = [...new Set(groups.map((name) => name.trim()).filter(Boolean))];
getSafeLocalStorage()?.setItem(SESSION_CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify(normalized));
} catch {
// Assigned groups still persist server-side via the session category field.
}
}
/** Move one custom group before another while preserving every other group. */
export function reorderSessionCustomGroups(
groups: readonly string[],
source: string,
target: string,
position: "before" | "after" = "before",
): string[] {
const ordered = [...new Set(groups.map((name) => name.trim()).filter(Boolean))];
const sourceIndex = ordered.indexOf(source);
const targetIndex = ordered.indexOf(target);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
return ordered;
}
const [moved] = ordered.splice(sourceIndex, 1);
const targetInsertionIndex = ordered.indexOf(target) + (position === "after" ? 1 : 0);
ordered.splice(targetInsertionIndex, 0, moved);
return ordered;
}
type SessionGroupClient = Pick<SessionCapability, "list" | "patch">;
// The gateway caps sessions.list at a 100-row default when no limit is sent

View file

@ -1,10 +1,12 @@
// Private MIME keeps stray text and file drags from becoming session actions.
export const SESSION_DRAG_MIME = "application/x-openclaw-session-key";
export const SESSION_GROUP_DRAG_MIME = "application/x-openclaw-session-group";
export function writeSessionDragData(dataTransfer: DataTransfer, sessionKey: string): void {
dataTransfer.setData(SESSION_DRAG_MIME, sessionKey);
dataTransfer.setData("text/plain", sessionKey);
dataTransfer.effectAllowed = "copy";
// Sidebar sessions can move between groups or copy into a chat split pane.
dataTransfer.effectAllowed = "copyMove";
}
export function readSessionDragData(dataTransfer: DataTransfer | null): string | null {
@ -15,3 +17,17 @@ export function readSessionDragData(dataTransfer: DataTransfer | null): string |
export function sessionDragActive(dataTransfer: DataTransfer | null): boolean {
return Array.from(dataTransfer?.types ?? []).includes(SESSION_DRAG_MIME);
}
export function writeSessionGroupDragData(dataTransfer: DataTransfer, group: string): void {
dataTransfer.setData(SESSION_GROUP_DRAG_MIME, group);
dataTransfer.effectAllowed = "move";
}
export function readSessionGroupDragData(dataTransfer: DataTransfer | null): string | null {
const group = dataTransfer?.getData(SESSION_GROUP_DRAG_MIME).trim();
return group || null;
}
export function sessionGroupDragActive(dataTransfer: DataTransfer | null): boolean {
return Array.from(dataTransfer?.types ?? []).includes(SESSION_GROUP_DRAG_MIME);
}

View file

@ -53,6 +53,18 @@ describe("groupSidebarSessionRows", () => {
expect(sections[1]?.rows.map((item) => item.key)).toEqual(["b"]);
});
it("keeps custom groups in their persisted order", () => {
const sections = groupSidebarSessionRows(
[row({ key: "a", category: "Alpha" }), row({ key: "z", category: "Zulu" })],
{ knownGroups: ["Zulu", "Alpha"] },
);
expect(sections.map((section) => section.id)).toEqual([
"category:Zulu",
"category:Alpha",
"ungrouped",
]);
});
it("collapses categories into the ungrouped list when grouping is none", () => {
const sections = groupSidebarSessionRows(
[

View file

@ -114,7 +114,8 @@ export function normalizeSidebarSessionsGrouping(raw: unknown): SidebarSessionsG
}
/**
* Pinned first, named categories alphabetically, then uncategorized rows.
* Pinned first, named categories in the persisted `knownGroups` order, then
* newly observed categories alphabetically, then uncategorized rows.
* `knownGroups` keeps stored-but-empty groups visible as move targets;
* `grouping: "none"` collapses categories into the ungrouped list (pinned stays).
*/
@ -156,7 +157,16 @@ export function groupSidebarSessionRows<Row extends { pinned?: boolean; category
if (pinned.length > 0) {
sections.push({ id: "pinned", rows: pinned });
}
for (const category of [...categories.keys()].toSorted((a, b) => a.localeCompare(b))) {
const knownGroups = [
...new Set((options.knownGroups ?? []).map((name) => name.trim()).filter(Boolean)),
];
const orderedCategories = [
...knownGroups.filter((name) => categories.has(name)),
...[...categories.keys()]
.filter((name) => !knownGroups.includes(name))
.toSorted((a, b) => a.localeCompare(b)),
];
for (const category of orderedCategories) {
sections.push({ id: `category:${category}`, category, rows: categories.get(category) ?? [] });
}
sections.push({ id: "ungrouped", rows: ungrouped });

View file

@ -25,7 +25,7 @@ describe("resolveSessionNavigation", () => {
sessionKey: "agent:main:recent-3",
});
expect(navigation.recentSessions.map((row) => row.key)).toEqual(rows.map((row) => row.key));
expect(navigation.visibleSessions.map((row) => row.key)).toEqual(rows.map((row) => row.key));
expect(navigation.activeRowKey).toBe("agent:main:recent-3");
});
@ -41,7 +41,7 @@ describe("resolveSessionNavigation", () => {
compareSessions: (a, b) => a.key.localeCompare(b.key),
});
expect(navigation.recentSessions.map((row) => row.key)).toEqual([
expect(navigation.visibleSessions.map((row) => row.key)).toEqual([
"agent:main:session-a",
"agent:main:session-b",
"agent:main:session-c",
@ -49,7 +49,7 @@ describe("resolveSessionNavigation", () => {
expect(navigation.activeRowKey).toBe("agent:main:session-b");
});
it("pins the selected session ahead of the nine most recent rows when the list omits it", () => {
it("keeps a deep-linked session ahead of every returned active row", () => {
const navigation = resolveSessionNavigation({
result: sessionsResult(
Array.from({ length: 11 }, (_, index) => ({
@ -62,19 +62,19 @@ describe("resolveSessionNavigation", () => {
sessionKey: "agent:main:oldest",
});
expect(navigation.recentSessions).toHaveLength(10);
expect(navigation.recentSessions[0]).toMatchObject({
expect(navigation.visibleSessions).toHaveLength(12);
expect(navigation.visibleSessions[0]).toMatchObject({
key: "agent:main:oldest",
kind: "direct",
updatedAt: null,
});
expect(navigation.activeRowKey).toBe("agent:main:oldest");
expect(navigation.recentSessions.slice(1).map((row) => row.key)).toEqual(
Array.from({ length: 9 }, (_, index) => `agent:main:recent-${index}`),
expect(navigation.visibleSessions.slice(1).map((row) => row.key)).toEqual(
Array.from({ length: 11 }, (_, index) => `agent:main:recent-${index}`),
);
});
it("surfaces the real row when the selected session sits beyond the recency cap", () => {
it("keeps the selected session in place in a long list", () => {
const rows = Array.from({ length: 12 }, (_, index) => ({
key: `agent:main:recent-${index}`,
kind: "direct" as const,
@ -86,8 +86,8 @@ describe("resolveSessionNavigation", () => {
sessionKey: "agent:main:recent-11",
});
expect(navigation.recentSessions[0]).toBe(rows[11]);
expect(navigation.recentSessions).toHaveLength(10);
expect(navigation.visibleSessions[11]).toBe(rows[11]);
expect(navigation.visibleSessions).toHaveLength(12);
expect(navigation.activeRowKey).toBe("agent:main:recent-11");
});
@ -107,13 +107,13 @@ describe("resolveSessionNavigation", () => {
sessionKey: "unknown",
});
expect(navigation.recentSessions.map((row) => row.key)).toEqual([
expect(navigation.visibleSessions.map((row) => row.key)).toEqual([
...pinnedSessions.map((row) => row.key),
"agent:main:recent",
]);
});
it("keeps nine recent chats in addition to pinned sessions", () => {
it("keeps every active chat in addition to pinned sessions", () => {
const pinnedSessions = Array.from({ length: 3 }, (_, index) => ({
key: `agent:main:pinned-${index}`,
kind: "direct" as const,
@ -131,9 +131,9 @@ describe("resolveSessionNavigation", () => {
sessionKey: "unknown",
});
expect(navigation.recentSessions.map((row) => row.key)).toEqual([
expect(navigation.visibleSessions.map((row) => row.key)).toEqual([
...pinnedSessions.map((row) => row.key),
...recentSessions.slice(0, 9).map((row) => row.key),
...recentSessions.map((row) => row.key),
]);
expect(navigation.activeRowKey).toBeNull();
});

View file

@ -33,7 +33,7 @@ export type SessionNavigation = {
selectedAgentId: string;
defaultAgentId: string;
selectedSession?: GatewaySessionRow;
recentSessions: GatewaySessionRow[];
visibleSessions: GatewaySessionRow[];
activeRowKey: string | null;
};
@ -249,26 +249,22 @@ export function resolveSessionNavigation(input: SessionNavigationInput): Session
defaultAgentId,
filterByAgent: shouldFilterByAgent,
}).toSorted(input.compareSessions ?? compareSessionRowsByUpdatedAt);
// Keep visible selections in their sorted slot. Hoisting every active row
// makes the list move after each click. Pinned chats remain outside the
// recent-chat cap so explicit pins never disappear.
const pinnedSessions = sortedSessions.filter((row) => row.pinned === true);
let recentSessions = [
...pinnedSessions,
...sortedSessions.filter((row) => row.pinned !== true).slice(0, 9),
];
let activeRow = recentSessions.find(matchesCurrentSession);
// The sidebar is the session list, not a recent-session preview. Keep every
// active row in its sorted slot so selecting a session never reshuffles or
// hides another one behind a separate route.
let visibleSessions = sortedSessions;
let activeRow = visibleSessions.find(matchesCurrentSession);
if (!activeRow && activeSession) {
// Deep-linked, archived, and capped sessions still need a visible row.
// Deep-linked and archived sessions still need a visible selected row.
activeRow = sortedSessions.find(matchesCurrentSession) ?? activeSession;
recentSessions = [activeRow, ...recentSessions.filter((row) => row !== activeRow)];
visibleSessions = [activeRow, ...visibleSessions.filter((row) => row !== activeRow)];
}
return {
currentSessionKey,
selectedAgentId,
defaultAgentId,
selectedSession: activeSession,
recentSessions,
visibleSessions,
activeRowKey: activeRow?.key ?? null,
};
}

View file

@ -599,13 +599,32 @@
gap: 10px;
margin: 0 -8px;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.sidebar-recent-sessions__group {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
min-height: 28px;
border-radius: var(--radius-md);
transition:
background var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
}
.sidebar-recent-sessions__group--session-drop {
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
}
.sidebar-recent-sessions__group--group-drop-before {
box-shadow: inset 0 2px 0 color-mix(in srgb, var(--accent) 75%, transparent);
}
.sidebar-recent-sessions__group--group-drop-after {
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--accent) 75%, transparent);
}
.sidebar-recent-sessions__head {
@ -617,10 +636,70 @@
color: color-mix(in srgb, var(--muted) 72%, var(--text) 28%);
}
.sidebar-recent-sessions__head--root {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg);
}
.sidebar-recent-sessions__head .sidebar-recent-sessions__label-text {
flex: 1 1 auto;
}
.sidebar-session-group-toggle {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
flex: 1 1 auto;
padding: 2px 0;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.sidebar-session-group-toggle__icon {
display: inline-flex;
flex: 0 0 auto;
opacity: 0.75;
}
.sidebar-session-group-toggle__icon svg {
width: 11px;
height: 11px;
stroke: currentColor;
fill: none;
stroke-width: 1.8px;
}
.sidebar-session-group-count {
flex: 0 0 auto;
color: var(--muted);
font-size: 10px;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
.sidebar-session-group-drag-handle {
width: 8px;
height: 18px;
flex: 0 0 8px;
border-radius: 3px;
background-image: radial-gradient(circle, currentColor 1px, transparent 1.2px);
background-position: 0 0;
background-size: 4px 4px;
color: var(--muted);
opacity: 0.55;
cursor: grab;
}
.sidebar-session-group-drag-handle:active {
cursor: grabbing;
}
.sidebar-session-sort {
display: inline-flex;
align-items: center;
@ -776,46 +855,7 @@
.sidebar-recent-sessions__list {
display: grid;
gap: 2px;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.sidebar-recent-sessions__all {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 30px;
padding: 0 9px;
border-radius: var(--radius-md);
color: var(--muted);
font-size: 12px;
text-decoration: none;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.sidebar-recent-sessions__all:hover {
background: color-mix(in srgb, var(--bg-hover) 78%, transparent);
color: var(--text);
text-decoration: none;
}
.sidebar-recent-sessions__all-icon {
display: inline-flex;
opacity: 0.6;
}
.sidebar-recent-sessions__all-icon svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
min-height: 12px;
}
.sidebar-recent-session--dragging {

View file

@ -379,7 +379,7 @@ function installControlUiMockGateway(input: {
return;
}
const patch = { ...sessionPatches.get(params.key) };
for (const key of ["model", "thinkingLevel", "fastMode"] as const) {
for (const key of ["model", "thinkingLevel", "fastMode", "category", "pinned"] as const) {
if (hasOwn(params, key)) {
patch[key] = params[key];
}