refactor(ui): extract sidebar session-list render functions (#112753)

* refactor(ui): extract sidebar session-list render functions

* refactor(ui): trim obsolete sidebar render wrappers

* refactor(ui): compact sidebar render snapshot

* refactor(ui): add sidebar startup margin

* refactor(ui): reduce sidebar render duplication

* refactor(ui): reduce sidebar render overhead
This commit is contained in:
Peter Steinberger
2026-07-22 19:30:39 -04:00
committed by GitHub
parent c26b0e0843
commit db5e59d15e
7 changed files with 964 additions and 750 deletions
-17
View File
@@ -26,9 +26,7 @@ import {
isSidebarRouteActive,
renderSidebarCustomizeMenu,
renderSidebarMoreMenu,
renderSidebarMoreRow,
renderSidebarNavRoute,
sidebarMoreMenuHoldsActiveRoute,
} from "./app-sidebar-nav-menus.ts";
import { AppSidebarSessionGroupsElement } from "./app-sidebar-session-groups.ts";
import {
@@ -594,21 +592,6 @@ export abstract class AppSidebarMenusElement extends AppSidebarSessionGroupsElem
});
}
protected renderMoreRow() {
return renderSidebarMoreRow({
open: this.moreMenuPosition !== null,
active: sidebarMoreMenuHoldsActiveRoute({
activeRouteId: this.activeRouteId,
activeWorkboardBoardId: this.activeWorkboardBoardIsPinned()
? this.activeWorkboardBoardId
: "",
sidebarEntries: this.sidebarEntries,
isRouteEnabled: (routeId) => this.isRouteEnabled(routeId),
}),
onToggle: (trigger) => this.toggleMoreMenu(trigger),
});
}
protected renderMoreMenu() {
const position = this.moreMenuPosition;
const trigger = this.moreMenuTrigger;
@@ -129,26 +129,6 @@ export function renderSidebarPluginTab(params: {
`;
}
/** Unpinned routes and the pin editor live in a popup behind this row. */
export function renderSidebarMoreRow(params: {
open: boolean;
active: boolean;
onToggle: (trigger: HTMLElement) => void;
}) {
return html`
<button
type="button"
class="nav-item nav-item--action ${params.active ? "nav-item--active" : ""}"
aria-haspopup="menu"
aria-expanded=${String(params.open)}
@click=${(event: MouseEvent) => params.onToggle(event.currentTarget as HTMLElement)}
>
<span class="nav-item__icon" aria-hidden="true">${icons.moreHorizontal}</span>
<span class="nav-item__text">${t("nav.more")}</span>
</button>
`;
}
type SidebarMenuNavigationHandlers = {
onNavigateRoute: (routeId: SidebarNavRoute) => void;
onPreloadRoute: (routeId: SidebarNavRoute, event: Event) => void;
@@ -337,18 +317,3 @@ export function renderSidebarCustomizeMenu(params: SidebarCustomizeMenuParams) {
</openclaw-menu-surface>
`;
}
/** More row carries the active highlight when the current route lives inside its menu. */
export function sidebarMoreMenuHoldsActiveRoute(params: {
activeRouteId: NavigationRouteId | undefined;
activeWorkboardBoardId?: string;
sidebarEntries: readonly string[];
isRouteEnabled: (routeId: NavigationRouteId) => boolean;
}): boolean {
return sidebarMoreRoutes(params.sidebarEntries).some(
(routeId) =>
params.isRouteEnabled(routeId) &&
isSidebarRouteActive(params.activeRouteId, routeId) &&
!(routeId === "workboard" && params.activeWorkboardBoardId),
);
}
@@ -0,0 +1,436 @@
import { html, nothing, type TemplateResult } from "lit";
import type { SessionCatalog } from "../../../packages/gateway-protocol/src/index.ts";
import type { GatewaySessionRow } from "../api/types.ts";
import { titleForRoute } from "../app-navigation.ts";
import type { CatalogOpenTarget } from "../app/settings.ts";
import { t } from "../i18n/index.ts";
import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts";
import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.ts";
import { writeSessionGroupDragData } from "../lib/sessions/drag.ts";
import type { SidebarSessionSection } from "../lib/sessions/grouping.ts";
import { renderSessionCatalogGroups } from "./app-sidebar-session-catalogs.ts";
import {
renderRecentSession,
renderSessionTree,
type SessionListRenderContext,
} from "./app-sidebar-session-row-render.ts";
import {
limitSidebarSessionRows,
rowDemandsVisibility,
RowVisibilityReason,
SIDEBAR_SESSION_PAGE_SIZE,
SIDEBAR_SESSION_SEE_LESS_THRESHOLD,
type SidebarRecentSession,
} from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
type RenderableSessionSection = SidebarSessionSection<SidebarRecentSession> & {
totalRowCount: number;
};
type SessionCatalogRenderSnapshot = {
catalogs: readonly SessionCatalog[];
basePath: string;
routeSessionKey: string;
newSessionAgentId: string;
loadingMoreCatalogIds: ReadonlySet<string>;
projectGrouping: CatalogProjectGrouping;
liveRows: readonly GatewaySessionRow[];
sidebarRowsByKey: ReadonlyMap<string, SidebarRecentSession>;
creatorId: string | null;
catalogOpenTarget: CatalogOpenTarget;
terminalAvailable: boolean;
};
function renderSessionSection(params: {
context: SessionListRenderContext;
section: RenderableSessionSection;
trailing?: TemplateResult | typeof nothing;
showDraft?: boolean;
}) {
const { context, section } = params;
const { data, cb } = context;
const trailing = params.trailing ?? nothing;
const showDraft = params.showDraft ?? false;
const totalRowCount = section.totalRowCount;
const group = section.category;
// zonedVisibleSections removes pinned rows; AppSidebar renders them through
// renderPinnedSidebarSession, so every section here has a header.
const collapsed = data.c.has(section.id);
const label = section.groups
? t("chat.sidebar.groups")
: section.work
? t("chat.sidebar.coding")
: group
? group
: t("chat.sidebar.threads");
const zone = section.groups ? "groups" : section.work ? "coding" : group ? "category" : "threads";
// Collapsed Coding still signals live runs so background work stays visible.
const collapsedRunningDot =
collapsed &&
section.work &&
section.rows.some((row) => rowDemandsVisibility(row, RowVisibilityReason.ActiveRun));
const collapsedAttentionDot =
collapsed &&
section.rows.some((row) => rowDemandsVisibility(row, RowVisibilityReason.Attention));
const acceptsSessions = data.g === "category" && (section.id === "ungrouped" || Boolean(group));
const sectionClass = [
"sidebar-recent-sessions__group",
`sidebar-recent-sessions__group--zone-${zone}`,
collapsed ? "sidebar-recent-sessions__group--collapsed" : "",
group && data.dg === group ? "sidebar-recent-sessions__group--dragging" : "",
data.q === section.id ? "sidebar-recent-sessions__group--session-drop" : "",
group && data.gd?.group === group
? `sidebar-recent-sessions__group--group-drop-${data.gd.position}`
: "",
]
.filter(Boolean)
.join(" ");
return html`
<div
class=${sectionClass}
data-session-section=${section.id}
@dragover=${acceptsSessions || group
? (event: DragEvent) => cb.ov(event, section.id, group)
: nothing}
@dragleave=${acceptsSessions || group
? (event: DragEvent) => cb.lv(event, section.id, group)
: nothing}
@drop=${acceptsSessions || group
? (event: DragEvent) => cb.sp(event, section.id, group)
: nothing}
>
${html`
<div
class="sidebar-recent-sessions__head ${group
? "sidebar-recent-sessions__head--draggable"
: ""}"
draggable=${group ? "true" : "false"}
@dragstart=${group
? (event: DragEvent) => {
if (event.dataTransfer) {
writeSessionGroupDragData(event.dataTransfer, group);
cb.gs(group);
}
}
: nothing}
@dragend=${group
? () => {
cb.ge();
}
: nothing}
@contextmenu=${group
? (event: MouseEvent) => {
event.preventDefault();
cb.gm(group, event.clientX, event.clientY, null);
}
: nothing}
>
${group
? html`<span class="sidebar-session-group-drag-handle" aria-hidden="true"></span>`
: nothing}
<button
type="button"
class="sidebar-session-group-toggle"
aria-expanded=${String(!collapsed)}
aria-label=${label}
@click=${() => cb.section(section.id)}
>
<span class="sidebar-recent-sessions__label-text">${label}</span>
<span class="sidebar-session-group-toggle__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
${collapsed && totalRowCount > 0
? html`<span class="sidebar-session-group-count">${totalRowCount}</span>`
: nothing}
${collapsedRunningDot
? html`<span
class="session-run-spinner sidebar-session-group-running"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`
: nothing}
${collapsedAttentionDot
? html`<span
class="sidebar-session-group-attention"
role="img"
aria-label=${t("sessionsView.attentionRequired")}
title=${t("sessionsView.attentionRequired")}
></span>`
: nothing}
</button>
${section.id === "ungrouped"
? html`
<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort"
title=${t("chat.sidebar.sortSessions")}
aria-label=${t("chat.sidebar.sortSessions")}
aria-haspopup="menu"
aria-expanded=${String(data.z)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
cb.z(event.currentTarget as HTMLElement);
}}
>
${icons.listFilter}
</button>
<button
type="button"
class="sidebar-session-group-actions sidebar-new-session"
title=${data.o
? t("chat.runControls.newSession")
: t("chat.runControls.newSessionDisconnected")}
aria-label=${t("chat.runControls.newSession")}
?disabled=${!data.o}
@click=${(event: MouseEvent) => {
event.stopPropagation();
cb.ns();
}}
>
${icons.plus}
</button>
`
: nothing}
${group
? html`
<button
type="button"
class="sidebar-session-group-actions"
title=${t("sessionsView.groupMenu", { group })}
aria-label=${t("sessionsView.groupMenu", { group })}
aria-haspopup="menu"
aria-expanded=${String(data.gm === group)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
cb.gm(group, rect.right, rect.bottom + 4, trigger);
}}
>
${icons.moreHorizontal}
</button>
`
: nothing}
</div>
`}
${collapsed
? nothing
: html`
${section.rows.length > 0 || showDraft
? html`<div class="sidebar-recent-sessions__list" role="list" aria-label=${label}>
${showDraft ? renderDraftSessionRow() : nothing}
${section.rows.map((session) => renderSessionTree({ context, session }))}
</div>`
: nothing}
${trailing}
`}
</div>
`;
}
function renderDraftSessionRow() {
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>
</span>
</div>
`;
}
function renderSessionPagination(params: {
context: SessionListRenderContext;
rows: SidebarRecentSession[];
visible: number;
}) {
const { context, rows, visible } = params;
const { cb } = context;
const canShowMore = visible < rows.length;
const collapsedVisible = limitSidebarSessionRows(rows, SIDEBAR_SESSION_PAGE_SIZE).length;
const canShowLess = visible > SIDEBAR_SESSION_SEE_LESS_THRESHOLD && visible > collapsedVisible;
if (!canShowMore && !canShowLess) {
return nothing;
}
return html`
<div class="sidebar-session-pagination">
${canShowMore
? html`<button
type="button"
class="sidebar-session-pagination__button"
aria-label=${t("chat.selectors.loadMoreSessions")}
@click=${() => {
cb.sl(visible + SIDEBAR_SESSION_PAGE_SIZE);
}}
>
${t("chat.selectors.loadMoreSessions")}
</button>`
: nothing}
${canShowLess
? html`<button
type="button"
class="sidebar-session-pagination__button"
aria-label=${t("usage.details.collapse")}
@click=${() => {
cb.cl();
cb.sl(SIDEBAR_SESSION_PAGE_SIZE);
}}
>
${t("usage.details.collapse")}
</button>`
: nothing}
</div>
`;
}
function renderSessionCatalogs(params: {
context: SessionListRenderContext;
snapshot: SessionCatalogRenderSnapshot;
}) {
const { context, snapshot } = params;
const { cb } = context;
return renderSessionCatalogGroups({
catalogs: snapshot.catalogs,
connected: context.data.o,
basePath: snapshot.basePath,
routeSessionKey: snapshot.routeSessionKey,
newSessionAgentId: snapshot.newSessionAgentId,
collapsedSections: context.data.c,
loadingMoreCatalogIds: snapshot.loadingMoreCatalogIds,
projectGrouping: snapshot.projectGrouping,
liveRows: snapshot.liveRows,
creatorId: snapshot.creatorId,
renderLiveRow: (row, display) =>
renderRecentSession({
context,
session: snapshot.sidebarRowsByKey.get(row.key)!,
display,
}),
onToggleSection: (sectionId) => cb.section(sectionId),
onToggleProjectGrouping: () => cb.cg(),
onLoadMore: (catalogId) => void cb.mo(catalogId),
onOpenNewSession: cb.tg,
onNavigate: cb.nv,
catalogOpenTarget: snapshot.catalogOpenTarget,
terminalAvailable: snapshot.terminalAvailable,
onOpenTerminal: openCatalogSessionInTerminal,
onOpenMenu: (request, x, y, trigger) => cb.ct(request, x, y, trigger),
});
}
function renderSessionListBody(params: {
context: SessionListRenderContext;
sections: RenderableSessionSection[];
expandedRows: SidebarRecentSession[];
visibleRowCount: number;
showDraft: boolean;
codingTrailing?: TemplateResult | typeof nothing;
codingTrailingPresent?: boolean;
}) {
const { context } = params;
const { data } = context;
return html`
${params.sections.map((section) => {
const showDraft = section.id === "ungrouped" && params.showDraft;
if (section.id === "work") {
// Coding hosts live work/ACP rows plus the CLI catalogs; hide the
// whole zone when both are empty.
if (section.totalRowCount === 0 && params.codingTrailingPresent !== true) {
return nothing;
}
return renderSessionSection({
context,
section,
trailing: params.codingTrailing ?? nothing,
});
}
// Threads hides its bare empty header; unfiltered custom categories stay
// visible because creation and drag flows depend on them as drop targets.
if (
section.id === "ungrouped" &&
section.totalRowCount === 0 &&
!showDraft &&
data.t === "active" &&
data.d === null
) {
return nothing;
}
return renderSessionSection({ context, section, showDraft });
})}
${renderSessionPagination({
context,
rows: params.expandedRows,
visible: params.visibleRowCount,
})}
`;
}
export function renderSessionList(params: {
context: SessionListRenderContext;
empty: boolean;
sections: RenderableSessionSection[];
expandedRows: SidebarRecentSession[];
visibleRowCount: number;
showDraft: boolean;
creatorFilter: TemplateResult | typeof nothing;
catalogs: SessionCatalogRenderSnapshot;
}) {
const { context } = params;
const { data, cb } = context;
return html`
<section
class="sidebar-sessions ${data.r ? "sidebar-sessions--removal-drop" : ""}"
@dragover=${(event: DragEvent) => cb.lo(event)}
@dragleave=${(event: DragEvent) => cb.ll(event)}
@drop=${(event: DragEvent) => cb.ld(event)}
>
${data.e
? html`
<div
class="sidebar-session-error callout danger callout--dismissible"
role="alert"
data-sidebar-session-error
>
<span class="callout__content">${data.e}</span>
<openclaw-tooltip .content=${t("chat.actions.dismissError")}>
<button
class="callout__dismiss"
type="button"
@click=${() => cb.di()}
aria-label=${t("chat.actions.dismissError")}
>
${icons.x}
</button>
</openclaw-tooltip>
</div>
`
: nothing}
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
${params.creatorFilter}
${renderSessionListBody({
context,
sections: params.sections,
expandedRows: params.expandedRows,
visibleRowCount: params.visibleRowCount,
showDraft: params.showDraft,
codingTrailing:
data.t === "archived"
? nothing
: html`${renderSessionCatalogs({ context, snapshot: params.catalogs })}`,
codingTrailingPresent: data.t !== "archived" && params.catalogs.catalogs.length > 0,
})}
${data.t === "archived" && params.empty
? html`<span class="sidebar-session-empty-hint"
>${t("sessionsView.noArchivedSessions")}</span
>`
: nothing}
</div>
</section>
`;
}
+160 -669
View File
@@ -1,42 +1,19 @@
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import { titleForRoute } from "../app-navigation.ts";
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
import { t } from "../i18n/index.ts";
import { sessionHasBoard } from "../lib/board/provider.ts";
import { formatDurationCompact } from "../lib/format.ts";
import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts";
import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.ts";
import { writeSessionDragData, writeSessionGroupDragData } from "../lib/sessions/drag.ts";
import { sidebarSectionHasHeader } from "../lib/sessions/grouping.ts";
import { normalizeAgentId } from "../lib/sessions/session-key.ts";
import {
type CatalogBackingSessionDisplay,
renderSessionCatalogGroups,
} from "./app-sidebar-session-catalogs.ts";
import { renderSessionList } from "./app-sidebar-session-list-render.ts";
import { AppSidebarSessionNarrationElement } from "./app-sidebar-session-narration-element.ts";
import {
limitSidebarSessionRows,
renderSessionTree,
type SessionListRenderContext,
} from "./app-sidebar-session-row-render.ts";
import {
loadStoredSidebarCatalogGrouping,
rowDemandsVisibility,
SIDEBAR_SESSION_PAGE_SIZE,
SIDEBAR_SESSION_SEE_LESS_THRESHOLD,
RowVisibilityReason,
sidebarSessionMetaId,
storeSidebarCatalogGrouping,
type SidebarRecentSession,
} from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
import { renderSessionLeadingState } from "./session-leading-indicator.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
import {
renderSidebarSessionSubtitle,
resolveSidebarSessionSubtitle,
} from "./session-row-subtitle.ts";
import "./elapsed-time.ts";
const SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT = 4;
import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts";
import { renderSessionCreatorFilter } from "./session-owner-chip.ts";
/** Session-list presentation and catalog renderer wiring. */
export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarrationElement {
@@ -44,7 +21,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
protected override willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
// A fresh draft must be visible where it will live: genuinely expand a
// A fresh draft must be visible where it will l: genuinely expand a
// collapsed Threads section (persisted) instead of overriding at render
// time, so the header toggle keeps matching the visible state.
if (
@@ -56,656 +33,170 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
}
}
private renderRecentSession(
session: SidebarRecentSession,
display?: CatalogBackingSessionDisplay,
) {
const label = display?.label ?? session.label;
const { subtitle, narration } = resolveSidebarSessionSubtitle({
session,
hasDisplay: display !== undefined,
displaySubtitle: display?.subtitle,
sidebarLiveActivity: this.sidebarLiveActivity,
narrationLine: this.sidebarNarrationLines.get(session.key),
observerDigest: this.sidebarObserverDigests.get(session.key) ?? null,
});
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(" · ");
const rowClass = [
"sidebar-recent-session",
"session-row-host",
session.isChild ? "sidebar-recent-session--child" : "",
session.archived ? "sidebar-session--archived" : "",
session.visuallyActive ? "sidebar-recent-session--active" : "",
this.selectedSessionKeys.has(session.key) ? "sidebar-recent-session--selected" : "",
session.pinned ? "session-row-host--pinned" : "",
running ? "session-row-host--running" : "",
session.attention.kind === "error"
? "sidebar-recent-session--attention-danger"
: session.attention.kind !== "none"
? "sidebar-recent-session--attention-amber"
: "",
this.draggingSessionKey === session.key ? "sidebar-recent-session--dragging" : "",
]
.filter(Boolean)
.join(" ");
const row = html`
<div
class=${rowClass}
data-session-key=${session.key}
role="listitem"
draggable=${session.isChild ? "false" : "true"}
@dragstart=${session.isChild
? nothing
: (event: DragEvent) => {
if (event.dataTransfer) {
writeSessionDragData(event.dataTransfer, session.key);
this.draggingSessionKey = session.key;
this.draggingSidebarEntry = session.pinned ? `session:${session.key}` : null;
}
}}
@dragend=${session.isChild
? nothing
: () => {
this.finishSidebarEntryDrag();
this.sessionDropTarget = null;
}}
@contextmenu=${session.isChild
? nothing
: (event: MouseEvent) => {
event.preventDefault();
this.openSessionMenuForRow(menuSession, event.clientX, event.clientY);
}}
@mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)}
@mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)}
>
<a
href=${session.href}
class="sidebar-recent-session__link"
draggable="false"
title=${title}
aria-current=${session.visuallyActive ? "page" : nothing}
aria-describedby=${metaId ?? nothing}
@click=${(event: MouseEvent) => this.handleSessionRowClick(event, session)}
>
<span class="sidebar-session-indicator">${leadingIndicator}</span
>${this.renderSidebarSessionOwnerChip(session)}
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee"
>${session.archived
? html`<span
class="sidebar-session__archive-glyph"
aria-label=${t("sessionsView.archived")}
title=${t("sessionsView.archived")}
>${icons.archive}</span
>`
: nothing}${label}</span
>
${renderSidebarSessionSubtitle({ subtitle, narration })}
</span>
${!session.isChild && sessionHasBoard(session.key)
? html`<span
class="sidebar-board-glyph"
role="img"
aria-label=${t("sessionsView.dashboardAvailable")}
title=${t("sessionsView.dashboardAvailable")}
>${icons.layoutDashboard}</span
>`
: nothing}
<openclaw-viewer-facepile
.presencePayload=${this.presencePayload}
.selfInstanceId=${this.presenceInstanceId}
.sessionKey=${session.key}
.maxVisible=${3}
variant="session"
></openclaw-viewer-facepile>
${renderSessionRowBadges({
...session,
pullRequest: session.pullRequest ?? display?.pullRequest,
hasApproval: sessionHasPendingApproval(this.approvalBadgeSnapshot(), session.key),
})}
${pinnedState}
</a>
${session.childSessionKeys.length > 0
? html`<button
class="sidebar-child-session-toggle ${session.runningChildCount > 0
? "sidebar-child-session-toggle--running"
: session.failedChildCount > 0
? "sidebar-child-session-toggle--failed"
: ""}"
type="button"
data-child-session-toggle=${session.key}
aria-expanded=${String(this.isSessionChildrenExpanded(session))}
aria-label=${t(
this.isSessionChildrenExpanded(session)
? "sessionsView.hideChildSessions"
: "sessionsView.showChildSessions",
{ count: String(session.childSessionKeys.length), session: label },
)}
@click=${() => this.toggleSessionChildren(session)}
>
<span class="sidebar-child-session-toggle__icon" aria-hidden="true"
>${this.isSessionChildrenExpanded(session)
? icons.chevronDown
: icons.chevronRight}</span
>
${this.isSessionChildrenExpanded(session)
? nothing
: html`<span class="sidebar-child-session-toggle__count"
>${session.childSessionKeys.length}</span
>`}
</button>`
: nothing}
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-trail" id=${metaId ?? nothing}
>${session.isChild && session.runtimeMs != null
? session.hasActiveRun || session.status === "running"
? html`<openclaw-elapsed-time
.startMs=${session.runtimeSampledAt! - session.runtimeMs}
></openclaw-elapsed-time>`
: (formatDurationCompact(session.runtimeMs, { spaced: true }) ?? "0ms")
: session.isChild && session.startedAt != null
? html`<openclaw-elapsed-time
.startMs=${session.startedAt}
.endMs=${session.endedAt ?? null}
></openclaw-elapsed-time>`
: nothing}</span
>
${session.isChild
? nothing
: html`<span class="session-row-actions">
<button
class="session-action session-action--pin"
data-sidebar-session-pin="true"
type="button"
title=${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}
aria-label=${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}
?disabled=${!this.connected}
@click=${() => void this.patchSession(session, { pinned: !session.pinned })}
>
${icons.pin}
</button>
<button
class="session-action"
data-session-menu="true"
type="button"
title=${t("chat.sidebar.openSessionMenu")}
aria-label=${t("chat.sidebar.openSessionMenu")}
aria-haspopup="menu"
aria-expanded=${String(this.sessionMenu?.session.key === session.key)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
if (this.sessionMenu?.session.key === session.key) {
this.closeSessionMenu();
return;
}
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
this.openSessionMenuForRow(menuSession, rect.right, rect.bottom + 4, trigger);
}}
>
${icons.moreHorizontal}
</button>
</span>`}
</span>
</div>
`;
// Marquee state mutates the row DOM; keying prevents cross-session reuse.
return keyed(session.key, row);
}
protected visibleSessionChildren(session: SidebarRecentSession): readonly SidebarRecentSession[] {
const showAllChildren = this.fullyShownChildSessionKeys.has(session.key);
// Active, running, and attention-bearing branches must bypass the quiet-child cap.
return showAllChildren
? session.children
: session.children.filter(
(child, index) =>
index < SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT || rowDemandsVisibility(child),
private createSessionListRenderContext(
rows: readonly SidebarRecentSession[],
): SessionListRenderContext {
const pullRequestStates = new Map<string, SessionPullRequestIndicatorState>();
const expandedSessionKeys = new Set<string>();
const append = (row: SidebarRecentSession) => {
if (row.worktreeId) {
pullRequestStates.set(
row.key,
this.sessionPullRequestIndicatorState(row.key, row.worktreeId),
);
}
}
if (this.isSessionChildrenExpanded(row)) {
expandedSessionKeys.add(row.key);
}
row.children.forEach(append);
};
rows.forEach(append);
private renderSessionTree(session: SidebarRecentSession): TemplateResult {
const expanded = this.isSessionChildrenExpanded(session);
const visibleChildren = this.visibleSessionChildren(session);
const hiddenChildCount = session.children.length - visibleChildren.length;
return html`<div class="sidebar-session-tree" data-session-tree=${session.key}>
${this.renderRecentSession(session)}
${expanded
? html`<div
class="sidebar-session-tree__children"
aria-label=${t("sessionsView.childSessions")}
>
${visibleChildren.map((child) => this.renderSessionTree(child))}
${hiddenChildCount > 0
? html`<button
class="sidebar-session-tree__show-more"
type="button"
data-show-more-children=${session.key}
aria-label=${t("sessionsView.showMoreChildren", {
count: String(hiddenChildCount),
})}
@click=${() => this.showAllSessionChildren(session.key)}
>
${t("sessionsView.showMoreChildren", { count: String(hiddenChildCount) })}
</button>`
: nothing}
${session.loadingChildren && session.children.length === 0
? html`<span class="sidebar-session-tree__loading">${t("common.loading")}</span>`
: nothing}
</div>`
: nothing}
</div>`;
return {
data: {
l: this.sidebarLiveActivity,
n: this.sidebarNarrationLines,
h: this.sidebarObserverDigests,
p: pullRequestStates,
a: this.approvalBadgeSnapshot(),
s: this.selectedSessionKeys,
d: this.draggingSessionKey,
o: this.connected,
v: this.presencePayload,
i: this.presenceInstanceId,
x: expandedSessionKeys,
f: this.fullyShownChildSessionKeys,
g: this.sessionsGrouping,
c: this.collapsedSessionSections,
dg: this.draggingSessionGroup,
q: this.sessionDropTarget,
gd: this.sessionGroupDropTarget,
z: this.sessionSortMenuPosition !== null,
m: this.sessionMenu?.session.key ?? null,
gm: this.sessionGroupMenu?.group ?? null,
t: this.sessionsStatusFilter,
r: this.sessionListRemovalDrop,
e: this.sessionMutationError,
w: this.sessionOwnershipVisible,
},
cb: {
sd: (session) => {
this.draggingSessionKey = session.key;
this.draggingSidebarEntry = session.pinned ? `session:${session.key}` : null;
},
ed: () => {
this.finishSidebarEntryDrag();
this.sessionDropTarget = null;
},
om: this.openSessionMenuForRow.bind(this),
rc: this.handleSessionRowClick.bind(this),
ch: this.toggleSessionChildren.bind(this),
pin: (session) => void this.patchSession(session, { pinned: !session.pinned }),
mc: (session, menuSession, trigger) => {
if (this.sessionMenu?.session.key === session.key) {
this.closeSessionMenu();
return;
}
const rect = trigger.getBoundingClientRect();
this.openSessionMenuForRow(menuSession, rect.right, rect.bottom + 4, trigger);
},
sh: this.showAllSessionChildren.bind(this),
ov: this.handleSessionSectionDragOver.bind(this),
lv: this.handleSessionSectionDragLeave.bind(this),
sp: this.handleSessionSectionDrop.bind(this),
gs: (group) => {
this.draggingSessionGroup = group;
},
ge: () => {
this.draggingSessionGroup = null;
this.sessionGroupDropTarget = null;
},
gm: this.openSessionGroupMenu.bind(this),
section: this.toggleSessionSection.bind(this),
z: this.toggleSessionSortMenu.bind(this),
ns: () => {
this.onOpenNewSession?.(this.expandedAgentId());
},
sl: (limit) => {
this.visibleSessionLimit = limit;
},
cl: this.clearSessionSelection.bind(this),
lo: this.handleSessionListDragOver.bind(this),
ll: this.handleSessionListDragLeave.bind(this),
ld: this.handleSessionListDrop.bind(this),
di: () => {
this.sessionMutationError = null;
},
cg: () => {
const next = this.catalogProjectGrouping === "project" ? "none" : "project";
storeSidebarCatalogGrouping(next);
this.catalogProjectGrouping = next;
},
mo: this.loadMoreSessionCatalog.bind(this),
tg: this.onOpenNewSession,
nv: this.onNavigate,
ct: this.catalogMenu.open.bind(this.catalogMenu),
},
};
}
protected renderPinnedSidebarSession(session: SidebarRecentSession): TemplateResult {
return this.renderSessionTree(session);
}
private renderSessionSection(
section: {
id: string;
category?: string;
groups?: boolean;
work?: boolean;
rows: SidebarRecentSession[];
/** Pre-pagination size; rows may be page-filtered for rendering. */
totalRowCount?: number;
},
trailing: TemplateResult | typeof nothing = nothing,
showDraft = false,
) {
const totalRowCount = section.totalRowCount ?? section.rows.length;
const group = section.category;
const isPinned = section.id === "pinned";
const showHeader = sidebarSectionHasHeader(section.id, this.sessionsGrouping);
const collapsed = showHeader && this.collapsedSessionSections.has(section.id);
const label = isPinned
? t("sessionsView.pinned")
: section.groups
? t("chat.sidebar.groups")
: section.work
? t("chat.sidebar.coding")
: group
? group
: t("chat.sidebar.threads");
const zone = isPinned
? "pinned"
: section.groups
? "groups"
: section.work
? "coding"
: group
? "category"
: "threads";
// Collapsed Coding still signals live runs so background work stays visible.
const collapsedRunningDot =
collapsed &&
section.work &&
section.rows.some((row) => rowDemandsVisibility(row, RowVisibilityReason.ActiveRun));
const collapsedAttentionDot =
collapsed &&
section.rows.some((row) => rowDemandsVisibility(row, RowVisibilityReason.Attention));
const acceptsSessions =
isPinned ||
(this.sessionsGrouping === "category" && (section.id === "ungrouped" || Boolean(group)));
const sectionClass = [
"sidebar-recent-sessions__group",
`sidebar-recent-sessions__group--zone-${zone}`,
collapsed ? "sidebar-recent-sessions__group--collapsed" : "",
group && this.draggingSessionGroup === group
? "sidebar-recent-sessions__group--dragging"
: "",
this.sessionDropTarget === section.id ? "sidebar-recent-sessions__group--session-drop" : "",
group && this.sessionGroupDropTarget?.group === group
? `sidebar-recent-sessions__group--group-drop-${this.sessionGroupDropTarget.position}`
: "",
]
.filter(Boolean)
.join(" ");
return html`
<div
class=${sectionClass}
data-session-section=${section.id}
@dragover=${acceptsSessions || group
? (event: DragEvent) => this.handleSessionSectionDragOver(event, section.id, group)
: nothing}
@dragleave=${acceptsSessions || group
? (event: DragEvent) => this.handleSessionSectionDragLeave(event, section.id, group)
: nothing}
@drop=${acceptsSessions || group
? (event: DragEvent) => this.handleSessionSectionDrop(event, section.id, group)
: nothing}
>
${showHeader
? html`
<div
class="sidebar-recent-sessions__head ${group
? "sidebar-recent-sessions__head--draggable"
: ""}"
draggable=${group ? "true" : "false"}
@dragstart=${group
? (event: DragEvent) => {
if (event.dataTransfer) {
writeSessionGroupDragData(event.dataTransfer, group);
this.draggingSessionGroup = group;
}
}
: nothing}
@dragend=${group
? () => {
this.draggingSessionGroup = null;
this.sessionGroupDropTarget = null;
}
: nothing}
@contextmenu=${group
? (event: MouseEvent) => {
event.preventDefault();
this.openSessionGroupMenu(group, event.clientX, event.clientY, null);
}
: nothing}
>
${group
? html`<span class="sidebar-session-group-drag-handle" aria-hidden="true"></span>`
: nothing}
<button
type="button"
class="sidebar-session-group-toggle"
aria-expanded=${String(!collapsed)}
aria-label=${label}
@click=${() => this.toggleSessionSection(section.id)}
>
<span class="sidebar-recent-sessions__label-text">${label}</span>
<span class="sidebar-session-group-toggle__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
>
${collapsed && totalRowCount > 0
? html`<span class="sidebar-session-group-count">${totalRowCount}</span>`
: nothing}
${collapsedRunningDot
? html`<span
class="session-run-spinner sidebar-session-group-running"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${t("sessionsView.activeRun")}
></span>`
: nothing}
${collapsedAttentionDot
? html`<span
class="sidebar-session-group-attention"
role="img"
aria-label=${t("sessionsView.attentionRequired")}
title=${t("sessionsView.attentionRequired")}
></span>`
: nothing}
</button>
${section.id === "ungrouped"
? html`
<button
type="button"
class="sidebar-session-group-actions sidebar-session-sort"
title=${t("chat.sidebar.sortSessions")}
aria-label=${t("chat.sidebar.sortSessions")}
aria-haspopup="menu"
aria-expanded=${String(this.sessionSortMenuPosition !== null)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
this.toggleSessionSortMenu(event.currentTarget as HTMLElement);
}}
>
${icons.listFilter}
</button>
<button
type="button"
class="sidebar-session-group-actions sidebar-new-session"
title=${this.connected
? t("chat.runControls.newSession")
: t("chat.runControls.newSessionDisconnected")}
aria-label=${t("chat.runControls.newSession")}
?disabled=${!this.connected}
@click=${(event: MouseEvent) => {
event.stopPropagation();
this.onOpenNewSession?.(this.expandedAgentId());
}}
>
${icons.plus}
</button>
`
: nothing}
${group
? html`
<button
type="button"
class="sidebar-session-group-actions"
title=${t("sessionsView.groupMenu", { group })}
aria-label=${t("sessionsView.groupMenu", { group })}
aria-haspopup="menu"
aria-expanded=${String(this.sessionGroupMenu?.group === group)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
this.openSessionGroupMenu(group, rect.right, rect.bottom + 4, trigger);
}}
>
${icons.moreHorizontal}
</button>
`
: nothing}
</div>
`
: nothing}
${collapsed
? nothing
: html`
${section.rows.length > 0 || showDraft
? html`<div class="sidebar-recent-sessions__list" role="list" aria-label=${label}>
${showDraft ? this.renderDraftSessionRow() : nothing}
${section.rows.map((session) => this.renderSessionTree(session))}
</div>`
: nothing}
${trailing}
`}
</div>
`;
}
private renderDraftSessionRow() {
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>
</span>
</div>
`;
}
private renderSessionListBody(
rows: SidebarRecentSession[],
options: {
showDraft: boolean;
codingTrailing?: TemplateResult | typeof nothing;
codingTrailingPresent?: boolean;
},
) {
const { sections, expandedRows, visibleRows } = this.zonedVisibleSections(rows);
return html`
${sections.map((section) => {
const showDraft = section.id === "ungrouped" && options.showDraft;
if (section.id === "work") {
// Coding hosts live work/ACP rows plus the CLI catalogs; hide the
// whole zone when both are empty.
if (section.totalRowCount === 0 && options.codingTrailingPresent !== true) {
return nothing;
}
return this.renderSessionSection(section, options.codingTrailing ?? nothing);
}
// Threads hides its bare empty header; unfiltered custom categories stay
// visible because creation and drag flows depend on them as drop targets.
if (
section.id === "ungrouped" &&
section.totalRowCount === 0 &&
!showDraft &&
this.sessionsStatusFilter === "active" &&
this.draggingSessionKey === null
) {
return nothing;
}
return this.renderSessionSection(section, nothing, showDraft);
})}
${this.renderSessionPagination(expandedRows, visibleRows.length)}
`;
}
private renderSessionPagination(rows: SidebarRecentSession[], visible: number) {
const canShowMore = visible < rows.length;
const collapsedVisible = limitSidebarSessionRows(rows, SIDEBAR_SESSION_PAGE_SIZE).length;
const canShowLess = visible > SIDEBAR_SESSION_SEE_LESS_THRESHOLD && visible > collapsedVisible;
if (!canShowMore && !canShowLess) {
return nothing;
}
return html`
<div class="sidebar-session-pagination">
${canShowMore
? html`<button
type="button"
class="sidebar-session-pagination__button"
aria-label=${t("chat.selectors.loadMoreSessions")}
@click=${() => {
this.visibleSessionLimit = visible + SIDEBAR_SESSION_PAGE_SIZE;
}}
>
${t("chat.selectors.loadMoreSessions")}
</button>`
: nothing}
${canShowLess
? html`<button
type="button"
class="sidebar-session-pagination__button"
aria-label=${t("usage.details.collapse")}
@click=${() => {
this.clearSessionSelection();
this.visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
}}
>
${t("usage.details.collapse")}
</button>`
: nothing}
</div>
`;
return renderSessionTree({
context: this.createSessionListRenderContext([session]),
session,
});
}
protected renderSessions() {
const navigationState = this.getSessionNavigationState();
const visibleSessions = this.selectedAgentSessionRows(navigationState);
const expandedAgentId = this.expandedAgentId();
return html`
<section
class="sidebar-sessions ${this.sessionListRemovalDrop
? "sidebar-sessions--removal-drop"
: ""}"
@dragover=${(event: DragEvent) => this.handleSessionListDragOver(event)}
@dragleave=${(event: DragEvent) => this.handleSessionListDragLeave(event)}
@drop=${(event: DragEvent) => this.handleSessionListDrop(event)}
>
${this.sessionMutationError
? html`
<div
class="sidebar-session-error callout danger callout--dismissible"
role="alert"
data-sidebar-session-error
>
<span class="callout__content">${this.sessionMutationError}</span>
<openclaw-tooltip .content=${t("chat.actions.dismissError")}>
<button
class="callout__dismiss"
type="button"
@click=${() => {
this.sessionMutationError = null;
}}
aria-label=${t("chat.actions.dismissError")}
>
${icons.x}
</button>
</openclaw-tooltip>
</div>
`
: nothing}
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
${this.renderSidebarSessionCreatorFilter()}
${this.renderSessionListBody(visibleSessions, {
showDraft:
Boolean(this.draftSessionAgentId) &&
normalizeAgentId(this.draftSessionAgentId) === expandedAgentId,
codingTrailing:
this.sessionsStatusFilter === "archived"
? nothing
: html`${this.renderSessionCatalogs(navigationState)}`,
codingTrailingPresent:
this.sessionsStatusFilter !== "archived" && this.sessionCatalogs.length > 0,
})}
${this.sessionsStatusFilter === "archived" && visibleSessions.length === 0
? html`<span class="sidebar-session-empty-hint"
>${t("sessionsView.noArchivedSessions")}</span
>`
: nothing}
</div>
</section>
`;
}
const liveRows = [
...(this.sessionsResult?.sessions ?? []),
...Object.values(this.sessionRowsByAgent).flat(),
];
const sidebarRowsByKey = new Map<string, SidebarRecentSession>();
for (const row of liveRows) {
if (!sidebarRowsByKey.has(row.key)) {
sidebarRowsByKey.set(row.key, navigationState.toSidebarSession(row));
}
}
const { sections, expandedRows, visibleRows } = this.zonedVisibleSections(visibleSessions);
const context = this.createSessionListRenderContext([
...visibleSessions,
...sidebarRowsByKey.values(),
]);
private renderSessionCatalogs(
navigationState: ReturnType<AppSidebarSessionListElement["getSessionNavigationState"]>,
) {
return renderSessionCatalogGroups({
catalogs: this.sessionCatalogs,
connected: this.connected,
basePath: this.basePath,
routeSessionKey: this.activeRouteId === "chat" ? this.getRouteSessionKey() : "",
newSessionAgentId: this.expandedAgentId(),
collapsedSections: this.collapsedSessionSections,
loadingMoreCatalogIds: this.loadingMoreSessionCatalogIds,
projectGrouping: this.catalogProjectGrouping,
liveRows: [
...(this.sessionsResult?.sessions ?? []),
...Object.values(this.sessionRowsByAgent).flat(),
],
creatorId: this.activeSessionCreatorId,
renderLiveRow: (row, display) =>
this.renderRecentSession(navigationState.toSidebarSession(row), display),
onToggleSection: (sectionId) => this.toggleSessionSection(sectionId),
onToggleProjectGrouping: () => {
const next = this.catalogProjectGrouping === "project" ? "none" : "project";
storeSidebarCatalogGrouping(next);
this.catalogProjectGrouping = next;
return renderSessionList({
context,
empty: visibleSessions.length === 0,
sections,
expandedRows,
visibleRowCount: visibleRows.length,
showDraft:
Boolean(this.draftSessionAgentId) &&
normalizeAgentId(this.draftSessionAgentId) === expandedAgentId,
creatorFilter: renderSessionCreatorFilter({
creators: this.sessionOwnershipVisible ? this.sessionCreatorOptions : [],
selectedId: this.sessionCreatorFilterActive ? this.sessionCreatorFilterId : null,
onChange: (creatorId) => {
this.sessionCreatorFilterId = creatorId;
void this.context?.sessions.setCreatorFilter(creatorId);
},
}),
catalogs: {
catalogs: this.sessionCatalogs,
basePath: this.basePath,
routeSessionKey: this.activeRouteId === "chat" ? this.getRouteSessionKey() : "",
newSessionAgentId: expandedAgentId,
loadingMoreCatalogIds: this.loadingMoreSessionCatalogIds,
projectGrouping: this.catalogProjectGrouping,
liveRows,
sidebarRowsByKey,
creatorId: this.activeSessionCreatorId,
catalogOpenTarget: this.catalogOpenTarget,
terminalAvailable: this.terminalAvailable,
},
onLoadMore: (catalogId) => void this.loadMoreSessionCatalog(catalogId),
onOpenNewSession: this.onOpenNewSession,
onNavigate: this.onNavigate,
catalogOpenTarget: this.catalogOpenTarget,
terminalAvailable: this.terminalAvailable,
onOpenTerminal: (key) => openCatalogSessionInTerminal(key),
onOpenMenu: (request, x, y, trigger) => this.catalogMenu.open(request, x, y, trigger),
});
}
}
@@ -6,6 +6,7 @@ import type {
SidebarNarrationSyncInput,
SidebarSessionNarrationController,
} from "./app-sidebar-session-narration.ts";
import { visibleSessionChildren } from "./app-sidebar-session-row-render.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
/** Gateway subscription and reactive narration state for the session-list renderer. */
@@ -21,16 +22,15 @@ export abstract class AppSidebarSessionNarrationElement extends AppSidebarMenusE
private narrationLoad: Promise<void> | null = null;
private readonly narrationSubscriptions = new SubscriptionsController(this);
protected abstract visibleSessionChildren(
session: SidebarRecentSession,
): readonly SidebarRecentSession[];
private visibleNarrationRowsInOrder(): SidebarRecentSession[] {
const rows: SidebarRecentSession[] = [];
const append = (session: SidebarRecentSession) => {
rows.push(session);
if (this.isSessionChildrenExpanded(session)) {
this.visibleSessionChildren(session).forEach(append);
visibleSessionChildren({
session,
fullyShownChildSessionKeys: this.fullyShownChildSessionKeys,
}).forEach(append);
}
};
this.visibleSessionRowsInOrder().forEach(append);
@@ -1,12 +1,7 @@
import { state } from "lit/decorators.js";
import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import {
listSessionCreators,
renderSessionCreatorFilter,
renderSessionOwnerChip,
type SessionCreatedBy,
} from "./session-owner-chip.ts";
import { listSessionCreators, type SessionCreatedBy } from "./session-owner-chip.ts";
/** Creator attribution, solo dormancy, and filtering shared by sidebar session surfaces. */
export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessionProjectionElement {
@@ -79,24 +74,6 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio
return filterTree(projected);
}
protected renderSidebarSessionOwnerChip(session: SidebarRecentSession) {
return renderSessionOwnerChip(
this.sessionOwnershipVisible ? session.createdBy : undefined,
"row",
);
}
protected renderSidebarSessionCreatorFilter() {
return renderSessionCreatorFilter({
creators: this.sessionOwnershipVisible ? this.sessionCreatorOptions : [],
selectedId: this.sessionCreatorFilterActive ? this.sessionCreatorFilterId : null,
onChange: (creatorId) => {
this.sessionCreatorFilterId = creatorId;
void this.context?.sessions.setCreatorFilter(creatorId);
},
});
}
protected hideEmptyCreatorFilteredGroup(category: string | undefined, rowCount: number): boolean {
return this.sessionCreatorFilterActive && Boolean(category) && rowCount === 0;
}
@@ -0,0 +1,362 @@
import { html, nothing, type TemplateResult } from "lit";
import { keyed } from "lit/directives/keyed.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { NavigationRouteId } from "../app-navigation.ts";
import type { ApprovalBadgeSnapshot } from "../app/approval-presentation.ts";
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
import type { ApplicationNavigationOptions } from "../app/context.ts";
import { t } from "../i18n/index.ts";
import { sessionHasBoard } from "../lib/board/provider.ts";
import { formatDurationCompact } from "../lib/format.ts";
import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts";
import { writeSessionDragData } from "../lib/sessions/drag.ts";
import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import type {
CatalogBackingSessionDisplay,
CatalogSessionMenuRequest,
} from "./app-sidebar-session-catalogs.ts";
import {
rowDemandsVisibility,
sidebarSessionMetaId,
type SidebarRecentSession,
type SidebarSessionGroupDropTarget,
type SidebarSessionStatusFilter,
} from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
import { renderSessionLeadingState } from "./session-leading-indicator.ts";
import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts";
import { renderSessionOwnerChip } from "./session-owner-chip.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
import {
renderSidebarSessionSubtitle,
resolveSidebarSessionSubtitle,
} from "./session-row-subtitle.ts";
import "./elapsed-time.ts";
const SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT = 4;
export type SessionListRenderContext = {
data: {
l: boolean;
n: ReadonlyMap<string, string>;
h: ReadonlyMap<string, SessionObserverDigest>;
p: ReadonlyMap<string, SessionPullRequestIndicatorState>;
a: ApprovalBadgeSnapshot;
s: ReadonlySet<string>;
d: string | null;
o: boolean;
v: unknown;
i: string | undefined;
x: ReadonlySet<string>;
f: ReadonlySet<string>;
g: SidebarSessionsGrouping;
c: ReadonlySet<string>;
dg: string | null;
q: string | null;
gd: SidebarSessionGroupDropTarget | null;
z: boolean;
m: string | null;
gm: string | null;
t: SidebarSessionStatusFilter;
r: boolean;
e: string | null;
w: boolean;
};
cb: {
sd: (session: SidebarRecentSession) => void;
ed: () => void;
om: (session: SidebarRecentSession, x: number, y: number, trigger?: HTMLElement) => void;
rc: (event: MouseEvent, session: SidebarRecentSession) => void;
ch: (session: SidebarRecentSession) => void;
pin: (session: SidebarRecentSession) => void;
mc: (
session: SidebarRecentSession,
menuSession: SidebarRecentSession,
trigger: HTMLElement,
) => void;
sh: (sessionKey: string) => void;
ov: (event: DragEvent, sectionId: string, group?: string) => void;
lv: (event: DragEvent, sectionId: string, group?: string) => void;
sp: (event: DragEvent, sectionId: string, group?: string) => void;
gs: (group: string) => void;
ge: () => void;
gm: (group: string, x: number, y: number, trigger: HTMLElement | null) => void;
section: (sectionId: string) => void;
z: (trigger: HTMLElement) => void;
ns: () => void;
sl: (limit: number) => void;
cl: () => void;
lo: (event: DragEvent) => void;
ll: (event: DragEvent) => void;
ld: (event: DragEvent) => void;
di: () => void;
cg: () => void;
mo: (catalogId: string) => Promise<void>;
tg?: (agentId: string, target?: NewSessionTarget) => void;
nv?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void;
ct: (request: CatalogSessionMenuRequest, x: number, y: number, trigger?: HTMLElement) => void;
};
};
export function visibleSessionChildren(params: {
session: SidebarRecentSession;
fullyShownChildSessionKeys: ReadonlySet<string>;
}): readonly SidebarRecentSession[] {
const showAllChildren = params.fullyShownChildSessionKeys.has(params.session.key);
// Active, running, and attention-bearing branches must bypass the quiet-child cap.
return showAllChildren
? params.session.children
: params.session.children.filter(
(child, index) =>
index < SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT || rowDemandsVisibility(child),
);
}
export function renderRecentSession(params: {
context: SessionListRenderContext;
session: SidebarRecentSession;
display?: CatalogBackingSessionDisplay;
}) {
const { context, session, display } = params;
const { data, cb } = context;
const label = display?.label ?? session.label;
const { subtitle, narration } = resolveSidebarSessionSubtitle({
session,
hasDisplay: display !== undefined,
displaySubtitle: display?.subtitle,
sidebarLiveActivity: data.l,
narrationLine: data.n.get(session.key),
observerDigest: data.h.get(session.key) ?? null,
});
const { running, pinnedState, leadingIndicator } = renderSessionLeadingState(
session,
data.p.get(session.key) ?? "none",
);
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(" · ");
const rowClass = [
"sidebar-recent-session",
"session-row-host",
session.isChild ? "sidebar-recent-session--child" : "",
session.archived ? "sidebar-session--archived" : "",
session.visuallyActive ? "sidebar-recent-session--active" : "",
data.s.has(session.key) ? "sidebar-recent-session--selected" : "",
session.pinned ? "session-row-host--pinned" : "",
running ? "session-row-host--running" : "",
session.attention.kind === "error"
? "sidebar-recent-session--attention-danger"
: session.attention.kind !== "none"
? "sidebar-recent-session--attention-amber"
: "",
data.d === session.key ? "sidebar-recent-session--dragging" : "",
]
.filter(Boolean)
.join(" ");
const childrenExpanded = data.x.has(session.key);
const row = html`
<div
class=${rowClass}
data-session-key=${session.key}
role="listitem"
draggable=${session.isChild ? "false" : "true"}
@dragstart=${session.isChild
? nothing
: (event: DragEvent) => {
if (event.dataTransfer) {
writeSessionDragData(event.dataTransfer, session.key);
cb.sd(session);
}
}}
@dragend=${session.isChild
? nothing
: () => {
cb.ed();
}}
@contextmenu=${session.isChild
? nothing
: (event: MouseEvent) => {
event.preventDefault();
cb.om(menuSession, event.clientX, event.clientY);
}}
@mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)}
@mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)}
>
<a
href=${session.href}
class="sidebar-recent-session__link"
draggable="false"
title=${title}
aria-current=${session.visuallyActive ? "page" : nothing}
aria-describedby=${metaId ?? nothing}
@click=${(event: MouseEvent) => cb.rc(event, session)}
>
<span class="sidebar-session-indicator">${leadingIndicator}</span>${renderSessionOwnerChip(
data.w ? session.createdBy : undefined,
"row",
)}
<span class="sidebar-recent-session__text">
<span class="sidebar-recent-session__name hover-marquee"
>${session.archived
? html`<span
class="sidebar-session__archive-glyph"
aria-label=${t("sessionsView.archived")}
title=${t("sessionsView.archived")}
>${icons.archive}</span
>`
: nothing}${label}</span
>
${renderSidebarSessionSubtitle({ subtitle, narration })}
</span>
${!session.isChild && sessionHasBoard(session.key)
? html`<span
class="sidebar-board-glyph"
role="img"
aria-label=${t("sessionsView.dashboardAvailable")}
title=${t("sessionsView.dashboardAvailable")}
>${icons.layoutDashboard}</span
>`
: nothing}
<openclaw-viewer-facepile
.presencePayload=${data.v}
.selfInstanceId=${data.i}
.sessionKey=${session.key}
.maxVisible=${3}
variant="session"
></openclaw-viewer-facepile>
${renderSessionRowBadges({
...session,
pullRequest: session.pullRequest ?? display?.pullRequest,
hasApproval: sessionHasPendingApproval(data.a, session.key),
})}
${pinnedState}
</a>
${session.childSessionKeys.length > 0
? html`<button
class="sidebar-child-session-toggle ${session.runningChildCount > 0
? "sidebar-child-session-toggle--running"
: session.failedChildCount > 0
? "sidebar-child-session-toggle--failed"
: ""}"
type="button"
data-child-session-toggle=${session.key}
aria-expanded=${String(childrenExpanded)}
aria-label=${t(
childrenExpanded
? "sessionsView.hideChildSessions"
: "sessionsView.showChildSessions",
{ count: String(session.childSessionKeys.length), session: label },
)}
@click=${() => cb.ch(session)}
>
<span class="sidebar-child-session-toggle__icon" aria-hidden="true"
>${childrenExpanded ? icons.chevronDown : icons.chevronRight}</span
>
${childrenExpanded
? nothing
: html`<span class="sidebar-child-session-toggle__count"
>${session.childSessionKeys.length}</span
>`}
</button>`
: nothing}
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-trail" id=${metaId ?? nothing}
>${session.isChild && session.runtimeMs != null
? session.hasActiveRun || session.status === "running"
? html`<openclaw-elapsed-time
.startMs=${session.runtimeSampledAt! - session.runtimeMs}
></openclaw-elapsed-time>`
: (formatDurationCompact(session.runtimeMs, { spaced: true }) ?? "0ms")
: session.isChild && session.startedAt != null
? html`<openclaw-elapsed-time
.startMs=${session.startedAt}
.endMs=${session.endedAt ?? null}
></openclaw-elapsed-time>`
: nothing}</span
>
${session.isChild
? nothing
: html`<span class="session-row-actions">
<button
class="session-action session-action--pin"
data-sidebar-session-pin="true"
type="button"
title=${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}
aria-label=${session.pinned
? t("sessionsView.unpinSession")
: t("sessionsView.pinSession")}
?disabled=${!data.o}
@click=${() => cb.pin(session)}
>
${icons.pin}
</button>
<button
class="session-action"
data-session-menu="true"
type="button"
title=${t("chat.sidebar.openSessionMenu")}
aria-label=${t("chat.sidebar.openSessionMenu")}
aria-haspopup="menu"
aria-expanded=${String(data.m === session.key)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
cb.mc(session, menuSession, trigger);
}}
>
${icons.moreHorizontal}
</button>
</span>`}
</span>
</div>
`;
// Marquee state mutates the row DOM; keying prevents cross-session reuse.
return keyed(session.key, row);
}
export function renderSessionTree(params: {
context: SessionListRenderContext;
session: SidebarRecentSession;
}): TemplateResult {
const { context, session } = params;
const { data, cb } = context;
const expanded = data.x.has(session.key);
const visibleChildren = visibleSessionChildren({
session,
fullyShownChildSessionKeys: data.f,
});
const hiddenChildCount = session.children.length - visibleChildren.length;
return html`<div class="sidebar-session-tree" data-session-tree=${session.key}>
${renderRecentSession({ context, session })}
${expanded
? html`<div
class="sidebar-session-tree__children"
aria-label=${t("sessionsView.childSessions")}
>
${visibleChildren.map((child) => renderSessionTree({ context, session: child }))}
${hiddenChildCount > 0
? html`<button
class="sidebar-session-tree__show-more"
type="button"
data-show-more-children=${session.key}
aria-label=${t("sessionsView.showMoreChildren", {
count: String(hiddenChildCount),
})}
@click=${() => cb.sh(session.key)}
>
${t("sessionsView.showMoreChildren", { count: String(hiddenChildCount) })}
</button>`
: nothing}
${session.loadingChildren && session.children.length === 0
? html`<span class="sidebar-session-tree__loading">${t("common.loading")}</span>`
: nothing}
</div>`
: nothing}
</div>`;
}