From ecc2cffad8aabbecf0b7fc82f260a8d77a3b064d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 09:40:53 +0100 Subject: [PATCH] improve(ui): keep sidebar sessions inline and draggable (#102558) * feat(ui): add drag-managed sidebar session groups * chore: keep release notes in PR body --- docs/web/control-ui.md | 6 +- ui/src/components/app-sidebar.ts | 428 ++++++++++++++++------ ui/src/e2e/session-management.e2e.test.ts | 196 +++++++++- ui/src/lib/sessions/custom-groups.test.ts | 19 + ui/src/lib/sessions/custom-groups.ts | 22 +- ui/src/lib/sessions/drag.ts | 18 +- ui/src/lib/sessions/grouping.test.ts | 12 + ui/src/lib/sessions/grouping.ts | 14 +- ui/src/lib/sessions/navigation.test.ts | 28 +- ui/src/lib/sessions/navigation.ts | 22 +- ui/src/styles/layout.css | 122 +++--- ui/src/test-helpers/control-ui-e2e.ts | 2 +- 12 files changed, 692 insertions(+), 197 deletions(-) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index da9e0d5f7698..07cd60914020 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -117,7 +117,7 @@ Appearance also has a browser-local Text size setting, stored with the rest of C ## Sidebar navigation -The sidebar pins navigation above a scrollable recent-session list split into **Pinned**, one section per custom group (the session `category`, sorted alphabetically), and **Ungrouped** for the rest. Every pinned session stays visible, while unpinned sessions keep an independent nine-item recent budget. Opening a visible session moves the selection highlight without reordering the rows; an off-list deep link is surfaced at the top. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Each session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Group headers have their own menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session, including archived ones, and deleting a group keeps its sessions and moves them back to Ungrouped. Groups created from the header start empty and stay visible as move targets. The sort control in the session list header also has a Group by toggle: Custom groups (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. Multi-agent setups show a compact scope control in the Ungrouped header. **Overview** is the only destination pinned by default; expand **More** to reach every other destination. Select **Customize sidebar** under More, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set and More expansion state are stored in the current browser profile and survive reloads. +The sidebar pins navigation above a scrollable session list split into **Pinned**, one section per custom group (the session `category`), and **Ungrouped** for the rest. Every active session loaded for the selected agent stays visible inline; opening a session moves the selection highlight without reordering the rows. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Each session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Drag a session onto a custom group or **Ungrouped** to move it. Group headers can be collapsed, expanded, or dragged to reorder them; the collapsed state and custom order are stored in the current browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session, including archived ones, and deleting a group keeps its sessions and moves them back to Ungrouped. Groups created from the header start empty and stay visible as move targets. The sort control in the session list header also has a Group by toggle: Custom groups (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. Multi-agent setups show a compact scope control in the session-list header. **Overview** is the only destination pinned by default; expand **More** to reach every other destination. Select **Customize sidebar** under More, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set and More expansion state are stored in the current browser profile and survive reloads. A **Search** field at the top of the sidebar opens the command palette (⌘K). The compact footer keeps connection status, **Settings**, **Docs**, mobile pairing, and the light/dark/system color-mode toggle together. The sidebar collapse toggle sits at the left edge of the top bar, macOS style; collapsing shrinks the sidebar to an icon rail. At drawer breakpoints, the same top bar button opens the sidebar as a slide-over drawer instead. @@ -239,8 +239,8 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi - During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up. - Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat). - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - - The sidebar lists recent sessions by pinned/custom/ungrouped section with a New Session action and an All Sessions link. Pinned sessions always stay visible; unpinned sessions keep a nine-item budget and a stable recency order, so opening a visible row moves only the highlight. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names are never replaced. Set `agents.defaults.utilityModel` (or `agents.list[].utilityModel`) to route this separate model call to a lower-cost model. Switching the compact agent scope shows only sessions tied to that agent and falls back to that agent's main session when it has no saved dashboard sessions yet. - - Session search lives in the command palette (⌘K, or the Search field at the top of the sidebar): typing a query follows a bounded number of matching pages across agents, filters internal child/cron rows, and lists visible matches next to navigation commands. The All Sessions page keeps the exhaustive searchable list with filters. + - The sidebar lists every loaded active session by pinned/custom/ungrouped section with a New Session action. Opening a visible row moves only the highlight. Custom groups are collapsible and drag-reorderable, and sessions can be dropped onto a group or Ungrouped; the browser preserves the group order and collapsed state across reloads. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names are never replaced. Set `agents.defaults.utilityModel` (or `agents.list[].utilityModel`) to route this separate model call to a lower-cost model. Switching the compact agent scope shows only sessions tied to that agent and falls back to that agent's main session when it has no saved dashboard sessions yet. + - Session search lives in the command palette (⌘K, or the Search field at the top of the sidebar): typing a query follows a bounded number of matching pages across agents, filters internal child/cron rows, and lists visible matches next to navigation commands. The Sessions page keeps the exhaustive searchable list with filters. - Each sidebar row keeps direct pin access plus a full context menu for unread state, rename, fork, grouping, archive, and delete. An active run and an agent's main session cannot be archived. Archiving or deleting the currently selected session switches Chat back to that agent's main session. - In the macOS app, the OpenClaw mark uses the otherwise-empty native titlebar strip next to the window controls instead of consuming a sidebar row. - On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls. diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 76252fec7451..2b7c4e259da4 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -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 { + 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) { + 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` +
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` + + ` + : nothing} + ${collapsed + ? nothing + : html` + + `} +
+ `; + } + 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` diff --git a/ui/src/e2e/session-management.e2e.test.ts b/ui/src/e2e/session-management.e2e.test.ts index 36a693f8e761..f649b2a03cfb 100644 --- a/ui/src/e2e/session-management.e2e.test.ts +++ b/ui/src/e2e/session-management.e2e.test.ts @@ -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(); } diff --git a/ui/src/lib/sessions/custom-groups.test.ts b/ui/src/lib/sessions/custom-groups.test.ts index c0de159cb95b..c1ca36736bf8 100644 --- a/ui/src/lib/sessions/custom-groups.test.ts +++ b/ui/src/lib/sessions/custom-groups.test.ts @@ -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"]); diff --git a/ui/src/lib/sessions/custom-groups.ts b/ui/src/lib/sessions/custom-groups.ts index 249301f5c1ce..6c8283305bb6 100644 --- a/ui/src/lib/sessions/custom-groups.ts +++ b/ui/src/lib/sessions/custom-groups.ts @@ -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; // The gateway caps sessions.list at a 100-row default when no limit is sent diff --git a/ui/src/lib/sessions/drag.ts b/ui/src/lib/sessions/drag.ts index 688bed84ee4d..c6455f93d74d 100644 --- a/ui/src/lib/sessions/drag.ts +++ b/ui/src/lib/sessions/drag.ts @@ -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); +} diff --git a/ui/src/lib/sessions/grouping.test.ts b/ui/src/lib/sessions/grouping.test.ts index 2296385ed4ab..b226b01bd97f 100644 --- a/ui/src/lib/sessions/grouping.test.ts +++ b/ui/src/lib/sessions/grouping.test.ts @@ -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( [ diff --git a/ui/src/lib/sessions/grouping.ts b/ui/src/lib/sessions/grouping.ts index caffc74c9b76..1e7587433ba2 100644 --- a/ui/src/lib/sessions/grouping.ts +++ b/ui/src/lib/sessions/grouping.ts @@ -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 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 }); diff --git a/ui/src/lib/sessions/navigation.test.ts b/ui/src/lib/sessions/navigation.test.ts index 20f778479057..18b01e719c9e 100644 --- a/ui/src/lib/sessions/navigation.test.ts +++ b/ui/src/lib/sessions/navigation.test.ts @@ -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(); }); diff --git a/ui/src/lib/sessions/navigation.ts b/ui/src/lib/sessions/navigation.ts index 1467f50cea2c..c4ccb23b8d53 100644 --- a/ui/src/lib/sessions/navigation.ts +++ b/ui/src/lib/sessions/navigation.ts @@ -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, }; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 20e17aad1c51..08e4148a0167 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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 { diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 45f5485cdfb2..e315f0bce7aa 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -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]; }