Group the flows list by project, with shared context and project runs

Projects group the flow list in place: no new page or tab, the existing sidebar
list gains collapsible per-project sections plus a project filter. Everything is
behind PROJECTS_ENABLED in shared/feature-flags, and with no projects the list
renders exactly as it did before - flows-list.spec guards that.

FlowsList stays the single owner of the load, filter, sort and list state; the
grouping is extracted into a pure flow-grouping.ts and a presentational
flows-group component. A wrapper rendering N flows lists would have re-registered
the same list state and re-triggered the load N times. The group chrome mirrors
tasks-executions-list, which already implements collapsible groups, so the two
sidebars read as one product.

toFlowCreateRequest deliberately still carries no project. It builds the
full-replace PUT the editor issues on every save, so a project sent there would
be silently dropped each time; membership changes only through
assignFlowToProject. flow-mapper.spec guards it.

Deleting a project destroys its flows, so it gets its own dialog rather than a
wider ConfirmDialogService: it names the count, lists the flows, says that
finalized flows go too - which the flow list otherwise forbids - and requires the
project name to be typed. Widening the shared confirm service for one destructive
caller would have rippled through every other call site.

Moving a flow between projects is a menu item, not drag-and-drop: the sidebar is
320px with its own scroll, and the whole card is already a click target, so a
drag gesture would fight the open-flow gesture. Flow order inside a project uses
up/down arrows for the same reason.

Shared context is edited in a dialog modelled on the Global Inputs panel, which
is the mental model users already have for ${{global.x}}; the title toolbar shows
the inherited values read-only, because that is where prompts are written.

"Run project" creates and starts a run - creating alone would look like nothing
happened. Runs come back BLOCKED or STOPPED when a step needs inputs or failed,
and the UI says so instead of claiming progress. Project runs surface in /tasks
as sibling groups labelled with a project chip, derived in tasks-executor: no
second nesting level and no backend change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 07:53:42 +02:00
parent abf21b1c24
commit 0b706ea95c
65 changed files with 3032 additions and 42 deletions

View File

@ -7,3 +7,6 @@
<app-bias-impact-experiment-dialog-host></app-bias-impact-experiment-dialog-host>
<app-bias-rerun-dialog-host></app-bias-rerun-dialog-host>
<app-bias-compare-dialog-host></app-bias-compare-dialog-host>
<app-project-dialog></app-project-dialog>
<app-project-delete-dialog></app-project-delete-dialog>
<app-project-context-dialog></app-project-context-dialog>

View File

