Improve execution group management

This commit is contained in:
Lucio Lelii 2026-09-23 12:09:57 +02:00
parent def50c229b
commit 1270bf7ea7
20 changed files with 314 additions and 95 deletions

View File

@ -13,6 +13,7 @@
[selectedExecutionId]="selectedExecutionId()"
(executionSelected)="selectExecution($event)"
(executionDeleteRequested)="removeExecution($event)"
(executionGroupDeleteRequested)="removeExecutionGroup($event)"
(executionRerunRequested)="rerunExecution($event)"
(compareRequested)="openComparison($event)">
</app-tasks-executions-list>

View File

@ -73,6 +73,7 @@ describe('TasksExecutor', () => {
init: vi.fn(),
retrieveExecution: vi.fn().mockReturnValue(of(null)),
deleteExecution: vi.fn().mockReturnValue(of(null)),
deleteExecutionGroup: vi.fn().mockReturnValue(of(null)),
rerunExecution: vi.fn().mockReturnValue(of(null)),
retrieveExecutionEvents: vi.fn().mockReturnValue(of([]))
}

View File

@ -331,6 +331,25 @@ export class TasksExecutor {
});
}
async removeExecutionGroup(group: TaskExecutionGroupListItem) {
const confirmed = await this.confirm.open(
`Delete all ${group.executionCount} executions in “${group.name}”? This permanently removes this group's history, including child executions and comparison data. This cannot be undone.`
);
if (!confirmed) return;
this.taskExecutionsService.deleteExecutionGroup(group.id).subscribe({
next: () => {
if (group.executions.some((execution) => execution.id === this.selectedExecutionId())) {
this.selectedExecutionId.set(null);
this.requestedExecutionId.set(null);
this.selectedChildExecutionId.set(null);
this.closeComparison();
}
},
error: (err) => console.error('Error deleting execution group:', err)
});
}
rerunExecution(id: string) {
this.taskExecutionsService.rerunExecution(id).subscribe({
next: (execution) => this.selectExecution(execution.id),

View File

@ -30,7 +30,7 @@
'cursor-none bg-gray-200 text-indigo-500': !flowsDisabled() && open == 'flows',
'text-gray-200': flowsDisabled()
}"
[attr.title]="flowsDisabled() ? 'Exit fullscreen to browse other flows' : 'Flows'"
[attr.aria-label]="flowsDisabled() ? 'Exit fullscreen to browse other flows' : 'Flows'"
class="bi bi-lightning-charge-fill text-xl p-2"
(click)="!flowsDisabled() && open != 'flows' && openSide('flows')"></i>
<i
@ -62,7 +62,7 @@
@switch (open) {
@case ('flows') {
<div data-tour="editor-sidebar-flows" class="h-full min-h-0 flex flex-col">
<app-group-holder title="Flows" icon="bi-lightning-charge-fill">
<app-group-holder headerTitle="Flows" icon="bi-lightning-charge-fill">
<span header-button class="editor-sidebar-new-flow-header-action">
<input
#importInput
@ -114,14 +114,14 @@
}
@case ('blocks') {
<div data-tour="editor-sidebar-blocks" class="h-full min-h-0 flex flex-col">
<app-group-holder title="Blocks" icon="bi-boxes">
<app-group-holder headerTitle="Blocks" icon="bi-boxes">
<app-blocks-list></app-blocks-list>
</app-group-holder>
</div>
}
@case ('containers') {
<div data-tour="editor-sidebar-containers" class="h-full min-h-0 flex flex-col">
<app-group-holder title="Containers" icon="bi-box-seam">
<app-group-holder headerTitle="Containers" icon="bi-box-seam">
<app-containers-list></app-containers-list>
</app-group-holder>
</div>

View File

@ -40,6 +40,7 @@ export abstract class TaskExecutionsCallServiceBase {
abstract listBiasImpactReports(executionId: string): Observable<BiasImpactReport[]>;
abstract getBiasImpactReport(reportId: string): Observable<BiasImpactReport>;
abstract deleteTaskExecution(executionId: string): Observable<void>;
abstract deleteTaskExecutionGroup(groupId: string): Observable<void>;
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor, credentialId?: string): Observable<TaskExecution>;
abstract cancelTaskExecution(executionId: string): Observable<TaskExecution>;

View File

@ -788,6 +788,13 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(void 0);
}
override deleteTaskExecutionGroup(groupId: string): Observable<void> {
for (let index = this.data.length - 1; index >= 0; index--) {
if (this.executionSourceFlowId(this.data[index]) === groupId) this.data.splice(index, 1);
}
return of(void 0);
}
override startTaskExecution(executionId: string): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.interactionSimulationEnabled = false;

View File

@ -128,6 +128,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
return this.http.delete<void>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`);
}
override deleteTaskExecutionGroup(groupId: string): Observable<void> {
return this.http.delete<void>(`${environment.apiUrl}/executions/groups/${encodeURIComponent(groupId)}`);
}
override startTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null).pipe(
map((raw) => this.mapExecution(raw))

View File

@ -235,6 +235,13 @@ export class TaskExecutionsService {
);
}
deleteExecutionGroup(groupId: string) {
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.deleteTaskExecutionGroup(groupId),
'Delete execution group failed'
);
}
startExecution(executionId: string) {
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.startTaskExecution(executionId),

View File

@ -17,7 +17,9 @@
[class.flow-item-visibility-public]="flow().visibility !== 'PRIVATE'"
[matTooltip]="flow().visibility === 'PRIVATE' ? 'Private flow' : 'Public flow'"
[fontIcon]="flow().visibility === 'PRIVATE' ? 'lock' : 'public'"></mat-icon>
<span class="flow-item-title">{{ flow().name }}</span>
<span class="flow-item-title" [appTruncatedTooltip]="flow().name">
{{ flow().name }}
</span>
</div>
<div class="flow-item-sub">
<span class="flow-item-author">{{ flow().author }}</span>

View File

@ -20,11 +20,12 @@ import { NotificationService } from '@services/notifications/notification';
import { ProjectsService } from '@services/projects/projects';
import { PROJECTS_ENABLED } from '@shared/feature-flags';
import { EditorStateHolder } from '@stores/flow-editor';
import { TruncatedTooltipDirective } from '@shared/truncated-tooltip/truncated-tooltip';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-flow-item',
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule],
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule, TruncatedTooltipDirective],
templateUrl: './flow-item.html',
styleUrl: './flow-item.css',
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -15,7 +15,9 @@
class="flows-list-group-chevron"
[fontIcon]="expanded() ? 'expand_less' : 'expand_more'"></mat-icon>
<div class="flows-list-group-main">
<div class="flows-list-group-title">{{ title() }}</div>
<div class="flows-list-group-title" [appTruncatedTooltip]="title()">
{{ title() }}
</div>
@if (project()?.description) {
<div class="flows-list-group-subtitle">{{ project()?.description }}</div>
}

View File

@ -11,6 +11,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { Flow } from '@models/flow';
import { Project } from '@models/project';
import { FlowItem } from '../flow-item/flow-item';
import { TruncatedTooltipDirective } from '@shared/truncated-tooltip/truncated-tooltip';
/**
* One collapsible project section of the flows list. Purely presentational: the list owns the
@ -21,7 +22,7 @@ import { FlowItem } from '../flow-item/flow-item';
*/
@Component({
selector: 'app-flows-group',
imports: [FlowItem, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule],
imports: [FlowItem, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule, TruncatedTooltipDirective],
templateUrl: './flows-group.html',
styleUrl: './flows-group.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -51,4 +52,5 @@ export class FlowsGroup {
event.stopPropagation();
this.toggled.emit();
}
}

View File

@ -19,7 +19,7 @@
<div class="flex items-center gap-2 min-w-0">
<i class="bi {{ icon }} text-lg"></i>
<span class="truncate">{{ title }}</span>
<span class="truncate">{{ headerTitle }}</span>
</div>
<span class="group-holder-header-action">
<ng-content select="[header-button]"></ng-content>

View File

@ -12,6 +12,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GroupHolder {
@Input() title = '';
@Input() headerTitle = '';
@Input() icon = '';
}

View File

@ -75,7 +75,7 @@
/* Flat, like the flows list: a scrolling history should not be a stack of raised, tinted panels. */
.tasks-list-group {
width: 100%;
padding: 8px 10px;
padding: 7px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #ffffff;
@ -95,14 +95,21 @@
background: #f8fafc;
}
.tasks-list-group-header-row {
display: flex;
align-items: center;
gap: 2px;
}
.tasks-list-group-header {
width: 100%;
flex: 1;
min-width: 0;
border: 0;
padding: 0;
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
align-items: center;
gap: 6px;
background: transparent;
text-align: left;
cursor: pointer;
@ -125,7 +132,11 @@
color: #1e293b;
}
/* Keep the compact ellipsis at rest, but reveal the real label in its own card on hover. */
.tasks-list-group-subtitle {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -137,8 +148,8 @@
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
gap: 4px;
flex-wrap: nowrap;
}
.tasks-list-count {
@ -151,7 +162,7 @@
color: #1d4ed8;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
padding: 1px 6px;
white-space: nowrap;
}
@ -173,7 +184,7 @@
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 3px 9px;
padding: 2px 7px;
border-radius: 999px;
border-width: 1px;
font-size: 10px;
@ -186,26 +197,26 @@
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
margin-top: 6px;
font-size: 11px;
color: #64748b;
padding-top: 8px;
padding-top: 6px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
.tasks-list-executions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
gap: 5px;
margin-top: 7px;
}
.tasks-list-execution {
position: relative;
border: 1px solid #eef2f6;
border-left: 3px solid transparent;
border-radius: 6px;
padding: 7px 8px;
border-radius: 5px;
padding: 5px 7px;
background: #ffffff;
cursor: pointer;
transition: border-color 0.12s ease, background-color 0.12s ease;
@ -229,6 +240,13 @@
gap: 8px;
}
.tasks-list-execution-trailing {
display: flex;
align-items: center;
gap: 3px;
flex: 0 0 auto;
}
.tasks-list-execution-title {
display: flex;
align-items: center;
@ -254,42 +272,71 @@
.tasks-list-execution-details {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
gap: 6px;
margin-top: 2px;
min-width: 0;
flex-wrap: wrap;
font-size: 11px;
font-size: 10px;
color: #64748b;
}
.tasks-list-execution-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 6px;
}
.tasks-list-latest {
width: 100%;
border: 1px solid #dbeafe;
border-radius: 8px;
padding: 8px 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 10px;
background: #f8fafc;
color: #2563eb;
font-size: 12px;
font-weight: 700;
cursor: pointer;
gap: 1px;
}
.tasks-list-latest:hover {
.tasks-list-execution-action {
width: 26px !important;
height: 26px !important;
padding: 0 !important;
}
.tasks-list-execution-action .mat-icon {
width: 17px;
height: 17px;
font-size: 17px;
line-height: 17px;
}
.tasks-list-execution-rerun {
color: #047857;
}
.tasks-list-execution-rerun:disabled {
color: #cbd5e1;
}
.tasks-list-execution-delete {
color: #dc2626;
}
.tasks-list-execution-delete:disabled {
color: #fecaca;
}
.tasks-list-open-latest {
flex: 0 0 auto;
color: #2563eb;
}
.tasks-list-open-latest:hover {
background: #eff6ff;
}
.tasks-list-delete-group {
flex: 0 0 auto;
color: #dc2626;
}
.tasks-list-delete-group:disabled {
color: #cbd5e1;
}
.tasks-list-delete-group:hover:not(:disabled) {
background: #fff1f2;
}
/* Clusters the sibling groups produced by one project run. */
.tasks-list-project-chip {
display: inline-block;

View File

@ -51,24 +51,51 @@
@for (group of filteredGroups(); track group.id) {
<mat-list-item class="tasks-list-list-item">
<mat-card class="tasks-list-group" [class.tasks-list-group-expanded]="isGroupExpanded(group.id)">
<button type="button" class="tasks-list-group-header" (click)="toggleGroup(group.id, $event)">
<mat-icon class="tasks-list-group-chevron" [fontIcon]="isGroupExpanded(group.id) ? 'expand_less' : 'expand_more'"></mat-icon>
<div class="tasks-list-group-main">
<div class="tasks-list-group-title">{{ group.name }}</div>
<div class="tasks-list-group-subtitle">
@if (group.projectName) {
<span class="tasks-list-project-chip">{{ group.projectName }}</span>
}
<div class="tasks-list-group-header-row">
<button type="button" class="tasks-list-group-header" (click)="toggleGroup(group.id, $event)">
<mat-icon class="tasks-list-group-chevron" [fontIcon]="isGroupExpanded(group.id) ? 'expand_less' : 'expand_more'"></mat-icon>
<div class="tasks-list-group-main">
<div class="tasks-list-group-title" [appTruncatedTooltip]="group.name">
{{ group.name }}
</div>
<div class="tasks-list-group-subtitle">
@if (group.projectName) {
<span class="tasks-list-project-chip">{{ group.projectName }}</span>
}
<span>Latest {{ group.lastExecutionTimeLabel }} · {{ runNumberLabel(group.latestRunNumber) }}</span>
</div>
</div>
</div>
<div class="tasks-list-group-summary">
<span class="tasks-list-count">{{ runCountLabel(group.executionCount) }}</span>
<span class="tasks-list-status" [ngClass]="statusBadgeClass(group.latestStatus)">
{{ group.latestStatus }}
</span>
</div>
</button>
<div class="tasks-list-group-summary">
<span class="tasks-list-count">{{ runCountLabel(group.executionCount) }}</span>
<span class="tasks-list-status" [ngClass]="statusBadgeClass(group.latestStatus)">
{{ group.latestStatus }}
</span>
</div>
</button>
@if (!isGroupExpanded(group.id)) {
<button
type="button"
mat-icon-button
class="tasks-list-open-latest"
matTooltip="Open latest execution"
aria-label="Open latest execution"
(click)="selectExecution(group.latestExecutionId)">
<mat-icon fontIcon="arrow_forward"></mat-icon>
</button>
}
<button
type="button"
mat-icon-button
class="tasks-list-delete-group"
[disabled]="isGroupDeleteDisabled(group)"
[matTooltip]="isGroupDeleteDisabled(group) ? 'Stop running executions before deleting this group' : 'Delete every execution in this group'"
aria-label="Delete execution group"
(click)="requestDeleteExecutionGroup(group, $event)">
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>
@if (isGroupExpanded(group.id)) {
<div class="tasks-list-group-meta">
<span>Latest {{ group.lastExecutionTimeLabel }}</span>
<span>Run {{ runNumberLabel(group.latestRunNumber) }}</span>
@ -94,7 +121,6 @@
</div>
}
@if (isGroupExpanded(group.id)) {
<div class="tasks-list-executions">
@for (execution of group.executions; track execution.id) {
<div
@ -119,9 +145,33 @@
{{ kindLabel(execution) }}
</span>
</div>
<span class="tasks-list-status" [ngClass]="statusBadgeClass(execution.status)">
{{ execution.status }}
</span>
<div class="tasks-list-execution-trailing">
<span class="tasks-list-status" [ngClass]="statusBadgeClass(execution.status)">
{{ execution.status }}
</span>
<div class="tasks-list-execution-actions">
<button
type="button"
mat-icon-button
class="tasks-list-execution-action tasks-list-execution-rerun"
matTooltip="Rerun execution"
aria-label="Rerun execution"
[disabled]="!canRerun(execution.status)"
(click)="requestRerunExecution(execution.id, $event)">
<mat-icon fontIcon="replay"></mat-icon>
</button>
<button
type="button"
mat-icon-button
class="tasks-list-execution-action tasks-list-execution-delete"
matTooltip="Delete execution"
aria-label="Delete execution"
[disabled]="isDeleteDisabled(execution.status)"
(click)="requestDeleteExecution(execution.id, $event)">
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>
</div>
</div>
<div class="tasks-list-execution-details">
<span>{{ execution.startedAt }}</span>
@ -134,36 +184,9 @@
<span class="tasks-list-simulated-badge">Simulated</span>
}
</div>
<div class="tasks-list-execution-actions">
<button
type="button"
mat-icon-button
class="!h-8 !w-8 enabled:!text-emerald-700 disabled:!text-slate-300"
matTooltip="Rerun execution"
aria-label="Rerun execution"
[disabled]="!canRerun(execution.status)"
(click)="requestRerunExecution(execution.id, $event)">
<mat-icon fontIcon="replay"></mat-icon>
</button>
<button
type="button"
mat-icon-button
class="!h-8 !w-8 enabled:!text-red-600 disabled:!text-red-200"
matTooltip="Delete execution"
aria-label="Delete execution"
[disabled]="isDeleteDisabled(execution.status)"
(click)="requestDeleteExecution(execution.id, $event)">
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>
</div>
}
</div>
} @else {
<button type="button" class="tasks-list-latest" (click)="selectExecution(group.latestExecutionId)">
<span>Open latest execution</span>
<mat-icon fontIcon="arrow_forward"></mat-icon>
</button>
}
</mat-card>
</mat-list-item>

View File

@ -100,6 +100,25 @@ describe('TasksExecutionsListComponent comparison picking', () => {
expect(component.selectedExecutionId()).toBe('e1');
});
it('requests clearing every run in one group from its header action', () => {
const deleteRequested = vi.fn();
const target = group('g1', ['e1', 'e2']);
component.executionGroupDeleteRequested.subscribe(deleteRequested);
component.requestDeleteExecutionGroup(target);
expect(deleteRequested).toHaveBeenCalledWith(target);
});
it('disables deleting a group while one of its executions is running', () => {
const runningGroup: TaskExecutionGroupListItem = {
...group('g1', ['e1']),
executions: [{ ...group('g1', ['e1']).executions[0], status: 'RUNNING' }]
};
expect(component.isGroupDeleteDisabled(runningGroup)).toBe(true);
});
it('confines comparison to one group, and clears the picks on leaving', () => {
// Runs of different flows share no node ids, so comparing across groups is meaningless.
component.toggleCompareMode('g1');

View File

@ -16,6 +16,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { getExecutionStatusGroup, TaskExecutionStatus, TaskExecutionStatusGroup } from '@models/task-execution';
import { OrderEvent, OrderField, Ordering, orderDirType } from '@shared/ordering/ordering';
import { OrderViewState } from '@utilities/list-state-holder';
import { TruncatedTooltipDirective } from '@shared/truncated-tooltip/truncated-tooltip';
export type TaskExecutionFilter = 'all' | TaskExecutionStatusGroup;
@ -67,7 +68,7 @@ export type TaskExecutionGroupListItem = {
@Component({
selector: 'app-tasks-executions-list',
imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule],
imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule, TruncatedTooltipDirective],
templateUrl: './tasks-executions-list.html',
styleUrl: './tasks-executions-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -77,6 +78,7 @@ export class TasksExecutionsListComponent {
readonly selectedExecutionId = input<string | null>(null);
readonly executionSelected = output<string>();
readonly executionDeleteRequested = output<string>();
readonly executionGroupDeleteRequested = output<TaskExecutionGroupListItem>();
readonly executionRerunRequested = output<string>();
/** Two runs of one group, picked to be compared. */
readonly compareRequested = output<{ leftId: string; rightId: string }>();
@ -201,6 +203,11 @@ export class TasksExecutionsListComponent {
this.executionDeleteRequested.emit(executionId);
}
requestDeleteExecutionGroup(group: TaskExecutionGroupListItem, event?: Event) {
event?.stopPropagation();
this.executionGroupDeleteRequested.emit(group);
}
requestRerunExecution(executionId: string, event?: Event) {
event?.stopPropagation();
this.executionRerunRequested.emit(executionId);
@ -246,6 +253,12 @@ export class TasksExecutionsListComponent {
return getExecutionStatusGroup(status) === 'RUNNING';
}
isGroupDeleteDisabled(group: TaskExecutionGroupListItem): boolean {
return group.executions.some((execution) =>
getExecutionStatusGroup(execution.status) === 'RUNNING'
);
}
canRerun(status: TaskExecutionStatus): boolean {
return getExecutionStatusGroup(status) === 'FINAL';
}

View File

@ -0,0 +1,54 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { AfterViewInit, Directive, ElementRef, Input, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { MatTooltip } from '@angular/material/tooltip';
/**
* Shows a title preview only when its host has actually been ellipsized. Measuring after render
* and observing size changes avoids the stale first-row-only result a template expression gives
* while a list is still laying itself out.
*/
@Directive({
selector: '[appTruncatedTooltip]',
standalone: true,
hostDirectives: [MatTooltip]
})
export class TruncatedTooltipDirective implements AfterViewInit, OnChanges, OnDestroy {
@Input({ required: true }) appTruncatedTooltip = '';
private readonly element = inject(ElementRef<HTMLElement>);
private readonly tooltip = inject(MatTooltip);
private resizeObserver: ResizeObserver | null = null;
ngAfterViewInit() {
this.tooltip.position = 'above';
this.tooltip.tooltipClass = 'title-preview-tooltip';
this.scheduleRefresh();
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.refresh());
this.resizeObserver.observe(this.element.nativeElement);
}
}
ngOnChanges(_changes: SimpleChanges) {
this.tooltip.message = this.appTruncatedTooltip;
this.scheduleRefresh();
}
ngOnDestroy() {
this.resizeObserver?.disconnect();
}
private scheduleRefresh() {
queueMicrotask(() => this.refresh());
}
private refresh() {
const host = this.element.nativeElement;
this.tooltip.message = this.appTruncatedTooltip;
this.tooltip.disabled = host.scrollWidth <= host.clientWidth;
}
}

View File

@ -422,6 +422,22 @@ body.node-focus-modal-open {
transform: translateY(0);
}
/* Full sidebar titles float above the sidebar like a horizontally expanded card. */
.cdk-overlay-container:has(.title-preview-tooltip) {
z-index: 2000;
}
.title-preview-tooltip.mat-mdc-tooltip-surface,
.title-preview-tooltip .mat-mdc-tooltip-surface {
max-width: calc(100vw - 2rem);
white-space: nowrap;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #ffffff;
color: #1e293b;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.2);
}
.llm-error-title,
.llm-warning-list-title {
font-size: 11px;