Merge remote-tracking branch 'origin/int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s

This commit is contained in:
ValueOn AG 2026-06-01 00:01:47 +02:00
commit e727996a18
20 changed files with 767 additions and 156 deletions

View file

@ -36,6 +36,7 @@ export interface PaginationParams {
search?: string; search?: string;
viewKey?: string; viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
owner?: 'all' | 'me' | 'shared';
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -109,6 +110,7 @@ export async function fetchFiles(
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey; if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (params.owner) requestParams.owner = params.owner;
if (Object.keys(paginationObj).length > 0) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -158,6 +158,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [versions, setVersions] = useState<AutoVersion[]>([]); const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null); const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false); const [versionLoading, setVersionLoading] = useState(false);
const didBootstrapEmptyCanvasRef = useRef(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId); const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
@ -598,8 +599,22 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
useEffect(() => { useEffect(() => {
if (loading || nodeTypes.length === 0) return; if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return; if (currentWorkflowId || initialWorkflowId) {
if (canvasNodes.length > 0) return; 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: [] }, [], { applyGraphWithSync({ nodes: [], connections: [] }, [], {
skipHistory: true, skipHistory: true,
}); });
@ -609,8 +624,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
currentWorkflowId, currentWorkflowId,
initialWorkflowId, initialWorkflowId,
canvasNodes.length, canvasNodes.length,
canvasConnections.length,
invocations.length,
applyGraphWithSync, applyGraphWithSync,
t,
]); ]);
const toggleCategory = useCallback((id: string) => { const toggleCategory = useCallback((id: string) => {

View file

@ -20,6 +20,8 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge'; import { AiBadge } from '../nodes/shared/AiBadge';
import { switchOutputLabel } from '../nodes/shared/graphUtils'; import { switchOutputLabel } from '../nodes/shared/graphUtils';
const LOG = '[FlowCanvas]';
export interface CanvasNode { export interface CanvasNode {
id: string; id: string;
type: string; type: string;
@ -842,6 +844,8 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
const onHistoryCheckpointRef = useRef(onHistoryCheckpoint); const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
onHistoryCheckpointRef.current = onHistoryCheckpoint; onHistoryCheckpointRef.current = onHistoryCheckpoint;
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
const emitHistoryCheckpoint = useCallback(() => { const emitHistoryCheckpoint = useCallback(() => {
onHistoryCheckpointRef.current?.(); onHistoryCheckpointRef.current?.();
@ -1019,12 +1023,19 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
] ]
); );
const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({
nodeId: null,
signature: null,
});
useEffect(() => { useEffect(() => {
if (onSelectionChange) { const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; const signature = node ? JSON.stringify(node) : null;
onSelectionChange(node); const last = lastEmittedSelectionRef.current;
} if (last.nodeId === selectedNodeId && last.signature === signature) return;
}, [selectedNodeId, nodes, onSelectionChange]); lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature };
onSelectionChangeRef.current?.(node);
}, [selectedNodeId, nodes]);
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => { const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
e.stopPropagation(); e.stopPropagation();
@ -1088,6 +1099,11 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
const handleDrop = useCallback( const handleDrop = useCallback(
async (e: React.DragEvent) => { async (e: React.DragEvent) => {
e.preventDefault(); 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) // 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
if (onExternalDrop) { if (onExternalDrop) {
const reservedMimes = new Set([ const reservedMimes = new Set([
@ -1113,16 +1129,35 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
} }
// 2) Standard: Node-Type aus der NodeSidebar // 2) Standard: Node-Type aus der NodeSidebar
const raw = e.dataTransfer.getData('application/json'); 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 { try {
const { type } = JSON.parse(raw); const { type } = JSON.parse(raw);
const el = containerRef.current; const el = containerRef.current;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2; const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 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)); onDropNodeType(type, Math.max(0, x), Math.max(0, y));
emitHistoryCheckpoint(); emitHistoryCheckpoint();
} catch (_) {} } catch (error) {
console.debug(`${LOG} drop parse failed`, {
raw,
error,
});
}
}, },
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint] [onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
); );

View file

@ -9,6 +9,7 @@ import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } f
import type { ApiRequestFunction } from '../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../api/workflowApi';
import { getLabel } from '../nodes/shared/utils'; import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker'; import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation'; import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
@ -253,6 +254,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
for (const param of sortedParameters) { for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue; if (param.frontendType === 'hidden') continue;
if (param.name === 'context') continue;
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue; if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue; if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
@ -378,6 +380,15 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
t, 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; if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.'); const isTrigger = node.type.startsWith('trigger.');
@ -483,11 +494,71 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
</div> </div>
)} )}
{extractContentAccordionItems !== null ? ( {extractContentAccordionItems !== null ? (
<AccordionList<string> <>
key={`${node.id}-extract-accordion`} {extractContentContextParam ? (
defaultOpenId={null} <div
items={extractContentAccordionItems} key={`${node.id}-${extractContentContextParam.name}`}
/> style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{extractContentContextParam.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && extractContentContextParam.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{extractContentContextParam.type}
</span>
)}
</div>
<ContextBuilderRenderer
param={extractContentContextParam}
value={workflowParamUiValue(params, extractContentContextParam)}
onChange={(val: unknown) => updateParam(extractContentContextParam.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
) : null}
{extractContentAccordionItems.length > 0 ? (
<AccordionList<string>
key={`${node.id}-extract-accordion`}
defaultOpenId={null}
items={extractContentAccordionItems}
/>
) : null}
</>
) : ( ) : (
parameters.map((param: NodeTypeParameter) => { parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row, // Safety net: hidden params have no UI footprint at all — no row,

View file

@ -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);
}

View file

@ -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<HTMLInputElement>;
onInputClick?: (e: React.MouseEvent<HTMLInputElement>) => 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 (
<div className={wrapperClassName ? `${styles.wrapper} ${wrapperClassName}` : styles.wrapper}>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputClass}
onClick={onInputClick}
onFocus={onFocus}
onBlur={onBlur}
/>
{value && (
<button
type="button"
className={styles.clearBtn}
onClick={(e) => {
e.stopPropagation();
onChange('');
}}
onMouseDown={(e) => e.preventDefault()}
title={clearTitle}
tabIndex={-1}
aria-label={clearTitle}
>
×
</button>
)}
</div>
);
}

View file

@ -0,0 +1,2 @@
export { FilterSearchInput } from './FilterSearchInput';
export type { FilterSearchInputProps } from './FilterSearchInput';

View file

@ -168,7 +168,7 @@
.searchInput { .searchInput {
width: 100%; width: 100%;
height: 40px; height: 40px;
padding: 8px 12px; padding: 8px 28px 8px 12px;
border: 1px solid var(--color-border, #E2E8F0); border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;

View file

@ -2,6 +2,7 @@ import React from 'react';
import type { IconType } from 'react-icons'; import type { IconType } from 'react-icons';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorControls.module.css'; import styles from './FormGeneratorControls.module.css';
import { FilterSearchInput } from '../FilterSearchInput';
import { Button } from '../../UiComponents/Button'; import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io"; import { IoIosRefresh } from "react-icons/io";
import { FaTrash, FaDownload } from "react-icons/fa"; import { FaTrash, FaDownload } from "react-icons/fa";
@ -189,14 +190,15 @@ export function FormGeneratorControls({
<div className={styles.searchContainer}> <div className={styles.searchContainer}>
{searchable && ( {searchable && (
<div className={styles.floatingLabelInput}> <div className={styles.floatingLabelInput}>
<input <FilterSearchInput
type="text" variant="inherit"
placeholder=" "
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={onSearchChange}
placeholder=" "
onFocus={() => onSearchFocus(true)} onFocus={() => onSearchFocus(true)}
onBlur={() => onSearchFocus(false)} onBlur={() => onSearchFocus(false)}
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`} inputClassName={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
clearTitle={t('Suche löschen')}
/> />
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}> <label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
{t('Suchen...')} {t('Suchen...')}

View file

@ -69,6 +69,7 @@ import {
import { formatUnixTimestamp } from '../../../utils/time'; import { formatUnixTimestamp } from '../../../utils/time';
import { applyFrontendFormat } from '../../../utils/applyFrontendFormat'; import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
import { FormGeneratorControls } from '../FormGeneratorControls'; import { FormGeneratorControls } from '../FormGeneratorControls';
import { FilterSearchInput } from '../FilterSearchInput';
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue'; import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
import { import {
isDateTimeType, isDateTimeType,
@ -446,22 +447,11 @@ function FilterValuesList({
<> <>
{showSearch && ( {showSearch && (
<div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}> <div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}>
<input <FilterSearchInput
ref={searchInputRef} inputRef={searchInputRef}
type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); setDisplayCount(_FILTER_PAGE_SIZE); }} onChange={(value) => { setSearchTerm(value); setDisplayCount(_FILTER_PAGE_SIZE); }}
placeholder="Filter..." onInputClick={(e) => e.stopPropagation()}
style={{
width: '100%',
padding: '3px 6px',
fontSize: '12px',
border: '1px solid var(--border-color, #ccc)',
borderRadius: '3px',
outline: 'none',
boxSizing: 'border-box',
}}
onClick={(e) => e.stopPropagation()}
/> />
</div> </div>
)} )}

View file

@ -1,7 +1,6 @@
import { FaFolder, FaFile, FaTrash } from 'react-icons/fa'; import { FaFolder, FaFile, FaTrash } from 'react-icons/fa';
import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types'; import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types';
import api from '../../../../api'; import api from '../../../../api';
import { getUserDataCache } from '../../../../utils/userCache';
interface FolderData { interface FolderData {
id: string; id: string;
@ -137,7 +136,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
if ((f.parentId ?? null) === null) out.add(f.id); if ((f.parentId ?? null) === null) out.add(f.id);
} }
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 }); 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 data = filesRes.data;
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data) const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
? (Array.isArray(data.items) ? data.items : []) ? (Array.isArray(data.items) ? data.items : [])
@ -193,7 +192,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
} }
const paginationParam = JSON.stringify({ filters, pageSize: 500 }); const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', { const filesRes = await api.get('/api/files/list', {
params: { pagination: paginationParam }, params: { pagination: paginationParam, owner },
}); });
const data = filesRes.data; const data = filesRes.data;
let rawFiles: FileData[] = []; let rawFiles: FileData[] = [];
@ -203,10 +202,6 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
rawFiles = data; rawFiles = data;
} }
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId); 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)); const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
if (apiParentId === null) { if (apiParentId === null) {
for (const n of fileNodes) n.parentId = synthRootId; for (const n of fileNodes) n.parentId = synthRootId;

View file

@ -29,7 +29,7 @@ interface ChatsTabProps {
onSelectChat?: (chatId: string, featureInstanceId: string) => void; onSelectChat?: (chatId: string, featureInstanceId: string) => void;
onDragStart?: (chatId: string, event: React.DragEvent) => void; onDragStart?: (chatId: string, event: React.DragEvent) => void;
activeWorkflowId?: string; activeWorkflowId?: string;
onCreateNew?: () => void; chatListRefreshKey?: number;
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>; onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
onDeleteChat?: (chatId: string) => void | Promise<void>; onDeleteChat?: (chatId: string) => void | Promise<void>;
} }
@ -72,7 +72,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
onSelectChat, onSelectChat,
onDragStart, onDragStart,
activeWorkflowId, activeWorkflowId,
onCreateNew, chatListRefreshKey,
onRenameChat, onRenameChat,
onDeleteChat, onDeleteChat,
}) => { }) => {
@ -82,13 +82,14 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ChatFilter>('active'); const [filter, setFilter] = useState<ChatFilter>('active');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true); const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
const groupsRef = useRef(groups);
groupsRef.current = groups;
const _loadChats = useCallback(async (serverSearch?: string) => { const _loadChats = useCallback(async (serverSearch?: string) => {
setLoading(true);
try { try {
const params: Record<string, unknown> = { includeArchived: true }; const params: Record<string, unknown> = { includeArchived: true };
if (serverSearch) params.search = serverSearch; if (serverSearch) params.search = serverSearch;
@ -140,7 +141,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
} catch (err) { } catch (err) {
console.error('Failed to load chats:', err); console.error('Failed to load chats:', err);
} finally { } finally {
setLoading(false); setHasLoadedOnce(true);
} }
}, [context.instanceId, t]); }, [context.instanceId, t]);
@ -163,6 +164,12 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
} }
}, [activeWorkflowId]); }, [activeWorkflowId]);
useEffect(() => {
if (chatListRefreshKey) {
_loadChats();
}
}, [chatListRefreshKey, _loadChats]);
useEffect(() => { useEffect(() => {
if (editingId && renameInputRef.current) { if (editingId && renameInputRef.current) {
renameInputRef.current.focus(); renameInputRef.current.focus();
@ -188,8 +195,18 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
const trimmed = editName.trim(); const trimmed = editName.trim();
setEditingId(null); setEditingId(null);
if (!trimmed || !onRenameChat) return; if (!trimmed || !onRenameChat) return;
await onRenameChat(chatId, trimmed); const prev = groupsRef.current;
_loadChats(); 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) => { const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
@ -201,23 +218,41 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ 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 _archiveChat = useCallback(async (chatId: string) => {
const prev = groupsRef.current;
_setChatStatus(chatId, 'archived');
try { try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' }); await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
_loadChats();
} catch (err) { } catch (err) {
console.error('Failed to archive chat:', err); console.error('Failed to archive chat:', err);
setGroups(prev);
} }
}, [context.instanceId, _loadChats]); }, [context.instanceId, _setChatStatus]);
const _restoreChat = useCallback(async (chatId: string) => { const _restoreChat = useCallback(async (chatId: string) => {
const prev = groupsRef.current;
_setChatStatus(chatId, 'active');
try { try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' }); await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
_loadChats();
} catch (err) { } catch (err) {
console.error('Failed to restore chat:', err); console.error('Failed to restore chat:', err);
setGroups(prev);
} }
}, [context.instanceId, _loadChats]); }, [context.instanceId, _setChatStatus]);
const _isArchived = (chat: ChatItem) => chat.status === 'archived'; const _isArchived = (chat: ChatItem) => chat.status === 'archived';
@ -311,7 +346,17 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
{onDeleteChat && ( {onDeleteChat && (
<button <button
className={`${styles.actionBtn} ${styles.actionBtnDanger}`} className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }} onClick={async (e) => {
e.stopPropagation();
const prev = groupsRef.current;
_removeChat(chat.id);
try {
await onDeleteChat(chat.id);
} catch (err) {
console.error('Failed to delete chat:', err);
setGroups(prev);
}
}}
title={t('Löschen')} title={t('Löschen')}
> >
🗑 🗑
@ -334,8 +379,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
return labels[code] || code; return labels[code] || code;
}; };
if (loading) return <div className={styles.loading}>{t('Chats werden geladen…')}</div>;
return ( return (
<div className={styles.chatsTab}> <div className={styles.chatsTab}>
<div className={styles.toolbar}> <div className={styles.toolbar}>
@ -346,11 +389,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
{onCreateNew && (
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title={t('Neuer Chat')}>
+
</button>
)}
<button <button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`} className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)} onClick={() => setFlatMode(!flatMode)}
@ -437,7 +475,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
</div> </div>
)} )}
{_allChats.length === 0 && ( {hasLoadedOnce && _allChats.length === 0 && (
<div className={styles.emptyState}> <div className={styles.emptyState}>
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')} {filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
</div> </div>

View file

@ -81,6 +81,60 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.uploadCircleButton {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: #f25843;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.uploadCircleButton:disabled {
cursor: not-allowed;
}
.uploadCircleWrap {
position: relative;
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.uploadCircleSvg {
position: absolute;
inset: 0;
transform: rotate(-90deg);
}
.uploadCircleTrack {
fill: none;
stroke: rgba(242, 88, 67, 0.25);
stroke-width: 2;
}
.uploadCircleProgress {
fill: none;
stroke: #f25843;
stroke-width: 2;
stroke-linecap: round;
transition: stroke-dashoffset 120ms linear;
}
.uploadCircleText {
font-size: 8px;
font-weight: 700;
line-height: 1;
color: #f25843;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.fileRow:hover { .fileRow:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);

View file

@ -28,6 +28,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const uploadRunIdRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const provider = useMemo(() => createFolderFileProvider(), []); const provider = useMemo(() => createFolderFileProvider(), []);
@ -54,21 +56,41 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return; if (!context.instanceId || uploading) return;
uploadRunIdRef.current += 1;
const runId = uploadRunIdRef.current;
setUploading(true); setUploading(true);
setUploadProgressPercent(0);
try { try {
for (const file of Array.from(fileList)) { const files = Array.from(fileList);
const totalFiles = files.length || 1;
for (const [index, file] of files.entries()) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('featureInstanceId', context.instanceId); formData.append('featureInstanceId', context.instanceId);
await api.post('/api/files/upload', formData, { await api.post('/api/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: progressEvent => {
if (uploadRunIdRef.current !== runId) return;
if (!progressEvent.total) return;
const fileProgress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
const baseProgress = (index / totalFiles) * 100;
const scaledFileProgress = fileProgress / totalFiles;
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
},
}); });
} }
if (uploadRunIdRef.current === runId) setUploadProgressPercent(100);
_handleRefresh(); _handleRefresh();
} catch (err) { } catch (err) {
console.error('File upload failed:', err); console.error('File upload failed:', err);
} finally { } finally {
setUploading(false); if (uploadRunIdRef.current === runId) {
setUploading(false);
// Let 100% render briefly, then reset.
window.setTimeout(() => {
if (uploadRunIdRef.current === runId) setUploadProgressPercent(0);
}, 250);
}
} }
}, [context.instanceId, uploading, _handleRefresh]); }, [context.instanceId, uploading, _handleRefresh]);
@ -135,6 +157,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]); onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
}, [onSendToChat]); }, [onSendToChat]);
const circleRadius = 11;
const circleCircumference = 2 * Math.PI * circleRadius;
const circleOffset = circleCircumference * (1 - uploadProgressPercent / 100);
return ( return (
<div <div
className={styles.filesTab} className={styles.filesTab}
@ -170,10 +196,26 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploading} disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }} className={styles.uploadCircleButton}
title={t('Dateien hochladen')} title={t('Dateien hochladen')}
> >
{uploading ? '...' : '+'} {uploading ? (
<span className={styles.uploadCircleWrap} aria-hidden="true">
<svg className={styles.uploadCircleSvg} viewBox="0 0 24 24">
<circle className={styles.uploadCircleTrack} cx="12" cy="12" r={circleRadius} />
<circle
className={styles.uploadCircleProgress}
cx="12"
cy="12"
r={circleRadius}
style={{ strokeDasharray: `${circleCircumference}`, strokeDashoffset: `${circleOffset}` }}
/>
</svg>
<span className={styles.uploadCircleText}>{uploadProgressPercent}%</span>
</span>
) : (
'+'
)}
</button> </button>
<button <button
onClick={_handleRefresh} onClick={_handleRefresh}

