;
+ 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)}
- >
-
- ${session.childSessionKeys.length > 0
- ? html``
- : nothing}
-
-
- `;
- // 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)}
+ >
+
+ ${session.childSessionKeys.length > 0
+ ? html``
+ : nothing}
+
+
+ `;
+ // 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``;
+}