mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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>`,
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user