diff --git a/ui/src/components/app-sidebar-menus.ts b/ui/src/components/app-sidebar-menus.ts index 0809e03d6688..cb991f35ec03 100644 --- a/ui/src/components/app-sidebar-menus.ts +++ b/ui/src/components/app-sidebar-menus.ts @@ -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; diff --git a/ui/src/components/app-sidebar-nav-menus.ts b/ui/src/components/app-sidebar-nav-menus.ts index 1dd02e306937..8a15edf6dfa9 100644 --- a/ui/src/components/app-sidebar-nav-menus.ts +++ b/ui/src/components/app-sidebar-nav-menus.ts @@ -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` - - `; -} - type SidebarMenuNavigationHandlers = { onNavigateRoute: (routeId: SidebarNavRoute) => void; onPreloadRoute: (routeId: SidebarNavRoute, event: Event) => void; @@ -337,18 +317,3 @@ export function renderSidebarCustomizeMenu(params: SidebarCustomizeMenuParams) { `; } - -/** 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), - ); -} diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts new file mode 100644 index 000000000000..a06b138edf43 --- /dev/null +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -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 & { + totalRowCount: number; +}; + +type SessionCatalogRenderSnapshot = { + catalogs: readonly SessionCatalog[]; + basePath: string; + routeSessionKey: string; + newSessionAgentId: string; + loadingMoreCatalogIds: ReadonlySet; + projectGrouping: CatalogProjectGrouping; + liveRows: readonly GatewaySessionRow[]; + sidebarRowsByKey: ReadonlyMap; + 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` +
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` + + `} + ${collapsed + ? nothing + : html` + ${section.rows.length > 0 || showDraft + ? html`` + : nothing} + ${trailing} + `} +
+ `; +} + +function renderDraftSessionRow() { + return html` + + `; +} + +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` + + `; +} + +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` + + `; +} diff --git a/ui/src/components/app-sidebar-session-list.ts b/ui/src/components/app-sidebar-session-list.ts index 062c74f3e5ad..02161eea3abf 100644 --- a/ui/src/components/app-sidebar-session-list.ts +++ b/ui/src/components/app-sidebar-session-list.ts @@ -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) { 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` -
{ - 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)} - > - this.handleSessionRowClick(event, session)} - > - ${leadingIndicator}${this.renderSidebarSessionOwnerChip(session)} - - ${session.archived - ? html`${icons.archive}` - : nothing}${label} - ${renderSidebarSessionSubtitle({ subtitle, narration })} - - ${!session.isChild && sessionHasBoard(session.key) - ? html`${icons.layoutDashboard}` - : nothing} - - ${renderSessionRowBadges({ - ...session, - pullRequest: session.pullRequest ?? display?.pullRequest, - hasApproval: sessionHasPendingApproval(this.approvalBadgeSnapshot(), session.key), - })} - ${pinnedState} - - ${session.childSessionKeys.length > 0 - ? html`` - : nothing} - - ${session.isChild && session.runtimeMs != null - ? session.hasActiveRun || session.status === "running" - ? html`` - : (formatDurationCompact(session.runtimeMs, { spaced: true }) ?? "0ms") - : session.isChild && session.startedAt != null - ? html`` - : nothing} - ${session.isChild - ? nothing - : html` - - - `} - -
- `; - // 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(); + const expandedSessionKeys = new Set(); + 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``; + 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` -
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` - - ` - : nothing} - ${collapsed - ? nothing - : html` - ${section.rows.length > 0 || showDraft - ? html`` - : nothing} - ${trailing} - `} -
- `; - } - - private renderDraftSessionRow() { - return html` - - `; - } - - 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` - - `; + return renderSessionTree({ + context: this.createSessionListRenderContext([session]), + session, + }); } protected renderSessions() { const navigationState = this.getSessionNavigationState(); const visibleSessions = this.selectedAgentSessionRows(navigationState); const expandedAgentId = this.expandedAgentId(); - return html` - - `; - } + const liveRows = [ + ...(this.sessionsResult?.sessions ?? []), + ...Object.values(this.sessionRowsByAgent).flat(), + ]; + const sidebarRowsByKey = new Map(); + 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, - ) { - 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), }); } } diff --git a/ui/src/components/app-sidebar-session-narration-element.ts b/ui/src/components/app-sidebar-session-narration-element.ts index 198b5c786ed5..53f045a5309b 100644 --- a/ui/src/components/app-sidebar-session-narration-element.ts +++ b/ui/src/components/app-sidebar-session-narration-element.ts @@ -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 | 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); diff --git a/ui/src/components/app-sidebar-session-ownership.ts b/ui/src/components/app-sidebar-session-ownership.ts index 72c458497b58..039deabd16e3 100644 --- a/ui/src/components/app-sidebar-session-ownership.ts +++ b/ui/src/components/app-sidebar-session-ownership.ts @@ -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; } diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts new file mode 100644 index 000000000000..fdc3948c7e61 --- /dev/null +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -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; + h: ReadonlyMap; + p: ReadonlyMap; + a: ApprovalBadgeSnapshot; + s: ReadonlySet; + d: string | null; + o: boolean; + v: unknown; + i: string | undefined; + x: ReadonlySet; + f: ReadonlySet; + g: SidebarSessionsGrouping; + c: ReadonlySet; + 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; + 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; +}): 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` +
{ + 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)} + > + cb.rc(event, session)} + > + ${leadingIndicator}${renderSessionOwnerChip( + data.w ? session.createdBy : undefined, + "row", + )} + + ${session.archived + ? html`${icons.archive}` + : nothing}${label} + ${renderSidebarSessionSubtitle({ subtitle, narration })} + + ${!session.isChild && sessionHasBoard(session.key) + ? html`${icons.layoutDashboard}` + : nothing} + + ${renderSessionRowBadges({ + ...session, + pullRequest: session.pullRequest ?? display?.pullRequest, + hasApproval: sessionHasPendingApproval(data.a, session.key), + })} + ${pinnedState} + + ${session.childSessionKeys.length > 0 + ? html`` + : nothing} + + ${session.isChild && session.runtimeMs != null + ? session.hasActiveRun || session.status === "running" + ? html`` + : (formatDurationCompact(session.runtimeMs, { spaced: true }) ?? "0ms") + : session.isChild && session.startedAt != null + ? html`` + : nothing} + ${session.isChild + ? nothing + : html` + + + `} + +
+ `; + // 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``; +}