fix: pin sessions dropped into Pinned (#107854)

* fix(ui): pin sessions dropped into pinned group

* test(ui): capture pinned session drop proof

* chore: keep release note in PR metadata
This commit is contained in:
Peter Steinberger
2026-07-14 17:14:39 -07:00
committed by GitHub
parent d91073eb1d
commit 86772735ac
3 changed files with 100 additions and 17 deletions
+2 -2
View File
@@ -173,7 +173,7 @@ local-path sources, updates, and advanced plugin configuration.
## Sidebar navigation
The sidebar pins navigation above a scrollable session list. In multi-agent setups every agent appears as a collapsible top-level section; expanding an agent browses its sessions without navigating away from the open chat, and collapsed agents show an unread indicator. Within an agent the list splits into **Pinned**, one built-in section per connected channel (Telegram, Slack, WhatsApp, ...), a built-in **Work** section for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), custom groups (the session `category`), and **Chats** for the rest. Channel and Work sections classify rows automatically; assigning a session to a custom group always wins. 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. Cmd/Ctrl-click toggles rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a session onto a custom group or **Chats** to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while the collapsed state stays in the 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 server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Chats. The single **+** in the session-list header opens the New session page (see below). The sort control also has a Group by toggle: Grouped (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. **Usage**, **Automations**, and **Plugins** are pinned by default; the **More** row opens a menu with every other destination, including plugin-provided tabs. Select **Edit pinned items** in that menu, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set is stored in the current browser profile and survives reloads.
The sidebar pins navigation above a scrollable session list. In multi-agent setups every agent appears as a collapsible top-level section; expanding an agent browses its sessions without navigating away from the open chat, and collapsed agents show an unread indicator. Within an agent the list splits into **Pinned**, one built-in section per connected channel (Telegram, Slack, WhatsApp, ...), a built-in **Work** section for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), custom groups (the session `category`), and **Chats** for the rest. Channel and Work sections classify rows automatically; assigning a session to a custom group always wins. 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. Cmd/Ctrl-click toggles rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a session onto **Pinned** to pin it, or onto a custom group or **Chats** to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while the collapsed state stays in the 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 server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Chats. The single **+** in the session-list header opens the New session page (see below). The sort control also has a Group by toggle: Grouped (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. **Usage**, **Automations**, and **Plugins** are pinned by default; the **More** row opens a menu with every other destination, including plugin-provided tabs. Select **Edit pinned items** in that menu, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set is stored in the current browser profile and survives reloads.
## New session page
@@ -356,7 +356,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
- 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 every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Custom groups are collapsible and drag-reorderable, and sessions can be dropped onto a group or Chats; group names and order sync through the gateway while the collapsed state stays in the browser. 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. Expanding another agent section browses that agent's sessions without leaving the open chat.
- The sidebar lists every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Sessions can be dropped onto Pinned to pin them, or onto a custom group or Chats to move them; custom groups are collapsible and drag-reorderable, group names and order sync through the gateway, and collapsed state stays in the browser. 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. Expanding another agent section browses that agent's sessions without leaving the open chat.
- 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. Multi-selected rows (Cmd/Ctrl-click, Shift-click for ranges) get a batch menu covering unread state, grouping, archive, and delete; batch archive/delete stays disabled unless every selected session is archivable. 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.
+20 -11
View File
@@ -1843,7 +1843,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
this.sessionDropTarget = null;
return;
}
if (!sessionDragActive(dataTransfer) || sectionId === "pinned") {
if (!sessionDragActive(dataTransfer)) {
return;
}
event.preventDefault();
@@ -1884,7 +1884,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
return undefined;
}
private handleSessionSectionDrop(event: DragEvent, category?: string) {
private handleSessionSectionDrop(event: DragEvent, sectionId: string, category?: string) {
event.preventDefault();
const sourceGroup = readSessionGroupDragData(event.dataTransfer);
if (sourceGroup && category && sourceGroup !== category) {
@@ -1898,9 +1898,19 @@ class AppSidebar extends OpenClawLightDomContentsElement {
// Rows can be dragged out of a browsed (non-active) agent section, so the
// lookup must cover every agent's cached rows, not just the active scope.
const session = sessionKey ? this.findSidebarSessionByKey(sessionKey) : undefined;
const nextCategory = category ?? null;
if (session && (session.category !== nextCategory || session.pinned)) {
this.assignSessionCategory(session, nextCategory, session.pinned ? { pinned: false } : {});
if (session && sectionId === "pinned") {
if (!session.pinned) {
void this.patchSession(session, { pinned: true });
}
} else if (session) {
const nextCategory = category ?? null;
if (session.category !== nextCategory || session.pinned) {
this.assignSessionCategory(
session,
nextCategory,
session.pinned ? { pinned: false } : {},
);
}
}
}
this.draggingSessionKey = null;
@@ -2536,14 +2546,13 @@ class AppSidebar extends OpenClawLightDomContentsElement {
: group
? group
: t("chat.sidebar.chats");
// Smart channel/work sections classify rows automatically; only custom
// groups and Chats accept manual drops (a drop means category assignment).
// Smart channel/work sections classify rows automatically. Pinned accepts
// pin drops; custom groups and Chats accept category assignment drops.
// Custom group headers drag as a whole (mirroring whole-row session drags);
// the dot handle inside is a pure visual affordance.
const acceptsSessions =
!isPinned &&
this.sessionsGrouping === "category" &&
(section.id === "ungrouped" || Boolean(group));
isPinned ||
(this.sessionsGrouping === "category" && (section.id === "ungrouped" || Boolean(group)));
const sectionClass = [
"sidebar-recent-sessions__group",
collapsed ? "sidebar-recent-sessions__group--collapsed" : "",
@@ -2568,7 +2577,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
? (event: DragEvent) => this.handleSessionSectionDragLeave(event, section.id, group)
: nothing}
@drop=${acceptsSessions || group
? (event: DragEvent) => this.handleSessionSectionDrop(event, group)
? (event: DragEvent) => this.handleSessionSectionDrop(event, section.id, group)
: nothing}
>
${showHeader
+78 -4
View File
@@ -18,6 +18,13 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections";
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"thread-management",
);
let browser: Browser;
let server: ControlUiE2eServer;
@@ -126,12 +133,11 @@ function actionPointerEvents(button: Locator): Promise<string> {
}
async function captureUiProof(page: Page, fileName: string) {
if (process.env.OPENCLAW_CAPTURE_UI_PROOF !== "1") {
if (!captureUiProofEnabled) {
return;
}
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "thread-management");
await mkdir(artifactDir, { recursive: true });
await page.screenshot({ fullPage: true, path: path.join(artifactDir, fileName) });
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({ fullPage: true, path: path.join(uiProofArtifactDir, fileName) });
}
describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
@@ -1245,6 +1251,74 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
}
});
it("pins a session dropped into the Pinned group", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
recordVideo: captureUiProofEnabled
? { dir: uiProofArtifactDir, size: { height: 900, width: 1280 } }
: undefined,
});
const page = await context.newPage();
const proofVideo = page.video();
const gateway = await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow(
"agent:main:pinned",
"Already pinned",
Date.parse("2026-07-01T16:00:00.000Z"),
{
pinned: true,
},
),
sessionRow("agent:main:candidate", "Pin me", Date.parse("2026-07-01T15:59:00.000Z"), {
category: "Research",
}),
]),
"sessions.patch": {},
},
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
sessionKey: "agent:main:candidate",
sessionGroups: ["Research"],
});
try {
await page.goto(`${server.baseUrl}chat`);
const pinnedGroup = page.locator('[data-session-section="pinned"]');
const researchGroup = page.locator('[data-session-section="category:Research"]');
await expect
.poll(() => trimmedTextContents(pinnedGroup.locator(".sidebar-recent-session__name")))
.toEqual(["Already pinned"]);
await captureUiProof(page, "sidebar-session-before-pinned-drop.png");
await researchGroup
.locator('.sidebar-recent-session[data-session-key="agent:main:candidate"]')
.dragTo(pinnedGroup);
const pinPatch = await waitForPatch(
gateway,
(params) => params.key === "agent:main:candidate" && params.pinned === true,
);
expect(requireRecord(pinPatch.params)).toMatchObject({
key: "agent:main:candidate",
pinned: true,
});
expect(requireRecord(pinPatch.params)).not.toHaveProperty("category");
await expect
.poll(() => trimmedTextContents(pinnedGroup.locator(".sidebar-recent-session__name")))
.toEqual(["Already pinned", "Pin me"]);
await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0);
await captureUiProof(page, "sidebar-session-dropped-into-pinned.png");
} finally {
await context.close();
if (proofVideo) {
await proofVideo.saveAs(path.join(uiProofArtifactDir, "sidebar-session-pinned-drop.webm"));
}
}
});
it("keeps raw ids out of work rows and survives rows growing subtitles in place", async () => {
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
const nodeHash = "11c38726acc6fac280357576c87acc6fac280357";