fix(ui): keep sidebar session titles aligned (#111698)

* fix(ui): stabilize sidebar session indicators

* test(ui): update sidebar leading-slot assertion
This commit is contained in:
Peter Steinberger
2026-07-20 01:27:25 -07:00
committed by GitHub
parent 343cfba638
commit 0e62293104
14 changed files with 708 additions and 83 deletions
+12 -22
View File
@@ -26,11 +26,7 @@ import {
type SidebarRecentSession,
} from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
import {
renderSessionAttentionIcon,
renderSessionState,
} from "./session-attention-presentation.ts";
import { resolveSessionIcon } from "./session-icon-registry.ts";
import { renderSessionLeadingState } from "./session-leading-indicator.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
import {
renderSidebarSessionSubtitle,
@@ -70,21 +66,19 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
sidebarLiveActivity: this.sidebarLiveActivity,
narrationLine: this.sidebarNarrationLines.get(session.key),
});
const running = session.hasActiveRun || session.status === "running";
const pullRequestState = session.worktreeId
? this.sessionPullRequestIndicatorState(session.key, session.worktreeId)
: "none";
const { running, pinnedState, leadingIndicator } = renderSessionLeadingState(
session,
pullRequestState,
);
const meta = display?.meta ?? session.meta;
const rowMeta = session.pinned ? "" : meta;
const hasTrail = session.isChild && (session.runtimeMs != null || session.startedAt != null);
const metaId = hasTrail ? sidebarSessionMetaId(session.key) : undefined;
const menuSession = display ? { ...session, meta } : session;
const title = display?.title ?? [label, narration, rowMeta].filter(Boolean).join(" · ");
// Pinned rows reposition the state badge into the nav-item slot; render
// every state renderSessionState knows (spinner, unread, child terminal
// badges) so pinning a subagent session cannot hide its outcome.
const sessionState = renderSessionState(session);
const pinnedState =
session.pinned && sessionState !== nothing
? html`<span class="nav-item__state">${sessionState}</span>`
: nothing;
const rowClass = [
"sidebar-recent-session",
"session-row-host",
@@ -141,13 +135,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
aria-describedby=${metaId ?? nothing}
@click=${(event: MouseEvent) => this.handleSessionRowClick(event, session)}
>
${session.attention.kind !== "none"
? renderSessionAttentionIcon(session.attention)
: session.pinned
? html`<span class="sidebar-pinned-session__icon" aria-hidden="true"
>${resolveSessionIcon(session.icon)}</span
>`
: nothing}
<span class="sidebar-session-indicator">${leadingIndicator}</span>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee">${label}</span>
${renderSidebarSessionSubtitle({ subtitle, narration })}
@@ -168,7 +156,6 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
.maxVisible=${3}
variant="session"
></openclaw-viewer-facepile>
${session.pinned ? nothing : sessionState}
${renderSessionRowBadges({
...session,
hasApproval: sessionHasPendingApproval(this.approvalBadgeSnapshot(), session.key),
@@ -532,6 +519,9 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
return html`
<div class="sidebar-recent-session sidebar-recent-session--draft">
<span class="sidebar-recent-session__link">
<span class="sidebar-session-indicator" aria-hidden="true">
<span class="sidebar-session-indicator__dot"></span>
</span>
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name">${t("newSession.draftRow")}</span>
</span>
@@ -34,11 +34,11 @@ import {
} from "../lib/sessions/session-key.ts";
import { reconcileSidebarZone } from "../lib/sidebar-zone.ts";
import { normalizeOptionalString } from "../lib/string-coerce.ts";
import { AppSidebarSessionAttentionElement } from "./app-sidebar-session-attention.ts";
import {
adoptedCatalogSessionKeys,
formatSidebarTimestamp,
} from "./app-sidebar-session-catalogs.ts";
import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts";
import { projectSessionTree } from "./app-sidebar-session-tree.ts";
import {
limitSidebarSessionRows,
@@ -47,17 +47,15 @@ import {
SIDEBAR_AGENT_SESSION_LIST_LIMIT,
SIDEBAR_SESSION_PAGE_SIZE,
type SidebarRecentSession,
type SidebarSessionSortMode,
} from "./app-sidebar-session-types.ts";
import { isStoppableCloudWorkerPlacement } from "./session-row-badges.ts";
/** Session-row projection, selection, sorting, and agent scope navigation. */
export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessionAttentionElement {
export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessionProjectionElement {
@state() protected selectedSessionKeys: ReadonlySet<string> = new Set();
@state() protected expandedChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected collapsedActiveChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected fullyShownChildSessionKeys: ReadonlySet<string> = new Set();
@state() protected sessionSortMode: SidebarSessionSortMode = "created";
@state() protected sessionsGrouping: SidebarSessionsGrouping =
loadStoredSidebarSessionsGrouping();
@state() protected sessionsShowCron = loadStoredSidebarSessionsShowCron();
@@ -109,35 +107,12 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
}
protected getRouteSessionKey(): string {
return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || "";
protected projectSidebarSession(row: GatewaySessionRow): SidebarRecentSession {
return this.getSessionNavigationState().toSidebarSession(row);
}
private readonly compareSidebarSessionRows = (
a: SessionsListResult["sessions"][number],
b: SessionsListResult["sessions"][number],
) => {
if (this.sessionSortMode === "updated") {
return compareSessionRowsByUpdatedAt(a, b);
}
return (
(this.sessionCreatedOrder.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
(this.sessionCreatedOrder.get(b.key) ?? Number.MAX_SAFE_INTEGER)
);
};
protected promoteCreatedSession(sessionKey: string) {
const currentOrder = this.sessionCreatedOrder.get(sessionKey);
if (currentOrder === 0) {
return;
}
for (const [key, order] of this.sessionCreatedOrder) {
if (key !== sessionKey && (currentOrder === undefined || order < currentOrder)) {
this.sessionCreatedOrder.set(key, order + 1);
}
}
this.sessionCreatedOrder.set(sessionKey, 0);
this.requestUpdate();
protected getRouteSessionKey(): string {
return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || "";
}
protected getSessionNavigationState() {
@@ -0,0 +1,80 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationGatewaySnapshot } from "../app/context.ts";
import { SessionPullRequestIndicatorsController } from "./app-sidebar-session-pr-indicators.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
class TestHost implements ReactiveControllerHost {
readonly controllers: ReactiveController[] = [];
readonly requestUpdate = vi.fn();
readonly updateComplete = Promise.resolve(true);
addController(controller: ReactiveController): void {
this.controllers.push(controller);
}
removeController(controller: ReactiveController): void {
this.controllers.splice(this.controllers.indexOf(controller), 1);
}
}
afterEach(() => {
vi.useRealTimers();
});
describe("SessionPullRequestIndicatorsController", () => {
it("refreshes visible PR state and keeps the last value while rate limited", async () => {
vi.useFakeTimers();
const host = new TestHost();
const row = {
key: "agent:main:demo",
isChild: false,
worktreeId: "wt-demo",
} as SidebarRecentSession;
let state: "open" | "merged" = "open";
let rateLimited = false;
const request = vi.fn(() =>
Promise.resolve({
pullRequests: rateLimited
? []
: [
{
number: 1,
owner: "openclaw",
repo: "openclaw",
branch: "feature/demo",
title: "Demo",
url: "https://example.test/pr/1",
state,
},
],
rateLimited,
}),
);
const snapshot = {
client: { request } as unknown as GatewayBrowserClient,
hello: { features: { methods: ["controlUi.sessionPullRequests"] } },
} as ApplicationGatewaySnapshot;
const controller = new SessionPullRequestIndicatorsController(host, {
getConnected: () => true,
getRows: () => [row],
getSelectedAgentId: () => "main",
getSnapshot: () => snapshot,
});
controller.hostConnected();
controller.hostUpdated();
await vi.advanceTimersByTimeAsync(0);
expect(controller.state(row.key, row.worktreeId ?? "")).toBe("open");
state = "merged";
await vi.advanceTimersByTimeAsync(60_000);
expect(controller.state(row.key, row.worktreeId ?? "")).toBe("merged");
rateLimited = true;
await vi.advanceTimersByTimeAsync(60_000);
expect(controller.state(row.key, row.worktreeId ?? "")).toBe("merged");
expect(request).toHaveBeenCalledTimes(3);
});
});
@@ -0,0 +1,241 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationGatewaySnapshot } from "../app/context.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { parseAgentSessionKey } from "../lib/sessions/session-key.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import {
fetchSessionPullRequestIndicatorState,
type SessionPullRequestIndicatorState,
} from "./session-menu-work.ts";
const REFRESH_MS = 60_000;
type IndicatorEntry = {
state: SessionPullRequestIndicatorState;
worktreeId: string;
};
type SessionPullRequestIndicatorsOptions = {
getConnected: () => boolean;
getRows: () => readonly SidebarRecentSession[];
getSelectedAgentId: () => string;
getSnapshot: () => ApplicationGatewaySnapshot | undefined;
};
/** Polls compact PR state for visible worktree rows; the gateway owns caching. */
export class SessionPullRequestIndicatorsController implements ReactiveController {
private readonly states = new Map<string, IndicatorEntry>();
private client: GatewayBrowserClient | null = null;
private agentId: string | null = null;
private connected = false;
private epoch = 0;
private eligibleSignature = "";
private refresh: Promise<void> | null = null;
private refreshAgain = false;
private refreshTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
private refreshScheduled = false;
constructor(
private readonly host: ReactiveControllerHost,
private readonly options: SessionPullRequestIndicatorsOptions,
) {
host.addController(this);
}
hostConnected(): void {
this.connected = true;
}
hostUpdated(): void {
this.scheduleRefresh();
}
hostDisconnected(): void {
this.connected = false;
this.client = null;
this.agentId = null;
this.reset(false);
}
state(sessionKey: string, worktreeId: string): SessionPullRequestIndicatorState {
const entry = this.states.get(sessionKey);
return entry?.worktreeId === worktreeId ? entry.state : "none";
}
private scheduleRefresh(): void {
if (this.refreshScheduled) {
return;
}
this.refreshScheduled = true;
globalThis.setTimeout(() => {
this.refreshScheduled = false;
if (this.connected) {
this.refreshVisible(false);
}
}, 0);
}
private clearRefreshTimer(): void {
if (this.refreshTimer === null) {
return;
}
globalThis.clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
private scheduleRefreshTimer(): void {
if (this.refreshTimer !== null) {
return;
}
this.refreshTimer = globalThis.setTimeout(() => {
this.refreshTimer = null;
this.refreshVisible(true);
}, REFRESH_MS);
}
private reset(requestUpdate: boolean): void {
this.epoch += 1;
this.eligibleSignature = "";
this.refreshAgain = false;
this.clearRefreshTimer();
if (this.states.size === 0) {
return;
}
this.states.clear();
if (requestUpdate) {
this.host.requestUpdate();
}
}
private refreshVisible(force: boolean): void {
const snapshot = this.options.getSnapshot();
if (
!snapshot?.client ||
!this.options.getConnected() ||
isGatewayMethodAdvertised(snapshot, "controlUi.sessionPullRequests") !== true
) {
this.client = null;
this.agentId = null;
this.reset(true);
return;
}
const selectedAgentId = this.options.getSelectedAgentId();
if (snapshot.client !== this.client || selectedAgentId !== this.agentId) {
this.reset(true);
this.client = snapshot.client;
this.agentId = selectedAgentId;
}
const eligibleRows = this.options
.getRows()
.filter((session) => !session.isChild && session.worktreeId);
const eligibleKeys = new Set(eligibleRows.map((session) => session.key));
if ([...this.states.keys()].some((sessionKey) => !eligibleKeys.has(sessionKey))) {
for (const sessionKey of this.states.keys()) {
if (!eligibleKeys.has(sessionKey)) {
this.states.delete(sessionKey);
}
}
this.host.requestUpdate();
}
if (eligibleRows.length === 0) {
this.eligibleSignature = "";
this.clearRefreshTimer();
return;
}
const signature = JSON.stringify(
eligibleRows.map((session) => [session.key, session.worktreeId]),
);
if (!force && signature === this.eligibleSignature) {
if (this.refresh === null) {
this.scheduleRefreshTimer();
}
return;
}
this.eligibleSignature = signature;
if (this.refresh) {
this.refreshAgain = true;
return;
}
this.clearRefreshTimer();
const epoch = this.epoch;
const refresh = this.load({
client: snapshot.client,
selectedAgentId,
eligibleRows,
epoch,
signature,
});
this.refresh = refresh;
void refresh.finally(() => {
if (this.refresh !== refresh) {
return;
}
this.refresh = null;
if (!this.connected) {
return;
}
if (this.refreshAgain) {
this.refreshAgain = false;
this.refreshVisible(true);
return;
}
if (epoch === this.epoch && signature === this.eligibleSignature) {
this.scheduleRefreshTimer();
}
});
}
private async load(params: {
client: GatewayBrowserClient;
selectedAgentId: string;
eligibleRows: readonly SidebarRecentSession[];
epoch: number;
signature: string;
}): Promise<void> {
for (const session of params.eligibleRows) {
if (!this.isCurrent(params)) {
return;
}
try {
const indicatorState = await fetchSessionPullRequestIndicatorState({
client: params.client,
pullRequestsAvailable: true,
sessionKey: session.key,
agentId: parseAgentSessionKey(session.key)?.agentId ?? params.selectedAgentId,
});
if (indicatorState === null || !this.isCurrent(params)) {
continue;
}
const worktreeId = session.worktreeId;
const current = this.states.get(session.key);
if (
worktreeId &&
(current?.state !== indicatorState || current.worktreeId !== worktreeId)
) {
this.states.set(session.key, { state: indicatorState, worktreeId });
this.host.requestUpdate();
}
} catch {
// Optional metadata: preserve the last-known indicator and retry next poll.
}
}
}
private isCurrent(params: {
client: GatewayBrowserClient;
epoch: number;
signature: string;
}): boolean {
return (
this.connected &&
this.options.getConnected() &&
params.epoch === this.epoch &&
params.signature === this.eligibleSignature &&
this.options.getSnapshot()?.client === params.client
);
}
}
@@ -0,0 +1,72 @@
import { state } from "lit/decorators.js";
import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts";
import { compareSessionRowsByUpdatedAt } from "../lib/sessions/index.ts";
import { AppSidebarSessionAttentionElement } from "./app-sidebar-session-attention.ts";
import { adoptedCatalogSessionKeys } from "./app-sidebar-session-catalogs.ts";
import { SessionPullRequestIndicatorsController } from "./app-sidebar-session-pr-indicators.ts";
import type { SidebarRecentSession, SidebarSessionSortMode } from "./app-sidebar-session-types.ts";
/** Shared ordering and PR-state projection used by sidebar navigation. */
export abstract class AppSidebarSessionProjectionElement extends AppSidebarSessionAttentionElement {
@state() protected sessionSortMode: SidebarSessionSortMode = "created";
private readonly sessionPullRequestIndicators = new SessionPullRequestIndicatorsController(this, {
getConnected: () => this.connected,
getRows: () => this.visibleSessionPullRequestRows(),
getSelectedAgentId: () => this.selectedAgentIdForSessions(),
getSnapshot: () => this.context?.gateway.snapshot,
});
protected readonly compareSidebarSessionRows = (
a: SessionsListResult["sessions"][number],
b: SessionsListResult["sessions"][number],
) => {
if (this.sessionSortMode === "updated") {
return compareSessionRowsByUpdatedAt(a, b);
}
return (
(this.sessionCreatedOrder.get(a.key) ?? Number.MAX_SAFE_INTEGER) -
(this.sessionCreatedOrder.get(b.key) ?? Number.MAX_SAFE_INTEGER)
);
};
protected promoteCreatedSession(sessionKey: string) {
const currentOrder = this.sessionCreatedOrder.get(sessionKey);
if (currentOrder === 0) {
return;
}
for (const [key, order] of this.sessionCreatedOrder) {
if (key !== sessionKey && (currentOrder === undefined || order < currentOrder)) {
this.sessionCreatedOrder.set(key, order + 1);
}
}
this.sessionCreatedOrder.set(sessionKey, 0);
this.requestUpdate();
}
protected sessionPullRequestIndicatorState(sessionKey: string, worktreeId: string) {
return this.sessionPullRequestIndicators.state(sessionKey, worktreeId);
}
private visibleSessionPullRequestRows(): SidebarRecentSession[] {
const rows = this.visibleSessionRowsInOrder();
const adopted = adoptedCatalogSessionKeys(this.sessionCatalogs);
if (adopted.size === 0) {
return rows;
}
const byKey = new Map(rows.map((row) => [row.key, row]));
const liveRows = [
...(this.sessionsResult?.sessions ?? []),
...Object.values(this.sessionRowsByAgent).flat(),
];
for (const row of liveRows) {
if (adopted.has(row.key) && !byKey.has(row.key)) {
byKey.set(row.key, this.projectSidebarSession(row));
}
}
return [...byKey.values()];
}
protected abstract visibleSessionRowsInOrder(): SidebarRecentSession[];
protected abstract projectSidebarSession(row: GatewaySessionRow): SidebarRecentSession;
}
@@ -42,7 +42,7 @@ export function sessionAttentionSubtitle(attention: SidebarSessionAttention): st
}
export function renderSessionState(session: SidebarRecentSession) {
if (session.hasActiveRun || (session.isChild && session.status === "running")) {
if (session.hasActiveRun || session.status === "running") {
return html`<span
class="session-run-spinner sidebar-recent-session__state"
role="img"
@@ -0,0 +1,70 @@
import { html, nothing } from "lit";
import { t } from "../i18n/index.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
import {
renderSessionAttentionIcon,
renderSessionState,
} from "./session-attention-presentation.ts";
import { resolveSessionIcon } from "./session-icon-registry.ts";
import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts";
export function renderSessionLeadingState(
session: SidebarRecentSession,
pullRequestState: SessionPullRequestIndicatorState,
) {
const running = session.hasActiveRun || session.status === "running";
const sessionState = renderSessionState(session);
// Pinned rows keep their custom icon in the fixed leading slot and place
// transient run/unread/terminal state at the row edge.
const pinnedState =
session.pinned && sessionState !== nothing
? html`<span class="nav-item__state">${sessionState}</span>`
: nothing;
if (session.attention.kind !== "none") {
return {
running,
pinnedState,
leadingIndicator: renderSessionAttentionIcon(session.attention),
};
}
if (session.pinned) {
return {
running,
pinnedState,
leadingIndicator: html`<span class="sidebar-pinned-session__icon" aria-hidden="true"
>${resolveSessionIcon(session.icon)}</span
>`,
};
}
if (running) {
return { running, pinnedState, leadingIndicator: sessionState };
}
if (pullRequestState !== "none") {
const label =
pullRequestState === "open"
? t("sessionsView.openPullRequest")
: t("chat.pullRequests.merged");
return {
running,
pinnedState,
leadingIndicator: html`<span
class="sidebar-session-pr-indicator sidebar-session-pr-indicator--${pullRequestState}"
data-session-pr-state=${pullRequestState}
role="img"
aria-label=${label}
title=${label}
>${icons.gitBranch}</span
>`,
};
}
return {
running,
pinnedState,
leadingIndicator:
sessionState !== nothing
? sessionState
: html`<span class="sidebar-session-indicator__dot" aria-hidden="true"></span>`,
};
}
+66 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { ControlUiSessionPullRequest } from "../../../src/gateway/control-ui-contract.js";
import { fetchSessionMenuWork } from "./session-menu-work.ts";
import {
fetchSessionMenuWork,
fetchSessionPullRequestIndicatorState,
} from "./session-menu-work.ts";
function pullRequest(overrides: Partial<ControlUiSessionPullRequest>): ControlUiSessionPullRequest {
return {
@@ -15,6 +18,68 @@ function pullRequest(overrides: Partial<ControlUiSessionPullRequest>): ControlUi
};
}
describe("session pull request indicators", () => {
it.each([
{
name: "prioritizes an active PR over merged history",
pullRequests: [
pullRequest({ number: 1, state: "merged" }),
pullRequest({ number: 2, state: "draft" }),
],
expected: "open",
},
{
name: "shows merged history",
pullRequests: [pullRequest({ state: "merged" })],
expected: "merged",
},
{
name: "ignores closed history",
pullRequests: [pullRequest({ state: "closed" })],
expected: "none",
},
] as const)("$name", async ({ pullRequests, expected }) => {
const request = vi.fn(() => Promise.resolve({ pullRequests, rateLimited: false }));
await expect(
fetchSessionPullRequestIndicatorState({
client: { request: request as never },
pullRequestsAvailable: true,
sessionKey: "agent:main:demo",
}),
).resolves.toBe(expected);
});
it("preserves the prior indicator when the gateway is rate limited", async () => {
const request = vi.fn(() => Promise.resolve({ pullRequests: [], rateLimited: true }));
await expect(
fetchSessionPullRequestIndicatorState({
client: { request: request as never },
pullRequestsAvailable: true,
sessionKey: "agent:main:demo",
}),
).resolves.toBeNull();
});
it("loads the compact indicator state through the existing PR surface", async () => {
const request = vi.fn(() =>
Promise.resolve({ pullRequests: [pullRequest({ state: "merged" })], rateLimited: false }),
);
await expect(
fetchSessionPullRequestIndicatorState({
client: { request: request as never },
pullRequestsAvailable: true,
sessionKey: "agent:main:demo",
agentId: "main",
}),
).resolves.toBe("merged");
expect(request).toHaveBeenCalledWith("controlUi.sessionPullRequests", {
sessionKey: "agent:main:demo",
agentId: "main",
});
});
});
describe("fetchSessionMenuWork", () => {
it("resolves the PR URL and worktree path in one pass", async () => {
const request = vi.fn((method: string) => {
+35 -4
View File
@@ -25,6 +25,8 @@ type SessionMenuWorkResult = {
worktreePath: string | null;
};
export type SessionPullRequestIndicatorState = "none" | "open" | "merged";
// Menu offers a single Open PR action; prefer the PR a maintainer most
// likely wants: active first, merged history next, closed last.
const PR_STATE_ORDER: ReadonlyArray<ControlUiSessionPullRequest["state"]> = [
@@ -46,15 +48,44 @@ function pickSessionMenuPullRequestUrl(
return null;
}
function resolveSessionPullRequestIndicatorState(
pullRequests: readonly ControlUiSessionPullRequest[],
): SessionPullRequestIndicatorState {
if (
pullRequests.some(
(pullRequest) => pullRequest.state === "open" || pullRequest.state === "draft",
)
) {
return "open";
}
return pullRequests.some((pullRequest) => pullRequest.state === "merged") ? "merged" : "none";
}
async function loadSessionPullRequests(
params: Omit<SessionMenuWorkParams, "worktreeId">,
): Promise<ControlUiSessionPullRequests> {
return params.client.request<ControlUiSessionPullRequests>("controlUi.sessionPullRequests", {
sessionKey: params.sessionKey,
...(params.agentId ? { agentId: params.agentId } : {}),
});
}
export async function fetchSessionPullRequestIndicatorState(
params: Omit<SessionMenuWorkParams, "worktreeId">,
): Promise<SessionPullRequestIndicatorState | null> {
if (!params.pullRequestsAvailable) {
return "none";
}
const result = await loadSessionPullRequests(params);
return result.rateLimited ? null : resolveSessionPullRequestIndicatorState(result.pullRequests);
}
async function loadPullRequestUrl(params: SessionMenuWorkParams): Promise<string | null> {
if (!params.pullRequestsAvailable) {
return null;
}
try {
const result = await params.client.request<ControlUiSessionPullRequests>(
"controlUi.sessionPullRequests",
{ sessionKey: params.sessionKey, ...(params.agentId ? { agentId: params.agentId } : {}) },
);
const result = await loadSessionPullRequests(params);
return pickSessionMenuPullRequestUrl(result.pullRequests);
} catch {
// Optional affordance: a GitHub or gateway hiccup just leaves Open PR disabled.
+2 -4
View File
@@ -61,12 +61,11 @@ describe("session row placement badges", () => {
renderSessionRowBadges({
hasAutomation: true,
placementState: "local",
worktreeId: "worktree-1",
}),
container,
);
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(2);
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(1);
expect(container.querySelector(".session-row-badge--cloud")).toBeNull();
});
@@ -84,11 +83,10 @@ describe("session row placement badges", () => {
expect(badge?.querySelector("svg")).not.toBeNull();
});
it("keeps child-only worktree and placement badges hidden while showing approval", () => {
it("keeps child-only automation and placement badges hidden while showing approval", () => {
render(
renderSessionRowBadges({
isChild: true,
worktreeId: "worktree-1",
hasAutomation: true,
hasApproval: true,
placementState: "active",
+1 -12
View File
@@ -18,33 +18,22 @@ export function isStoppableCloudWorkerPlacement(
export function renderSessionRowBadges(params: {
isChild?: boolean;
worktreeId?: string;
hasAutomation: boolean;
hasApproval?: boolean;
placementState?: SessionPlacementState;
}) {
const worktreeId = params.isChild ? undefined : params.worktreeId;
const hasAutomation = !params.isChild && params.hasAutomation;
const placementState = params.isChild ? undefined : params.placementState;
const cloudPlacementState = isCloudWorkerPlacementState(placementState)
? placementState
: undefined;
if (!worktreeId && !hasAutomation && !params.hasApproval && !cloudPlacementState) {
if (!hasAutomation && !params.hasApproval && !cloudPlacementState) {
return nothing;
}
const cloudLabel = cloudPlacementState
? t("sessionsView.cloudWorkerPlacement", { state: cloudPlacementState })
: "";
return html`<span class="session-row-badges">
${worktreeId
? html`<span
class="session-row-badge"
role="img"
aria-label=${t("sessionsView.worktreeSession")}
title=${t("sessionsView.worktreeSession")}
>${icons.gitBranch}</span
>`
: nothing}
${hasAutomation
? html`<span
class="session-row-badge"
+1 -1
View File
@@ -5202,7 +5202,7 @@ td.data-table-key-col {
pointer-events: none;
}
/* Attribute badges (worktree fork, attached automation, cloud placement):
/* Attribute badges (attached automation, approval, 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. */
+38 -6
View File
@@ -1864,12 +1864,6 @@ html.openclaw-native-macos
text-decoration: none;
}
.sidebar-recent-session__state,
.sidebar-recent-session__unread {
order: -1;
flex: none;
}
.sidebar-board-glyph {
display: inline-flex;
flex: 0 0 auto;
@@ -3729,6 +3723,44 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
}
/* Sidebar sessions redesign: work-session subtitles, agent sections, draft row. */
.sidebar-session-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex: 0 0 16px;
}
.sidebar-session-indicator__dot {
width: 7px;
height: 7px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--muted) 76%, transparent);
}
.sidebar-session-pr-indicator--open {
color: var(--ok);
}
.sidebar-session-pr-indicator--merged {
color: #a78bfa;
}
:root[data-theme-mode="light"] .sidebar-session-pr-indicator--merged {
color: #7c3aed;
}
.sidebar-session-pr-indicator svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.7px;
stroke-linecap: round;
stroke-linejoin: round;
}
.sidebar-recent-session__text {
display: flex;
flex: 1 1 auto;
@@ -23,6 +23,87 @@ import {
import { waitForFast } from "../wait-for.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar session indicators", () => {
it("keeps one leading slot across neutral, running, open, and merged states", async () => {
const keys = [
"agent:main:plain",
"agent:main:status-running",
"agent:main:open-pr",
"agent:main:merged-pr",
];
const sessions = createSessionsHarness("main", keys);
const result = sessions.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
for (const row of result.sessions) {
if (row.key === keys[1]) {
row.status = "running";
} else if (row.key === keys[2] || row.key === keys[3]) {
row.worktree = {
id: `wt-${row.key}`,
branch: row.key.endsWith("open-pr") ? "feature/open" : "feature/merged",
repoRoot: "/repo",
};
}
}
const request = vi.fn((_method: string, params: { sessionKey: string }) =>
Promise.resolve({
pullRequests: [
{
number: 1,
owner: "openclaw",
repo: "openclaw",
branch: "feature/test",
title: "Test",
url: "https://example.test/pr/1",
state: params.sessionKey.endsWith("open-pr") ? "open" : "merged",
},
],
rateLimited: false,
}),
);
const gatewayHarness = createGatewayHarness({ request } as unknown as GatewayBrowserClient);
gatewayHarness.publish({
hello: {
features: { methods: ["controlUi.sessionPullRequests"] },
} as ApplicationGatewaySnapshot["hello"],
});
const { sidebar } = await mountSidebar(gatewayHarness.gateway, sessions.sessions);
sidebar.connected = true;
await sidebar.updateComplete;
await waitForFast(() => {
expect(sidebar.querySelector('[data-session-pr-state="open"]')).not.toBeNull();
expect(sidebar.querySelector('[data-session-pr-state="merged"]')).not.toBeNull();
});
for (const key of keys) {
expect(
sidebar.querySelector(`[data-session-key="${key}"] .sidebar-session-indicator`),
).not.toBeNull();
}
expect(
sidebar.querySelector(`[data-session-key="${keys[0]}"] .sidebar-session-indicator__dot`),
).not.toBeNull();
expect(
sidebar.querySelector(`[data-session-key="${keys[1]}"] .session-run-spinner`),
).not.toBeNull();
const openPullRequestRow = result.sessions.find((row) => row.key === keys[2]);
if (!openPullRequestRow) {
throw new Error("expected open PR session");
}
openPullRequestRow.worktree = undefined;
sessions.publishList({ result });
await waitForFast(() => {
expect(sidebar.querySelector('[data-session-pr-state="open"]')).toBeNull();
expect(
sidebar.querySelector(`[data-session-key="${keys[2]}"] .sidebar-session-indicator__dot`),
).not.toBeNull();
});
});
});
describe("AppSidebar session pagination", () => {
it("does not show pagination controls at the ten-session boundary", async () => {
const keys = [
@@ -354,7 +435,8 @@ describe("AppSidebar session accessibility", () => {
expect(row?.hasAttribute("aria-label")).toBe(false);
expect(link?.hasAttribute("aria-label")).toBe(false);
expect(link?.getAttribute("aria-current")).toBe("page");
expect(link?.firstElementChild?.classList.contains("sidebar-recent-session__text")).toBe(true);
expect(link?.firstElementChild?.classList.contains("sidebar-session-indicator")).toBe(true);
expect(link?.children[1]?.classList.contains("sidebar-recent-session__text")).toBe(true);
expect(link?.querySelector(".sidebar-recent-session__name")?.textContent).toBe(
"Quarterly launch plan",
);