) {
+ this.entries.update((entries) =>
+ entries.map((entry, i) => (i === index ? { ...entry, ...changes } : entry)));
+ }
+}
diff --git a/src/app/shared/project-delete-dialog/project-delete-dialog.css b/src/app/shared/project-delete-dialog/project-delete-dialog.css
new file mode 100644
index 0000000..f9c8087
--- /dev/null
+++ b/src/app/shared/project-delete-dialog/project-delete-dialog.css
@@ -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;
+}
diff --git a/src/app/shared/project-delete-dialog/project-delete-dialog.html b/src/app/shared/project-delete-dialog/project-delete-dialog.html
new file mode 100644
index 0000000..4972f13
--- /dev/null
+++ b/src/app/shared/project-delete-dialog/project-delete-dialog.html
@@ -0,0 +1,71 @@
+@if (state(); as dialogState) {
+
+
+
+ @if (flowCount() > 0) {
+
+
+
+
+ Deleting this project also permanently deletes the
+ {{ flowCount() === 1 ? 'flow' : flowCount() + ' flows' }} it contains.
+
+ @if (finalizedCount() > 0) {
+
+ {{ finalizedCount() }} of them
+ {{ finalizedCount() === 1 ? 'is finalized and would' : 'are finalized and would' }}
+ normally be protected from deletion.
+
+ }
+
+
+
+
+ @for (flow of listedFlows(); track flow.id) {
+ -
+ {{ flow.name }}
+ @if (flow.finalized) {
+ finalized
+ }
+
+ }
+ @if (hiddenFlowCount() > 0) {
+ - + {{ hiddenFlowCount() }} more
+ }
+
+ } @else {
+
This project contains no flows.
+ }
+
+
+ Type “{{ dialogState.project.name }}” to confirm
+
+
+
+
+
+
+}
diff --git a/src/app/shared/project-delete-dialog/project-delete-dialog.spec.ts b/src/app/shared/project-delete-dialog/project-delete-dialog.spec.ts
new file mode 100644
index 0000000..70db20c
--- /dev/null
+++ b/src/app/shared/project-delete-dialog/project-delete-dialog.spec.ts
@@ -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);
+ });
+});
diff --git a/src/app/shared/project-delete-dialog/project-delete-dialog.ts b/src/app/shared/project-delete-dialog/project-delete-dialog.ts
new file mode 100644
index 0000000..ebdfcf1
--- /dev/null
+++ b/src/app/shared/project-delete-dialog/project-delete-dialog.ts
@@ -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);
+ }
+}
diff --git a/src/app/shared/project-dialog/project-dialog.css b/src/app/shared/project-dialog/project-dialog.css
new file mode 100644
index 0000000..878e059
--- /dev/null
+++ b/src/app/shared/project-dialog/project-dialog.css
@@ -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;
+}
diff --git a/src/app/shared/project-dialog/project-dialog.html b/src/app/shared/project-dialog/project-dialog.html
new file mode 100644
index 0000000..12dc9fc
--- /dev/null
+++ b/src/app/shared/project-dialog/project-dialog.html
@@ -0,0 +1,33 @@
+@if (state(); as dialogState) {
+
+
+
+
+
+
+}
diff --git a/src/app/shared/project-dialog/project-dialog.ts b/src/app/shared/project-dialog/project-dialog.ts
new file mode 100644
index 0000000..29fa4c1
--- /dev/null
+++ b/src/app/shared/project-dialog/project-dialog.ts
@@ -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() });
+ }
+}
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
index e3e3095..df47459 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
@@ -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
}
};
diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.css b/src/app/shared/tasks-executions-list/tasks-executions-list.css
index 1ad8235..6f0568f 100644
--- a/src/app/shared/tasks-executions-list/tasks-executions-list.css
+++ b/src/app/shared/tasks-executions-list/tasks-executions-list.css
@@ -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;
+}
diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.html b/src/app/shared/tasks-executions-list/tasks-executions-list.html
index ee2681c..f5d676c 100644
--- a/src/app/shared/tasks-executions-list/tasks-executions-list.html
+++ b/src/app/shared/tasks-executions-list/tasks-executions-list.html
@@ -38,7 +38,12 @@
{{ group.name }}
-
{{ group.sourceFlowId }}
+
+ @if (group.projectName) {
+ {{ group.projectName }}
+ }
+ {{ group.sourceFlowId }}
+
{{ group.executionCount }} runs
diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts
index cf0ed2d..29e6701 100644
--- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts
+++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts
@@ -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) ||
diff --git a/src/app/shared/template-placeholder-text/template-placeholder.spec.ts b/src/app/shared/template-placeholder-text/template-placeholder.spec.ts
index 318ca8f..3d03948 100644
--- a/src/app/shared/template-placeholder-text/template-placeholder.spec.ts
+++ b/src/app/shared/template-placeholder-text/template-placeholder.spec.ts
@@ -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');
+ });
+});
diff --git a/src/app/shared/template-placeholder-text/template-placeholder.ts b/src/app/shared/template-placeholder-text/template-placeholder.ts
index 072751c..adffc25 100644
--- a/src/app/shared/template-placeholder-text/template-placeholder.ts
+++ b/src/app/shared/template-placeholder-text/template-placeholder.ts
@@ -26,7 +26,8 @@ export function buildTemplateSubstitutions(
inputs: Record | null | undefined,
globalInputs: Record | null | undefined,
executionVariables: Record | null | undefined,
- context?: TemplateContextMeta
+ context?: TemplateContextMeta,
+ projectVariables?: Record | null
): Record {
const values: Record = {};
@@ -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;
}
diff --git a/src/app/shared/title-toolbar/title-toolbar.css b/src/app/shared/title-toolbar/title-toolbar.css
index 1375120..030170a 100644
--- a/src/app/shared/title-toolbar/title-toolbar.css
+++ b/src/app/shared/title-toolbar/title-toolbar.css
@@ -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;
+}
diff --git a/src/app/shared/title-toolbar/title-toolbar.html b/src/app/shared/title-toolbar/title-toolbar.html
index 653f17e..7b6ed4d 100644
--- a/src/app/shared/title-toolbar/title-toolbar.html
+++ b/src/app/shared/title-toolbar/title-toolbar.html
@@ -222,6 +222,31 @@
}
}
+
+ @if (projectsEnabled && inheritedProject(); as project) {
+
+
+ @if (!inheritedProjectEntries().length) {
+
This project shares no values.
+ } @else {
+ @for (entry of inheritedProjectEntries(); track entry.name) {
+
+ {{ entry.name }}
+ {{ entry.type }}
+
+
+ Available references: {{ projectTemplateReference(entry.name) }} and {{ projectSpelReference(entry.name) }}
+
+ }
+ }
+
+ Edit these in the flows list, from the project's menu.
+
+
+ }
}
diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts
index f2a8843..93c35d6 100644
--- a/src/app/shared/title-toolbar/title-toolbar.ts
+++ b/src/app/shared/title-toolbar/title-toolbar.ts
@@ -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}}}`;
diff --git a/src/app/utilities/list-state-holder.ts b/src/app/utilities/list-state-holder.ts
index 4a7bd36..7873ee0 100644
--- a/src/app/utilities/list-state-holder.ts
+++ b/src/app/utilities/list-state-holder.ts
@@ -16,6 +16,13 @@ export class ListView {
filter?: string;
list?: Signal;
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;
+ /** A second, independent filter dimension - `filter` is already taken by the primary toggle. */
+ secondaryFilter?: string;
}
/**
diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts
index e6c44e9..72c33e5 100644
--- a/src/environments/environment.development.ts
+++ b/src/environments/environment.development.ts
@@ -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,
diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts
index fe1775a..03c86bf 100644
--- a/src/environments/environment.staging.ts
+++ b/src/environments/environment.staging.ts
@@ -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,
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index bfd98a8..8d6d3cb 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -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,