mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 17:07:40 +00:00
fix(ui): local sessions show no placement icon (#108615)
* fix(ui): distinguish cloud worker sessions * chore: defer session icon release note * fix(ui): keep placement helper private
This commit is contained in:
committed by
GitHub
parent
513200125d
commit
5bfcd779b1
@@ -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 **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.
|
||||
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. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. 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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "../lib/sessions/grouping.ts";
|
||||
import type { SessionCapability } from "../lib/sessions/index.ts";
|
||||
import { getSafeLocalStorage } from "../local-storage.ts";
|
||||
import type { CloudPlacementState } from "./session-row-badges.ts";
|
||||
import type { SessionPlacementState } from "./session-row-badges.ts";
|
||||
|
||||
export type SidebarRecentSession = {
|
||||
key: string;
|
||||
@@ -27,7 +27,7 @@ export type SidebarRecentSession = {
|
||||
channelSession?: boolean;
|
||||
workSession?: boolean;
|
||||
worktreeId?: string;
|
||||
placementState?: CloudPlacementState;
|
||||
placementState?: SessionPlacementState;
|
||||
cloudWorkerActive: boolean;
|
||||
hasAutomation: boolean;
|
||||
unread: boolean;
|
||||
|
||||
72
ui/src/components/session-row-badges.test.ts
Normal file
72
ui/src/components/session-row-badges.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import { renderSessionRowBadges, type SessionPlacementState } from "./session-row-badges.ts";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.setLocale("en");
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function renderBadges(placementState?: SessionPlacementState) {
|
||||
render(
|
||||
renderSessionRowBadges({
|
||||
hasAutomation: false,
|
||||
placementState,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
describe("session row placement badges", () => {
|
||||
it.each(["local", "reclaimed"] satisfies SessionPlacementState[])(
|
||||
"keeps %s placement visually quiet",
|
||||
(placementState) => {
|
||||
renderBadges(placementState);
|
||||
|
||||
expect(container.querySelector(".session-row-badges")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"requested",
|
||||
"provisioning",
|
||||
"syncing",
|
||||
"starting",
|
||||
"active",
|
||||
"draining",
|
||||
"reconciling",
|
||||
"failed",
|
||||
] satisfies SessionPlacementState[])("renders %s as a cloud-worker globe", (placementState) => {
|
||||
renderBadges(placementState);
|
||||
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.placementState).toBe(placementState);
|
||||
expect(badge?.getAttribute("aria-label")).toBe(`Cloud worker: ${placementState}`);
|
||||
expect(badge?.querySelector("circle")).not.toBeNull();
|
||||
expect(badge?.querySelector("rect")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps unrelated badges while omitting local placement", () => {
|
||||
render(
|
||||
renderSessionRowBadges({
|
||||
hasAutomation: true,
|
||||
placementState: "local",
|
||||
worktreeId: "worktree-1",
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(2);
|
||||
expect(container.querySelector(".session-row-badge--cloud")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,13 @@ import type { GatewaySessionRow } from "../api/types.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
|
||||
export type CloudPlacementState = NonNullable<GatewaySessionRow["placement"]>["state"];
|
||||
export type SessionPlacementState = NonNullable<GatewaySessionRow["placement"]>["state"];
|
||||
|
||||
function isCloudWorkerPlacementState(
|
||||
state: SessionPlacementState | undefined,
|
||||
): state is Exclude<SessionPlacementState, "local" | "reclaimed"> {
|
||||
return state !== undefined && state !== "local" && state !== "reclaimed";
|
||||
}
|
||||
|
||||
export function isStoppableCloudWorkerPlacement(
|
||||
placement: GatewaySessionRow["placement"],
|
||||
@@ -14,13 +20,16 @@ export function isStoppableCloudWorkerPlacement(
|
||||
export function renderSessionRowBadges(params: {
|
||||
worktreeId?: string;
|
||||
hasAutomation: boolean;
|
||||
placementState?: CloudPlacementState;
|
||||
placementState?: SessionPlacementState;
|
||||
}) {
|
||||
if (!params.worktreeId && !params.hasAutomation && !params.placementState) {
|
||||
const cloudPlacementState = isCloudWorkerPlacementState(params.placementState)
|
||||
? params.placementState
|
||||
: undefined;
|
||||
if (!params.worktreeId && !params.hasAutomation && !cloudPlacementState) {
|
||||
return nothing;
|
||||
}
|
||||
const cloudLabel = params.placementState
|
||||
? t("sessionsView.cloudWorkerPlacement", { state: params.placementState })
|
||||
const cloudLabel = cloudPlacementState
|
||||
? t("sessionsView.cloudWorkerPlacement", { state: cloudPlacementState })
|
||||
: "";
|
||||
return html`<span class="session-row-badges">
|
||||
${params.worktreeId
|
||||
@@ -41,14 +50,14 @@ export function renderSessionRowBadges(params: {
|
||||
>${icons.clock}</span
|
||||
>`
|
||||
: nothing}
|
||||
${params.placementState
|
||||
${cloudPlacementState
|
||||
? html`<span
|
||||
class="session-row-badge session-row-badge--cloud"
|
||||
data-placement-state=${params.placementState}
|
||||
data-placement-state=${cloudPlacementState}
|
||||
role="img"
|
||||
aria-label=${cloudLabel}
|
||||
title=${cloudLabel}
|
||||
>${icons.server}</span
|
||||
>${icons.globe}</span
|
||||
>`
|
||||
: nothing}
|
||||
</span>`;
|
||||
|
||||
@@ -878,24 +878,35 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
]);
|
||||
|
||||
await gateway.setMethodResponse("sessions.list", {
|
||||
count: 1,
|
||||
count: 2,
|
||||
path: "",
|
||||
defaults: {},
|
||||
sessions: [
|
||||
{
|
||||
key: sessionKey,
|
||||
kind: "direct",
|
||||
label: "Cloud session",
|
||||
updatedAt: Date.now(),
|
||||
worktree: { id: "worktree-1", branch: "openclaw/cloud-e2e", repoRoot: WORKSPACE },
|
||||
placement: { state: "active" },
|
||||
},
|
||||
{
|
||||
key: "agent:cloud:local-e2e",
|
||||
kind: "direct",
|
||||
label: "Local session",
|
||||
updatedAt: Date.now() - 1,
|
||||
placement: { state: "local" },
|
||||
},
|
||||
],
|
||||
ts: Date.now(),
|
||||
});
|
||||
await gateway.emitGatewayEvent("sessions.changed", { sessionKey, reason: "dispatch" });
|
||||
const sessionRow = page.locator('[data-session-key="agent:cloud:cloud-e2e"]');
|
||||
const localSessionRow = page.locator('[data-session-key="agent:cloud:local-e2e"]');
|
||||
await sessionRow.waitFor();
|
||||
await page.locator('[data-placement-state="active"]').waitFor();
|
||||
await localSessionRow.waitFor();
|
||||
const cloudPlacementBadge = sessionRow.locator('[data-placement-state="active"]');
|
||||
await cloudPlacementBadge.waitFor();
|
||||
await sessionRow.hover();
|
||||
await sessionRow.getByRole("button", { name: "Open session menu" }).click();
|
||||
const stopWorker = page
|
||||
@@ -903,6 +914,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
|
||||
.getByRole("menuitem", { name: "Stop cloud worker…" });
|
||||
await stopWorker.waitFor();
|
||||
await captureUiProof(page, "02-active-cloud-worker-stop.png");
|
||||
expect(await localSessionRow.locator(".session-row-badge--cloud").count()).toBe(0);
|
||||
expect(await cloudPlacementBadge.locator("circle").count()).toBe(1);
|
||||
expect(await cloudPlacementBadge.locator("rect").count()).toBe(0);
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await stopWorker.click();
|
||||
const reclaim = await gateway.waitForRequest("sessions.reclaim");
|
||||
|
||||
@@ -5017,9 +5017,10 @@ td.data-table-key-col {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Attribute badges (worktree fork, attached automation): muted metadata that
|
||||
sits after the title inside the row link, outside the trail/action overlap
|
||||
cell, so touch devices (which hide the trail) and hovered rows keep them. */
|
||||
/* Attribute badges (worktree fork, attached automation, cloud placement):
|
||||
muted metadata that sits after the title inside the row link, outside the
|
||||
trail/action overlap cell, so touch devices (which hide the trail) and
|
||||
hovered rows keep them. */
|
||||
.session-row-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user