Skip to content

Commit 8677273

Browse files
authored
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
1 parent d91073e commit 8677273

3 files changed

Lines changed: 100 additions & 17 deletions

File tree

docs/web/control-ui.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ local-path sources, updates, and advanced plugin configuration.
173173

174174
## Sidebar navigation
175175

176-
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.
176+
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.
177177

178178
## New session page
179179

@@ -356,7 +356,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
356356
- 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.
357357
- 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).
358358
- `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).
359-
- 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.
359+
- 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.
360360
- 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.
361361
- 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.
362362
- 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.

ui/src/components/app-sidebar.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1843,7 +1843,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
18431843
this.sessionDropTarget = null;
18441844
return;
18451845
}
1846-
if (!sessionDragActive(dataTransfer) || sectionId === "pinned") {
1846+
if (!sessionDragActive(dataTransfer)) {
18471847
return;
18481848
}
18491849
event.preventDefault();
@@ -1884,7 +1884,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
18841884
return undefined;
18851885
}
18861886

1887-
private handleSessionSectionDrop(event: DragEvent, category?: string) {
1887+
private handleSessionSectionDrop(event: DragEvent, sectionId: string, category?: string) {
18881888
event.preventDefault();
18891889
const sourceGroup = readSessionGroupDragData(event.dataTransfer);
18901890
if (sourceGroup && category && sourceGroup !== category) {
@@ -1898,9 +1898,19 @@ class AppSidebar extends OpenClawLightDomContentsElement {
18981898
// Rows can be dragged out of a browsed (non-active) agent section, so the
18991899
// lookup must cover every agent's cached rows, not just the active scope.
19001900
const session = sessionKey ? this.findSidebarSessionByKey(sessionKey) : undefined;
1901-
const nextCategory = category ?? null;
1902-
if (session && (session.category !== nextCategory || session.pinned)) {
1903-
this.assignSessionCategory(session, nextCategory, session.pinned ? { pinned: false } : {});
1901+
if (session && sectionId === "pinned") {
1902+
if (!session.pinned) {
1903+
void this.patchSession(session, { pinned: true });
1904+
}
1905+
} else if (session) {
1906+
const nextCategory = category ?? null;
1907+
if (session.category !== nextCategory || session.pinned) {
1908+
this.assignSessionCategory(
1909+
session,
1910+
nextCategory,
1911+
session.pinned ? { pinned: false } : {},
1912+
);
1913+
}
19041914
}
19051915
}
19061916
this.draggingSessionKey = null;
@@ -2536,14 +2546,13 @@ class AppSidebar extends OpenClawLightDomContentsElement {
25362546
: group
25372547
? group
25382548
: t("chat.sidebar.chats");
2539-
// Smart channel/work sections classify rows automatically; only custom
2540-
// groups and Chats accept manual drops (a drop means category assignment).
2549+
// Smart channel/work sections classify rows automatically. Pinned accepts
2550+
// pin drops; custom groups and Chats accept category assignment drops.
25412551
// Custom group headers drag as a whole (mirroring whole-row session drags);
25422552
// the dot handle inside is a pure visual affordance.
25432553
const acceptsSessions =
2544-
!isPinned &&
2545-
this.sessionsGrouping === "category" &&
2546-
(section.id === "ungrouped" || Boolean(group));
2554+
isPinned ||
2555+
(this.sessionsGrouping === "category" && (section.id === "ungrouped" || Boolean(group)));
25472556
const sectionClass = [
25482557
"sidebar-recent-sessions__group",
25492558
collapsed ? "sidebar-recent-sessions__group--collapsed" : "",
@@ -2568,7 +2577,7 @@ class AppSidebar extends OpenClawLightDomContentsElement {
25682577
? (event: DragEvent) => this.handleSessionSectionDragLeave(event, section.id, group)
25692578
: nothing}
25702579
@drop=${acceptsSessions || group
2571-
? (event: DragEvent) => this.handleSessionSectionDrop(event, group)
2580+
? (event: DragEvent) => this.handleSessionSectionDrop(event, section.id, group)
25722581
: nothing}
25732582
>
25742583
${showHeader

ui/src/e2e/session-management.e2e.test.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
1818
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
1919
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
2020
const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections";
21+
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
22+
const uiProofArtifactDir = path.join(
23+
process.cwd(),
24+
".artifacts",
25+
"control-ui-e2e",
26+
"thread-management",
27+
);
2128

2229
let browser: Browser;
2330
let server: ControlUiE2eServer;
@@ -126,12 +133,11 @@ function actionPointerEvents(button: Locator): Promise<string> {
126133
}
127134

128135
async function captureUiProof(page: Page, fileName: string) {
129-
if (process.env.OPENCLAW_CAPTURE_UI_PROOF !== "1") {
136+
if (!captureUiProofEnabled) {
130137
return;
131138
}
132-
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "thread-management");
133-
await mkdir(artifactDir, { recursive: true });
134-
await page.screenshot({ fullPage: true, path: path.join(artifactDir, fileName) });
139+
await mkdir(uiProofArtifactDir, { recursive: true });
140+
await page.screenshot({ fullPage: true, path: path.join(uiProofArtifactDir, fileName) });
135141
}
136142

137143
describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
@@ -1245,6 +1251,74 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
12451251
}
12461252
});
12471253

1254+
it("pins a session dropped into the Pinned group", async () => {
1255+
const context = await browser.newContext({
1256+
locale: "en-US",
1257+
serviceWorkers: "block",
1258+
viewport: { height: 900, width: 1280 },
1259+
recordVideo: captureUiProofEnabled
1260+
? { dir: uiProofArtifactDir, size: { height: 900, width: 1280 } }
1261+
: undefined,
1262+
});
1263+
const page = await context.newPage();
1264+
const proofVideo = page.video();
1265+
const gateway = await installMockGateway(page, {
1266+
methodResponses: {
1267+
"sessions.list": sessionsListResponse([
1268+
sessionRow(
1269+
"agent:main:pinned",
1270+
"Already pinned",
1271+
Date.parse("2026-07-01T16:00:00.000Z"),
1272+
{
1273+
pinned: true,
1274+
},
1275+
),
1276+
sessionRow("agent:main:candidate", "Pin me", Date.parse("2026-07-01T15:59:00.000Z"), {
1277+
category: "Research",
1278+
}),
1279+
]),
1280+
"sessions.patch": {},
1281+
},
1282+
featureMethods: ["chat.metadata", "chat.startup", "sessions.groups.list"],
1283+
sessionKey: "agent:main:candidate",
1284+
sessionGroups: ["Research"],
1285+
});
1286+
1287+
try {
1288+
await page.goto(`${server.baseUrl}chat`);
1289+
1290+
const pinnedGroup = page.locator('[data-session-section="pinned"]');
1291+
const researchGroup = page.locator('[data-session-section="category:Research"]');
1292+
await expect
1293+
.poll(() => trimmedTextContents(pinnedGroup.locator(".sidebar-recent-session__name")))
1294+
.toEqual(["Already pinned"]);
1295+
await captureUiProof(page, "sidebar-session-before-pinned-drop.png");
1296+
await researchGroup
1297+
.locator('.sidebar-recent-session[data-session-key="agent:main:candidate"]')
1298+
.dragTo(pinnedGroup);
1299+
1300+
const pinPatch = await waitForPatch(
1301+
gateway,
1302+
(params) => params.key === "agent:main:candidate" && params.pinned === true,
1303+
);
1304+
expect(requireRecord(pinPatch.params)).toMatchObject({
1305+
key: "agent:main:candidate",
1306+
pinned: true,
1307+
});
1308+
expect(requireRecord(pinPatch.params)).not.toHaveProperty("category");
1309+
await expect
1310+
.poll(() => trimmedTextContents(pinnedGroup.locator(".sidebar-recent-session__name")))
1311+
.toEqual(["Already pinned", "Pin me"]);
1312+
await expect.poll(() => researchGroup.locator(".sidebar-recent-session").count()).toBe(0);
1313+
await captureUiProof(page, "sidebar-session-dropped-into-pinned.png");
1314+
} finally {
1315+
await context.close();
1316+
if (proofVideo) {
1317+
await proofVideo.saveAs(path.join(uiProofArtifactDir, "sidebar-session-pinned-drop.webm"));
1318+
}
1319+
}
1320+
});
1321+
12481322
it("keeps raw ids out of work rows and survives rows growing subtitles in place", async () => {
12491323
const baseTime = Date.parse("2026-07-01T16:00:00.000Z");
12501324
const nodeHash = "11c38726acc6fac280357576c87acc6fac280357";

0 commit comments

Comments
 (0)