feat: Rebuild admin-ui with Preact + Signals
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
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>
This commit is contained in:
330
admin-ui/src/components/layout/ChatSidebar.tsx
Normal file
330
admin-ui/src/components/layout/ChatSidebar.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user