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>
331 lines
10 KiB
TypeScript
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>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|