mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
c26b0e0843
commit
db5e59d15e
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user