Files
dss/admin-ui/src/components/layout/ChatSidebar.tsx
Bruno Sarlo 71c6dc805a
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
feat: Rebuild admin-ui with Preact + Signals
Complete rebuild of the admin-ui using Preact + Signals for a lightweight,
reactive framework. Features include:

- Team-centric workdesks (UI, UX, QA, Admin)
- Comprehensive API client with 150+ DSS backend endpoints
- Dark mode with system preference detection
- Keyboard shortcuts and command palette
- AI chat sidebar with Claude integration
- Toast notifications system
- Export/import functionality for project backup
- TypeScript throughout with full type coverage

Bundle size: ~66KB main (21KB gzipped), ~5KB framework overhead

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 20:29:21 -03:00

331 lines
10 KiB
TypeScript

import { useState, useRef, useEffect } from 'preact/hooks';
import { chatSidebarOpen } from '../../state/app';
import { currentProject } from '../../state/project';
import { activeTeam } from '../../state/team';
import { endpoints } from '../../api/client';
import { Button } from '../base/Button';
import { Badge } from '../base/Badge';
import { Spinner } from '../base/Spinner';
import type { ToolCall, ToolResult } from '../../api/types';
import './ChatSidebar.css';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
}
export function ChatSidebar() {
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
role: 'assistant',
content: 'Hello! I\'m your DSS AI assistant. I can help you with:\n\n- Extracting design tokens from Figma\n- Generating component code\n- Analyzing token drift\n- Managing ESRE definitions\n- Project configuration\n\nHow can I help you today?',
timestamp: Date.now()
}
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [mcpTools, setMcpTools] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Load available MCP tools
useEffect(() => {
endpoints.mcp.tools()
.then(tools => setMcpTools(tools.map(t => t.name)))
.catch(() => setMcpTools([]));
}, []);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Focus input on open
useEffect(() => {
if (chatSidebarOpen.value) {
inputRef.current?.focus();
}
}, []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: input.trim(),
timestamp: Date.now()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
// Build context from current state
const context = {
team: activeTeam.value,
project: currentProject.value ? {
id: currentProject.value.id,
name: currentProject.value.name,
path: currentProject.value.path
} : null,
currentView: window.location.hash
};
const response = await endpoints.claude.chat(
userMessage.content,
currentProject.value?.id,
context
);
const assistantMessage: Message = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: response.message?.content || 'Sorry, I couldn\'t process that request.',
timestamp: Date.now(),
toolCalls: response.tool_calls,
toolResults: response.tool_results
};
setMessages(prev => [...prev, assistantMessage]);
} catch (error) {
console.error('Chat error:', error);
const errorMessage: Message = {
id: `error-${Date.now()}`,
role: 'assistant',
content: 'Sorry, I encountered an error. Please make sure the DSS server is running.',
timestamp: Date.now()
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
const handleClearChat = () => {
setMessages([{
id: `welcome-${Date.now()}`,
role: 'assistant',
content: 'Chat cleared. How can I help you?',
timestamp: Date.now()
}]);
};
const quickActions = [
{ label: 'Extract tokens', prompt: 'Extract design tokens from Figma' },
{ label: 'Find drift', prompt: 'Check for token drift in my project' },
{ label: 'Quick wins', prompt: 'Find quick wins for design system adoption' },
{ label: 'Help', prompt: 'What can you help me with?' }
];
const handleQuickAction = (prompt: string) => {
setInput(prompt);
inputRef.current?.focus();
};
return (
<aside className="chat-sidebar">
<div className="chat-header">
<div className="chat-header-title">
<h2 className="chat-title">AI Assistant</h2>
{mcpTools.length > 0 && (
<Badge variant="success" size="sm">{mcpTools.length} tools</Badge>
)}
</div>
<div className="chat-header-actions">
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
aria-label="Clear chat"
>
Clear
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => chatSidebarOpen.value = false}
aria-label="Close chat"
icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
}
/>
</div>
</div>
{/* Quick Actions */}
<div className="chat-quick-actions">
{quickActions.map(action => (
<button
key={action.label}
className="chat-quick-action"
onClick={() => handleQuickAction(action.prompt)}
>
{action.label}
</button>
))}
</div>
<div className="chat-messages">
{messages.map(message => (
<div
key={message.id}
className={`chat-message chat-message-${message.role}`}
>
<div className="chat-message-avatar">
{message.role === 'assistant' ? 'AI' : message.role === 'system' ? 'SYS' : 'You'}
</div>
<div className="chat-message-body">
<div className="chat-message-content">
{formatMessageContent(message.content)}
</div>
{/* Tool Calls */}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="chat-tool-calls">
{message.toolCalls.map(tool => (
<div key={tool.id} className="chat-tool-call">
<Badge size="sm">Tool: {tool.name}</Badge>
<code className="chat-tool-args">
{JSON.stringify(tool.arguments, null, 2)}
</code>
</div>
))}
</div>
)}
{/* Tool Results */}
{message.toolResults && message.toolResults.length > 0 && (
<div className="chat-tool-results">
{message.toolResults.map(result => (
<div key={result.tool_call_id} className="chat-tool-result">
<Badge variant={result.error ? 'error' : 'success'} size="sm">
{result.error ? 'Error' : 'Result'}
</Badge>
<code className="chat-tool-output">
{result.error || JSON.stringify(result.output, null, 2).slice(0, 200)}
</code>
</div>
))}
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="chat-message chat-message-assistant">
<div className="chat-message-avatar">AI</div>
<div className="chat-message-body">
<div className="chat-message-content chat-loading">
<Spinner size="sm" />
<span>Thinking...</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Context indicator */}
{currentProject.value && (
<div className="chat-context">
<Badge variant="default" size="sm">
Project: {currentProject.value.name}
</Badge>
</div>
)}
<form className="chat-input-form" onSubmit={handleSubmit}>
<textarea
ref={inputRef}
className="chat-input"
placeholder="Ask me anything about DSS..."
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
onKeyDown={handleKeyDown}
rows={2}
disabled={isLoading}
/>
<Button
type="submit"
variant="primary"
size="sm"
disabled={!input.trim() || isLoading}
icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
}
>
Send
</Button>
</form>
</aside>
);
}
// Helper to format message content with basic markdown support
function formatMessageContent(content: string): preact.JSX.Element {
// Split by code blocks
const parts = content.split(/(```[\s\S]*?```)/g);
return (
<>
{parts.map((part, idx) => {
if (part.startsWith('```') && part.endsWith('```')) {
// Code block
const code = part.slice(3, -3);
const lines = code.split('\n');
const lang = lines[0]?.trim() || '';
const codeContent = lang ? lines.slice(1).join('\n') : code;
return (
<pre key={idx} className="chat-code-block">
<code>{codeContent}</code>
</pre>
);
}
// Regular text - handle inline formatting
return (
<span key={idx}>
{part.split('\n').map((line, lineIdx) => (
<span key={lineIdx}>
{lineIdx > 0 && <br />}
{line.split(/(`[^`]+`)/g).map((segment, segIdx) => {
if (segment.startsWith('`') && segment.endsWith('`')) {
return <code key={segIdx} className="chat-inline-code">{segment.slice(1, -1)}</code>;
}
// Bold
return segment.split(/(\*\*[^*]+\*\*)/g).map((boldPart, boldIdx) => {
if (boldPart.startsWith('**') && boldPart.endsWith('**')) {
return <strong key={boldIdx}>{boldPart.slice(2, -2)}</strong>;
}
return boldPart;
});
})}
</span>
))}
</span>
);
})}
</>
);
}