View file

@ -47,8 +47,8 @@ interface UnifiedDataBarProps {
hideTabs?: UdbTab[]; hideTabs?: UdbTab[];
onSelectChat?: (chatId: string, featureInstanceId: string) => void; onSelectChat?: (chatId: string, featureInstanceId: string) => void;
activeWorkflowId?: string; activeWorkflowId?: string;
onCreateNewChat?: () => void;
onRenameChat?: (chatId: string, newName: string) => void; onRenameChat?: (chatId: string, newName: string) => void;
chatListRefreshKey?: number;
onDeleteChat?: (chatId: string) => void; onDeleteChat?: (chatId: string) => void;
onChatDragStart?: (chatId: string, event: React.DragEvent) => void; onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
onFileSelect?: (fileId: string, fileName?: string) => void; onFileSelect?: (fileId: string, fileName?: string) => void;
@ -78,8 +78,8 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
hideTabs, hideTabs,
onSelectChat, onSelectChat,
activeWorkflowId, activeWorkflowId,
onCreateNewChat,
onRenameChat, onRenameChat,
chatListRefreshKey,
onDeleteChat, onDeleteChat,
onChatDragStart, onChatDragStart,
onFileSelect, onFileSelect,
@ -122,7 +122,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
onSelectChat={onSelectChat} onSelectChat={onSelectChat}
onDragStart={onChatDragStart} onDragStart={onChatDragStart}
activeWorkflowId={activeWorkflowId} activeWorkflowId={activeWorkflowId}
onCreateNew={onCreateNewChat} chatListRefreshKey={chatListRefreshKey}
onRenameChat={onRenameChat} onRenameChat={onRenameChat}
onDeleteChat={onDeleteChat} onDeleteChat={onDeleteChat}
/> />

View file

@ -69,6 +69,7 @@ export interface PaginationParams {
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
viewKey?: string; viewKey?: string;
owner?: 'all' | 'me' | 'shared';
} }
// Files list hook // Files list hook
@ -150,6 +151,7 @@ export function useUserFiles() {
groupField: string; groupField: string;
groupDirection?: 'asc' | 'desc'; groupDirection?: 'asc' | 'desc';
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>; groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
owner?: 'all' | 'me' | 'shared';
}) => { }) => {
const levels = base.groupByLevels?.length const levels = base.groupByLevels?.length
? base.groupByLevels ? base.groupByLevels
@ -164,7 +166,11 @@ export function useUserFiles() {
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort; if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey; if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
const { data } = await api.get('/api/files/list', { 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 : []; 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.search) (pObj as { search: string }).search = paginationParams.search;
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey; if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
const { data } = await api.get('/api/files/list', { 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) { if (data && typeof data === 'object' && 'items' in data) {
return { items: data.items, pagination: data.pagination }; return { items: data.items, pagination: data.pagination };
@ -408,6 +417,7 @@ export function useFileOperations() {
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set()); const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set()); const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
const [uploadingFile, setUploadingFile] = useState(false); const [uploadingFile, setUploadingFile] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isLoading] = useState(false); const [isLoading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null); const [downloadError, setDownloadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
@ -564,9 +574,11 @@ export function useFileOperations() {
file: globalThis.File, file: globalThis.File,
workflowId?: string, workflowId?: string,
featureInstanceId?: string, featureInstanceId?: string,
onProgress?: (progress: number) => void,
) => { ) => {
setUploadError(null); setUploadError(null);
setUploadingFile(true); setUploadingFile(true);
setUploadProgress(0);
try { try {
@ -593,7 +605,14 @@ export function useFileOperations() {
// Do NOT set Content-Type manually axios sets multipart/form-data with boundary for FormData // Do NOT set Content-Type manually axios sets multipart/form-data with boundary for FormData
const response = await api.post('/api/files/upload', formData); const response = await api.post('/api/files/upload', formData, {
onUploadProgress: progressEvent => {
if (!progressEvent.total) return;
const progress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
setUploadProgress(progress);
onProgress?.(progress);
},
});
const fileData = response.data; const fileData = response.data;
// Check if the response indicates a duplicate file // Check if the response indicates a duplicate file
@ -625,6 +644,7 @@ export function useFileOperations() {
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} finally { } finally {
setUploadingFile(false); setUploadingFile(false);
setUploadProgress(0);
} }
}; };
@ -749,6 +769,7 @@ export function useFileOperations() {
deletingFiles, deletingFiles,
editingFiles, editingFiles,
uploadingFile, uploadingFile,
uploadProgress,
downloadError, downloadError,
deleteError, deleteError,
uploadError, uploadError,

View file

@ -31,6 +31,17 @@ interface UserFile {
} }
type ViewMode = 'folder' | 'all'; 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 = () => { export const FilesPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -67,14 +78,16 @@ export const FilesPage: React.FC = () => {
handleInlineUpdate, handleInlineUpdate,
deletingFiles, deletingFiles,
downloadingFiles, downloadingFiles,
uploadingFile,
previewingFiles, previewingFiles,
} = useFileOperations(); } = useFileOperations();
const [editingFile, setEditingFile] = useState<UserFile | null>(null); const [editingFile, setEditingFile] = useState<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]); const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null); const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own');
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null); const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const [isUploadingBatch, setIsUploadingBatch] = useState(false);
const [treeWidth, setTreeWidth] = useState(300); const [treeWidth, setTreeWidth] = useState(300);
const [treeVisible, setTreeVisible] = useState(true); const [treeVisible, setTreeVisible] = useState(true);
@ -103,14 +116,24 @@ export const FilesPage: React.FC = () => {
const _tableRefetch = useCallback(async (params?: any) => { const _tableRefetch = useCallback(async (params?: any) => {
const nextParams = { ...(params || {}) }; const nextParams = { ...(params || {}) };
const nextFilters = { ...(nextParams.filters || {}) }; const nextFilters = { ...(nextParams.filters || {}) };
if (viewMode === 'folder' && selectedFolderId) { const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
nextFilters.folderId = selectedFolderId; const rootSelected = isSyntheticRootFolderId(selectedFolderId);
const owner: FileOwnerScope =
selectedOwnership === 'own'
? 'me'
: selectedOwnership === 'shared'
? 'shared'
: 'all';
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
nextFilters.folderId = normalizedFolderId;
} else { } else {
delete nextFilters.folderId; delete nextFilters.folderId;
} }
nextParams.filters = nextFilters; nextParams.filters = nextFilters;
if (owner !== 'all') nextParams.owner = owner;
else delete nextParams.owner;
await tableRefetch(nextParams); await tableRefetch(nextParams);
}, [tableRefetch, selectedFolderId, viewMode]); }, [tableRefetch, selectedFolderId, selectedOwnership, viewMode]);
const fetchGroupSectionSummaries = useCallback( const fetchGroupSectionSummaries = useCallback(
async (base: { async (base: {
@ -122,12 +145,20 @@ export const FilesPage: React.FC = () => {
groupDirection?: 'asc' | 'desc'; groupDirection?: 'asc' | 'desc';
}) => { }) => {
const filters = { ...(base.filters || {}) }; const filters = { ...(base.filters || {}) };
if (viewMode === 'folder' && selectedFolderId) { const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
filters.folderId = 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( const refetchForSection = useCallback(
@ -137,12 +168,20 @@ export const FilesPage: React.FC = () => {
parentColumnFilters?: Record<string, unknown>, parentColumnFilters?: Record<string, unknown>,
) => { ) => {
const merged = { ...(parentColumnFilters || {}) }; const merged = { ...(parentColumnFilters || {}) };
if (viewMode === 'folder' && selectedFolderId) { const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
merged.folderId = 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 () => { const _refreshAll = useCallback(async () => {
@ -152,14 +191,15 @@ export const FilesPage: React.FC = () => {
useEffect(() => { useEffect(() => {
_tableRefetch({ page: 1, pageSize: 25 }); _tableRefetch({ page: 1, pageSize: 25 });
}, [selectedFolderId, viewMode, _tableRefetch]); }, [selectedFolderId, selectedOwnership, viewMode, _tableRefetch]);
// ── Tree interaction ────────────────────────────────────────────────── // ── Tree interaction ──────────────────────────────────────────────────
const _handleTreeNodeClick = useCallback((node: TreeNode) => { const _handleTreeNodeClick = useCallback((node: TreeNode) => {
setSelectedOwnership(node.ownership);
if (node.type === 'folder') { if (node.type === 'folder') {
setSelectedFolderId(node.id); setSelectedFolderId(node.id);
} else if (node.type === 'file') { } else if (node.type === 'file') {
setSelectedFolderId(node.parentId); setSelectedFolderId(node.parentId ?? null);
setHighlightedFileId(node.id); setHighlightedFileId(node.id);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const row = document.querySelector('tr[data-highlighted="true"]'); const row = document.querySelector('tr[data-highlighted="true"]');
@ -264,24 +304,38 @@ export const FilesPage: React.FC = () => {
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files; const picked = e.target.files;
if (picked && picked.length > 0) { if (picked && picked.length > 0) {
let successCount = 0; setIsUploadingBatch(true);
let errorCount = 0; setUploadProgressPercent(0);
for (const file of Array.from(picked)) { try {
const result = await handleFileUpload(file); let successCount = 0;
if (result?.success) successCount++; else errorCount++; let errorCount = 0;
} const files = Array.from(picked);
if (fileInputRef.current) fileInputRef.current.value = ''; const totalFiles = files.length;
await _tableRefetch(); for (const [index, file] of files.entries()) {
setTreeKey(k => k + 1); const result = await handleFileUpload(file, undefined, undefined, fileProgress => {
if (successCount > 0) { const baseProgress = (index / totalFiles) * 100;
showSuccess( const scaledFileProgress = fileProgress / totalFiles;
t('Upload erfolgreich'), setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
errorCount > 0 });
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount }) if (result?.success) successCount++; else errorCount++;
: t('{successCount} Datei(en) hochgeladen', { successCount }), }
); setUploadProgressPercent(100);
} else if (errorCount > 0) { if (fileInputRef.current) fileInputRef.current.value = '';
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount })); await _tableRefetch();
setTreeKey(k => k + 1);
if (successCount > 0) {
showSuccess(
t('Upload erfolgreich'),
errorCount > 0
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
: t('{successCount} Datei(en) hochgeladen', { successCount }),
);
} else if (errorCount > 0) {
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
}
} finally {
setIsUploadingBatch(false);
setUploadProgressPercent(0);
} }
} }
}; };
@ -436,8 +490,39 @@ export const FilesPage: React.FC = () => {
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
{canCreate && ( {canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}> <button
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')} className={styles.primaryButton}
onClick={handleUploadClick}
disabled={isUploadingBatch}
style={{ position: 'relative', overflow: 'hidden' }}
>
{isUploadingBatch && (
<span
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${uploadProgressPercent}%`,
background: 'rgba(255, 255, 255, 0.25)',
transition: 'width 120ms linear',
}}
/>
)}
<span
style={{
position: 'relative',
zIndex: 1,
display: 'inline-flex',
alignItems: 'center',
gap: 6,
}}
>
<FaUpload />
<span>{t('Datei hochladen')}</span>
{isUploadingBatch && <span>{uploadProgressPercent}%</span>}
</span>
</button> </button>
)} )}
</div> </div>

View file

@ -851,6 +851,52 @@
background: var(--surface-alt, #fafafa); 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, .transcriptList,
.responseList { .responseList {
flex: 1; flex: 1;

View file

@ -25,6 +25,7 @@ import styles from './Teamsbot.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Popup } from '../../../components/UiComponents/Popup';
/** /**
* TeamsbotSessionView - Live session view with real-time transcript and bot responses. * 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 [screenshotsLoading, setScreenshotsLoading] = useState(false);
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false); const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false); const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
const [transcriptPopupOpen, setTranscriptPopupOpen] = useState(false);
const [botResponsesPopupOpen, setBotResponsesPopupOpen] = useState(false);
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{ const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
status: string; status: string;
message?: string; message?: string;
@ -746,6 +749,64 @@ export const TeamsbotSessionView: React.FC = () => {
return colors[Math.abs(hash) % colors.length]; return colors[Math.abs(hash) % colors.length];
}; };
const _renderExpandIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
);
const _renderTranscriptList = (endRef?: React.RefObject<HTMLDivElement | null>) => (
<>
{transcripts.map((seg) => (
<div key={seg.id} className={styles.transcriptItem}>
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
<span
className={styles.transcriptSpeaker}
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
>
{seg.speaker || t('Unbekannt')}:
</span>
<span className={styles.transcriptText}>{seg.text}</span>
</div>
))}
{endRef && <div ref={endRef} />}
{transcripts.length === 0 && (
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
)}
</>
);
const _renderBotResponsesList = () => (
<>
{botResponses.map((r) => (
<div key={r.id} className={styles.responseItem}>
<div className={styles.responseHeader}>
<span className={styles.responseIntent}>{r.detectedIntent}</span>
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
</div>
<div className={styles.responseText}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
</div>
{r.reasoning && (
<div className={styles.responseReasoning}>
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
</div>
)}
{(r.modelName || r.processingTime != null) && (
<div className={styles.responseMeta}>
<span>{r.modelName || ''}</span>
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
</div>
)}
</div>
))}
{botResponses.length === 0 && (
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
)}
</>
);
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>; if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
if (noSessions) return ( if (noSessions) return (
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}> <div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
@ -1154,63 +1215,69 @@ export const TeamsbotSessionView: React.FC = () => {
<div className={styles.sessionContent}> <div className={styles.sessionContent}>
{/* Left: Transcript */} {/* Left: Transcript */}
<div className={styles.transcriptPanel}> <div className={styles.transcriptPanel}>
<h4 className={styles.panelTitle}> <div className={styles.panelTitleBar}>
{t('Transkript ({count} Segmente)', { count: transcripts.length })} <h4 className={styles.panelTitle}>
</h4> {t('Transkript ({count} Segmente)', { count: transcripts.length })}
</h4>
<button
type="button"
className={styles.panelExpandBtn}
onClick={() => setTranscriptPopupOpen(true)}
title={t('Vollbild')}
aria-label={t('Transkript im Vollbild anzeigen')}
>
{_renderExpandIcon()}
</button>
</div>
<div className={styles.transcriptList}> <div className={styles.transcriptList}>
{transcripts.map((seg) => ( {_renderTranscriptList(transcriptEndRef)}
<div key={seg.id} className={styles.transcriptItem}>
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
<span
className={styles.transcriptSpeaker}
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
>
{seg.speaker || t('Unbekannt')}:
</span>
<span className={styles.transcriptText}>{seg.text}</span>
</div>
))}
<div ref={transcriptEndRef} />
{transcripts.length === 0 && (
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
)}
</div> </div>
</div> </div>
{/* Right: Bot Responses */} {/* Right: Bot Responses */}
<div className={styles.responsesPanel}> <div className={styles.responsesPanel}>
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4> <div className={styles.panelTitleBar}>
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
<button
type="button"
className={styles.panelExpandBtn}
onClick={() => setBotResponsesPopupOpen(true)}
title={t('Vollbild')}
aria-label={t('Bot-Antworten im Vollbild anzeigen')}
>
{_renderExpandIcon()}
</button>
</div>
<div className={styles.responseList}> <div className={styles.responseList}>
{botResponses.map((r) => ( {_renderBotResponsesList()}
<div key={r.id} className={styles.responseItem}>
<div className={styles.responseHeader}>
<span className={styles.responseIntent}>{r.detectedIntent}</span>
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
</div>
<div className={styles.responseText}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
</div>
{r.reasoning && (
<div className={styles.responseReasoning}>
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
</div>
)}
{(r.modelName || r.processingTime != null) && (
<div className={styles.responseMeta}>
<span>{r.modelName || ''}</span>
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
</div>
)}
</div>
))}
{botResponses.length === 0 && (
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
)}
</div> </div>
</div> </div>
</div> </div>
<Popup
isOpen={transcriptPopupOpen}
title={t('Transkript ({count} Segmente)', { count: transcripts.length })}
onClose={() => setTranscriptPopupOpen(false)}
size="fullscreen"
closeOnBackdropClick
>
<div className={`${styles.transcriptList} ${styles.popupPanelList}`}>
{_renderTranscriptList()}
</div>
</Popup>
<Popup
isOpen={botResponsesPopupOpen}
title={`Bot-Antworten (${botResponses.length})`}
onClose={() => setBotResponsesPopupOpen(false)}
size="fullscreen"
closeOnBackdropClick
>
<div className={`${styles.responseList} ${styles.popupPanelList}`}>
{_renderBotResponsesList()}
</div>
</Popup>
{/* Summary (for ended sessions) */} {/* Summary (for ended sessions) */}
{session.summary && ( {session.summary && (
<div className={styles.summaryCard}> <div className={styles.summaryCard}>

View file

@ -94,6 +94,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
); );
const [mobileLeftOpen, setMobileLeftOpen] = useState(false); const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
const [mobileRightOpen, setMobileRightOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false);
const [chatListRefreshKey, setChatListRefreshKey] = useState(0);
useEffect(() => { useEffect(() => {
const _handleResize = () => { const _handleResize = () => {
@ -254,6 +255,27 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
workspace.loadWorkflow(wfId); 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 => ({ const tabButtonStyle = (active: boolean): React.CSSProperties => ({
flex: 1, flex: 1,
padding: '6px 0', padding: '6px 0',
@ -356,7 +378,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onTabChange={setUdbTab} onTabChange={setUdbTab}
onSelectChat={_handleConversationSelect} onSelectChat={_handleConversationSelect}
activeWorkflowId={workspace.workflowId ?? undefined} activeWorkflowId={workspace.workflowId ?? undefined}
onCreateNewChat={workspace.resetToNew} chatListRefreshKey={chatListRefreshKey}
onRenameChat={_handleRenameChat} onRenameChat={_handleRenameChat}
onDeleteChat={_handleDeleteChat} onDeleteChat={_handleDeleteChat}
onFileSelect={_handleFileSelect} onFileSelect={_handleFileSelect}
@ -408,7 +430,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
}}> }}>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span> <span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
<button onClick={() => setLeftCollapsed(true)} style={sidebarHeaderBtnStyle}></button>
</div>
</div> </div>
{_leftPanelBody} {_leftPanelBody}
</aside> </aside>
@ -604,7 +629,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
> >
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span> <span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
</div>
</div> </div>
{_leftPanelBody} {_leftPanelBody}
</aside> </aside>