feat: window management system with minimize, fullscreen, and AI chat split

- Window manager store (Zustand) for managing open/minimized/fullscreen windows
- Window component with header controls: KI-Chat toggle, fullscreen, minimize, close
- AI chat panel with streaming chat via existing /ai/sessions API
- WindowContainer renders all non-minimized windows
- TopBar shows minimized windows as clickable pills
- ContactEditForm extracted from ContactEditModal for window rendering
- ContactsList and ContactDetailPage use openWindow() instead of modal state
- Draggable windows with z-index management
This commit is contained in:
Agent Zero
2026-07-24 23:42:53 +02:00
parent c1d49e4dfe
commit 35e87b5d51
9 changed files with 936 additions and 39 deletions
@@ -0,0 +1,210 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Sparkles, Send } from 'lucide-react';
import {
streamChat,
createSession,
fetchMessages,
type ChatMessage,
} from '@/api/ai';
interface AiChatPanelProps {
windowTitle: string;
windowType: string;
}
interface SimpleMessage {
id: string;
role: string;
content: string;
}
export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
const [messages, setMessages] = useState<SimpleMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [error, setError] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [loadingSession, setLoadingSession] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const msgIdCounter = useRef(0);
// Create a sidebar session on mount
useEffect(() => {
let cancelled = false;
setLoadingSession(true);
createSession({ title: `KI: ${windowTitle}`, is_sidebar: true })
.then((session) => {
if (cancelled) return;
setSessionId(session.id);
return fetchMessages(session.id).then((msgs: ChatMessage[]) => {
if (cancelled) return;
setMessages(
msgs.map((m) => ({ id: m.id, role: m.role, content: m.content }))
);
});
})
.catch((e) => {
if (!cancelled) {
console.error('AI session creation failed:', e);
setError('KI Chat ist in diesem Kontext nicht verfügbar');
}
})
.finally(() => {
if (!cancelled) setLoadingSession(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, streamingContent]);
const handleSend = useCallback(async () => {
const content = input.trim();
if (!content || isStreaming || !sessionId) return;
setInput('');
setIsStreaming(true);
setStreamingContent('');
setError(null);
const userMsg: SimpleMessage = {
id: `msg-${++msgIdCounter.current}`,
role: 'user',
content,
};
setMessages((prev) => [...prev, userMsg]);
try {
const stream = streamChat(sessionId, content);
let accumulated = '';
for await (const event of stream) {
if (event.type === 'token' && event.content) {
accumulated += event.content;
setStreamingContent(accumulated);
} else if (event.type === 'done') {
const msgs = await fetchMessages(sessionId);
setMessages(
msgs.map((m) => ({ id: m.id, role: m.role, content: m.content }))
);
setStreamingContent('');
} else if (event.type === 'error') {
setError(event.content || 'Ein Fehler ist aufgetreten');
}
}
} catch (e: any) {
setError(e?.message || 'KI Chat nicht verfügbar');
} finally {
setIsStreaming(false);
}
}, [input, isStreaming, sessionId]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (loadingSession) {
return (
<div className="flex flex-col h-full">
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
</div>
<div className="flex-1 flex items-center justify-center text-sm text-secondary-400">
Verbinde mit KI...
</div>
</div>
);
}
if (error && !sessionId) {
return (
<div className="flex flex-col h-full">
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
</div>
<div className="flex-1 flex items-center justify-center p-4 text-center">
<div>
<Sparkles className="w-8 h-8 text-secondary-300 mx-auto mb-2" />
<p className="text-sm text-secondary-500">{error}</p>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary-500" />
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
</div>
{/* Context info */}
<div className="px-3 py-1.5 bg-primary-50 border-b border-primary-100 text-xs text-primary-700">
Kontext: {windowTitle}
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.length === 0 && !streamingContent && (
<p className="text-sm text-secondary-400 text-center mt-4">
Stelle eine Frage zum aktuellen Fenster...
</p>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={
msg.role === 'user'
? 'ml-8 bg-primary-100 text-primary-900 rounded-lg px-3 py-2 text-sm'
: 'mr-8 bg-secondary-100 text-secondary-900 rounded-lg px-3 py-2 text-sm'
}
>
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
</div>
))}
{streamingContent && (
<div className="mr-8 bg-secondary-100 text-secondary-900 rounded-lg px-3 py-2 text-sm">
<div className="whitespace-pre-wrap break-words">{streamingContent}</div>
</div>
)}
{error && (
<div className="text-xs text-danger-600 px-2 py-1">{error}</div>
)}
</div>
{/* Input */}
<div className="border-t border-secondary-200 p-3">
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Nachricht eingeben..."
rows={1}
className="flex-1 px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
disabled={isStreaming}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isStreaming}
className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Senden"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import React, { useRef, useState, useCallback } from 'react';
import clsx from 'clsx';
import { Sparkles, Maximize2, Minimize2, Minus, X } from 'lucide-react';
import { useWindowStore, type WindowState } from '@/store/windowStore';
import { AiChatPanel } from './AiChatPanel';
interface WindowProps {
window: WindowState;
}
export function Window({ window: win }: WindowProps) {
const {
closeWindow,
minimizeWindow,
toggleFullscreen,
toggleAiChat,
setActiveWindow,
updateWindowPosition,
} = useWindowStore();
const headerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, posX: 0, posY: 0 });
const ContentComponent = win.component;
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (win.state === 'fullscreen') return;
// Only drag from header, not from buttons
if ((e.target as HTMLElement).closest('button')) return;
setIsDragging(true);
dragStart.current = {
x: e.clientX,
y: e.clientY,
posX: win.position.x,
posY: win.position.y,
};
setActiveWindow(win.id);
},
[win.id, win.state, win.position, setActiveWindow]
);
React.useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
const newX = dragStart.current.posX + dx;
const newY = Math.max(0, dragStart.current.posY + dy);
updateWindowPosition(win.id, { x: newX, y: newY });
};
const handleMouseUp = () => setIsDragging(false);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, win.id, updateWindowPosition]);
if (win.state === 'minimized') return null;
const isFullscreen = win.state === 'fullscreen';
const containerClass = clsx(
'fixed flex flex-col bg-white shadow-2xl rounded-lg overflow-hidden',
isFullscreen
? 'inset-2 rounded-lg'
: 'rounded-lg',
isDragging && 'cursor-grabbing'
);
const containerStyle: React.CSSProperties = isFullscreen
? { zIndex: win.zIndex }
: {
left: win.position.x,
top: win.position.y,
width: win.size.width,
height: win.size.height,
zIndex: win.zIndex,
};
return (
<div
className={containerClass}
style={containerStyle}
onMouseDown={() => setActiveWindow(win.id)}
role="dialog"
aria-label={win.title}
>
{/* Header */}
<div
ref={headerRef}
onMouseDown={handleMouseDown}
className="bg-secondary-50 border-b border-secondary-200 px-4 py-2 flex items-center justify-between cursor-grab select-none flex-shrink-0"
>
<span className="text-sm font-semibold text-secondary-700 truncate">
{win.title}
</span>
<div className="flex items-center gap-1">
{/* AI Chat Toggle */}
<button
onClick={() => toggleAiChat(win.id)}
className={clsx(
'p-1.5 rounded hover:bg-secondary-200',
win.aiChatVisible && 'bg-primary-100 text-primary-600'
)}
aria-label="KI Chat ein/aus"
title="KI Chat"
>
<Sparkles className="w-4 h-4" />
</button>
{/* Fullscreen Toggle */}
<button
onClick={() => toggleFullscreen(win.id)}
className="p-1.5 rounded hover:bg-secondary-200"
aria-label={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
>
{isFullscreen ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</button>
{/* Minimize */}
<button
onClick={() => minimizeWindow(win.id)}
className="p-1.5 rounded hover:bg-secondary-200"
aria-label="Minimieren"
title="Minimieren"
>
<Minus className="w-4 h-4" />
</button>
{/* Close */}
<button
onClick={() => closeWindow(win.id)}
className="p-1.5 rounded hover:bg-danger-100 hover:text-danger-600"
aria-label="Schließen"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Main Content */}
<div
className={clsx(
'flex-1 overflow-y-auto',
win.aiChatVisible && 'border-r border-secondary-200'
)}
>
<ContentComponent {...win.componentProps} />
</div>
{/* AI Chat Panel */}
{win.aiChatVisible && (
<div className="w-1/2 flex flex-col">
<AiChatPanel windowTitle={win.title} windowType={win.type} />
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import React from 'react';
import { useWindowStore } from '@/store/windowStore';
import { Window } from './Window';
export function WindowContainer() {
const windows = useWindowStore((s) => s.windows);
const visibleWindows = windows.filter((w) => w.state !== 'minimized');
if (visibleWindows.length === 0) return null;
return (
<>
{visibleWindows.map((win) => (
<Window key={win.id} window={win} />
))}
</>
);
}