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;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
owner?: 'all' | 'me' | 'shared';
}
export interface PaginatedResponse<T> {
@ -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);

View file

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

View file

@ -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<FlowCanvasHandle, FlowCanvasProps>(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<FlowCanvasHandle, FlowCanvasProps>(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<FlowCanvasHandle, FlowCanvasProps>(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<FlowCanvasHandle, FlowCanvasProps>(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]
);

View file

@ -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<NodeConfigPanelProps> = ({ 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<NodeConfigPanelProps> = ({ 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<NodeConfigPanelProps> = ({ node,
</div>
)}
{extractContentAccordionItems !== null ? (
<AccordionList<string>
key={`${node.id}-extract-accordion`}
defaultOpenId={null}
items={extractContentAccordionItems}
/>
<>
{extractContentContextParam ? (
<div
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) => {
// 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 {
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;

View file

@ -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({
<div className={styles.searchContainer}>
{searchable && (
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
<FilterSearchInput
variant="inherit"
value={searchTerm}
onChange={(e) => 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')}
/>
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
{t('Suchen...')}

View file

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

View file

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

View file

@ -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<void>;
onDeleteChat?: (chatId: string) => void | Promise<void>;
}
@ -72,7 +72,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
onSelectChat,
onDragStart,
activeWorkflowId,
onCreateNew,
chatListRefreshKey,
onRenameChat,
onDeleteChat,
}) => {
@ -82,13 +82,14 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ChatFilter>('active');
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 [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const groupsRef = useRef(groups);
groupsRef.current = groups;
const _loadChats = useCallback(async (serverSearch?: string) => {
setLoading(true);
try {
const params: Record<string, unknown> = { includeArchived: true };
if (serverSearch) params.search = serverSearch;
@ -140,7 +141,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ 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<ChatsTabProps> = ({ context,
}
}, [activeWorkflowId]);
useEffect(() => {
if (chatListRefreshKey) {
_loadChats();
}
}, [chatListRefreshKey, _loadChats]);
useEffect(() => {
if (editingId && renameInputRef.current) {
renameInputRef.current.focus();
@ -188,8 +195,18 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ 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<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 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<ChatsTabProps> = ({ context,
{onDeleteChat && (
<button
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')}
>
🗑
@ -334,8 +379,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
return labels[code] || code;
};
if (loading) return <div className={styles.loading}>{t('Chats werden geladen…')}</div>;
return (
<div className={styles.chatsTab}>
<div className={styles.toolbar}>
@ -346,11 +389,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{onCreateNew && (
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title={t('Neuer Chat')}>
+
</button>
)}
<button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)}
@ -437,7 +475,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
</div>
)}
{_allChats.length === 0 && (
{hasLoadedOnce && _allChats.length === 0 && (
<div className={styles.emptyState}>
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
</div>

View file

@ -81,6 +81,60 @@
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) {
.fileRow:hover {
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 [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const uploadRunIdRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const provider = useMemo(() => createFolderFileProvider(), []);
@ -54,21 +56,41 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return;
uploadRunIdRef.current += 1;
const runId = uploadRunIdRef.current;
setUploading(true);
setUploadProgressPercent(0);
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();
formData.append('file', file);
formData.append('featureInstanceId', context.instanceId);
await api.post('/api/files/upload', formData, {
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();
} catch (err) {
console.error('File upload failed:', err);
} 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]);
@ -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]);
const circleRadius = 11;
const circleCircumference = 2 * Math.PI * circleRadius;
const circleOffset = circleCircumference * (1 - uploadProgressPercent / 100);
return (
<div
className={styles.filesTab}
@ -170,10 +196,26 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
className={styles.uploadCircleButton}
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
onClick={_handleRefresh}

View file

@ -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<UnifiedDataBarProps> = ({
hideTabs,
onSelectChat,
activeWorkflowId,
onCreateNewChat,
onRenameChat,
chatListRefreshKey,
onDeleteChat,
onChatDragStart,
onFileSelect,
@ -122,7 +122,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
onSelectChat={onSelectChat}
onDragStart={onChatDragStart}
activeWorkflowId={activeWorkflowId}
onCreateNew={onCreateNewChat}
chatListRefreshKey={chatListRefreshKey}
onRenameChat={onRenameChat}
onDeleteChat={onDeleteChat}
/>

View file

@ -69,6 +69,7 @@ export interface PaginationParams {
filters?: Record<string, any>;
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 };
@ -408,6 +417,7 @@ export function useFileOperations() {
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
const [uploadingFile, setUploadingFile] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isLoading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@ -564,9 +574,11 @@ export function useFileOperations() {
file: globalThis.File,
workflowId?: string,
featureInstanceId?: string,
onProgress?: (progress: number) => void,
) => {
setUploadError(null);
setUploadingFile(true);
setUploadProgress(0);
try {
@ -593,7 +605,14 @@ export function useFileOperations() {
// 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;
// Check if the response indicates a duplicate file
@ -625,6 +644,7 @@ export function useFileOperations() {
return { success: false, error: errorMessage };
} finally {
setUploadingFile(false);
setUploadProgress(0);
}
};
@ -749,6 +769,7 @@ export function useFileOperations() {
deletingFiles,
editingFiles,
uploadingFile,
uploadProgress,
downloadError,
deleteError,
uploadError,

View file

@ -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();
@ -67,14 +78,16 @@ export const FilesPage: React.FC = () => {
handleInlineUpdate,
deletingFiles,
downloadingFiles,
uploadingFile,
previewingFiles,
} = useFileOperations();
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own');
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
const [isUploadingBatch, setIsUploadingBatch] = useState(false);
const [treeWidth, setTreeWidth] = useState(300);
const [treeVisible, setTreeVisible] = useState(true);
@ -103,14 +116,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 +145,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 +168,20 @@ export const FilesPage: React.FC = () => {
parentColumnFilters?: Record<string, unknown>,
) => {
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 +191,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"]');
@ -264,24 +304,38 @@ export const FilesPage: React.FC = () => {
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files;
if (picked && picked.length > 0) {
let successCount = 0;
let errorCount = 0;
for (const file of Array.from(picked)) {
const result = await handleFileUpload(file);
if (result?.success) successCount++; else errorCount++;
}
if (fileInputRef.current) fileInputRef.current.value = '';
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 }));
setIsUploadingBatch(true);
setUploadProgressPercent(0);
try {
let successCount = 0;
let errorCount = 0;
const files = Array.from(picked);
const totalFiles = files.length;
for (const [index, file] of files.entries()) {
const result = await handleFileUpload(file, undefined, undefined, fileProgress => {
const baseProgress = (index / totalFiles) * 100;
const scaledFileProgress = fileProgress / totalFiles;
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
});
if (result?.success) successCount++; else errorCount++;
}
setUploadProgressPercent(100);
if (fileInputRef.current) fileInputRef.current.value = '';
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 }} />
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
<button
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>
)}
</div>

View file

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

View file

@ -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<Array<{
status: string;
message?: string;
@ -746,6 +749,64 @@ export const TeamsbotSessionView: React.FC = () => {
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 (noSessions) return (
<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}>
{/* Left: Transcript */}
<div className={styles.transcriptPanel}>
<h4 className={styles.panelTitle}>
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
</h4>
<div className={styles.panelTitleBar}>
<h4 className={styles.panelTitle}>
{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}>
{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>
))}
<div ref={transcriptEndRef} />
{transcripts.length === 0 && (
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
)}
{_renderTranscriptList(transcriptEndRef)}
</div>
</div>
{/* Right: Bot Responses */}
<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}>
{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>
)}
{_renderBotResponsesList()}
</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) */}
{session.summary && (
<div className={styles.summaryCard}>

View file

@ -94,6 +94,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ 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<WorkspacePageProps> = ({ 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<WorkspacePageProps> = ({ 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<WorkspacePageProps> = ({ persistentInstance
}}>
<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>
<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>
{_leftPanelBody}
</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' }}>
<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>
{_leftPanelBody}
</aside>