@ -8,10 +8,13 @@ import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialo
import { BiasImpactExperimentDialogHostComponent } from '@shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog';
import { BiasRerunDialogHostComponent } from '@shared/bias-rerun-dialog/bias-rerun-dialog';
import { BiasCompareDialogHostComponent } from '@shared/bias-compare-dialog/bias-compare-dialog';
import { ProjectContextDialogComponent } from '@shared/project-context-dialog/project-context-dialog';
import { ProjectDeleteDialogComponent } from '@shared/project-delete-dialog/project-delete-dialog';
import { ProjectDialogComponent } from '@shared/project-dialog/project-dialog';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent],
imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -28,6 +28,8 @@ import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { FlowsService } from '@services/flows/flows';
import { ProjectsService } from '@services/projects/projects';
import { catchError, EMPTY, exhaustMap, timer } from 'rxjs';
export type InteractiveSubflowTarget = {
@ -73,6 +75,8 @@ export class TasksExecutor {
private taskExecutionsService = inject(TaskExecutionsService);
private confirm = inject(ConfirmDialogService);
private blocksService = inject(BlocksService);
private flowsService = inject(FlowsService);
private projectsService = inject(ProjectsService);
private containersService = inject(ContainersService);
private route = inject(ActivatedRoute);
private router = inject(Router);
@ -154,6 +158,13 @@ export class TasksExecutor {
void this.containersService.getAllContainerTypes().catch((err) => {
console.error('Error preloading container types for task executor', err);
});
// Needed only to label a group with its project; failure just leaves the chip off.
void this.flowsService.getAllFlows().catch((err) => {
console.error('Error preloading flows for task executor', err);
});
void this.projectsService.getAllProjects().catch((err) => {
console.error('Error preloading projects for task executor', err);
});
this.taskExecutionsService.init();
effect(() => {
const routeSelectedId = this.routeExecutionId().get('executionId');
@ -291,10 +302,19 @@ export class TasksExecutor {
latestExecutionId: group.latestExecutionId || latestExecution?.id || '',
latestStatus: latestExecution?.status ?? 'CREATED',
latestRunNumber: latestExecution?.runNumber ?? null,
projectName: this.projectNameForFlow(group.sourceFlowId),
executions
};
}
/** Resolves source flow -> project -> name; null when the flow has no project. */
private projectNameForFlow(sourceFlowId: string | null | undefined): string | null {
if (!sourceFlowId) return null;
const flow = this.flowsService.flows().find((candidate) => candidate.id === sourceFlowId);
if (!flow?.projectId) return null;
return this.projectsService.projectById().get(flow.projectId)?.name ?? flow.projectName ?? null;
}
private toExecutionListItem(execution: TaskExecution, fallbackRunNumber: number): TaskExecutionListItem {
return {
id: execution.id,

View File

@ -15,6 +15,12 @@ export type Flow = {
updatedAt: Date;
published?: boolean;
finalized?: boolean;
/**
* Owning project, or undefined when the flow belongs to none. The backend discloses this only
* to the flow's owner, so another user's published flow always reads as unassigned.
*/
projectId?: string;
projectName?: string;
validationErrors?: FlowValidationError[];
};

91
src/app/models/project.ts Normal file
View File

@ -0,0 +1,91 @@
/** The value types a project shared-context entry can hold - mirrors the backend IOType enum. */
export type ProjectContextEntryType = 'TEXT' | 'BOOLEAN' | 'FILE' | 'CSV' | 'JSON';
/**
* One shared value a project hands to its flows. The `name` is what a flow author writes as
* `${{project.<name>}}`, so it must be a valid placeholder name and must not start with a
* reserved prefix (`project.`, `global.`, `context.`, `vars.`) - the backend enforces both.
*/
export type ProjectContextEntry = {
name: string;
type: ProjectContextEntryType;
multiple: boolean;
value: unknown;
description?: string | null;
};
export type ProjectContext = {
entries: ProjectContextEntry[];
};
export type Project = {
id: string;
name: string;
description?: string;
owner: string;
createdAt: Date;
updatedAt: Date;
sharedContext: ProjectContext;
/** Only present on the listing shape, which never carries the flows themselves. */
flowCount?: number;
};
export type ProjectDraft = Pick<Project, 'name'> & { description?: string };
/** Key used for the pseudo-group holding flows that belong to no project. */
export const UNGROUPED_PROJECT_KEY = '__ungrouped__';
const CONTEXT_ENTRY_TYPES: ProjectContextEntryType[] = ['TEXT', 'BOOLEAN', 'FILE', 'CSV', 'JSON'];
export function normalizeSharedContext(raw: unknown): ProjectContext {
const value = (raw ?? {}) as Record<string, unknown>;
const rawEntries = Array.isArray(value['entries']) ? value['entries'] : [];
const entries = rawEntries
.filter((entry): entry is Record<string, unknown> =>
!!entry && typeof entry === 'object' && !Array.isArray(entry))
.map((entry) => {
const type = String(entry['type'] ?? 'TEXT').toUpperCase() as ProjectContextEntryType;
return {
name: String(entry['name'] ?? ''),
type: CONTEXT_ENTRY_TYPES.includes(type) ? type : 'TEXT',
multiple: Boolean(entry['multiple']),
value: entry['value'] ?? null,
description: typeof entry['description'] === 'string' ? entry['description'] : null
} satisfies ProjectContextEntry;
})
.filter((entry) => entry.name.length > 0);
return { entries };
}
/** The placeholder a flow author writes to read this entry. */
export function projectTemplateReference(name: string): string {
return `\${{project.${name}}}`;
}
/**
* Derived from the run's executions; the run keeps no state of its own.
* BLOCKED = the next step still needs inputs or credentials. STOPPED = a step failed.
*/
export type ProjectRunStatus = 'PENDING' | 'RUNNING' | 'BLOCKED' | 'STOPPED' | 'COMPLETED';
/** One run of a project: the executions started together, tied by `projectRunId`. */
export type ProjectRun = {
projectRunId: string;
projectId: string;
name: string;
createdAt: number;
executionCount: number;
status: ProjectRunStatus;
currentExecutionId: string | null;
completedCount: number;
blockedReason: string | null;
executionIds: string[];
};
/** What a project run created, and the flows it deliberately left out. */
export type ProjectExecutionPlan = {
run: ProjectRun;
skipped: { flowId: string; flowName: string; reason: string }[];
};

View File

@ -45,6 +45,10 @@ export type TaskExecution = {
creationTime: number;
flowId?: string | null;
sourceFlowId?: string | null;
/** The project the source flow belonged to when the run was created. */
projectId?: string | null;
/** Ties together the executions started by one project run; null for a single-flow run. */
projectRunId?: string | null;
runNumber?: number | null;
rerunOfExecutionId?: string | null;
executionKind?: ExecutionKind;
@ -90,6 +94,11 @@ export type TaskExecutionContext = {
inputs: Record<string, unknown>;
globalInputs?: Record<string, unknown>;
globalInputDescriptors?: Record<string, TaskExecutionGlobalInputDescriptor>;
/**
* Values inherited from the flow's project, frozen when the run was created. Read in prompts as
* `${{project.<name>}}`.
*/
projectContext?: Record<string, unknown>;
result: Record<string, unknown>;
partialResult?: Record<string, unknown>;
startTime?: number | null;

View File

@ -75,6 +75,12 @@
<mat-icon fontIcon="auto_awesome"></mat-icon>
<span>Create with AI</span>
</button>
@if (projectsEnabled) {
<button type="button" mat-menu-item class="editor-sidebar-new-flow-menu-item" (click)="createProject()">
<mat-icon fontIcon="create_new_folder"></mat-icon>
<span>New project</span>
</button>
}
</mat-menu>
} @else {
<button
@ -86,7 +92,7 @@
</button>
}
</span>
<app-flows-list></app-flows-list>
<app-flows-list #flowsList></app-flows-list>
</app-group-holder>
</div>
}

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, effect, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, effect, inject, signal, viewChild } from '@angular/core';
import { GroupHolder } from '@shared/group-holder/group-holder';
import { FlowsList } from '@shared/flows-list/flows-list';
import { BlocksList } from '@shared/blocks-list/blocks-list';
@ -13,6 +13,7 @@ import { finalize } from 'rxjs';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { PROJECTS_ENABLED } from '@shared/feature-flags';
type OpenedId = 'flows' | 'blocks' | 'containers';
@ -34,6 +35,15 @@ export class EditorSidebar {
containersService = inject(ContainersService);
confirm = inject(ConfirmDialogService);
readonly projectsEnabled = PROJECTS_ENABLED;
/** The flows list owns the project dialogs, since it also owns the grouping they act on. */
private readonly flowsList = viewChild(FlowsList);
createProject() {
void this.flowsList()?.createProject();
}
blockDisabled = computed(() => !this.flowState.hasFlow());
containerDisabled = computed(() =>
!this.flowState.hasFlow() || this.flowState.isEditingSubflow()

View File

@ -0,0 +1,27 @@
import { Injectable, signal } from '@angular/core';
import { Project, ProjectContext } from '@models/project';
type ProjectContextDialogState = {
project: Project;
resolve: (context: ProjectContext | null) => void;
};
@Injectable({ providedIn: 'root' })
export class ProjectContextDialogService {
private readonly _state = signal<ProjectContextDialogState | null>(null);
readonly state = this._state.asReadonly();
open(project: Project): Promise<ProjectContext | null> {
return new Promise<ProjectContext | null>((resolve) => {
this._state.set({ project, resolve });
});
}
close(context: ProjectContext | null) {
const state = this._state();
if (!state) return;
state.resolve(context);
this._state.set(null);
}
}

View File

@ -0,0 +1,40 @@
import { Injectable, signal } from '@angular/core';
import { Project } from '@models/project';
export type ProjectDeleteDialogInput = {
project: Project;
/**
* The flows that will be destroyed, taken from the client cache rather than from the API count:
* the user must be shown exactly what they can see in the sidebar.
*/
flows: { id: string; name: string; finalized?: boolean }[];
};
type ProjectDeleteDialogState = ProjectDeleteDialogInput & {
resolve: (confirmed: boolean) => void;
};
/**
* A dedicated dialog rather than a wider ConfirmDialogService: that service's single-message
* signature is used by many callers, and widening it for one destructive case would ripple through
* every one of them.
*/
@Injectable({ providedIn: 'root' })
export class ProjectDeleteDialogService {
private readonly _state = signal<ProjectDeleteDialogState | null>(null);
readonly state = this._state.asReadonly();
open(input: ProjectDeleteDialogInput): Promise<boolean> {
return new Promise<boolean>((resolve) => {
this._state.set({ ...input, resolve });
});
}
close(confirmed: boolean) {
const state = this._state();
if (!state) return;
state.resolve(confirmed);
this._state.set(null);
}
}

View File

@ -0,0 +1,31 @@
import { Injectable, signal } from '@angular/core';
import { Project, ProjectDraft } from '@models/project';
export type ProjectDialogInput = {
/** Null creates a project; a project edits it. */
project: Project | null;
};
type ProjectDialogState = ProjectDialogInput & {
resolve: (draft: ProjectDraft | null) => void;
};
@Injectable({ providedIn: 'root' })
export class ProjectDialogService {
private readonly _state = signal<ProjectDialogState | null>(null);
readonly state = this._state.asReadonly();
open(input: ProjectDialogInput): Promise<ProjectDraft | null> {
return new Promise<ProjectDraft | null>((resolve) => {
this._state.set({ ...input, resolve });
});
}
close(draft: ProjectDraft | null) {
const state = this._state();
if (!state) return;
state.resolve(draft);
this._state.set(null);
}
}

View File

@ -76,3 +76,40 @@ describe('flow-mapper', () => {
});
});
});
describe('flowFromApi and toFlowCreateRequest project membership', () => {
it('reads projectId and projectName from the API shape', () => {
const flow = flowFromApi({
id: 'f1',
name: 'Flow',
projectId: 'p1',
projectName: 'Recruiting'
});
expect(flow.projectId).toBe('p1');
expect(flow.projectName).toBe('Recruiting');
});
it('also tolerates a nested project object', () => {
const flow = flowFromApi({ id: 'f1', name: 'Flow', project: { id: 'p2', name: 'Reporting' } });
expect(flow.projectId).toBe('p2');
expect(flow.projectName).toBe('Reporting');
});
it('leaves project membership undefined when the API withholds it', () => {
// A non-owner reading a published flow gets no project, and it must read as unassigned.
const flow = flowFromApi({ id: 'f1', name: 'Flow' });
expect(flow.projectId).toBeUndefined();
expect(flow.projectName).toBeUndefined();
});
it('never sends a project in the update body', () => {
// This body is a full replace used by every editor save. Carrying projectId here - or worse,
// omitting it while the backend honoured it - would silently detach the flow on every save.
const body = toFlowCreateRequest('Flow', 'desc', undefined, 'DRAFT') as Record<string, unknown>;
expect('projectId' in body).toBe(false);
});
});

View File

@ -16,6 +16,13 @@ export function flowFromApi(raw: unknown): Flow {
? value['published']
: ((value['visibility'] as FlowVisibility | undefined) === 'PUBLIC');
const visibility: FlowVisibility = published ? 'PUBLIC' : 'PRIVATE';
const project = (value['project'] ?? null) as Record<string, unknown> | null;
const projectId = typeof value['projectId'] === 'string'
? value['projectId']
: (project && typeof project['id'] === 'string' ? project['id'] : undefined);
const projectName = typeof value['projectName'] === 'string'
? value['projectName']
: (project && typeof project['name'] === 'string' ? project['name'] : undefined);
const rawStatus = typeof value['status'] === 'string' ? value['status'].toUpperCase() : null;
const status: FlowStatus = rawStatus === 'EXECUTABLE' ? 'EXECUTABLE' : 'DRAFT';
@ -30,6 +37,8 @@ export function flowFromApi(raw: unknown): Flow {
visibility,
published,
finalized: typeof value['finalized'] === 'boolean' ? value['finalized'] : undefined,
projectId,
projectName,
validationErrors: normalizeFlowValidationErrors(value['validationErrors'] ?? value['errors']),
data: {
blocks: normalizeNodes(data.blocks, 'block') as FlowBlock[],
@ -42,6 +51,11 @@ export function flowFromApi(raw: unknown): Flow {
};
}
/**
* Deliberately omits projectId. This is the body of the full-replace PUT the editor issues on
* every save, so carrying a project here would silently detach a flow whenever the field was
* missing. Membership is changed only through FlowsCallService.assignFlowToProject.
*/
export function toFlowCreateRequest(name: string, description?: string, flow?: FlowData, status: FlowStatus = 'DRAFT') {
return {
name,

View File

@ -23,4 +23,11 @@ export abstract class FlowsCallServiceBase {
abstract getGroupedFlowValidation(flowId: string) : Observable<GroupedFlowValidation>;
/**
* Moves a flow into a project, or detaches it when projectId is null. Its own endpoint, not a
* field on the update body: that body is a full replace, so a project carried there would be
* lost on every editor save. Works on finalized flows - membership is metadata, not content.
*/
abstract assignFlowToProject(flowId: string, projectId: string | null) : Observable<Flow>;
}

View File

@ -31,11 +31,22 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
authorizationService = inject(Authorization);
private data: Record<string, Flow> = {
'1': { id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") },
'2': { id: '2', name: 'Test Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") },
'1': { id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00"), projectId: 'p1', projectName: 'Recruiting' },
'2': { id: '2', name: 'Test Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00"), projectId: 'p2', projectName: 'Reporting' },
'testFlow': flowFromApi(testDataFlow)
}
override assignFlowToProject(flowId: string, projectId: string | null) {
return defer(() => {
const flow = this.requireFlow(flowId);
this.requireOwner(flow);
// No finalized check on purpose: membership stays changeable on a finalized flow.
const updated: Flow = { ...flow, projectId: projectId ?? undefined, updatedAt: new Date() };
this.data[flowId] = updated;
return of(updated);
});
}
override retrieveAllFlows() {
return of(Object.values(this.data));
}

View File

@ -61,6 +61,13 @@ export class FlowsCallService extends FlowsCallServiceBase {
.pipe(map((raw) => flowFromApi(raw)));
}
override assignFlowToProject(flowId: string, projectId: string | null): Observable<Flow> {
const encodedId = encodeURIComponent(flowId);
return this.http
.put<unknown>(`${environment.apiUrl}/flows/${encodedId}/project`, { projectId })
.pipe(map((raw) => flowFromApi(raw)));
}
override updatePublished(flowId: string, value: boolean): Observable<Flow> {
const encodedId = encodeURIComponent(flowId);
return this.http

View File

@ -2,7 +2,7 @@ import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { Flow } from '@models/flow';
import { FlowsCallServiceBase } from './flows-call.base';
import { catchError, firstValueFrom, Observable, of, tap, throwError } from 'rxjs';
import { catchError, firstValueFrom, Observable, of, switchMap, tap, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
@ -133,6 +133,28 @@ export class FlowsService {
);
}
/**
* Moves a flow into a project, or detaches it when projectId is null. Patches the cache in place
* so the sidebar re-groups reactively without a refetch.
*/
assignFlowToProject(flowId: string, projectId: string | null) {
return this.flowsCallService.assignFlowToProject(flowId, projectId).pipe(
tap((updatedFlow) => {
this._flows.update((flows) => {
const index = flows.findIndex((current) => current.id === updatedFlow.id);
if (index < 0) return [updatedFlow, ...flows];
const next = [...flows];
next[index] = updatedFlow;
return next;
});
}),
catchError(err => {
console.error('Assign flow to project failed', err);
return throwError(() => err);
})
);
}
finalizeFlow(flowId: string) {
return this.flowsCallService.finalizeFlow(flowId).pipe(
tap((updatedFlow) => {
@ -169,13 +191,18 @@ export class FlowsService {
);
}
cloneFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'>): Observable<Flow> {
cloneFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'> & Pick<Partial<Flow>, 'projectId'>): Observable<Flow> {
return this.createFlow({
name: `${flow.name} (cloned)`,
description: flow.description,
data: flow.data,
status: flow.status
}).pipe(
// A clone is made from inside a group, so it belongs there too. Creation cannot carry the
// project - that is what keeps it out of the full-replace body - so assign it afterwards.
switchMap((created) => flow.projectId
? this.assignFlowToProject(created.id, flow.projectId)
: of(created)),
catchError(err => {
console.error('Cloning flow failed', err);
return throwError(() => err);

View File

@ -0,0 +1,73 @@
import { projectFromApi, toProjectContextRequest, toProjectRequest } from './project-mapper';
describe('project-mapper', () => {
describe('projectFromApi', () => {
it('maps the backend shape, including lastUpdateAt', () => {
const project = projectFromApi({
id: 'p1',
name: 'Recruiting',
description: 'Screening',
owner: 'alice',
createdAt: '2026-01-05T09:00:00',
lastUpdateAt: '2026-01-09T11:30:00',
flowCount: 3,
sharedContext: { entries: [{ name: 'tone', type: 'text', value: 'formal' }] }
});
expect(project.id).toBe('p1');
expect(project.name).toBe('Recruiting');
expect(project.owner).toBe('alice');
expect(project.flowCount).toBe(3);
expect(project.updatedAt.getFullYear()).toBe(2026);
expect(project.sharedContext.entries).toEqual([
{ name: 'tone', type: 'TEXT', multiple: false, value: 'formal', description: null }
]);
});
it('falls back to createdAt when no update timestamp is present', () => {
const project = projectFromApi({ id: 'p1', name: 'P', createdAt: '2026-02-01T00:00:00' });
expect(project.updatedAt.getTime()).toBe(project.createdAt.getTime());
});
it('defaults a missing shared context to no entries', () => {
expect(projectFromApi({ id: 'p1', name: 'P' }).sharedContext).toEqual({ entries: [] });
});
it('drops context entries without a name, which could never be referenced', () => {
const project = projectFromApi({
id: 'p1',
name: 'P',
sharedContext: { entries: [{ type: 'TEXT', value: 'x' }, { name: 'ok', type: 'TEXT' }] }
});
expect(project.sharedContext.entries.map((entry) => entry.name)).toEqual(['ok']);
});
it('falls back to TEXT for an unknown entry type', () => {
const project = projectFromApi({
id: 'p1',
name: 'P',
sharedContext: { entries: [{ name: 'x', type: 'SOMETHING_ELSE' }] }
});
expect(project.sharedContext.entries[0].type).toBe('TEXT');
});
});
describe('request bodies', () => {
it('sends an empty description rather than undefined', () => {
expect(toProjectRequest({ name: 'P' })).toEqual({ name: 'P', description: '' });
});
it('serializes context entries with an explicit null description', () => {
const body = toProjectContextRequest({
entries: [{ name: 'tone', type: 'TEXT', multiple: false, value: 'formal' }]
});
expect(body).toEqual({
entries: [{ name: 'tone', type: 'TEXT', multiple: false, value: 'formal', description: null }]
});
});
});
});

View File

@ -0,0 +1,44 @@
import { Project, ProjectContext, ProjectDraft, normalizeSharedContext } from '@models/project';
function parseDate(value: unknown, fallback: Date): Date {
if (typeof value !== 'string' || !value) return fallback;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
}
export function projectFromApi(raw: unknown): Project {
const value = (raw ?? {}) as Record<string, unknown>;
const now = new Date();
const createdAt = parseDate(value['createdAt'], now);
return {
id: String(value['id'] ?? ''),
name: String(value['name'] ?? 'Untitled Project'),
description: typeof value['description'] === 'string' ? value['description'] : undefined,
owner: String(value['owner'] ?? 'unknown'),
createdAt,
// The backend field is lastUpdateAt; tolerate updatedAt the way flowFromApi does.
updatedAt: parseDate(value['updatedAt'] ?? value['lastUpdateAt'], createdAt),
sharedContext: normalizeSharedContext(value['sharedContext']),
flowCount: typeof value['flowCount'] === 'number' ? value['flowCount'] : undefined
};
}
export function toProjectRequest(draft: ProjectDraft) {
return {
name: draft.name,
description: draft.description ?? ''
};
}
export function toProjectContextRequest(context: ProjectContext) {
return {
entries: context.entries.map((entry) => ({
name: entry.name,
type: entry.type,
multiple: entry.multiple,
value: entry.value,
description: entry.description ?? null
}))
};
}

View File

@ -0,0 +1,24 @@
import { Project, ProjectContext, ProjectDraft } from '@models/project';
import { Observable } from 'rxjs';
export abstract class ProjectsCallServiceBase {
abstract retrieveAllProjects(): Observable<Project[]>;
abstract getProjectById(projectId: string): Observable<Project>;
abstract createProject(draft: ProjectDraft): Observable<Project>;
abstract updateProject(projectId: string, draft: ProjectDraft): Observable<Project>;
abstract updateSharedContext(projectId: string, context: ProjectContext): Observable<Project>;
/** Sets the order a project run executes the flows in. */
abstract updateFlowOrder(projectId: string, flowIds: string[]): Observable<Project>;
/**
* Deletes the project and every flow in it, finalized flows included. Without `confirm` the
* backend refuses a non-empty project with 409 rather than destroying anything.
*/
abstract deleteProject(projectId: string, confirm: boolean): Observable<void>;
}

View File

@ -0,0 +1,116 @@
import { inject } from '@angular/core';
import { Project, ProjectContext, ProjectDraft } from '@models/project';
import { Authorization } from '@services/authorization/authorization';
import { defer, Observable, of } from 'rxjs';
import { ProjectsCallServiceBase } from './projects-call.base';
export class ProjectsCallServiceFake extends ProjectsCallServiceBase {
authorizationService = inject(Authorization);
/**
* Seeded so that flows '1' and '2' from FlowsCallServiceFake land in a project while 'testFlow'
* stays outside one: that exercises both the grouped and the ungrouped rendering paths in dev.
*/
private data: Record<string, Project> = {
'p1': {
id: 'p1',
name: 'Recruiting',
description: 'Candidate screening flows',
owner: 'Alice',
createdAt: new Date('January 5, 2026 09:00:00'),
updatedAt: new Date('January 9, 2026 11:30:00'),
sharedContext: {
entries: [
{ name: 'tone', type: 'TEXT', multiple: false, value: 'formal', description: 'Writing tone' }
]
}
},
'p2': {
id: 'p2',
name: 'Reporting',
owner: 'Bob',
createdAt: new Date('February 2, 2026 14:00:00'),
updatedAt: new Date('February 3, 2026 08:15:00'),
sharedContext: { entries: [] }
}
};
private ownerUsername(): string {
return this.authorizationService.loggedInUser()?.username ?? '';
}
private requireProject(projectId: string): Project {
const project = this.data[projectId];
if (!project) {
throw new Error(`Project with id ${projectId} not found`);
}
return project;
}
override retrieveAllProjects(): Observable<Project[]> {
return of(Object.values(this.data));
}
override getProjectById(projectId: string): Observable<Project> {
return defer(() => of(this.requireProject(projectId)));
}
override createProject(draft: ProjectDraft): Observable<Project> {
return defer(() => {
const id = `p${Object.keys(this.data).length + 1}`;
const now = new Date();
const created: Project = {
id,
name: draft.name,
description: draft.description,
owner: this.ownerUsername(),
createdAt: now,
updatedAt: now,
sharedContext: { entries: [] },
flowCount: 0
};
this.data[id] = created;
return of(created);
});
}
override updateProject(projectId: string, draft: ProjectDraft): Observable<Project> {
return defer(() => {
const updated: Project = {
...this.requireProject(projectId),
name: draft.name,
description: draft.description,
updatedAt: new Date()
};
this.data[projectId] = updated;
return of(updated);
});
}
override updateSharedContext(projectId: string, context: ProjectContext): Observable<Project> {
return defer(() => {
const updated: Project = {
...this.requireProject(projectId),
sharedContext: context,
updatedAt: new Date()
};
this.data[projectId] = updated;
return of(updated);
});
}
override updateFlowOrder(projectId: string, _flowIds: string[]): Observable<Project> {
return defer(() => of(this.requireProject(projectId)));
}
override deleteProject(projectId: string, confirm: boolean): Observable<void> {
return defer(() => {
this.requireProject(projectId);
if (!confirm) {
throw new Error('Project deletion must be confirmed.');
}
delete this.data[projectId];
return of(undefined);
});
}
}

View File

@ -0,0 +1,56 @@
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { environment } from '@environment';
import { Project, ProjectContext, ProjectDraft } from '@models/project';
import { map, Observable } from 'rxjs';
import { projectFromApi, toProjectContextRequest, toProjectRequest } from './project-mapper';
import { ProjectsCallServiceBase } from './projects-call.base';
export class ProjectsCallService extends ProjectsCallServiceBase {
private readonly http = inject(HttpClient);
override retrieveAllProjects(): Observable<Project[]> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/projects`)
.pipe(map((raw) => raw.map((project) => projectFromApi(project))));
}
override getProjectById(projectId: string): Observable<Project> {
return this.http
.get<unknown>(`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}`)
.pipe(map((raw) => projectFromApi(raw)));
}
override createProject(draft: ProjectDraft): Observable<Project> {
return this.http
.post<unknown>(`${environment.apiUrl}/projects`, toProjectRequest(draft))
.pipe(map((raw) => projectFromApi(raw)));
}
override updateProject(projectId: string, draft: ProjectDraft): Observable<Project> {
return this.http
.put<unknown>(`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}`, toProjectRequest(draft))
.pipe(map((raw) => projectFromApi(raw)));
}
override updateSharedContext(projectId: string, context: ProjectContext): Observable<Project> {
return this.http
.put<unknown>(
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}/context`,
toProjectContextRequest(context)
)
.pipe(map((raw) => projectFromApi(raw)));
}
override updateFlowOrder(projectId: string, flowIds: string[]): Observable<Project> {
return this.http
.put<unknown>(`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}/flow-order`, { flowIds })
.pipe(map((raw) => projectFromApi(raw)));
}
override deleteProject(projectId: string, confirm: boolean): Observable<void> {
return this.http.delete<void>(
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}?confirm=${confirm}`
);
}
}

View File

@ -0,0 +1,110 @@
import { TestBed } from '@angular/core/testing';
import { Project } from '@models/project';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ProjectsCallServiceBase } from './projects-call.base';
import { ProjectsService } from './projects';
function makeProject(id: string, name: string): Project {
return {
id,
name,
owner: 'alice',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
sharedContext: { entries: [] }
};
}
describe('ProjectsService', () => {
let service: ProjectsService;
let callServiceSpy: {
retrieveAllProjects: ReturnType<typeof vi.fn>;
getProjectById: ReturnType<typeof vi.fn>;
createProject: ReturnType<typeof vi.fn>;
updateProject: ReturnType<typeof vi.fn>;
updateSharedContext: ReturnType<typeof vi.fn>;
deleteProject: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
callServiceSpy = {
retrieveAllProjects: vi.fn(),
getProjectById: vi.fn(),
createProject: vi.fn(),
updateProject: vi.fn(),
updateSharedContext: vi.fn(),
deleteProject: vi.fn()
};
TestBed.configureTestingModule({});
service = TestBed.inject(ProjectsService);
service.projectsCallService = callServiceSpy as unknown as ProjectsCallServiceBase;
service.toInit = true;
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('loads projects once and exposes them as a signal', async () => {
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'Recruiting')]));
await service.getAllProjects();
await service.getAllProjects();
expect(callServiceSpy.retrieveAllProjects).toHaveBeenCalledTimes(1);
expect(service.projects().map((project) => project.id)).toEqual(['p1']);
});
it('stays empty and reports not loaded when the endpoint fails', async () => {
// The flows list falls back to the flat rendering on this path: projects must never be able
// to break the flows.
callServiceSpy.retrieveAllProjects.mockReturnValue(throwError(() => new Error('boom')));
await service.getAllProjects();
expect(service.projects()).toEqual([]);
expect(service.hasLoadedProjects()).toBe(false);
});
it('indexes projects by id for O(1) lookup from a flow', async () => {
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'Recruiting')]));
await service.getAllProjects();
expect(service.projectById().get('p1')?.name).toBe('Recruiting');
});
it('prepends a created project to the cache', async () => {
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'One')]));
await service.getAllProjects();
callServiceSpy.createProject.mockReturnValue(of(makeProject('p2', 'Two')));
await new Promise((resolve) => service.createProject({ name: 'Two' }).subscribe(resolve));
expect(service.projects().map((project) => project.id)).toEqual(['p2', 'p1']);
});
it('replaces an updated project in place', async () => {
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'Old')]));
await service.getAllProjects();
callServiceSpy.updateProject.mockReturnValue(of(makeProject('p1', 'New')));
await new Promise((resolve) => service.updateProject('p1', { name: 'New' }).subscribe(resolve));
expect(service.projects()).toHaveLength(1);
expect(service.projects()[0].name).toBe('New');
});
it('drops a deleted project from the cache and confirms by default', async () => {
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'Doomed')]));
await service.getAllProjects();
callServiceSpy.deleteProject.mockReturnValue(of(undefined));
await new Promise((resolve) => service.deleteProject('p1').subscribe(resolve));
expect(service.projects()).toEqual([]);
expect(callServiceSpy.deleteProject).toHaveBeenCalledWith('p1', true);
});
});

View File

@ -0,0 +1,133 @@
import { computed, Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { Project, ProjectContext, ProjectDraft } from '@models/project';
import { catchError, firstValueFrom, Observable, of, tap, throwError } from 'rxjs';
import { ProjectsCallServiceBase } from './projects-call.base';
@Injectable({
providedIn: 'root',
})
export class ProjectsService {
projectsCallService: ProjectsCallServiceBase = new environment.projectsCallService();
toInit: boolean = true;
private loadingPromise: Promise<void> | null = null;
private _projects = signal<Project[]>([]);
readonly projects = this._projects.asReadonly();
/** Lets a flow resolve its project in O(1) without threading inputs through the tree. */
readonly projectById = computed(() => new Map(this._projects().map((project) => [project.id, project])));
hasLoadedProjects() {
return this._projects().length > 0 || !this.toInit;
}
async getAllProjects() {
if (this.toInit) {
this.toInit = false;
try {
await this.refresh();
} catch {
this.toInit = true;
}
}
return this.projects;
}
getProjectById(projectId: string, forceRefresh = false): Observable<Project> {
const cached = this._projects().find((project) => project.id === projectId);
if (cached && !forceRefresh) return of(cached);
return this.projectsCallService.getProjectById(projectId).pipe(
tap((project) => this.patch(project))
);
}
async refresh(force = false): Promise<void> {
if (this.loadingPromise && !force) {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(this.projectsCallService.retrieveAllProjects())
.then((projects) => {
this._projects.set(projects);
})
.catch((err) => {
this.loadingPromise = null;
console.error('Retrieve projects failed', err);
throw err;
})
.finally(() => {
this.loadingPromise = null;
});
return this.loadingPromise;
}
createProject(draft: ProjectDraft) {
return this.projectsCallService.createProject(draft).pipe(
tap((created) => this._projects.update((projects) => [created, ...projects])),
catchError((err) => {
console.error('Create project failed', err);
return throwError(() => err);
})
);
}
updateProject(projectId: string, draft: ProjectDraft) {
return this.projectsCallService.updateProject(projectId, draft).pipe(
tap((updated) => this.patch(updated)),
catchError((err) => {
console.error('Update project failed', err);
return throwError(() => err);
})
);
}
updateSharedContext(projectId: string, context: ProjectContext) {
return this.projectsCallService.updateSharedContext(projectId, context).pipe(
tap((updated) => this.patch(updated)),
catchError((err) => {
console.error('Update project shared context failed', err);
return throwError(() => err);
})
);
}
updateFlowOrder(projectId: string, flowIds: string[]) {
return this.projectsCallService.updateFlowOrder(projectId, flowIds).pipe(
tap((updated) => this.patch(updated)),
catchError((err) => {
console.error('Update project flow order failed', err);
return throwError(() => err);
})
);
}
/**
* Deletes the project and every flow in it. The caller must also refresh the flow cache, which
* this cascade leaves stale.
*/
deleteProject(projectId: string, confirm = true) {
return this.projectsCallService.deleteProject(projectId, confirm).pipe(
tap(() => this._projects.update((projects) => projects.filter((p) => p.id !== projectId))),
catchError((err) => {
console.error('Delete project failed', err);
return throwError(() => err);
})
);
}
private patch(project: Project) {
this._projects.update((projects) => {
const index = projects.findIndex((current) => current.id === project.id);
if (index < 0) return [project, ...projects];
const next = [...projects];
next[index] = project;
return next;
});
}
}

View File

@ -6,6 +6,7 @@ import {
BiasRerunRequest
} from '@models/bias-impact';
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
import { Observable } from 'rxjs';
export abstract class TaskExecutionsCallServiceBase {
@ -15,6 +16,16 @@ export abstract class TaskExecutionsCallServiceBase {
abstract retrieveStepIterations(executionId: string, stepId: string): Observable<TaskExecution[]>;
abstract retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]>;
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
/**
* Runs a whole project: creates one execution per flow, all sharing a projectRunId. It does not
* start them - the client still supplies inputs and credentials, exactly as for a single flow.
*/
abstract createProjectExecutions(projectId: string, skipNonExecutable: boolean): Observable<ProjectExecutionPlan>;
/**
* Starts, or resumes, a project run. The flows run one at a time in the project's order; each
* step starts only when the previous one succeeded.
*/
abstract startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun>;
abstract rerunTaskExecution(executionId: string): Observable<TaskExecution>;
abstract runBiasImpactExperiment(
executionId: string,

View File

@ -6,6 +6,7 @@ import {
BiasRerunRequest
} from '@models/bias-impact';
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
import { map, Observable, of, throwError } from 'rxjs';
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
@ -527,6 +528,75 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(this.withSimulationAvailability(execution));
}
override createProjectExecutions(projectId: string, _skipNonExecutable: boolean): Observable<ProjectExecutionPlan> {
// The dev fake seeds flows '1' and '2' into project 'p1'; one execution per flow, sharing a run id.
const projectRunId = crypto.randomUUID();
const flowIds = projectId === 'p1' ? ['1'] : projectId === 'p2' ? ['2'] : [];
const now = Date.now();
const executionIds = flowIds.map((flowId) => {
const execution: TaskExecution = {
id: crypto.randomUUID(),
name: `${projectId} - ${flowId}`,
creationTime: now,
flowId,
sourceFlowId: flowId,
projectId,
projectRunId,
runNumber: this.nextRunNumber(flowId),
simulationAvailable: false,
context: {
inputs: {},
result: {},
startTime: null,
endTime: null,
errors: {},
warnings: {},
steps: {},
status: 'CREATED',
waitingSteps: []
}
};
this.data.unshift(execution);
return execution.id;
});
return of({
run: {
projectRunId,
projectId,
name: projectId,
createdAt: now,
executionCount: executionIds.length,
status: 'PENDING' as const,
currentExecutionId: executionIds[0] ?? null,
completedCount: 0,
blockedReason: null,
executionIds
},
skipped: []
});
}
override startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun> {
const executionIds = this.data
.filter((execution) => execution.projectRunId === projectRunId)
.map((execution) => execution.id);
return of({
projectRunId,
projectId,
name: projectId,
createdAt: Date.now(),
executionCount: executionIds.length,
status: 'RUNNING' as const,
currentExecutionId: executionIds[0] ?? null,
completedCount: 0,
blockedReason: null,
executionIds
});
}
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
const source = this.findExecution(executionId);
const sourceFlowId = this.executionSourceFlowId(source);

View File

@ -14,6 +14,7 @@ import {
BiasRoutingChangeEntry
} from '@models/bias-impact';
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
import { map, Observable } from 'rxjs';
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
@ -55,6 +56,58 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
);
}
override createProjectExecutions(projectId: string, skipNonExecutable: boolean): Observable<ProjectExecutionPlan> {
return this.http
.post<unknown>(
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}/execute?skipNonExecutable=${skipNonExecutable}`,
{}
)
.pipe(map((raw) => this.mapProjectExecutionPlan(raw)));
}
override startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun> {
return this.http
.post<unknown>(
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}`
+ `/runs/${encodeURIComponent(projectRunId)}/start`,
{}
)
.pipe(map((raw) => this.mapProjectRun(raw)));
}
private mapProjectRun(raw: unknown): ProjectRun {
const run = (raw ?? {}) as Record<string, any>;
const executions = Array.isArray(run['executions']) ? run['executions'] : [];
return {
projectRunId: String(run['projectRunId'] ?? ''),
projectId: String(run['projectId'] ?? ''),
name: String(run['name'] ?? ''),
createdAt: typeof run['createdAt'] === 'number' ? run['createdAt'] : Date.now(),
executionCount: typeof run['executionCount'] === 'number' ? run['executionCount'] : executions.length,
status: (run['status'] ?? 'PENDING') as ProjectRun['status'],
currentExecutionId: typeof run['currentExecutionId'] === 'string' ? run['currentExecutionId'] : null,
completedCount: typeof run['completedCount'] === 'number' ? run['completedCount'] : 0,
blockedReason: typeof run['blockedReason'] === 'string' ? run['blockedReason'] : null,
executionIds: executions
.map((execution: Record<string, unknown>) => String(execution?.['id'] ?? ''))
.filter((id: string) => id.length > 0)
};
}
private mapProjectExecutionPlan(raw: unknown): ProjectExecutionPlan {
const value = (raw ?? {}) as Record<string, any>;
return {
run: this.mapProjectRun(value['run']),
skipped: (Array.isArray(value['skipped']) ? value['skipped'] : []).map((entry: Record<string, unknown>) => ({
flowId: String(entry?.['flowId'] ?? ''),
flowName: String(entry?.['flowName'] ?? ''),
reason: String(entry?.['reason'] ?? 'Flow is not executable')
}))
};
}
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.post<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/rerun`, null).pipe(
map((raw) => this.mapExecution(raw))

View File

@ -126,6 +126,33 @@ export class TaskExecutionsService {
);
}
/**
* Runs a whole project. Mirrors createExecution so the pending flag and the /tasks refresh come
* for free.
*/
createProjectExecutions(projectId: string, skipNonExecutable = false) {
this._pendingExecutionCreation.set(true);
return this.taskExecutionsCallService.createProjectExecutions(projectId, skipNonExecutable).pipe(
finalize(() => this._pendingExecutionCreation.set(false)),
tap(() => this.refresh()),
catchError((err) => {
console.error('Create project executions failed', err);
return throwError(() => err);
})
);
}
/** Starts, or resumes, a project run. */
startProjectRun(projectId: string, projectRunId: string) {
return this.taskExecutionsCallService.startProjectRun(projectId, projectRunId).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Start project run failed', err);
return throwError(() => err);
})
);
}
rerunExecution(executionId: string) {
this._pendingExecutionCreation.set(true);
return this.taskExecutionsCallService.rerunTaskExecution(executionId).pipe(

View File

@ -3,3 +3,10 @@
* See docs/project-memory.md before changing this flag.
*/
export const SWIMLANES_ENABLED = false;
/**
* Projects group the flow list. While this is false the flows list must render exactly as it did
* before projects existed, which keeps "do not regress the flat list" provable rather than
* aspirational. See docs/project-memory.md before changing this flag.
*/
export const PROJECTS_ENABLED = true;

View File

@ -0,0 +1,79 @@
import { Flow } from '@models/flow';
import { Project, UNGROUPED_PROJECT_KEY } from '@models/project';
import { groupFlowsByProject } from './flow-grouping';
function makeFlow(id: string, projectId?: string): Flow {
return {
id,
name: `Flow ${id}`,
visibility: 'PRIVATE',
data: { blocks: [], containers: [], connections: [], dependencies: [] },
author: 'alice',
createdAt: new Date('2026-01-01'),
status: 'DRAFT',
updatedAt: new Date('2026-01-02'),
projectId
};
}
function makeProject(id: string, name: string): Project {
return {
id,
name,
owner: 'alice',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
sharedContext: { entries: [] }
};
}
describe('groupFlowsByProject', () => {
const zeta = makeProject('p1', 'Zeta');
const alpha = makeProject('p2', 'Alpha');
it('returns nothing for an empty flow list', () => {
expect(groupFlowsByProject([], [zeta])).toEqual([]);
});
it('orders projects by name and always puts the ungrouped bucket last', () => {
const groups = groupFlowsByProject(
[makeFlow('a', 'p1'), makeFlow('b'), makeFlow('c', 'p2')],
[zeta, alpha]
);
expect(groups.map((group) => group.key)).toEqual(['p2', 'p1', UNGROUPED_PROJECT_KEY]);
expect(groups[2].project).toBeNull();
});
it('drops groups whose flows were all filtered out rather than rendering them empty', () => {
const groups = groupFlowsByProject([makeFlow('a', 'p1')], [zeta, alpha]);
expect(groups.map((group) => group.key)).toEqual(['p1']);
});
it('keeps the incoming order within a group, so the list sort still applies', () => {
const groups = groupFlowsByProject(
[makeFlow('third', 'p1'), makeFlow('first', 'p1'), makeFlow('second', 'p1')],
[zeta]
);
expect(groups[0].flows.map((flow) => flow.id)).toEqual(['third', 'first', 'second']);
});
it('treats a flow whose project is unknown as ungrouped instead of hiding it', () => {
// The backend withholds project membership from non-owners, and ids can go stale; a flow must
// never disappear from the list because of it.
const groups = groupFlowsByProject([makeFlow('a', 'deleted-project')], [zeta]);
expect(groups.map((group) => group.key)).toEqual([UNGROUPED_PROJECT_KEY]);
expect(groups[0].flows).toHaveLength(1);
});
it('produces a single ungrouped bucket when there are no projects at all', () => {
const groups = groupFlowsByProject([makeFlow('a'), makeFlow('b')], []);
expect(groups).toHaveLength(1);
expect(groups[0].key).toBe(UNGROUPED_PROJECT_KEY);
expect(groups[0].flows).toHaveLength(2);
});
});

View File

@ -0,0 +1,45 @@
import { Flow } from '@models/flow';
import { Project, UNGROUPED_PROJECT_KEY } from '@models/project';
export type FlowGroupView = {
/** Project id, or UNGROUPED_PROJECT_KEY for the flows that belong to no project. */
key: string;
project: Project | null;
flows: Flow[];
};
/**
* Groups an already filtered and sorted flow list by project.
*
* Rules, fixed deliberately:
* - Groups keep the incoming flow order, so whatever sort the list applies holds within a group.
* - Projects are ordered by name; the "no project" group is always last.
* - A group with no flows is dropped entirely rather than rendered empty - with a search term
* active, an empty group is noise.
* - A flow whose projectId does not resolve to a known project counts as ungrouped: the backend
* withholds project membership from non-owners, and a stale id must never hide a flow.
*/
export function groupFlowsByProject(flows: Flow[], projects: Project[]): FlowGroupView[] {
const byId = new Map(projects.map((project) => [project.id, project]));
const groups = new Map<string, FlowGroupView>();
for (const flow of flows) {
const project = flow.projectId ? byId.get(flow.projectId) ?? null : null;
const key = project ? project.id : UNGROUPED_PROJECT_KEY;
const existing = groups.get(key);
if (existing) {
existing.flows.push(flow);
} else {
groups.set(key, { key, project, flows: [flow] });
}
}
return [...groups.values()].sort(compareGroups);
}
function compareGroups(a: FlowGroupView, b: FlowGroupView): number {
if (!a.project) return 1;
if (!b.project) return -1;
return a.project.name.localeCompare(b.project.name);
}

View File

@ -25,6 +25,43 @@
(click)="$event.stopPropagation(); clone()">
<mat-icon fontIcon="content_copy"></mat-icon>
</button>
@if (projectsEnabled) {
<button
mat-icon-button
[matTooltip]="assignTooltip()"
[disabled]="!canAssignProject()"
[matMenuTriggerFor]="projectMenu"
(click)="$event.stopPropagation()">
<mat-icon fontIcon="drive_file_move"></mat-icon>
</button>
<mat-menu #projectMenu="matMenu">
@for (project of assignableProjects(); track project.id) {
<button
mat-menu-item
type="button"
[disabled]="project.id === flow().projectId"
(click)="assignToProject(project)">
@if (project.id === flow().projectId) {
<mat-icon fontIcon="check"></mat-icon>
}
<span>{{ project.name }}</span>
</button>
}
@if (assignableProjects().length === 0) {
<button mat-menu-item type="button" disabled>
<span>No projects yet</span>
</button>
}
<button
mat-menu-item
type="button"
[disabled]="!flow().projectId"
(click)="assignToProject(null)">
<mat-icon fontIcon="remove"></mat-icon>
<span>No project</span>
</button>
</mat-menu>
}
<button
mat-icon-button
[matTooltip]="deleteTooltip()"

View File

@ -3,17 +3,22 @@ import { ChangeDetectionStrategy, Component, computed, inject, input, model } fr
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Flow } from '@models/flow';
import { Project } from '@models/project';
import { Authorization } from '@services/authorization/authorization';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
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 { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-flow-item',
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatTooltipModule],
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule],
templateUrl: './flow-item.html',
styleUrl: './flow-item.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -25,6 +30,11 @@ export class FlowItem {
private authorization = inject(Authorization);
private flowsService = inject(FlowsService);
private projectsService = inject(ProjectsService);
private notifications = inject(NotificationService);
readonly projectsEnabled = PROJECTS_ENABLED;
readonly assignableProjects = this.projectsService.projects;
flow = input.required<Flow>();
@ -38,6 +48,34 @@ export class FlowItem {
const username = this.authorization.loggedInUser()?.username ?? null;
return !!username && this.flow().author === username && !this.flow().finalized;
});
/**
* Ownership only - deliberately not gated on `finalized`. Assignment goes through the dedicated
* project endpoint, not the flow update path, so a finalized flow stays reorganizable.
*/
canAssignProject = computed(() => {
const username = this.authorization.loggedInUser()?.username ?? null;
return !!username && this.flow().author === username;
});
assignTooltip = computed(() =>
this.canAssignProject() ? 'Move to project' : 'Only the owner can move this flow'
);
async assignToProject(project: Project | null) {
if (!this.canAssignProject()) return;
const targetId = project?.id ?? null;
if ((this.flow().projectId ?? null) === targetId) return;
try {
await firstValueFrom(this.flowsService.assignFlowToProject(this.flow().id, targetId));
this.notifications.show(
project ? `Moved to “${project.name}”` : 'Removed from project',
'success'
);
} catch {
this.notifications.show('Could not move the flow.', 'error');
}
}
deleteTooltip = computed(() =>
this.flow().finalized
? 'Finalized flows cannot be deleted'

View File

@ -0,0 +1,104 @@
.flows-list-group {
padding: 0;
width: 100%;
box-shadow: none;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.flows-list-group-headrow {
display: flex;
align-items: center;
gap: 0.25rem;
padding-right: 0.25rem;
background: #f8fafc;
}
.flows-list-group-header {
display: flex;
flex: 1 1 auto;
min-width: 0;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.25rem 0.5rem 0.5rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
}
.flows-list-group-chevron {
flex: 0 0 auto;
color: #475569;
}
.flows-list-group-main {
flex: 1 1 auto;
min-width: 0;
}
.flows-list-group-title {
font-weight: 600;
font-size: 0.875rem;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flows-list-group-subtitle {
font-size: 0.75rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flows-list-group-count {
flex: 0 0 auto;
font-size: 0.75rem;
color: #475569;
white-space: nowrap;
}
.flows-list-group-flows {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
background: #ffffff;
}
.flows-list-group-flow {
display: flex;
align-items: center;
gap: 0.125rem;
min-width: 0;
}
.flows-list-group-flow-item {
flex: 1 1 auto;
min-width: 0;
}
/* Compact arrows: the sidebar is 320px, so they must not crowd out the flow card. */
.flows-list-group-move {
display: flex;
flex: 0 0 auto;
flex-direction: column;
}
.flows-list-group-move button {
width: 22px;
height: 18px;
line-height: 18px;
padding: 0;
}
.flows-list-group-move mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}

View File

@ -0,0 +1,94 @@
<mat-card class="flows-list-group" [class.flows-list-group-expanded]="expanded()">
<div class="flows-list-group-headrow">
<button
type="button"
class="flows-list-group-header"
[attr.aria-expanded]="expanded()"
(click)="onHeaderClick($event)">
<mat-icon
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>
@if (project()?.description) {
<div class="flows-list-group-subtitle">{{ project()?.description }}</div>
}
</div>
<span class="flows-list-group-count">{{ countLabel() }}</span>
</button>
@if (!isUngrouped()) {
<button
mat-icon-button
class="flows-list-group-run"
[matTooltip]="runTooltip()"
[disabled]="!canRun()"
[attr.aria-label]="'Run project ' + title()"
(click)="$event.stopPropagation(); runRequested.emit(project()!)">
<mat-icon fontIcon="play_arrow"></mat-icon>
</button>
<button
mat-icon-button
class="flows-list-group-actions"
matTooltip="Project actions"
[attr.aria-label]="'Actions for project ' + title()"
[matMenuTriggerFor]="projectMenu"
(click)="$event.stopPropagation()">
<mat-icon fontIcon="more_vert"></mat-icon>
</button>
<mat-menu #projectMenu="matMenu">
<button mat-menu-item type="button" (click)="newFlowRequested.emit(project()!)">
<mat-icon fontIcon="note_add"></mat-icon>
<span>New flow in this project</span>
</button>
<button mat-menu-item type="button" [disabled]="!canRun()" (click)="runRequested.emit(project()!)">
<mat-icon fontIcon="play_arrow"></mat-icon>
<span>Run project</span>
</button>
<button mat-menu-item type="button" (click)="editRequested.emit(project()!)">
<mat-icon fontIcon="edit"></mat-icon>
<span>Edit project</span>
</button>
<button mat-menu-item type="button" (click)="contextRequested.emit(project()!)">
<mat-icon fontIcon="data_object"></mat-icon>
<span>Shared context…</span>
</button>
<button mat-menu-item type="button" (click)="deleteRequested.emit(project()!)">
<mat-icon fontIcon="delete"></mat-icon>
<span>Delete project</span>
</button>
</mat-menu>
}
</div>
@if (expanded()) {
<div class="flows-list-group-flows">
@for (flow of flows(); track flow.id) {
<div class="flows-list-group-flow">
@if (canReorder()) {
<div class="flows-list-group-move">
<button
mat-icon-button
type="button"
matTooltip="Run earlier in this project"
[disabled]="$first"
(click)="move(flow.id, -1, $event)">
<mat-icon fontIcon="keyboard_arrow_up"></mat-icon>
</button>
<button
mat-icon-button
type="button"
matTooltip="Run later in this project"
[disabled]="$last"
(click)="move(flow.id, 1, $event)">
<mat-icon fontIcon="keyboard_arrow_down"></mat-icon>
</button>
</div>
}
<app-flow-item class="flows-list-group-flow-item" [(detailOpenedId)]="detailOpenedId" [flow]="flow"></app-flow-item>
</div>
}
</div>
}
</mat-card>

View File

@ -0,0 +1,98 @@
import { TestBed } from '@angular/core/testing';
import { Flow } from '@models/flow';
import { Project } from '@models/project';
import { Authorization } from '@services/authorization/authorization';
import { FlowsService } from '@services/flows/flows';
import { ProjectsService } from '@services/projects/projects';
import { signal } from '@angular/core';
import { vi } from 'vitest';
import { FlowsGroup } from './flows-group';
function makeFlow(id: string): Flow {
return {
id,
name: `Flow ${id}`,
visibility: 'PRIVATE',
data: { blocks: [], containers: [], connections: [], dependencies: [] },
author: 'alice',
createdAt: new Date('2026-01-01'),
status: 'DRAFT',
updatedAt: new Date('2026-01-02')
};
}
const project: Project = {
id: 'p1',
name: 'Recruiting',
description: 'Screening flows',
owner: 'alice',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
sharedContext: { entries: [] }
};
async function build(inputs: { project: Project | null; flows: Flow[]; expanded: boolean }) {
await TestBed.configureTestingModule({
imports: [FlowsGroup],
providers: [
{ provide: FlowsService, useValue: { flows: signal([]), assignFlowToProject: vi.fn() } },
{ provide: ProjectsService, useValue: { projects: signal([]) } },
{ provide: Authorization, useValue: { loggedInUser: signal({ username: 'alice' }) } }
]
}).compileComponents();
const fixture = TestBed.createComponent(FlowsGroup);
fixture.componentRef.setInput('project', inputs.project);
fixture.componentRef.setInput('flows', inputs.flows);
fixture.componentRef.setInput('expanded', inputs.expanded);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
return fixture;
}
describe('FlowsGroup', () => {
afterEach(() => TestBed.resetTestingModule());
it('shows the project name and a pluralised flow count', async () => {
const fixture = await build({ project, flows: [makeFlow('1'), makeFlow('2')], expanded: false });
expect(fixture.componentInstance.title()).toBe('Recruiting');
expect(fixture.componentInstance.countLabel()).toBe('2 flows');
});
it('uses the singular for one flow', async () => {
const fixture = await build({ project, flows: [makeFlow('1')], expanded: false });
expect(fixture.componentInstance.countLabel()).toBe('1 flow');
});
it('renders the flows only while expanded', async () => {
const collapsed = await build({ project, flows: [makeFlow('1')], expanded: false });
expect(collapsed.nativeElement.querySelectorAll('app-flow-item').length).toBe(0);
TestBed.resetTestingModule();
const expanded = await build({ project, flows: [makeFlow('1')], expanded: true });
expect(expanded.nativeElement.querySelectorAll('app-flow-item').length).toBe(1);
});
it('labels the ungrouped bucket and gives it no project actions', async () => {
const fixture = await build({ project: null, flows: [makeFlow('1')], expanded: true });
expect(fixture.componentInstance.title()).toBe('No project');
expect(fixture.componentInstance.isUngrouped()).toBe(true);
// The pseudo-group is not a project, so edit/delete/context would be meaningless.
expect(fixture.nativeElement.querySelector('.flows-list-group-actions')).toBeNull();
});
it('reports a header click as a toggle intent', async () => {
const fixture = await build({ project, flows: [makeFlow('1')], expanded: false });
const toggled = vi.fn();
fixture.componentInstance.toggled.subscribe(toggled);
fixture.nativeElement.querySelector('.flows-list-group-header').click();
expect(toggled).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,69 @@
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Flow } from '@models/flow';
import { Project } from '@models/project';
import { FlowItem } from '../flow-item/flow-item';
/**
* One collapsible project section of the flows list. Purely presentational: the list owns the
* grouping and the expansion state, this only renders and reports intent.
*
* The DOM shape and class naming deliberately mirror `tasks-executions-list`, which already
* implements collapsible groups, so the two sidebars read as one product.
*/
@Component({
selector: 'app-flows-group',
imports: [FlowItem, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule],
templateUrl: './flows-group.html',
styleUrl: './flows-group.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlowsGroup {
readonly project = input<Project | null>(null);
readonly flows = input.required<Flow[]>();
readonly expanded = input<boolean>(false);
readonly detailOpenedId = model<string | null>(null);
readonly toggled = output<void>();
readonly editRequested = output<Project>();
readonly contextRequested = output<Project>();
readonly deleteRequested = output<Project>();
readonly newFlowRequested = output<Project>();
readonly runRequested = output<Project>();
readonly moveRequested = output<{ project: Project; flowId: string; direction: -1 | 1 }>();
/** The ungrouped bucket is a pseudo-project: it has no actions of its own. */
readonly title = computed(() => this.project()?.name ?? 'No project');
readonly isUngrouped = computed(() => this.project() === null);
/** A project run needs at least one executable flow, and the backend refuses the rest. */
readonly canRun = computed(() => this.flows().some((flow) => flow.status === 'EXECUTABLE'));
readonly runTooltip = computed(() =>
this.canRun() ? 'Run every executable flow in this project' : 'No executable flow in this project'
);
readonly countLabel = computed(() => {
const count = this.flows().length;
return `${count} ${count === 1 ? 'flow' : 'flows'}`;
});
/** Reordering is per-project, so it is offered only inside a real project group. */
readonly canReorder = computed(() => !this.isUngrouped() && this.flows().length > 1);
move(flowId: string, direction: -1 | 1, event: Event) {
event.stopPropagation();
const project = this.project();
if (project) {
this.moveRequested.emit({ project, flowId, direction });
}
}
onHeaderClick(event: Event) {
event.stopPropagation();
this.toggled.emit();
}
}

View File

@ -75,3 +75,27 @@
overflow: visible !important;
min-width: 0;
}
/*
* The grouped container relies on the flex column for its height instead of the flat list's fixed
* max-height calc, which does not account for the project filter row above it.
*/
.flows-list-groups {
display: flex;
flex-direction: column;
gap: 10px;
max-height: none;
padding: 10px 0 20px;
}
.flows-list-project-filter {
padding: 0 0.5rem;
}
.flows-list-project-filter mat-form-field {
width: 100%;
}
.flows-list-project-filter .mat-mdc-form-field-subscript-wrapper {
display: none;
}

View File

@ -22,10 +22,44 @@
<div class="flows-list-ordering">
<app-ordering [orderFields]="orderFields" [orderView]="orderView" (orderChanged)="onOrderChanged($event)" ></app-ordering>
</div>
@if (projectsEnabled && projects().length > 0) {
<div class="flows-list-project-filter">
<mat-form-field appearance="outline">
<mat-label>Project</mat-label>
<mat-select [value]="projectFilter()" (valueChange)="onProjectFilterChange($event)">
<mat-option value="all">All</mat-option>
@for (project of projects(); track project.id) {
<mat-option [value]="project.id">{{ project.name }}</mat-option>
}
<mat-option [value]="ungroupedKey">No project</mat-option>
</mat-select>
</mat-form-field>
</div>
}
@if (orderedFlows().length === 0) {
<div class="flows-list-empty">
No flows found
</div>
} @else if (groupingActive()) {
<div class="flows-list-items flows-list-groups">
@for (group of groupedFlows(); track group.key) {
<app-flows-group
[project]="group.project"
[flows]="group.flows"
[expanded]="isGroupExpanded(group.key)"
[(detailOpenedId)]="detailOpenedId"
(toggled)="toggleGroup(group.key)"
(editRequested)="editProject($event)"
(contextRequested)="editProjectContext($event)"
(deleteRequested)="deleteProject($event)"
(newFlowRequested)="createFlowInProject($event)"
(runRequested)="runProject($event)"
(moveRequested)="moveFlowInProject($event)">
</app-flows-group>
}
</div>
} @else {
<mat-list class="flows-list-items">
@for (flow of orderedFlows(); track flow.id) {

View File

@ -1,39 +1,235 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { provideRouter } from '@angular/router';
import { Flow } from '@models/flow';
import { Project } from '@models/project';
import { FlowsService } from '@services/flows/flows';
import { ProjectsService } from '@services/projects/projects';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { of, throwError } from 'rxjs';
import { ListState } from '@stores/list-state';
import { vi } from 'vitest';
import { FlowsList } from './flows-list';
describe('FlowsList', () => {
let component: FlowsList;
let fixture: ComponentFixture<FlowsList>;
function makeFlow(id: string, name: string, projectId?: string): Flow {
return {
id,
name,
visibility: 'PRIVATE',
data: { blocks: [], containers: [], connections: [], dependencies: [] },
author: 'alice',
createdAt: new Date('2026-01-01'),
status: 'DRAFT',
updatedAt: new Date('2026-01-02'),
projectId
};
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FlowsList],
providers: [
ListState,
{
provide: FlowsService,
useValue: {
flows: signal([]),
hasLoadedFlows: vi.fn().mockReturnValue(true),
getAllFlows: vi.fn().mockResolvedValue(signal([]))
}
function makeProject(id: string, name: string): Project {
return {
id,
name,
owner: 'alice',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
sharedContext: { entries: [] }
};
}
let taskExecutionsStub: {
createProjectExecutions: ReturnType<typeof vi.fn>;
startProjectRun: ReturnType<typeof vi.fn>;
};
async function build(flows: Flow[], projects: Project[]) {
taskExecutionsStub = {
createProjectExecutions: vi.fn(),
startProjectRun: vi.fn().mockReturnValue(of({
projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1,
executionCount: 1, status: 'RUNNING', currentExecutionId: 'e1',
completedCount: 0, blockedReason: null, executionIds: ['e1']
}))
};
await TestBed.configureTestingModule({
imports: [FlowsList],
providers: [
ListState,
provideRouter([]),
{
provide: FlowsService,
useValue: {
flows: signal(flows),
hasLoadedFlows: vi.fn().mockReturnValue(true),
getAllFlows: vi.fn().mockResolvedValue(signal(flows)),
refresh: vi.fn().mockResolvedValue(undefined)
}
]
})
.compileComponents();
},
{
provide: ProjectsService,
useValue: {
projects: signal(projects),
hasLoadedProjects: vi.fn().mockReturnValue(true),
getAllProjects: vi.fn().mockResolvedValue(signal(projects))
}
},
{ provide: TaskExecutionsService, useValue: taskExecutionsStub }
]
}).compileComponents();
fixture = TestBed.createComponent(FlowsList);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
const fixture = TestBed.createComponent(FlowsList);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
return fixture;
}
describe('FlowsList', () => {
afterEach(() => TestBed.resetTestingModule());
it('should create', async () => {
const fixture = await build([], []);
expect(fixture.componentInstance).toBeTruthy();
});
it('should create', () => {
expect(component).toBeTruthy();
it('renders the flat list, with no group headers, when there are no projects', async () => {
// Regression guard: a user with no projects must see exactly the pre-projects list.
const fixture = await build([makeFlow('1', 'Alpha'), makeFlow('2', 'Beta')], []);
expect(fixture.componentInstance.groupingActive()).toBe(false);
expect(fixture.nativeElement.querySelectorAll('app-flows-group').length).toBe(0);
expect(fixture.nativeElement.querySelectorAll('app-flow-item').length).toBe(2);
});
it('groups the flows once projects exist', async () => {
const fixture: ComponentFixture<FlowsList> = await build(
[makeFlow('1', 'Alpha', 'p1'), makeFlow('2', 'Beta')],
[makeProject('p1', 'Recruiting')]
);
expect(fixture.componentInstance.groupingActive()).toBe(true);
const groups = fixture.componentInstance.groupedFlows();
expect(groups.map((group) => group.key)).toEqual(['p1', '__ungrouped__']);
expect(fixture.nativeElement.querySelectorAll('app-flows-group').length).toBe(2);
});
it('renders a flat list when a single project is selected', async () => {
const fixture = await build(
[makeFlow('1', 'Alpha', 'p1'), makeFlow('2', 'Beta')],
[makeProject('p1', 'Recruiting')]
);
fixture.componentInstance.onProjectFilterChange('p1');
fixture.detectChanges();
expect(fixture.componentInstance.groupingActive()).toBe(false);
expect(fixture.componentInstance.orderedFlows().map((flow) => flow.id)).toEqual(['1']);
});
it('scopes the list to unassigned flows when "No project" is selected', async () => {
const fixture = await build(
[makeFlow('1', 'Alpha', 'p1'), makeFlow('2', 'Beta')],
[makeProject('p1', 'Recruiting')]
);
fixture.componentInstance.onProjectFilterChange('__ungrouped__');
fixture.detectChanges();
expect(fixture.componentInstance.orderedFlows().map((flow) => flow.id)).toEqual(['2']);
});
it('auto-expands groups while a search term is active, without touching the saved expansion', async () => {
const fixture = await build(
[makeFlow('1', 'Alpha', 'p1')],
[makeProject('p1', 'Recruiting')]
);
const component = fixture.componentInstance;
expect(component.isGroupExpanded('p1')).toBe(false);
component.searchTerm.set('alp');
fixture.detectChanges();
// A search hit must never sit hidden behind a collapsed header.
expect(component.isGroupExpanded('p1')).toBe(true);
component.searchTerm.set('');
fixture.detectChanges();
// Clearing the search restores the user's own expansion rather than leaving it forced open.
expect(component.isGroupExpanded('p1')).toBe(false);
});
it('persists group expansion in the shared list state', async () => {
const fixture = await build(
[makeFlow('1', 'Alpha', 'p1')],
[makeProject('p1', 'Recruiting')]
);
fixture.componentInstance.toggleGroup('p1');
fixture.detectChanges();
expect(fixture.componentInstance.isGroupExpanded('p1')).toBe(true);
expect(fixture.componentInstance.view.expandedGroupIds?.has('p1')).toBe(true);
});
it('runs a project and navigates to the created executions', async () => {
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1, executionIds: ['e1'] },
skipped: []
}));
const router = TestBed.inject(Router);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
// Non-executable flows are skipped rather than failing the whole run from the UI.
expect(taskExecutionsStub.createProjectExecutions).toHaveBeenCalledWith('p1', true);
// Creating is not enough: the run must also be started, or nothing would happen.
expect(taskExecutionsStub.startProjectRun).toHaveBeenCalledWith('p1', 'r1');
expect(navigate).toHaveBeenCalledWith(['/tasks'], { queryParams: { executionId: 'e1' } });
});
it('still navigates when a project run produced no executions', async () => {
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 0, executionIds: [] },
skipped: [{ flowId: '1', flowName: 'Alpha', reason: 'Flow is not executable' }]
}));
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
expect(navigate).toHaveBeenCalledWith(['/tasks'], { queryParams: {} });
});
it('does not navigate when the project run fails', async () => {
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
taskExecutionsStub.createProjectExecutions.mockReturnValue(throwError(() => new Error('boom')));
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
expect(navigate).not.toHaveBeenCalled();
});
it('reports a blocked run instead of claiming it is running', async () => {
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1,
status: 'PENDING', currentExecutionId: 'e1', completedCount: 0, blockedReason: null, executionIds: ['e1'] },
skipped: []
}));
taskExecutionsStub.startProjectRun.mockReturnValue(of({
projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1,
status: 'BLOCKED', currentExecutionId: 'e1', completedCount: 0,
blockedReason: 'missing global inputs: requirements', executionIds: ['e1']
}));
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
// Still navigates, so the user can supply what the run is waiting for.
expect(navigate).toHaveBeenCalled();
});
});

View File

@ -1,5 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { Router } from '@angular/router';
import { Flow, FlowVisibility } from '@models/flow';
import { firstValueFrom } from 'rxjs';
import { FlowsService } from '@services/flows/flows';
import { FlowItem } from './flow-item/flow-item';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
@ -9,14 +11,29 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { Project, UNGROUPED_PROJECT_KEY } from '@models/project';
import { ProjectContextDialogService } from '@services/dialogs/project-context-dialog';
import { ProjectDeleteDialogService } from '@services/dialogs/project-delete-dialog';
import { ProjectDialogService } from '@services/dialogs/project-dialog';
import { NotificationService } from '@services/notifications/notification';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { ProjectsService } from '@services/projects/projects';
import { EditorStateHolder } from '@stores/flow-editor';
import { PROJECTS_ENABLED } from '@shared/feature-flags';
import { OrderEvent, OrderField, Ordering, orderDirType } from "@shared/ordering/ordering";
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { FlowsGroup } from './flows-group/flows-group';
import { groupFlowsByProject } from './flow-grouping';
type FlowFilter = FlowVisibility | 'FINALIZED' | 'all';
/** 'all' groups by project; a concrete id (or the ungrouped key) renders a flat, scoped list. */
const ALL_PROJECTS = 'all';
@Component({
selector: 'app-flows-list',
imports: [FlowItem, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule],
imports: [FlowItem, FlowsGroup, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule, MatSelectModule],
templateUrl: './flows-list.html',
styleUrl: './flows-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -36,6 +53,22 @@ export class FlowsList extends ListStateViewHolder<Flow> {
searchTerm = model<string>('');
private flowsService = inject(FlowsService);
private projectsService = inject(ProjectsService);
private projectDialog = inject(ProjectDialogService);
private projectDeleteDialog = inject(ProjectDeleteDialogService);
private projectContextDialog = inject(ProjectContextDialogService);
private notifications = inject(NotificationService);
private taskExecutions = inject(TaskExecutionsService);
private router = inject(Router);
private editorState = inject(EditorStateHolder);
readonly projectsEnabled = PROJECTS_ENABLED;
readonly ungroupedKey = UNGROUPED_PROJECT_KEY;
readonly projects = this.projectsService.projects;
/** 'all' | UNGROUPED_PROJECT_KEY | <projectId> */
readonly projectFilter = signal<string>(ALL_PROJECTS);
loading: WritableSignal<boolean> = signal(true);
@ -48,6 +81,8 @@ export class FlowsList extends ListStateViewHolder<Flow> {
detailOpenedId = signal<string | null>(null);
private readonly expandedGroupIds = signal<Set<string>>(new Set<string>());
constructor() {
super('flowsList', {defaultOrder: { orderBy: 'name', orderDir: 'asc' } as OrderViewState, defaultFilter: 'all'});
effect(() => {
@ -57,6 +92,16 @@ export class FlowsList extends ListStateViewHolder<Flow> {
}
ngOnInit() {
// Loaded alongside the flows, never gated on them: if the projects endpoint fails the list
// simply falls back to the flat rendering. A project must never be able to break the flows.
this.projectFilter.set((this.view.secondaryFilter as string) ?? ALL_PROJECTS);
this.expandedGroupIds.set(this.view.expandedGroupIds ?? new Set<string>());
if (PROJECTS_ENABLED && !this.projectsService.hasLoadedProjects()) {
this.projectsService.getAllProjects().catch((err) => {
console.error('Error loading projects', err);
});
}
const existingState = this.view;
this.orderBy.set(existingState.order.orderBy);
this.orderDir.set(existingState.order.orderDir);
@ -89,12 +134,20 @@ export class FlowsList extends ListStateViewHolder<Flow> {
filteredFlows = computed(() => {
const flows = this.flows ? this.flows() : [];
if (!flows) return [];
const filteredFlows = flows.filter(f => f.name.toLowerCase().includes(this.searchTerm().toLowerCase()));
if (this.filter() === 'all') return filteredFlows;
if (this.filter() === 'FINALIZED') return filteredFlows.filter(f => !!f.finalized);
return filteredFlows.filter(f => f.visibility === this.filter());
const searched = flows.filter(f => f.name.toLowerCase().includes(this.searchTerm().toLowerCase()));
const scoped = this.scopeToProject(searched);
if (this.filter() === 'all') return scoped;
if (this.filter() === 'FINALIZED') return scoped.filter(f => !!f.finalized);
return scoped.filter(f => f.visibility === this.filter());
});
private scopeToProject(flows: Flow[]): Flow[] {
const selected = this.projectFilter();
if (!PROJECTS_ENABLED || selected === ALL_PROJECTS) return flows;
if (selected === UNGROUPED_PROJECT_KEY) return flows.filter(f => !f.projectId);
return flows.filter(f => f.projectId === selected);
}
orderedFlows = computed(() => {
const flows = [...this.filteredFlows()];
const orderBy = this.orderBy();
@ -114,6 +167,172 @@ export class FlowsList extends ListStateViewHolder<Flow> {
});
/**
* Grouping is on only when there is something to group by and no single project is selected -
* picking one project renders a flat, scoped list, which is clearer and cheaper than a single
* group. With no projects at all the list renders exactly as it did before projects existed.
*/
readonly groupingActive = computed(() =>
PROJECTS_ENABLED && this.projects().length > 0 && this.projectFilter() === ALL_PROJECTS);
/** Flows already filtered and sorted, then grouped: the existing pipeline is untouched. */
readonly groupedFlows = computed(() => groupFlowsByProject(this.orderedFlows(), this.projects()));
readonly searchActive = computed(() => this.searchTerm().trim().length > 0);
/**
* While a search term is active every surviving group renders expanded, derived rather than by
* mutating the persisted set - so clearing the search restores the user's own expansion. A
* search hit must never sit hidden behind a collapsed header.
*/
isGroupExpanded(key: string): boolean {
return this.searchActive() || this.expandedGroupIds().has(key);
}
toggleGroup(key: string) {
this.expandedGroupIds.update((current) => {
const next = new Set(current);
if (!next.delete(key)) {
next.add(key);
}
this.view.expandedGroupIds = next;
return next;
});
}
onProjectFilterChange(value: string) {
this.projectFilter.set(value);
this.view.secondaryFilter = value;
}
async createProject() {
const draft = await this.projectDialog.open({ project: null });
if (!draft) return;
try {
const created = await firstValueFrom(this.projectsService.createProject(draft));
this.toggleGroup(created.id);
this.notifications.show(`Project “${created.name}” created`, 'success');
} catch {
this.notifications.show('Could not create the project.', 'error');
}
}
async editProject(project: Project) {
const draft = await this.projectDialog.open({ project });
if (!draft) return;
try {
await firstValueFrom(this.projectsService.updateProject(project.id, draft));
this.notifications.show('Project updated', 'success');
} catch {
this.notifications.show('Could not update the project.', 'error');
}
}
async editProjectContext(project: Project) {
const context = await this.projectContextDialog.open(project);
if (!context) return;
try {
await firstValueFrom(this.projectsService.updateSharedContext(project.id, context));
this.notifications.show('Shared context saved', 'success');
} catch {
this.notifications.show('Could not save the shared context.', 'error');
}
}
/**
* Deleting a project destroys its flows too. The count shown comes from the client cache, so it
* matches exactly what the user has in front of them.
*/
async deleteProject(project: Project) {
const flows = (this.flows ? this.flows() : []).filter((flow) => flow.projectId === project.id);
const confirmed = await this.projectDeleteDialog.open({
project,
flows: flows.map((flow) => ({ id: flow.id, name: flow.name, finalized: flow.finalized }))
});
if (!confirmed) return;
try {
await firstValueFrom(this.projectsService.deleteProject(project.id, true));
// The open document may have just been deleted along with the project.
const openFlowId = this.editorState.currentFlow()?.id;
if (openFlowId && flows.some((flow) => flow.id === openFlowId)) {
this.editorState.closeDocument();
}
// The flow cache is stale after a cascade: N flows are gone server-side.
await this.flowsService.refresh(true);
this.notifications.show(`Project “${project.name}” deleted`, 'success');
} catch {
this.notifications.show('Could not delete the project.', 'error');
}
}
/**
* Creates one execution per executable flow in the project and shows them in /tasks. They are
* created, not started: each still needs its inputs and credentials, exactly as a single run does.
*/
async runProject(project: Project) {
try {
const plan = await firstValueFrom(
this.taskExecutions.createProjectExecutions(project.id, true));
// The flows run one at a time in the project's order; each step starts only when the
// previous one succeeded, so this kicks off the first and the backend advances the rest.
const run = await firstValueFrom(
this.taskExecutions.startProjectRun(project.id, plan.run.projectRunId));
const skipped = plan.skipped.length;
const skippedNote = skipped > 0 ? `; ${skipped} flow(s) skipped as not executable` : '';
this.notifications.show(
run.status === 'BLOCKED'
? `“${project.name}” is waiting: ${run.blockedReason ?? 'the first flow needs inputs'}`
: `Running “${project.name}” — ${plan.run.executionCount} flow(s), one at a time${skippedNote}`,
run.status === 'BLOCKED' ? 'info' : 'success');
await this.router.navigate(['/tasks'], {
queryParams: plan.run.executionIds.length ? { executionId: plan.run.executionIds[0] } : {}
});
} catch {
this.notifications.show('Could not run the project.', 'error');
}
}
/** Moves a flow one position within its project, persisting the run order. */
async moveFlowInProject(event: { project: Project; flowId: string; direction: -1 | 1 }) {
const current = this.groupedFlows().find((group) => group.key === event.project.id)?.flows ?? [];
const index = current.findIndex((flow) => flow.id === event.flowId);
const target = index + event.direction;
if (index < 0 || target < 0 || target >= current.length) return;
const reordered = [...current];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
try {
await firstValueFrom(this.projectsService.updateFlowOrder(
event.project.id, reordered.map((flow) => flow.id)));
await this.flowsService.refresh(true);
} catch {
this.notifications.show('Could not reorder the flows.', 'error');
}
}
async createFlowInProject(project: Project) {
try {
const created = await firstValueFrom(this.flowsService.createNewFlow());
await firstValueFrom(this.flowsService.assignFlowToProject(created.id, project.id));
this.toggleGroupOn(project.id);
} catch {
this.notifications.show('Could not create the flow.', 'error');
}
}
private toggleGroupOn(key: string) {
if (this.expandedGroupIds().has(key)) return;
this.toggleGroup(key);
}
onOrderChanged(event: OrderEvent) {
const { orderBy, orderDir } = event;
this.orderBy.set(orderBy);

View File

@ -670,6 +670,11 @@ export class TaskStepNodeComponent {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
private projectContextValue(): Record<string, unknown> {
const value = this.blockConfiguration?.['__projectContext'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
private executionName(): string | null {
const value = this.blockConfiguration?.['__executionName'];
return typeof value === 'string' && value.trim().length > 0 ? value : null;
@ -680,7 +685,7 @@ export class TaskStepNodeComponent {
return buildTemplateSubstitutions(inputs, this.globalInputsValue(), this.executionVariablesValue(), {
executionId: this.executionId(),
executionName: this.executionName()
});
}, this.projectContextValue());
}
private currentInputValue(): string {

View File

@ -0,0 +1,61 @@
.project-context__body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-context__empty {
margin: 0;
color: #64748b;
font-size: 0.875rem;
}
.project-context__rows {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 320px;
overflow-y: auto;
}
.project-context__row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.project-context__name {
flex: 2 1 0;
min-width: 0;
}
.project-context__type {
flex: 1 1 0;
min-width: 7rem;
}
.project-context__value {
flex: 3 1 0;
min-width: 0;
}
.project-context__reference {
display: inline-block;
margin: -0.5rem 0 0.5rem;
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: #f1f5f9;
color: #334155;
font-size: 0.75rem;
}
.project-context__add {
align-self: flex-start;
}
.project-context__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1.25rem 1.25rem;
}

View File

@ -0,0 +1,89 @@
@if (state(); as dialogState) {
<app-modal-shell
title="Shared context"
[subtitle]="'Values every flow in “' + dialogState.project.name + '” can read at run time.'"
ariaLabel="Project shared context"
maxWidth="720px"
closeLabel="Cancel"
(backdropClick)="cancel()"
(closeClick)="cancel()">
<div class="project-context__body">
@if (entries().length === 0) {
<p class="project-context__empty">No shared variables defined.</p>
} @else {
<div class="project-context__rows">
@for (entry of entries(); track $index) {
<div class="project-context__row">
<mat-form-field appearance="outline" class="project-context__name">
<mat-label>Name</mat-label>
<input
matInput
[ngModel]="entry.name"
(ngModelChange)="updateName($index, $event)"
[name]="'entry-name-' + $index"
autocomplete="off" />
@if (validationErrors()[$index]; as error) {
<mat-error>{{ error }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="project-context__type">
<mat-label>Type</mat-label>
<mat-select [value]="entry.type" (valueChange)="updateType($index, $event)">
@for (type of entryTypes; track type) {
<mat-option [value]="type">{{ type }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="project-context__value">
<mat-label>Value</mat-label>
<input
matInput
[ngModel]="entry.value"
(ngModelChange)="updateValue($index, $event)"
[name]="'entry-value-' + $index"
autocomplete="off" />
</mat-form-field>
<button
mat-icon-button
type="button"
class="project-context__copy"
[disabled]="!entry.name"
[matTooltip]="entry.name ? 'Copy ' + templateReference(entry.name) : 'Name the variable first'"
(click)="copyReference(entry.name)">
<mat-icon fontIcon="content_copy"></mat-icon>
</button>
<button
mat-icon-button
type="button"
matTooltip="Remove variable"
(click)="removeEntry($index)">
<mat-icon fontIcon="delete"></mat-icon>
</button>
</div>
@if (entry.name && !validationErrors()[$index]) {
<code class="project-context__reference">{{ templateReference(entry.name) }}</code>
}
}
</div>
}
<button type="button" mat-stroked-button class="project-context__add" (click)="addEntry()">
<mat-icon fontIcon="add"></mat-icon>
Add variable
</button>
</div>
<footer class="project-context__footer">
<button type="button" mat-stroked-button (click)="cancel()">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="hasErrors()" (click)="save()">
Save
</button>
</footer>
</app-modal-shell>
}

View File

@ -0,0 +1,132 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
ProjectContext,
ProjectContextEntry,
ProjectContextEntryType,
projectTemplateReference
} from '@models/project';
import { ProjectContextDialogService } from '@services/dialogs/project-context-dialog';
import { NotificationService } from '@services/notifications/notification';
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
/** Names starting with these would collide with the runtime's own placeholder namespaces. */
const RESERVED_PREFIXES = ['project.', 'global.', 'context.', 'vars.'];
const VALID_NAME = /^[A-Za-z][A-Za-z0-9_.-]*$/;
/**
* Editor for the values a project shares with its flows.
*
* Modelled directly on the Global Inputs panel in the title toolbar: users already map that panel
* onto `${{global.x}}`, and these values work the same way under `${{project.x}}`.
*/
@Component({
selector: 'app-project-context-dialog',
imports: [
FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule,
MatTooltipModule, ModalShellComponent
],
templateUrl: './project-context-dialog.html',
styleUrl: './project-context-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectContextDialogComponent {
private readonly dialog = inject(ProjectContextDialogService);
private readonly notifications = inject(NotificationService);
readonly state = this.dialog.state;
readonly entries = signal<ProjectContextEntry[]>([]);
readonly entryTypes: ProjectContextEntryType[] = ['TEXT', 'BOOLEAN', 'JSON', 'CSV'];
/** Same rules, wording and shape as TitleToolbar.globalInputValidationErrors. */
readonly validationErrors = computed(() => {
const entries = this.entries();
const nameCounts = new Map<string, number>();
for (const entry of entries) {
const normalized = String(entry.name ?? '').trim().toLowerCase();
if (!normalized) continue;
nameCounts.set(normalized, (nameCounts.get(normalized) ?? 0) + 1);
}
return entries.map((entry) => {
const name = String(entry.name ?? '').trim();
if (!name) return 'Name is required';
if ((nameCounts.get(name.toLowerCase()) ?? 0) > 1) return 'Name must be unique';
if (RESERVED_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix))) {
return 'Name must not start with project., global., context. or vars.';
}
if (!VALID_NAME.test(name)) return 'Name must be usable as a ${{...}} placeholder';
return null;
});
});
readonly hasErrors = computed(() => this.validationErrors().some((message) => !!message));
constructor() {
effect(() => {
const project = this.state()?.project;
this.entries.set(project ? project.sharedContext.entries.map((entry) => ({ ...entry })) : []);
});
}
templateReference(name: string): string {
return projectTemplateReference(name);
}
addEntry() {
this.entries.update((entries) => [
...entries,
{ name: '', type: 'TEXT', multiple: false, value: '', description: null }
]);
}
removeEntry(index: number) {
this.entries.update((entries) => entries.filter((_, i) => i !== index));
}
updateName(index: number, name: string) {
this.patch(index, { name });
}
updateType(index: number, type: ProjectContextEntryType) {
this.patch(index, { type });
}
updateValue(index: number, value: string) {
this.patch(index, { value });
}
async copyReference(name: string) {
try {
await navigator.clipboard.writeText(this.templateReference(name));
this.notifications.show('Placeholder copied', 'success');
} catch {
// Clipboard access can be denied; the reference is visible on screen either way.
}
}
cancel() {
this.dialog.close(null);
}
save() {
if (this.hasErrors()) return;
const context: ProjectContext = {
entries: this.entries().map((entry) => ({ ...entry, name: entry.name.trim() }))
};
this.dialog.close(context);
}
private patch(index: number, changes: Partial<ProjectContextEntry>) {
this.entries.update((entries) =>
entries.map((entry, i) => (i === index ? { ...entry, ...changes } : entry)));
}
}

View File

@ -0,0 +1,79 @@
.project-delete__body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.project-delete__warning {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fef2f2;
color: #991b1b;
}
.project-delete__warning p {
margin: 0 0 0.25rem;
font-size: 0.875rem;
}
.project-delete__finalized {
font-weight: 600;
}
.project-delete__flows {
margin: 0;
padding: 0.5rem 0.75rem;
max-height: 180px;
overflow-y: auto;
list-style: none;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.project-delete__flows li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.125rem 0;
font-size: 0.875rem;
}
.project-delete__flow-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-delete__badge {
flex: 0 0 auto;
padding: 0 0.375rem;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
font-size: 0.6875rem;
text-transform: uppercase;
}
.project-delete__more {
color: #64748b;
font-style: italic;
}
.project-delete__confirm {
width: 100%;
}
.project-delete__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1.25rem 1.25rem;
}
.project-delete__confirm-button:not([disabled]) {
background-color: #dc2626;
color: #ffffff;
}

View File

@ -0,0 +1,71 @@
@if (state(); as dialogState) {
<app-modal-shell
[title]="headline()"
subtitle="This cannot be undone."
ariaLabel="Confirm project deletion"
maxWidth="560px"
closeLabel="Cancel"
(backdropClick)="cancel()"
(closeClick)="cancel()">
<div class="project-delete__body">
@if (flowCount() > 0) {
<div class="project-delete__warning">
<mat-icon fontIcon="warning"></mat-icon>
<div>
<p>
Deleting this project also permanently deletes the
{{ flowCount() === 1 ? 'flow' : flowCount() + ' flows' }} it contains.
</p>
@if (finalizedCount() > 0) {
<p class="project-delete__finalized">
{{ finalizedCount() }} of them
{{ finalizedCount() === 1 ? 'is finalized and would' : 'are finalized and would' }}
normally be protected from deletion.
</p>
}
</div>
</div>
<ul class="project-delete__flows">
@for (flow of listedFlows(); track flow.id) {
<li>
<span class="project-delete__flow-name">{{ flow.name }}</span>
@if (flow.finalized) {
<span class="project-delete__badge">finalized</span>
}
</li>
}
@if (hiddenFlowCount() > 0) {
<li class="project-delete__more">+ {{ hiddenFlowCount() }} more</li>
}
</ul>
} @else {
<p>This project contains no flows.</p>
}
<mat-form-field appearance="outline" class="project-delete__confirm">
<mat-label>Type “{{ dialogState.project.name }}” to confirm</mat-label>
<input
matInput
[ngModel]="typedName()"
(ngModelChange)="typedName.set($event)"
name="projectNameConfirmation"
autocomplete="off" />
</mat-form-field>
</div>
<footer class="project-delete__footer">
<button type="button" mat-stroked-button (click)="cancel()">Cancel</button>
<button
type="button"
mat-flat-button
color="warn"
class="project-delete__confirm-button"
[disabled]="!canConfirm()"
(click)="confirm()">
Delete project
</button>
</footer>
</app-modal-shell>
}

View File

@ -0,0 +1,96 @@
import { TestBed } from '@angular/core/testing';
import { Project } from '@models/project';
import { ProjectDeleteDialogService } from '@services/dialogs/project-delete-dialog';
import { ProjectDeleteDialogComponent } from './project-delete-dialog';
const project: Project = {
id: 'p1',
name: 'Recruiting',
owner: 'alice',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
sharedContext: { entries: [] }
};
async function open(flows: { id: string; name: string; finalized?: boolean }[]) {
await TestBed.configureTestingModule({ imports: [ProjectDeleteDialogComponent] }).compileComponents();
const dialog = TestBed.inject(ProjectDeleteDialogService);
const fixture = TestBed.createComponent(ProjectDeleteDialogComponent);
fixture.detectChanges();
const result = dialog.open({ project, flows });
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
return { fixture, dialog, result };
}
describe('ProjectDeleteDialogComponent', () => {
afterEach(() => TestBed.resetTestingModule());
it('keeps the destructive button disabled until the exact project name is typed', async () => {
const { fixture } = await open([{ id: 'f1', name: 'Flow A' }]);
const component = fixture.componentInstance;
expect(component.canConfirm()).toBe(false);
component.typedName.set('recruiting');
expect(component.canConfirm()).toBe(false);
component.typedName.set('Recruiting');
expect(component.canConfirm()).toBe(true);
const button: HTMLButtonElement =
fixture.nativeElement.querySelector('.project-delete__confirm-button');
fixture.detectChanges();
expect(button.disabled).toBe(false);
});
it('names the count and warns that finalized flows go too', async () => {
const { fixture } = await open([
{ id: 'f1', name: 'Flow A' },
{ id: 'f2', name: 'Flow B', finalized: true }
]);
expect(fixture.componentInstance.headline()).toBe('Delete project “Recruiting” and its 2 flows?');
expect(fixture.componentInstance.finalizedCount()).toBe(1);
const text: string = fixture.nativeElement.textContent;
// The cascade breaks a rule the flow list otherwise enforces, so it must be spelled out.
expect(text).toContain('finalized');
expect(text).toContain('Flow A');
expect(text).toContain('Flow B');
});
it('resolves false when cancelled', async () => {
const { fixture, result } = await open([{ id: 'f1', name: 'Flow A' }]);
fixture.componentInstance.cancel();
await expect(result).resolves.toBe(false);
});
it('resolves true only once the name matches', async () => {
const { fixture, result } = await open([{ id: 'f1', name: 'Flow A' }]);
const component = fixture.componentInstance;
component.confirm();
expect(component.state()).not.toBeNull();
component.typedName.set('Recruiting');
component.confirm();
await expect(result).resolves.toBe(true);
});
it('still asks for an empty project, but does not demand the name since nothing is destroyed', async () => {
const { fixture } = await open([]);
expect(fixture.componentInstance.flowCount()).toBe(0);
expect(fixture.nativeElement.textContent).toContain('contains no flows');
expect(fixture.componentInstance.canConfirm()).toBe(true);
});
});

View File

@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ProjectDeleteDialogService } from '@services/dialogs/project-delete-dialog';
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
const MAX_LISTED_FLOWS = 8;
/**
* Confirmation for a cascading project delete. Deliberately harder to dismiss than an ordinary
* confirm: it names the count, lists the flows, says that finalized flows go too - which the flow
* list otherwise forbids - and requires the project name to be typed.
*/
@Component({
selector: 'app-project-delete-dialog',
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, ModalShellComponent],
templateUrl: './project-delete-dialog.html',
styleUrl: './project-delete-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectDeleteDialogComponent {
private readonly dialog = inject(ProjectDeleteDialogService);
readonly state = this.dialog.state;
readonly typedName = signal('');
readonly flowCount = computed(() => this.state()?.flows.length ?? 0);
readonly finalizedCount = computed(() => this.state()?.flows.filter((flow) => flow.finalized).length ?? 0);
readonly listedFlows = computed(() => (this.state()?.flows ?? []).slice(0, MAX_LISTED_FLOWS));
readonly hiddenFlowCount = computed(() => Math.max(0, this.flowCount() - MAX_LISTED_FLOWS));
readonly headline = computed(() => {
const project = this.state()?.project;
if (!project) return '';
const count = this.flowCount();
return count === 0
? `Delete project “${project.name}”?`
: `Delete project “${project.name}” and its ${count} ${count === 1 ? 'flow' : 'flows'}?`;
});
readonly canConfirm = computed(() => {
const project = this.state()?.project;
if (!project) return false;
// Nothing is destroyed when the project is empty, so typing the name would be busywork.
if (this.flowCount() === 0) return true;
return this.typedName().trim() === project.name;
});
constructor() {
// Clear the typed name each time the dialog opens, so a previous confirmation cannot carry over.
effect(() => {
this.state();
this.typedName.set('');
});
}
cancel() {
this.dialog.close(false);
}
confirm() {
if (!this.canConfirm()) return;
this.dialog.close(true);
}
}

View File

@ -0,0 +1,12 @@
.project-dialog__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1.25rem 1.25rem;
}

View File

@ -0,0 +1,33 @@
@if (state(); as dialogState) {
<app-modal-shell
[title]="isEdit() ? 'Edit project' : 'New project'"
[subtitle]="isEdit() ? 'Rename the project or change its description.' : 'Group related flows under one project.'"
ariaLabel="Project details"
maxWidth="520px"
closeLabel="Cancel"
(backdropClick)="cancel()"
(closeClick)="cancel()">
<form class="project-dialog__form" (submit)="submit($event)">
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input matInput [field]="projectForm.name" placeholder="e.g. Recruiting" />
@if (isInvalid(projectForm.name())) {
<mat-error>{{ projectForm.name().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Description (optional)</mat-label>
<textarea matInput rows="3" [field]="projectForm.description"></textarea>
</mat-form-field>
</form>
<footer class="project-dialog__footer">
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
<button type="button" mat-flat-button color="primary" [disabled]="!canSubmit()" (click)="submit($event)">
{{ isEdit() ? 'Save' : 'Create project' }}
</button>
</footer>
</app-modal-shell>
}

View File

@ -0,0 +1,56 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Field, form, required } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ProjectDialogService } from '@services/dialogs/project-dialog';
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
import { FormUtility } from '@utilities/form-utility';
/** Host for the create/edit project dialog. Mounted once in app.html. */
@Component({
selector: 'app-project-dialog',
imports: [FormsModule, Field, MatButtonModule, MatFormFieldModule, MatInputModule, ModalShellComponent],
templateUrl: './project-dialog.html',
styleUrl: './project-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectDialogComponent extends FormUtility {
private readonly dialog = inject(ProjectDialogService);
readonly state = this.dialog.state;
readonly isEdit = computed(() => !!this.state()?.project);
readonly model = signal({ name: '', description: '' });
readonly projectForm = form(this.model, (model) => {
required(model.name, { message: 'Name is required' });
});
readonly canSubmit = computed(() => !this.projectForm().invalid());
constructor() {
super();
// Seed the fields whenever the dialog opens, so editing starts from the current values.
effect(() => {
const project = this.state()?.project;
this.model.set({ name: project?.name ?? '', description: project?.description ?? '' });
});
}
cancel(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.dialog.close(null);
}
submit(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.canSubmit()) return;
const { name, description } = this.model();
this.dialog.close({ name: name.trim(), description: description.trim() });
}
}

View File

@ -399,6 +399,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const activeAnnotationIdsByNode = this.execution()?.biasExecutionContext?.activeAnnotationIdsByNode ?? {};
const globalInputsValue = this.execution()?.context.globalInputs ?? {};
const executionVariablesValue = this.execution()?.context.executionVariables ?? {};
const projectContextValue = this.execution()?.context.projectContext ?? {};
const executionName = this.execution()?.name ?? null;
const steps = this.stepsArray();
const execution = this.execution();
@ -458,6 +459,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__biasActiveAnnotationIds: activeAnnotationIdsByNode[step.id] ?? [],
__globalInputs: globalInputsValue,
__executionVariables: executionVariablesValue,
__projectContext: projectContextValue,
__executionName: executionName
},
position: stepNode.position ?? {
@ -508,6 +510,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__biasActiveAnnotationIds: activeAnnotationIdsByNode[sourceNode.id] ?? [],
__globalInputs: globalInputsValue,
__executionVariables: executionVariablesValue,
__projectContext: projectContextValue,
__executionName: executionName
}
};

View File

@ -283,3 +283,15 @@
.tasks-list-latest:hover {
background: #eff6ff;
}
/* Clusters the sibling groups produced by one project run. */
.tasks-list-project-chip {
display: inline-block;
margin-right: 0.375rem;
padding: 0 0.375rem;
border-radius: 999px;
background: #d8f4e7;
color: #14532d;
font-size: 0.6875rem;
font-weight: 600;
}

View File

@ -38,7 +38,12 @@
<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">{{ group.sourceFlowId }}</div>
<div class="tasks-list-group-subtitle">
@if (group.projectName) {
<span class="tasks-list-project-chip">{{ group.projectName }}</span>
}
<span>{{ group.sourceFlowId }}</span>
</div>
</div>
<div class="tasks-list-group-summary">
<span class="tasks-list-count">{{ group.executionCount }} runs</span>

View File

@ -38,6 +38,12 @@ export type TaskExecutionGroupListItem = {
latestExecutionId: string;
latestStatus: TaskExecutionStatus;
latestRunNumber: number | null;
/**
* Name of the project the group's flow belongs to, derived by the host. Execution groups have a
* single level and are keyed by source flow, so a project run shows up as N sibling groups: the
* chip is what visually clusters them.
*/
projectName?: string | null;
executions: TaskExecutionListItem[];
};
@ -69,7 +75,9 @@ export class TasksExecutionsListComponent {
{ field: 'name', label: 'Name' },
{ field: 'executionCount', label: 'Executions' },
{ field: 'lastExecutionTime', label: 'Latest Run' },
{ field: 'latestStatus', label: 'Status' }
{ field: 'latestStatus', label: 'Status' },
// Sorting by project keeps the sibling groups of one project run adjacent.
{ field: 'projectName', label: 'Project' }
];
readonly filteredGroups = computed(() => {
@ -85,6 +93,7 @@ export class TasksExecutionsListComponent {
return (
group.name.toLowerCase().includes(term) ||
group.sourceFlowId.toLowerCase().includes(term) ||
(group.projectName ?? '').toLowerCase().includes(term) ||
group.id.toLowerCase().includes(term) ||
group.executions.some((execution) =>
execution.title.toLowerCase().includes(term) ||

View File

@ -97,3 +97,42 @@ describe('resolveTemplateSegments', () => {
]);
});
});
describe('buildTemplateSubstitutions project namespace', () => {
it('exposes project values under the project prefix', () => {
const values = buildTemplateSubstitutions(null, null, null, undefined, { tone: 'formal' });
expect(values['project.tone']).toBe('formal');
});
it('keeps every namespace distinct, so no source shadows another', () => {
// Mirrors the backend: project., global., vars. and a bare input are four different keys.
const values = buildTemplateSubstitutions(
{ tone: 'from-input' },
{ tone: 'from-global' },
{ tone: 'from-vars' },
undefined,
{ tone: 'from-project' }
);
expect(values['tone']).toBe('from-input');
expect(values['global.tone']).toBe('from-global');
expect(values['vars.tone']).toBe('from-vars');
expect(values['project.tone']).toBe('from-project');
});
it('adds nothing when a flow has no project', () => {
const values = buildTemplateSubstitutions({ a: 1 }, null, null);
expect(Object.keys(values).some((key) => key.startsWith('project.'))).toBe(false);
});
it('resolves a project placeholder in text', () => {
const segments = resolveTemplateSegments(
'Write in a ${{project.tone}} tone',
buildTemplateSubstitutions(null, null, null, undefined, { tone: 'formal' })
);
expect(segments.map((segment) => segment.value ?? segment.text).join('')).toContain('formal');
});
});

View File

@ -26,7 +26,8 @@ export function buildTemplateSubstitutions(
inputs: Record<string, unknown> | null | undefined,
globalInputs: Record<string, unknown> | null | undefined,
executionVariables: Record<string, unknown> | null | undefined,
context?: TemplateContextMeta
context?: TemplateContextMeta,
projectVariables?: Record<string, unknown> | null
): Record<string, unknown> {
const values: Record<string, unknown> = {};
@ -39,6 +40,11 @@ export function buildTemplateSubstitutions(
for (const [name, value] of Object.entries(executionVariables ?? {})) {
values[`vars.${name}`] = value;
}
// Mirrors the backend namespace: project.x, global.x, vars.x and a bare input are distinct keys,
// so no source can shadow another.
for (const [name, value] of Object.entries(projectVariables ?? {})) {
values[`project.${name}`] = value;
}
if (context?.executionId) {
values['context.executionId'] = context.executionId;
}

View File

@ -547,3 +547,44 @@
display: grid;
}
}
/* Read-only view of the values inherited from the flow's project. */
.title-toolbar-project-context {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed #cbd5e1;
}
.title-toolbar-project-context-header {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.25rem;
color: #475569;
font-size: 0.8125rem;
font-weight: 600;
}
.title-toolbar-project-context-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8125rem;
}
.title-toolbar-project-context-name {
font-weight: 600;
color: #0f172a;
}
.title-toolbar-project-context-type {
color: #64748b;
font-size: 0.75rem;
}
.title-toolbar-project-context-hint {
margin-top: 0.25rem;
color: #94a3b8;
font-size: 0.75rem;
font-style: italic;
}

View File

@ -222,6 +222,31 @@
</div>
}
}
@if (projectsEnabled && inheritedProject(); as project) {
<div class="title-toolbar-project-context">
<div class="title-toolbar-project-context-header">
<mat-icon fontIcon="folder_shared"></mat-icon>
<span>Inherited from project “{{ project.name }}”</span>
</div>
@if (!inheritedProjectEntries().length) {
<div class="title-toolbar-global-inputs-empty">This project shares no values.</div>
} @else {
@for (entry of inheritedProjectEntries(); track entry.name) {
<div class="title-toolbar-project-context-row">
<span class="title-toolbar-project-context-name">{{ entry.name }}</span>
<span class="title-toolbar-project-context-type">{{ entry.type }}</span>
</div>
<div class="title-toolbar-global-syntax">
Available references: <code>{{ projectTemplateReference(entry.name) }}</code> and <code>{{ projectSpelReference(entry.name) }}</code>
</div>
}
}
<div class="title-toolbar-project-context-hint">
Edit these in the flows list, from the project's menu.
</div>
</div>
}
}
</div>

View File

@ -9,13 +9,15 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { FlowGlobalInput, FlowLane } from '@models/flow';
import { projectTemplateReference } from '@models/project';
import { BlocksService } from '@services/blocks/blocks';
import { Authorization } from '@services/authorization/authorization';
import { FlowsService } from '@services/flows/flows';
import { ProjectsService } from '@services/projects/projects';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { take } from 'rxjs';
import { EditorStateHolder } from '@stores/flow-editor';
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
import { PROJECTS_ENABLED, SWIMLANES_ENABLED } from '@shared/feature-flags';
@Component({
selector: 'app-title-toolbar',
@ -36,6 +38,9 @@ export class TitleToolbar {
private router = inject(Router);
private blocksService = inject(BlocksService);
private flowsService = inject(FlowsService);
private projectsService = inject(ProjectsService);
readonly projectsEnabled = PROJECTS_ENABLED;
private authorization = inject(Authorization);
private taskExecutionsService = inject(TaskExecutionsService);
flow = computed(() => this.editorState.currentFlow());
@ -231,6 +236,26 @@ export class TitleToolbar {
});
}
/**
* The values this flow inherits from its project, read-only. Shown here because this is exactly
* where prompts are written, so the ${{project.x}} placeholders are discoverable at the point of
* use. Editing them lives with the project, in the flows list.
*/
readonly inheritedProject = computed(() => {
const projectId = this.flow()?.projectId;
return projectId ? this.projectsService.projectById().get(projectId) ?? null : null;
});
readonly inheritedProjectEntries = computed(() => this.inheritedProject()?.sharedContext.entries ?? []);
projectTemplateReference(name: string): string {
return projectTemplateReference(name.trim() || 'name');
}
projectSpelReference(name: string): string {
return `#project['${name.trim() || 'name'}']`;
}
globalTemplateReference(name: string): string {
const resolved = name.trim() || 'name';
return `\${{global.${resolved}}}`;

View File

@ -16,6 +16,13 @@ export class ListView<T> {
filter?: string;
list?: Signal<T[]>;
order: OrderViewState = new OrderViewState();
/**
* Which collapsible groups the user has expanded. Explicit fields rather than a generic bag,
* because ListView already carries domain-shaped state and these keep their types.
*/
expandedGroupIds?: Set<string>;
/** A second, independent filter dimension - `filter` is already taken by the primary toggle. */
secondaryFilter?: string;
}
/**

View File

@ -7,6 +7,7 @@ import { ExecutionVaultCredentialsCallServiceFake } from "@services/llm-provider
import { LlmProviderCallServiceFake } from "@services/llm-provider/llm-provider-call.fake";
import { VaultCallServiceFake } from "@services/vault/vault-call.fake";
import { FlowsCallServiceFake } from "@services/flows/flows-call.fake";
import { ProjectsCallServiceFake } from "@services/projects/projects-call.fake";
import { FieldRetrieverCallServiceFake } from "@services/retriever/field-retriever-call.fake";
import { TaskExecutionsCallServiceFake } from "@services/task-executions/task-executions-call.fake";
@ -20,6 +21,7 @@ export const environment = {
adminCallService: AdminCallFakeService,
assistantCallService: AssistantCallServiceFake,
flowsCallService: FlowsCallServiceFake,
projectsCallService: ProjectsCallServiceFake,
blocksCallService: BlocksCallServiceFake,
containersCallService: ContainersCallServiceFake,
fieldRetrieverCallService: FieldRetrieverCallServiceFake,

View File

@ -7,6 +7,7 @@ import { ExecutionVaultCredentialsCallService } from "@services/llm-provider/exe
import { LlmProviderCallService } from "@services/llm-provider/llm-provider-call";
import { VaultCallService } from "@services/vault/vault-call";
import { FlowsCallService } from "@services/flows/flows-call";
import { ProjectsCallService } from "@services/projects/projects-call";
import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call";
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
@ -20,6 +21,7 @@ export const environment = {
authorizationCallService: AuthorizationCallService,
adminCallService: AdminCallService,
flowsCallService: FlowsCallService,
projectsCallService: ProjectsCallService,
blocksCallService: BlocksCallService,
containersCallService: ContainersCallService,
fieldRetrieverCallService: FieldRetrieverCallService,

View File

@ -7,6 +7,7 @@ import { ExecutionVaultCredentialsCallService } from "@services/llm-provider/exe
import { LlmProviderCallService } from "@services/llm-provider/llm-provider-call";
import { VaultCallService } from "@services/vault/vault-call";
import { FlowsCallService } from "@services/flows/flows-call";
import { ProjectsCallService } from "@services/projects/projects-call";
import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call";
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
@ -20,6 +21,7 @@ export const environment = {
adminCallService: AdminCallService,
assistantCallService: AssistantCallService,
flowsCallService: FlowsCallService,
projectsCallService: ProjectsCallService,
blocksCallService: BlocksCallService,
containersCallService: ContainersCallService,
fieldRetrieverCallService: FieldRetrieverCallService,