diff --git a/src/components/FlowEditor/editor/CanvasHeader.tsx b/src/components/FlowEditor/editor/CanvasHeader.tsx
index 0d82bff..f5b7509 100644
--- a/src/components/FlowEditor/editor/CanvasHeader.tsx
+++ b/src/components/FlowEditor/editor/CanvasHeader.tsx
@@ -10,6 +10,11 @@ import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
+interface TargetInstanceOption {
+ id: string;
+ label: string;
+}
+
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
@@ -45,6 +50,9 @@ interface CanvasHeaderProps {
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
+ targetFeatureInstanceId?: string | null;
+ onTargetInstanceChange?: (instanceId: string) => void;
+ targetInstanceOptions?: TargetInstanceOption[];
}
function _getStatusBadge(t: (key: string) => string): Record {
@@ -84,6 +92,9 @@ export const CanvasHeader: React.FC = ({ workflows,
onAutoLayout,
verboseSchema,
onVerboseSchemaChange,
+ targetFeatureInstanceId,
+ onTargetInstanceChange,
+ targetInstanceOptions,
}) => {
const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
@@ -209,6 +220,21 @@ export const CanvasHeader: React.FC = ({ workflows,
)}
+ {targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
+
+ )}
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/TemplateTextareaRenderer.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/TemplateTextareaRenderer.tsx
new file mode 100644
index 0000000..684e14e
--- /dev/null
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/TemplateTextareaRenderer.tsx
@@ -0,0 +1,171 @@
+/**
+ * TemplateTextarea — Freitext mit eingebetteten {{nodeId.path}} Tokens.
+ * Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
+ */
+
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import type { FieldRendererProps } from './index';
+import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
+import { DataPicker } from '../shared/DataPicker';
+import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
+import { useLanguage } from '../../../../providers/language/LanguageContext';
+import styles from '../../editor/Automation2FlowEditor.module.css';
+
+const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
+
+function _refToTemplateToken(ref: DataRef): string {
+ const pathSegs = (ref.path ?? []).map((p) => String(p));
+ if (pathSegs.length === 0) {
+ return `{{${ref.nodeId}}}`;
+ }
+ return `{{${ref.nodeId}.${pathSegs.join('.')}}}`;
+}
+
+function _insertAtCursor(
+ text: string,
+ insert: string,
+ start: number,
+ end: number,
+): { next: string; caret: number } {
+ const next = text.slice(0, start) + insert + text.slice(end);
+ const caret = start + insert.length;
+ return { next, caret };
+}
+
+function _parseTokensInTemplate(
+ template: string,
+ nodes: Array<{ id: string; title?: string }>,
+ getNodeLabel: (n: { id: string; title?: string }) => string,
+): Array<{ raw: string; label: string }> {
+ const out: Array<{ raw: string; label: string }> = [];
+ const seen = new Set
();
+ let m: RegExpExecArray | null;
+ const re = new RegExp(_TEMPLATE_TOKEN_RE.source, 'g');
+ while ((m = re.exec(template)) !== null) {
+ const inner = m[1].trim();
+ if (seen.has(inner)) continue;
+ seen.add(inner);
+ const parts = inner.split('.');
+ const nodeId = parts[0];
+ if (!nodeId) continue;
+ const path = parts.slice(1).map((seg) => (/^\d+$/.test(seg) ? parseInt(seg, 10) : seg));
+ const ref: DataRef = { type: 'ref', nodeId, path };
+ const label = formatRefLabel(ref, nodes, (id) =>
+ getNodeLabel(nodes.find((n) => n.id === id) ?? { id }),
+ );
+ out.push({ raw: m[0], label });
+ }
+ return out;
+}
+
+export const TemplateTextareaRenderer: React.FC = ({ param, value, onChange }) => {
+ const { t } = useLanguage();
+ const dataFlow = useAutomation2DataFlow();
+ const textareaRef = useRef(null);
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
+
+ const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
+ const hasSources = sourceIds.some((id) => {
+ const n = dataFlow?.nodes.find((x) => x.id === id);
+ return n?.type !== 'trigger.manual';
+ });
+
+ const tokenLegend = useMemo(() => {
+ if (!dataFlow || !strVal.includes('{{')) return [];
+ return _parseTokensInTemplate(strVal, dataFlow.nodes, dataFlow.getNodeLabel);
+ }, [strVal, dataFlow]);
+
+ const handlePick = useCallback(
+ (picked: DataRef | SystemVarRef) => {
+ if (isSystemVar(picked)) {
+ setPickerOpen(false);
+ return;
+ }
+ if (!isRef(picked)) {
+ setPickerOpen(false);
+ return;
+ }
+ const token = _refToTemplateToken(picked);
+ const el = textareaRef.current;
+ const start = el?.selectionStart ?? strVal.length;
+ const end = el?.selectionEnd ?? strVal.length;
+ const { next, caret } = _insertAtCursor(strVal, token, start, end);
+ onChange(next);
+ setPickerOpen(false);
+ requestAnimationFrame(() => {
+ const ta = textareaRef.current;
+ if (ta) {
+ ta.focus();
+ ta.setSelectionRange(caret, caret);
+ }
+ });
+ },
+ [onChange, strVal],
+ );
+
+ return (
+
+
+
+
+ {!hasSources && (
+ {t('Keine vorherigen Nodes verfügbar')}
+ )}
+
+
+ );
+};
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
index 595850f..fdd8de8 100644
--- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
@@ -33,6 +33,7 @@ import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { FeatureInstancePicker } from './FeatureInstancePicker';
+import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
const TextInput: React.FC = ({ param, value, onChange }) => (
@@ -743,6 +744,7 @@ const FilterExpressionEditor: React.FC
= ({ param, value, on
export const FRONTEND_TYPE_RENDERERS: Record = {
text: TextInput,
textarea: TextareaInput,
+ templateTextarea: TemplateTextareaRenderer,
number: NumberInput,
checkbox: CheckboxInput,
date: DateInput,
diff --git a/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx b/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx
index e1d39ff..8100d6f 100644
--- a/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx
+++ b/src/components/FlowEditor/nodes/shared/RefSourceSelect.tsx
@@ -91,6 +91,37 @@ function buildFormSchemaPayloadPaths(params: Record): Array<{
return out;
}
+function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
+ const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
+ { path: ['currentItem'], pathLabel: 'currentItem' },
+ { path: ['currentIndex'], pathLabel: 'currentIndex' },
+ { path: ['count'], pathLabel: 'count' },
+ ];
+ if (preview && typeof preview === 'object') {
+ const ci = (preview as Record).currentItem;
+ if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
+ for (const [k, v] of Object.entries(ci as Record)) {
+ paths.push(...buildPickablePaths(v, ['currentItem', k]));
+ }
+ }
+ }
+ return paths;
+}
+
+function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
+ const paths = buildPickablePaths(preview);
+ if (preview && typeof preview === 'object') {
+ const rd = (preview as Record).responseData;
+ if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
+ for (const k of Object.keys(rd as Record)) {
+ const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
+ if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
+ }
+ }
+ }
+ return paths;
+}
+
export function pickPathsForNode(
node: { type?: string; parameters?: Record } | undefined,
preview: unknown,
@@ -113,6 +144,12 @@ export function pickPathsForNode(
if (nt.startsWith('clickup.')) {
return buildClickUpOutputPaths(preview);
}
+ if (nt === 'flow.loop') {
+ return buildLoopCurrentItemPaths(preview);
+ }
+ if (nt === 'ai.prompt') {
+ return buildAiPromptPaths(preview);
+ }
return buildPickablePaths(preview);
}
diff --git a/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts b/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts
index e0bcd44..ad6e6e9 100644
--- a/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts
+++ b/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts
@@ -76,6 +76,32 @@ export function buildNodeOutputPreview(
return _buildSchemaPreview(port0.schema);
}
+function _buildEmailItemPreview(): Record {
+ return {
+ from: { emailAddress: { address: 'sender@example.com', name: 'Sender' } },
+ subject: '...',
+ body: { contentType: 'HTML', content: '...' },
+ receivedDateTime: '2026-01-01T00:00:00Z',
+ toRecipients: [],
+ hasAttachments: false,
+ id: '...',
+ };
+}
+
+function _buildAiResponseDataPreview(params: Record): Record | null {
+ if (params.resultType !== 'json') return null;
+ const prompt = String(params.aiPrompt || params.prompt || '');
+ if (!prompt) return null;
+ const fields: Record = {};
+ const re = /["']?(\w+)["']?\s*:\s*(?:true|false|"[^"]*"|'[^']*'|\d+|boolean|string|number|bool)/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(prompt)) !== null) {
+ const f = m[1];
+ if (f && !['type', 'value', 'key'].includes(f)) fields[f] = '...';
+ }
+ return Object.keys(fields).length > 0 ? fields : null;
+}
+
/** Build full nodeOutputsPreview map from graph */
export function buildNodeOutputsPreview(
nodes: CanvasNode[],
@@ -92,5 +118,32 @@ export function buildNodeOutputsPreview(
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
}
}
+
+ for (const n of nodes) {
+ if (n.id in (nodeOutputsFromRun ?? {})) continue;
+
+ if (n.type === 'flow.loop') {
+ const items = n.parameters?.items;
+ if (items && typeof items === 'object' && (items as { type?: string }).type === 'ref') {
+ const ref = items as { nodeId: string; path?: (string | number)[] };
+ const sourceNode = nodes.find((sn) => sn.id === ref.nodeId);
+ const sourceDef = sourceNode ? typeMap.get(sourceNode.type) : undefined;
+ const sourceSchema = sourceDef?.outputPorts?.[0]?.schema;
+ if (sourceSchema === 'EmailList') {
+ const existing = (result[n.id] ?? {}) as Record;
+ result[n.id] = { ...existing, currentItem: _buildEmailItemPreview() };
+ }
+ }
+ }
+
+ if (n.type === 'ai.prompt' && n.parameters) {
+ const rdPreview = _buildAiResponseDataPreview(n.parameters);
+ if (rdPreview) {
+ const existing = (result[n.id] ?? {}) as Record;
+ result[n.id] = { ...existing, responseData: rdPreview };
+ }
+ }
+ }
+
return result;
}
diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx
index 3941bd6..2ac5c5c 100644
--- a/src/pages/AutomationsDashboardPage.tsx
+++ b/src/pages/AutomationsDashboardPage.tsx
@@ -15,7 +15,7 @@ import { useToast } from '../contexts/ToastContext';
import { usePrompt } from '../hooks/usePrompt';
import { useApiRequest } from '../hooks/useApi';
import { formatUnixTimestamp } from '../utils/time';
-import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
+import { updateWorkflow, executeGraph, deleteSystemWorkflow, fetchWorkspaceRuns, fetchWorkspaceRunDetail, type WorkspaceRun } from '../api/workflowApi';
import { fetchAttributes } from '../api/attributesApi';
import type { AttributeDefinition } from '../api/attributesApi';
import { resolveColumnTypes } from '../utils/columnTypeResolver';
@@ -1060,6 +1060,186 @@ const _WorkflowsTab: React.FC = () => {
);
};
+// ===========================================================================
+// Workspace Tab (user-facing workflow run history)
+// ===========================================================================
+
+const _WorkspaceTab: React.FC = () => {
+ const { t } = useLanguage();
+ const { request } = useApiRequest();
+ const navigate = useNavigate();
+ const [runs, setRuns] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [scope, setScope] = useState<'mine' | 'mandate'>('mine');
+ const [statusFilter, setStatusFilter] = useState('');
+ const [selectedRunId, setSelectedRunId] = useState(null);
+ const [runDetail, setRunDetail] = useState> | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+
+ const _loadRuns = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await fetchWorkspaceRuns(request, {
+ scope,
+ status: statusFilter || undefined,
+ limit: 50,
+ });
+ setRuns(data.runs || []);
+ setTotal(data.total || 0);
+ } catch (e) {
+ console.error('Workspace runs load failed', e);
+ } finally {
+ setLoading(false);
+ }
+ }, [request, scope, statusFilter]);
+
+ useEffect(() => { _loadRuns(); }, [_loadRuns]);
+
+ const _loadDetail = useCallback(async (runId: string) => {
+ setDetailLoading(true);
+ try {
+ const detail = await fetchWorkspaceRunDetail(request, runId);
+ setRunDetail(detail);
+ } catch (e) {
+ console.error('Workspace run detail failed', e);
+ } finally {
+ setDetailLoading(false);
+ }
+ }, [request]);
+
+ useEffect(() => {
+ if (selectedRunId) _loadDetail(selectedRunId);
+ else setRunDetail(null);
+ }, [selectedRunId, _loadDetail]);
+
+ if (selectedRunId && runDetail) {
+ const { run, steps, files, workflow } = runDetail;
+ return (
+
+
+
{run.workflowLabel || run.workflowId}
+
+ {t('Status')}: {run.status}
+ {run.startedAt && {t('Start')}: {formatUnixTimestamp(run.startedAt)}}
+ {run.completedAt && {t('Ende')}: {formatUnixTimestamp(run.completedAt)}}
+ {workflow?.targetFeatureInstanceId && {t('Ziel-Instanz')}: {run.targetInstanceLabel || workflow.targetFeatureInstanceId}}
+ {(run.costTokens ?? 0) > 0 && Tokens: {run.costTokens}}
+
+ {run.error && (
+
+ {run.error}
+
+ )}
+
{t('Schritte')}
+ {steps.length === 0 ? (
+
{t('Keine Schritte protokolliert.')}
+ ) : (
+
+ {steps.map((step) => (
+
+
+
+ {step.status}
+
+ {step.nodeType} ({step.nodeId})
+ {step.durationMs != null && {step.durationMs}ms}
+
+ {step.output && Object.keys(step.output).length > 0 && (
+
+ {JSON.stringify(step.output, null, 2)}
+
+ )}
+ {step.error && {step.error}
}
+
+ ))}
+
+ )}
+ {files.length > 0 && (
+ <>
+
{t('Dokumente')}
+
+ >
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {total} {t('Runs')}
+
+ {loading ? (
+
{t('Laden…')}
+ ) : runs.length === 0 ? (
+
{t('Keine Workflow-Runs gefunden.')}
+ ) : (
+
+
+
+ | {t('Workflow')} |
+ {t('Status')} |
+ {t('Gestartet')} |
+ {t('Ziel-Instanz')} |
+ Tokens |
+
+
+
+ {runs.map((run) => (
+ setSelectedRunId(run.id)}
+ style={{ borderBottom: '1px solid var(--border-color)', cursor: 'pointer' }}
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover, rgba(0,0,0,0.03))')}
+ onMouseLeave={(e) => (e.currentTarget.style.background = '')}
+ >
+ | {run.workflowLabel || run.workflowId} |
+
+
+ {run.status}
+
+ |
+ {run.startedAt ? formatUnixTimestamp(run.startedAt) : '—'} |
+ {run.targetInstanceLabel || '—'} |
+ {run.costTokens ?? 0} |
+
+ ))}
+
+
+ )}
+
+ );
+};
+
// ===========================================================================
// Main page with Tabs
// ===========================================================================
@@ -1078,6 +1258,11 @@ export const AutomationsDashboardPage: React.FC = () => {
label: t('Workflows'),
content: <_WorkflowsTab />,
},
+ {
+ id: 'workspace',
+ label: t('Workspace'),
+ content: <_WorkspaceTab />,
+ },
], [t]);
return (
diff --git a/src/pages/views/trustee/TrusteeAbschlussView.tsx b/src/pages/views/trustee/TrusteeAbschlussView.tsx
index 0a10a88..a8ade64 100644
--- a/src/pages/views/trustee/TrusteeAbschlussView.tsx
+++ b/src/pages/views/trustee/TrusteeAbschlussView.tsx
@@ -8,7 +8,7 @@
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { useSearchParams, useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
@@ -76,6 +76,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
export const TrusteeAbschlussView: React.FC = () => {
const { t } = useLanguage();
+ const navigate = useNavigate();
const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
@@ -325,6 +326,25 @@ export const TrusteeAbschlussView: React.FC = () => {
{runState === 'error' && t('Fehler')}
{runSummary && {runSummary}
}
{runError && {runError}
}
+ {runState === 'completed' && runId && (
+
+
{
+ e.preventDefault();
+ navigate(`/automations?tab=workspace&runId=${runId}`);
+ }}
+ style={{
+ display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
+ padding: '0.375rem 0.75rem', borderRadius: '6px',
+ background: 'var(--primary-color, #007bff)', color: '#fff',
+ fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
+ }}
+ >
+ {t('Im Workspace ansehen')}
+
+
+ )}
)}
diff --git a/src/pages/views/trustee/TrusteeAnalyseView.tsx b/src/pages/views/trustee/TrusteeAnalyseView.tsx
index b3752d4..45f8a39 100644
--- a/src/pages/views/trustee/TrusteeAnalyseView.tsx
+++ b/src/pages/views/trustee/TrusteeAnalyseView.tsx
@@ -8,7 +8,7 @@
*/
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { useSearchParams, useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
@@ -102,6 +102,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
export const TrusteeAnalyseView: React.FC = () => {
const { t } = useLanguage();
+ const navigate = useNavigate();
const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
@@ -481,58 +482,34 @@ export const TrusteeAnalyseView: React.FC = () => {
)}
- {/* Results */}
- {runState === 'completed' && (resultText || resultDocuments.length > 0) && (
+ {/* Workspace link (replaces inline results) */}
+ {runState === 'completed' && runId && (