From 1c539076e5bdaa6d77af5c4973c5da79ccb4ed9e Mon Sep 17 00:00:00 2001 From: Ida Date: Tue, 26 May 2026 10:47:26 +0200 Subject: [PATCH 1/6] fix: canvas loop bug and node placement --- .../editor/Automation2FlowEditor.tsx | 22 +++++++-- .../FlowEditor/editor/FlowCanvas.tsx | 49 ++++++++++++++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 1e8c157..b9923ce 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -158,6 +158,7 @@ export const Automation2FlowEditor: React.FC = ({ in const [versions, setVersions] = useState([]); const [currentVersionId, setCurrentVersionId] = useState(null); const [versionLoading, setVersionLoading] = useState(false); + const didBootstrapEmptyCanvasRef = useRef(false); const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState(instanceId); @@ -598,8 +599,22 @@ export const Automation2FlowEditor: React.FC = ({ in useEffect(() => { if (loading || nodeTypes.length === 0) return; - if (currentWorkflowId || initialWorkflowId) return; - if (canvasNodes.length > 0) return; + if (currentWorkflowId || initialWorkflowId) { + didBootstrapEmptyCanvasRef.current = false; + return; + } + if (didBootstrapEmptyCanvasRef.current) return; + didBootstrapEmptyCanvasRef.current = true; + if (canvasNodes.length === 0 && canvasConnections.length === 0 && invocations.length === 0) { + return; + } + console.debug(`${LOG} bootstrapping empty canvas`, { + currentWorkflowId, + initialWorkflowId, + canvasNodes: canvasNodes.length, + canvasConnections: canvasConnections.length, + invocations: invocations.length, + }); applyGraphWithSync({ nodes: [], connections: [] }, [], { skipHistory: true, }); @@ -609,8 +624,9 @@ export const Automation2FlowEditor: React.FC = ({ in currentWorkflowId, initialWorkflowId, canvasNodes.length, + canvasConnections.length, + invocations.length, applyGraphWithSync, - t, ]); const toggleCategory = useCallback((id: string) => { diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index 5a8010b..77ad2c8 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -20,6 +20,8 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; import { AiBadge } from '../nodes/shared/AiBadge'; import { switchOutputLabel } from '../nodes/shared/graphUtils'; +const LOG = '[FlowCanvas]'; + export interface CanvasNode { id: string; type: string; @@ -842,6 +844,8 @@ export const FlowCanvas = forwardRef(function const onHistoryCheckpointRef = useRef(onHistoryCheckpoint); onHistoryCheckpointRef.current = onHistoryCheckpoint; + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; const emitHistoryCheckpoint = useCallback(() => { onHistoryCheckpointRef.current?.(); @@ -1019,12 +1023,19 @@ export const FlowCanvas = forwardRef(function ] ); + const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({ + nodeId: null, + signature: null, + }); + useEffect(() => { - if (onSelectionChange) { - const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; - onSelectionChange(node); - } - }, [selectedNodeId, nodes, onSelectionChange]); + const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; + const signature = node ? JSON.stringify(node) : null; + const last = lastEmittedSelectionRef.current; + if (last.nodeId === selectedNodeId && last.signature === signature) return; + lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature }; + onSelectionChangeRef.current?.(node); + }, [selectedNodeId, nodes]); const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => { e.stopPropagation(); @@ -1088,6 +1099,11 @@ export const FlowCanvas = forwardRef(function const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); + console.debug(`${LOG} drop received`, { + types: Array.from(e.dataTransfer.types), + clientX: e.clientX, + clientY: e.clientY, + }); // 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab) if (onExternalDrop) { const reservedMimes = new Set([ @@ -1113,16 +1129,35 @@ export const FlowCanvas = forwardRef(function } // 2) Standard: Node-Type aus der NodeSidebar const raw = e.dataTransfer.getData('application/json'); - if (!raw || !containerRef.current) return; + if (!raw || !containerRef.current) { + console.debug(`${LOG} drop ignored`, { + hasRaw: Boolean(raw), + hasContainer: Boolean(containerRef.current), + }); + return; + } try { const { type } = JSON.parse(raw); const el = containerRef.current; const rect = el.getBoundingClientRect(); const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2; const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2; + console.debug(`${LOG} placing node from drop`, { + type, + raw, + dropX: x, + dropY: y, + panOffset, + zoom, + }); onDropNodeType(type, Math.max(0, x), Math.max(0, y)); emitHistoryCheckpoint(); - } catch (_) {} + } catch (error) { + console.debug(`${LOG} drop parse failed`, { + raw, + error, + }); + } }, [onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint] ); From 9d081e881972ba15be2ca5abb631745e562db345 Mon Sep 17 00:00:00 2001 From: Ida Date: Tue, 26 May 2026 12:03:53 +0200 Subject: [PATCH 2/6] fix: node inhalt extrahieren nimmt jetzt context, files page formgenerator und folder tree zeigen jetzt die gleichen elemente --- src/api/fileApi.ts | 2 + .../FlowEditor/editor/NodeConfigPanel.tsx | 81 +++++++++++++++++-- .../providers/FolderFileProvider.tsx | 9 +-- src/hooks/useFiles.ts | 13 ++- src/pages/basedata/FilesPage.tsx | 65 ++++++++++++--- 5 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 75151c3..c153b5f 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -36,6 +36,7 @@ export interface PaginationParams { search?: string; viewKey?: string; groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; + owner?: 'all' | 'me' | 'shared'; } export interface PaginatedResponse { @@ -109,6 +110,7 @@ export async function fetchFiles( if (params.search) paginationObj.search = params.search; if (params.viewKey) paginationObj.viewKey = params.viewKey; if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; + if (params.owner) requestParams.owner = params.owner; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/components/FlowEditor/editor/NodeConfigPanel.tsx b/src/components/FlowEditor/editor/NodeConfigPanel.tsx index c60600b..9f8462c 100644 --- a/src/components/FlowEditor/editor/NodeConfigPanel.tsx +++ b/src/components/FlowEditor/editor/NodeConfigPanel.tsx @@ -9,6 +9,7 @@ import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } f import type { ApiRequestFunction } from '../../../api/workflowApi'; import { getLabel } from '../nodes/shared/utils'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers'; +import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer'; import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker'; import { findRequiredErrors } from '../nodes/shared/paramValidation'; import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext'; @@ -253,6 +254,7 @@ export const NodeConfigPanel: React.FC = ({ node, for (const param of sortedParameters) { if (param.frontendType === 'hidden') continue; + if (param.name === 'context') continue; if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue; if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue; @@ -378,6 +380,15 @@ export const NodeConfigPanel: React.FC = ({ node, t, ]); + const extractContentContextParam = useMemo((): NodeTypeParameter | null => { + if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null; + const param = sortedParameters.find((p) => p.name === 'context') ?? null; + if (!param) return null; + if (param.frontendType === 'hidden') return null; + if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null; + return param; + }, [node, nodeType, sortedParameters, params]); + if (!node || !nodeType) return null; const isTrigger = node.type.startsWith('trigger.'); @@ -483,11 +494,71 @@ export const NodeConfigPanel: React.FC = ({ node, )} {extractContentAccordionItems !== null ? ( - - key={`${node.id}-extract-accordion`} - defaultOpenId={null} - items={extractContentAccordionItems} - /> + <> + {extractContentContextParam ? ( +
+
+ {extractContentContextParam.required && ( + + * + + )} + {verboseSchema && extractContentContextParam.type && ( + + {extractContentContextParam.type} + + )} +
+ updateParam(extractContentContextParam.name, val)} + allParams={params} + instanceId={instanceId} + request={request} + nodeType={node.type} + onPatchParams={patchParams} + /> +
+ ) : null} + {extractContentAccordionItems.length > 0 ? ( + + key={`${node.id}-extract-accordion`} + defaultOpenId={null} + items={extractContentAccordionItems} + /> + ) : null} + ) : ( parameters.map((param: NodeTypeParameter) => { // Safety net: hidden params have no UI footprint at all — no row, diff --git a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx index cb3bb2d..f843fa0 100644 --- a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx @@ -1,7 +1,6 @@ import { FaFolder, FaFile, FaTrash } from 'react-icons/fa'; import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types'; import api from '../../../../api'; -import { getUserDataCache } from '../../../../utils/userCache'; interface FolderData { id: string; @@ -137,7 +136,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { if ((f.parentId ?? null) === null) out.add(f.id); } const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 }); - const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam } }); + const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam, owner } }); const data = filesRes.data; const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data) ? (Array.isArray(data.items) ? data.items : []) @@ -193,7 +192,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { } const paginationParam = JSON.stringify({ filters, pageSize: 500 }); const filesRes = await api.get('/api/files/list', { - params: { pagination: paginationParam }, + params: { pagination: paginationParam, owner }, }); const data = filesRes.data; let rawFiles: FileData[] = []; @@ -203,10 +202,6 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { rawFiles = data; } let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId); - if (ownership === 'shared') { - const myId = getUserDataCache()?.id; - if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId); - } const fileNodes = matched.map((f) => _mapFileToNode(f, ownership)); if (apiParentId === null) { for (const n of fileNodes) n.parentId = synthRootId; diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 704d778..17c9c3e 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -69,6 +69,7 @@ export interface PaginationParams { filters?: Record; search?: string; viewKey?: string; + owner?: 'all' | 'me' | 'shared'; } // Files list hook @@ -150,6 +151,7 @@ export function useUserFiles() { groupField: string; groupDirection?: 'asc' | 'desc'; groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>; + owner?: 'all' | 'me' | 'shared'; }) => { const levels = base.groupByLevels?.length ? base.groupByLevels @@ -164,7 +166,11 @@ export function useUserFiles() { if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort; if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey; const { data } = await api.get('/api/files/list', { - params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) }, + params: { + mode: 'groupSummary', + pagination: JSON.stringify(pObj), + ...(base.owner ? { owner: base.owner } : {}), + }, }); return Array.isArray(data?.groups) ? data.groups : []; }, @@ -192,7 +198,10 @@ export function useUserFiles() { if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search; if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey; const { data } = await api.get('/api/files/list', { - params: { pagination: JSON.stringify(pObj) }, + params: { + pagination: JSON.stringify(pObj), + ...(paginationParams.owner ? { owner: paginationParams.owner } : {}), + }, }); if (data && typeof data === 'object' && 'items' in data) { return { items: data.items, pagination: data.pagination }; diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index e5dbecd..6c7a861 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -31,6 +31,17 @@ interface UserFile { } type ViewMode = 'folder' | 'all'; +type FileOwnerScope = 'all' | 'me' | 'shared'; + +function normalizeFolderFilterId(folderId: string | null): string | null { + if (!folderId) return null; + if (folderId.startsWith('__filesRoot:')) return null; + return folderId; +} + +function isSyntheticRootFolderId(folderId: string | null): boolean { + return Boolean(folderId && folderId.startsWith('__filesRoot:')); +} export const FilesPage: React.FC = () => { const { t } = useLanguage(); @@ -74,6 +85,7 @@ export const FilesPage: React.FC = () => { const [editingFile, setEditingFile] = useState(null); const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFolderId, setSelectedFolderId] = useState(null); + const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own'); const [highlightedFileId, setHighlightedFileId] = useState(null); const [treeWidth, setTreeWidth] = useState(300); @@ -103,14 +115,24 @@ export const FilesPage: React.FC = () => { const _tableRefetch = useCallback(async (params?: any) => { const nextParams = { ...(params || {}) }; const nextFilters = { ...(nextParams.filters || {}) }; - if (viewMode === 'folder' && selectedFolderId) { - nextFilters.folderId = selectedFolderId; + const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); + const rootSelected = isSyntheticRootFolderId(selectedFolderId); + const owner: FileOwnerScope = + selectedOwnership === 'own' + ? 'me' + : selectedOwnership === 'shared' + ? 'shared' + : 'all'; + if (viewMode === 'folder' && selectedFolderId && !rootSelected) { + nextFilters.folderId = normalizedFolderId; } else { delete nextFilters.folderId; } nextParams.filters = nextFilters; + if (owner !== 'all') nextParams.owner = owner; + else delete nextParams.owner; await tableRefetch(nextParams); - }, [tableRefetch, selectedFolderId, viewMode]); + }, [tableRefetch, selectedFolderId, selectedOwnership, viewMode]); const fetchGroupSectionSummaries = useCallback( async (base: { @@ -122,12 +144,20 @@ export const FilesPage: React.FC = () => { groupDirection?: 'asc' | 'desc'; }) => { const filters = { ...(base.filters || {}) }; - if (viewMode === 'folder' && selectedFolderId) { - filters.folderId = selectedFolderId; + const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); + const rootSelected = isSyntheticRootFolderId(selectedFolderId); + if (viewMode === 'folder' && selectedFolderId && !rootSelected) { + filters.folderId = normalizedFolderId; } - return fetchGroupSectionSummariesFromHook({ ...base, filters }); + const owner: FileOwnerScope = + selectedOwnership === 'own' + ? 'me' + : selectedOwnership === 'shared' + ? 'shared' + : 'all'; + return fetchGroupSectionSummariesFromHook({ ...base, filters, owner }); }, - [fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId], + [fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId, selectedOwnership], ); const refetchForSection = useCallback( @@ -137,12 +167,20 @@ export const FilesPage: React.FC = () => { parentColumnFilters?: Record, ) => { const merged = { ...(parentColumnFilters || {}) }; - if (viewMode === 'folder' && selectedFolderId) { - merged.folderId = selectedFolderId; + const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); + const rootSelected = isSyntheticRootFolderId(selectedFolderId); + if (viewMode === 'folder' && selectedFolderId && !rootSelected) { + merged.folderId = normalizedFolderId; } - return refetchForSectionFromHook(paginationParams, sectionFilter, merged); + const owner: FileOwnerScope = + selectedOwnership === 'own' + ? 'me' + : selectedOwnership === 'shared' + ? 'shared' + : 'all'; + return refetchForSectionFromHook({ ...paginationParams, owner }, sectionFilter, merged); }, - [refetchForSectionFromHook, viewMode, selectedFolderId], + [refetchForSectionFromHook, viewMode, selectedFolderId, selectedOwnership], ); const _refreshAll = useCallback(async () => { @@ -152,14 +190,15 @@ export const FilesPage: React.FC = () => { useEffect(() => { _tableRefetch({ page: 1, pageSize: 25 }); - }, [selectedFolderId, viewMode, _tableRefetch]); + }, [selectedFolderId, selectedOwnership, viewMode, _tableRefetch]); // ── Tree interaction ────────────────────────────────────────────────── const _handleTreeNodeClick = useCallback((node: TreeNode) => { + setSelectedOwnership(node.ownership); if (node.type === 'folder') { setSelectedFolderId(node.id); } else if (node.type === 'file') { - setSelectedFolderId(node.parentId); + setSelectedFolderId(node.parentId ?? null); setHighlightedFileId(node.id); requestAnimationFrame(() => { const row = document.querySelector('tr[data-highlighted="true"]'); From 8e67efa0923ce0adfe239fd0f029c5c9d237d672 Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 27 May 2026 10:07:00 +0200 Subject: [PATCH 3/6] feat: Bot antowrten im Vollbild --- src/pages/views/teamsbot/Teamsbot.module.css | 46 +++++ .../views/teamsbot/TeamsbotSessionView.tsx | 159 +++++++++++++----- 2 files changed, 159 insertions(+), 46 deletions(-) diff --git a/src/pages/views/teamsbot/Teamsbot.module.css b/src/pages/views/teamsbot/Teamsbot.module.css index 19414b2..e1b5e03 100644 --- a/src/pages/views/teamsbot/Teamsbot.module.css +++ b/src/pages/views/teamsbot/Teamsbot.module.css @@ -851,6 +851,52 @@ background: var(--surface-alt, #fafafa); } +.panelTitleBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--surface-alt, #fafafa); + flex-shrink: 0; +} + +.panelTitleBar .panelTitle { + padding: 0; + border: none; + background: none; + flex: 1; + min-width: 0; +} + +.panelExpandBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 4px; + background: var(--surface-color, #fff); + color: var(--text-secondary, #666); + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.panelExpandBtn:hover { + background: var(--surface-alt, #f5f5f5); + color: var(--primary-color, #4A90D9); + border-color: var(--primary-color, #4A90D9); +} + +.popupPanelList { + max-height: none; + padding: 0; +} + .transcriptList, .responseList { flex: 1; diff --git a/src/pages/views/teamsbot/TeamsbotSessionView.tsx b/src/pages/views/teamsbot/TeamsbotSessionView.tsx index 9e8febd..151f73c 100644 --- a/src/pages/views/teamsbot/TeamsbotSessionView.tsx +++ b/src/pages/views/teamsbot/TeamsbotSessionView.tsx @@ -25,6 +25,7 @@ import styles from './Teamsbot.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { Popup } from '../../../components/UiComponents/Popup'; /** * TeamsbotSessionView - Live session view with real-time transcript and bot responses. @@ -54,6 +55,8 @@ export const TeamsbotSessionView: React.FC = () => { const [screenshotsLoading, setScreenshotsLoading] = useState(false); const [screenshotsLoaded, setScreenshotsLoaded] = useState(false); const [screenshotsExpanded, setScreenshotsExpanded] = useState(false); + const [transcriptPopupOpen, setTranscriptPopupOpen] = useState(false); + const [botResponsesPopupOpen, setBotResponsesPopupOpen] = useState(false); const [ttsStatusEvents, setTtsStatusEvents] = useState { return colors[Math.abs(hash) % colors.length]; }; + const _renderExpandIcon = () => ( + + + + ); + + const _renderTranscriptList = (endRef?: React.RefObject) => ( + <> + {transcripts.map((seg) => ( +
+ {_formatTime(seg.timestamp)} + + {seg.speaker || t('Unbekannt')}: + + {seg.text} +
+ ))} + {endRef &&
} + {transcripts.length === 0 && ( +
{t('Noch kein Transkript vorhanden')}
+ )} + + ); + + const _renderBotResponsesList = () => ( + <> + {botResponses.map((r) => ( +
+
+ {r.detectedIntent} + {_formatTime(r.timestamp || '')} +
+
+ {r.responseText || ''} +
+ {r.reasoning && ( +
+ {t('Begründung: {text}', { text: r.reasoning })} +
+ )} + {(r.modelName || r.processingTime != null) && ( +
+ {r.modelName || ''} + {r.processingTime != null && {r.processingTime.toFixed(1)}s} + {r.priceCHF != null && {r.priceCHF.toFixed(4)} CHF} +
+ )} +
+ ))} + {botResponses.length === 0 && ( +
{t('Noch keine Botantworten')}
+ )} + + ); + if (loading) return
{t('Sitzung laden')}
; if (noSessions) return (
@@ -1154,63 +1215,69 @@ export const TeamsbotSessionView: React.FC = () => {
{/* Left: Transcript */}
-

- {t('Transkript ({count} Segmente)', { count: transcripts.length })} -

+
+

+ {t('Transkript ({count} Segmente)', { count: transcripts.length })} +

+ +
- {transcripts.map((seg) => ( -
- {_formatTime(seg.timestamp)} - - {seg.speaker || t('Unbekannt')}: - - {seg.text} -
- ))} -
- {transcripts.length === 0 && ( -
{t('Noch kein Transkript vorhanden')}
- )} + {_renderTranscriptList(transcriptEndRef)}
{/* Right: Bot Responses */}
-

Bot-Antworten ({botResponses.length})

+
+

Bot-Antworten ({botResponses.length})

+ +
- {botResponses.map((r) => ( -
-
- {r.detectedIntent} - {_formatTime(r.timestamp || '')} -
-
- {r.responseText || ''} -
- {r.reasoning && ( -
- {t('Begründung: {text}', { text: r.reasoning })} -
- )} - {(r.modelName || r.processingTime != null) && ( -
- {r.modelName || ''} - {r.processingTime != null && {r.processingTime.toFixed(1)}s} - {r.priceCHF != null && {r.priceCHF.toFixed(4)} CHF} -
- )} -
- ))} - {botResponses.length === 0 && ( -
{t('Noch keine Botantworten')}
- )} + {_renderBotResponsesList()}
+ setTranscriptPopupOpen(false)} + size="fullscreen" + closeOnBackdropClick + > +
+ {_renderTranscriptList()} +
+
+ + setBotResponsesPopupOpen(false)} + size="fullscreen" + closeOnBackdropClick + > +
+ {_renderBotResponsesList()} +
+
+ {/* Summary (for ended sessions) */} {session.summary && (
From ece5f17e2a23915b2b993602bd4a60c99e705441 Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 27 May 2026 10:25:09 +0200 Subject: [PATCH 4/6] feat: New Chat, moved new chat button, fixed reloading animation to be more user friendly --- src/components/UnifiedDataBar/ChatsTab.tsx | 78 ++++++++++++++----- .../UnifiedDataBar/UnifiedDataBar.tsx | 6 +- src/pages/views/workspace/WorkspacePage.tsx | 34 +++++++- 3 files changed, 92 insertions(+), 26 deletions(-) diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx index 86e474e..5599615 100644 --- a/src/components/UnifiedDataBar/ChatsTab.tsx +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -29,7 +29,7 @@ interface ChatsTabProps { onSelectChat?: (chatId: string, featureInstanceId: string) => void; onDragStart?: (chatId: string, event: React.DragEvent) => void; activeWorkflowId?: string; - onCreateNew?: () => void; + chatListRefreshKey?: number; onRenameChat?: (chatId: string, newName: string) => void | Promise; onDeleteChat?: (chatId: string) => void | Promise; } @@ -72,7 +72,7 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart, activeWorkflowId, - onCreateNew, + chatListRefreshKey, onRenameChat, onDeleteChat, }) => { @@ -82,13 +82,14 @@ const ChatsTab: React.FC = ({ context, const [search, setSearch] = useState(''); const [filter, setFilter] = useState('active'); const [expandedGroups, setExpandedGroups] = useState>(new Set()); - const [loading, setLoading] = useState(true); + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const renameInputRef = useRef(null); + const groupsRef = useRef(groups); + groupsRef.current = groups; const _loadChats = useCallback(async (serverSearch?: string) => { - setLoading(true); try { const params: Record = { includeArchived: true }; if (serverSearch) params.search = serverSearch; @@ -140,7 +141,7 @@ const ChatsTab: React.FC = ({ context, } catch (err) { console.error('Failed to load chats:', err); } finally { - setLoading(false); + setHasLoadedOnce(true); } }, [context.instanceId, t]); @@ -163,6 +164,12 @@ const ChatsTab: React.FC = ({ context, } }, [activeWorkflowId]); + useEffect(() => { + if (chatListRefreshKey) { + _loadChats(); + } + }, [chatListRefreshKey, _loadChats]); + useEffect(() => { if (editingId && renameInputRef.current) { renameInputRef.current.focus(); @@ -188,8 +195,18 @@ const ChatsTab: React.FC = ({ context, const trimmed = editName.trim(); setEditingId(null); if (!trimmed || !onRenameChat) return; - await onRenameChat(chatId, trimmed); - _loadChats(); + const prev = groupsRef.current; + setGroups(gs => gs.map(g => ({ + ...g, + chats: g.chats.map(c => (c.id === chatId ? { ...c, label: trimmed } : c)), + }))); + try { + await onRenameChat(chatId, trimmed); + _loadChats(); + } catch (err) { + console.error('Failed to rename chat:', err); + setGroups(prev); + } }; const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => { @@ -201,23 +218,41 @@ const ChatsTab: React.FC = ({ context, } }; + const _setChatStatus = useCallback((chatId: string, status: string) => { + setGroups(gs => gs.map(g => ({ + ...g, + chats: g.chats.map(c => (c.id === chatId ? { ...c, status } : c)), + }))); + }, []); + + const _removeChat = useCallback((chatId: string) => { + setGroups(gs => gs.map(g => ({ + ...g, + chats: g.chats.filter(c => c.id !== chatId), + })).filter(g => g.chats.length > 0)); + }, []); + const _archiveChat = useCallback(async (chatId: string) => { + const prev = groupsRef.current; + _setChatStatus(chatId, 'archived'); try { await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' }); - _loadChats(); } catch (err) { console.error('Failed to archive chat:', err); + setGroups(prev); } - }, [context.instanceId, _loadChats]); + }, [context.instanceId, _setChatStatus]); const _restoreChat = useCallback(async (chatId: string) => { + const prev = groupsRef.current; + _setChatStatus(chatId, 'active'); try { await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' }); - _loadChats(); } catch (err) { console.error('Failed to restore chat:', err); + setGroups(prev); } - }, [context.instanceId, _loadChats]); + }, [context.instanceId, _setChatStatus]); const _isArchived = (chat: ChatItem) => chat.status === 'archived'; @@ -311,7 +346,17 @@ const ChatsTab: React.FC = ({ context, {onDeleteChat && ( - )}
)} - {_allChats.length === 0 && ( + {hasLoadedOnce && _allChats.length === 0 && (
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index 4eaf16a..2366c2c 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -47,8 +47,8 @@ interface UnifiedDataBarProps { hideTabs?: UdbTab[]; onSelectChat?: (chatId: string, featureInstanceId: string) => void; activeWorkflowId?: string; - onCreateNewChat?: () => void; onRenameChat?: (chatId: string, newName: string) => void; + chatListRefreshKey?: number; onDeleteChat?: (chatId: string) => void; onChatDragStart?: (chatId: string, event: React.DragEvent) => void; onFileSelect?: (fileId: string, fileName?: string) => void; @@ -78,8 +78,8 @@ const UnifiedDataBar: React.FC = ({ hideTabs, onSelectChat, activeWorkflowId, - onCreateNewChat, onRenameChat, + chatListRefreshKey, onDeleteChat, onChatDragStart, onFileSelect, @@ -122,7 +122,7 @@ const UnifiedDataBar: React.FC = ({ onSelectChat={onSelectChat} onDragStart={onChatDragStart} activeWorkflowId={activeWorkflowId} - onCreateNew={onCreateNewChat} + chatListRefreshKey={chatListRefreshKey} onRenameChat={onRenameChat} onDeleteChat={onDeleteChat} /> diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index 27b913d..9948da4 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -94,6 +94,7 @@ export const WorkspacePage: React.FC = ({ persistentInstance ); const [mobileLeftOpen, setMobileLeftOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false); + const [chatListRefreshKey, setChatListRefreshKey] = useState(0); useEffect(() => { const _handleResize = () => { @@ -254,6 +255,27 @@ export const WorkspacePage: React.FC = ({ persistentInstance workspace.loadWorkflow(wfId); }; + const sidebarHeaderBtnStyle: React.CSSProperties = { + background: 'none', + border: 'none', + cursor: 'pointer', + fontSize: 14, + color: '#888', + }; + + const createChatBtnStyle: React.CSSProperties = { + ...sidebarHeaderBtnStyle, + fontSize: 20, + fontWeight: 700, + lineHeight: 1, + color: 'var(--text-secondary, #555)', + }; + + const _handleCreateNewChat = useCallback(() => { + workspace.resetToNew(); + setChatListRefreshKey(k => k + 1); + }, [workspace]); + const tabButtonStyle = (active: boolean): React.CSSProperties => ({ flex: 1, padding: '6px 0', @@ -356,7 +378,7 @@ export const WorkspacePage: React.FC = ({ persistentInstance onTabChange={setUdbTab} onSelectChat={_handleConversationSelect} activeWorkflowId={workspace.workflowId ?? undefined} - onCreateNewChat={workspace.resetToNew} + chatListRefreshKey={chatListRefreshKey} onRenameChat={_handleRenameChat} onDeleteChat={_handleDeleteChat} onFileSelect={_handleFileSelect} @@ -408,7 +430,10 @@ export const WorkspacePage: React.FC = ({ persistentInstance }}>
{t('Workspace')} - +
+ + +
{_leftPanelBody} @@ -604,7 +629,10 @@ export const WorkspacePage: React.FC = ({ persistentInstance >
{t('Workspace')} - +
+ + +
{_leftPanelBody} From 5aacf17b13b42ced708f242801b37b64177cb5b6 Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 27 May 2026 10:38:01 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20Filter=20zur=C3=BCcksetzen=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FilterSearchInput.module.css | 48 +++++++++++++ .../FilterSearchInput/FilterSearchInput.tsx | 69 +++++++++++++++++++ .../FormGenerator/FilterSearchInput/index.ts | 2 + .../FormGeneratorControls.module.css | 2 +- .../FormGeneratorControls.tsx | 12 ++-- .../FormGeneratorTable/FormGeneratorTable.tsx | 20 ++---- 6 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 src/components/FormGenerator/FilterSearchInput/FilterSearchInput.module.css create mode 100644 src/components/FormGenerator/FilterSearchInput/FilterSearchInput.tsx create mode 100644 src/components/FormGenerator/FilterSearchInput/index.ts diff --git a/src/components/FormGenerator/FilterSearchInput/FilterSearchInput.module.css b/src/components/FormGenerator/FilterSearchInput/FilterSearchInput.module.css new file mode 100644 index 0000000..9756ac2 --- /dev/null +++ b/src/components/FormGenerator/FilterSearchInput/FilterSearchInput.module.css @@ -0,0 +1,48 @@ +.wrapper { + position: relative; + width: 100%; +} + +.input { + width: 100%; + padding: 3px 30px 3px 6px; + font-size: 12px; + border: 1px solid var(--border-color, #ccc); + border-radius: 3px; + outline: none; + box-sizing: border-box; + background: var(--color-bg, #fff); + color: var(--color-text, #334155); +} + +.input:focus { + border-color: var(--primary-color, #F25843); +} + +.input::placeholder { + color: var(--color-text-muted, #94a3b8); +} + +.clearBtn { + position: absolute; + right: 5px; + top: 3px; + bottom: 3px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 25px; + padding: 0; + border: none; + border-radius: 3px; + background: none; + cursor: pointer; + font-size: 25px; + line-height: 1; + color: var(--color-text-secondary, #94a3b8); +} + +.clearBtn:hover { + background: none; + color: var(--color-text-secondary, #94a3b8); +} diff --git a/src/components/FormGenerator/FilterSearchInput/FilterSearchInput.tsx b/src/components/FormGenerator/FilterSearchInput/FilterSearchInput.tsx new file mode 100644 index 0000000..00aaf29 --- /dev/null +++ b/src/components/FormGenerator/FilterSearchInput/FilterSearchInput.tsx @@ -0,0 +1,69 @@ +import React, { type Ref } from 'react'; +import styles from './FilterSearchInput.module.css'; + +export interface FilterSearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + inputRef?: Ref; + onInputClick?: (e: React.MouseEvent) => void; + onFocus?: () => void; + onBlur?: () => void; + /** When set, only `inputClassName` styles the input (for floating-label toolbar search). */ + variant?: 'compact' | 'inherit'; + inputClassName?: string; + wrapperClassName?: string; + clearTitle?: string; +} + +export function FilterSearchInput({ + value, + onChange, + placeholder = 'Filter...', + inputRef, + onInputClick, + onFocus, + onBlur, + variant = 'compact', + inputClassName, + wrapperClassName, + clearTitle = 'Eingabe löschen', +}: FilterSearchInputProps) { + const inputClass = variant === 'inherit' + ? inputClassName + : inputClassName + ? `${styles.input} ${inputClassName}` + : styles.input; + + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + className={inputClass} + onClick={onInputClick} + onFocus={onFocus} + onBlur={onBlur} + /> + {value && ( + + )} +
+ ); +} diff --git a/src/components/FormGenerator/FilterSearchInput/index.ts b/src/components/FormGenerator/FilterSearchInput/index.ts new file mode 100644 index 0000000..7b8e18a --- /dev/null +++ b/src/components/FormGenerator/FilterSearchInput/index.ts @@ -0,0 +1,2 @@ +export { FilterSearchInput } from './FilterSearchInput'; +export type { FilterSearchInputProps } from './FilterSearchInput'; diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css index 329e7a3..6bf10a7 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.module.css @@ -168,7 +168,7 @@ .searchInput { width: 100%; height: 40px; - padding: 8px 12px; + padding: 8px 28px 8px 12px; border: 1px solid var(--color-border, #E2E8F0); border-radius: 6px; font-size: 14px; diff --git a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx index e57fce2..7d23d9f 100644 --- a/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx +++ b/src/components/FormGenerator/FormGeneratorControls/FormGeneratorControls.tsx @@ -2,6 +2,7 @@ import React from 'react'; import type { IconType } from 'react-icons'; import { useLanguage } from '../../../providers/language/LanguageContext'; import styles from './FormGeneratorControls.module.css'; +import { FilterSearchInput } from '../FilterSearchInput'; import { Button } from '../../UiComponents/Button'; import { IoIosRefresh } from "react-icons/io"; import { FaTrash, FaDownload } from "react-icons/fa"; @@ -189,14 +190,15 @@ export function FormGeneratorControls({
{searchable && (
- onSearchChange(e.target.value)} + onChange={onSearchChange} + placeholder=" " onFocus={() => onSearchFocus(true)} onBlur={() => onSearchFocus(false)} - className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`} + inputClassName={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`} + clearTitle={t('Suche löschen')} />