diff options
Diffstat (limited to 'renderer/src/components')
| -rw-r--r-- | renderer/src/components/ActionBar.tsx | 97 | ||||
| -rw-r--r-- | renderer/src/components/ChatPane.tsx | 66 | ||||
| -rw-r--r-- | renderer/src/components/DocumentPane.tsx | 104 | ||||
| -rw-r--r-- | renderer/src/components/Header.tsx | 99 |
4 files changed, 366 insertions, 0 deletions
diff --git a/renderer/src/components/ActionBar.tsx b/renderer/src/components/ActionBar.tsx new file mode 100644 index 0000000..22f34b4 --- /dev/null +++ b/renderer/src/components/ActionBar.tsx | |||
| @@ -0,0 +1,97 @@ | |||
| 1 | import React from "react"; | ||
| 2 | import type { Phase, PermissionMode, TokenUsage } from "../types"; | ||
| 3 | |||
| 4 | interface ActionBarProps { | ||
| 5 | phase: Phase; | ||
| 6 | hasChanges: boolean; | ||
| 7 | isLoading: boolean; | ||
| 8 | tokenUsage: TokenUsage; | ||
| 9 | permissionMode: PermissionMode; | ||
| 10 | onReview: () => void; | ||
| 11 | onSubmit: () => void; | ||
| 12 | onPermissionModeChange: (mode: PermissionMode) => void; | ||
| 13 | disabled: boolean; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function ActionBar({ | ||
| 17 | phase, | ||
| 18 | hasChanges, | ||
| 19 | isLoading, | ||
| 20 | tokenUsage, | ||
| 21 | permissionMode, | ||
| 22 | onReview, | ||
| 23 | onSubmit, | ||
| 24 | onPermissionModeChange, | ||
| 25 | disabled, | ||
| 26 | }: ActionBarProps) { | ||
| 27 | const totalTokens = tokenUsage.inputTokens + tokenUsage.outputTokens; | ||
| 28 | const maxTokens = 200000; | ||
| 29 | const usagePercent = Math.min((totalTokens / maxTokens) * 100, 100); | ||
| 30 | |||
| 31 | const getBarColor = () => { | ||
| 32 | if (usagePercent > 80) return "#ef4444"; | ||
| 33 | if (usagePercent > 50) return "#f59e0b"; | ||
| 34 | return "#10b981"; | ||
| 35 | }; | ||
| 36 | |||
| 37 | return ( | ||
| 38 | <div className="action-bar"> | ||
| 39 | <div className="action-bar-left"> | ||
| 40 | <div className="token-indicator"> | ||
| 41 | <div className="token-bar"> | ||
| 42 | <div | ||
| 43 | className="token-fill" | ||
| 44 | style={{ | ||
| 45 | width: `${usagePercent}%`, | ||
| 46 | backgroundColor: getBarColor(), | ||
| 47 | }} | ||
| 48 | /> | ||
| 49 | </div> | ||
| 50 | <span className="token-label"> | ||
| 51 | {(totalTokens / 1000).toFixed(1)}k / 200k | ||
| 52 | </span> | ||
| 53 | </div> | ||
| 54 | |||
| 55 | {phase === "implement" && ( | ||
| 56 | <label className="permission-toggle"> | ||
| 57 | <input | ||
| 58 | type="checkbox" | ||
| 59 | checked={permissionMode === "bypassPermissions"} | ||
| 60 | onChange={(e) => | ||
| 61 | onPermissionModeChange( | ||
| 62 | e.target.checked ? "bypassPermissions" : "acceptEdits" | ||
| 63 | ) | ||
| 64 | } | ||
| 65 | disabled={disabled} | ||
| 66 | /> | ||
| 67 | Bypass Permissions | ||
| 68 | </label> | ||
| 69 | )} | ||
| 70 | </div> | ||
| 71 | |||
| 72 | <div className="action-bar-right"> | ||
| 73 | {phase !== "implement" && ( | ||
| 74 | <> | ||
| 75 | <button | ||
| 76 | onClick={onReview} | ||
| 77 | disabled={disabled || isLoading || !hasChanges} | ||
| 78 | className="btn-secondary" | ||
| 79 | > | ||
| 80 | Review | ||
| 81 | </button> | ||
| 82 | <button | ||
| 83 | onClick={onSubmit} | ||
| 84 | disabled={disabled || isLoading} | ||
| 85 | className="btn-primary" | ||
| 86 | > | ||
| 87 | Submit → | ||
| 88 | </button> | ||
| 89 | </> | ||
| 90 | )} | ||
| 91 | {phase === "implement" && isLoading && ( | ||
| 92 | <span className="implementing-status">Implementing...</span> | ||
| 93 | )} | ||
| 94 | </div> | ||
| 95 | </div> | ||
| 96 | ); | ||
| 97 | } | ||
diff --git a/renderer/src/components/ChatPane.tsx b/renderer/src/components/ChatPane.tsx new file mode 100644 index 0000000..917d462 --- /dev/null +++ b/renderer/src/components/ChatPane.tsx | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | import React, { useState, useRef, useEffect } from "react"; | ||
| 2 | import type { Message } from "../types"; | ||
| 3 | |||
| 4 | interface ChatPaneProps { | ||
| 5 | messages: Message[]; | ||
| 6 | onSend: (message: string) => void; | ||
| 7 | isLoading: boolean; | ||
| 8 | disabled: boolean; | ||
| 9 | placeholder: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function ChatPane({ | ||
| 13 | messages, | ||
| 14 | onSend, | ||
| 15 | isLoading, | ||
| 16 | disabled, | ||
| 17 | placeholder, | ||
| 18 | }: ChatPaneProps) { | ||
| 19 | const [input, setInput] = useState(""); | ||
| 20 | const messagesEndRef = useRef<HTMLDivElement>(null); | ||
| 21 | |||
| 22 | useEffect(() => { | ||
| 23 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | ||
| 24 | }, [messages]); | ||
| 25 | |||
| 26 | const handleSend = () => { | ||
| 27 | if (!input.trim() || isLoading || disabled) return; | ||
| 28 | onSend(input.trim()); | ||
| 29 | setInput(""); | ||
| 30 | }; | ||
| 31 | |||
| 32 | return ( | ||
| 33 | <div className="chat-pane"> | ||
| 34 | <div className="chat-messages"> | ||
| 35 | {messages.map((msg) => ( | ||
| 36 | <div key={msg.id} className={`message ${msg.role}`}> | ||
| 37 | <div className="message-content">{msg.content}</div> | ||
| 38 | </div> | ||
| 39 | ))} | ||
| 40 | {isLoading && ( | ||
| 41 | <div className="message assistant loading"> | ||
| 42 | <div className="message-content">Thinking...</div> | ||
| 43 | </div> | ||
| 44 | )} | ||
| 45 | <div ref={messagesEndRef} /> | ||
| 46 | </div> | ||
| 47 | |||
| 48 | <div className="chat-input"> | ||
| 49 | <input | ||
| 50 | type="text" | ||
| 51 | value={input} | ||
| 52 | onChange={(e) => setInput(e.target.value)} | ||
| 53 | onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()} | ||
| 54 | placeholder={placeholder} | ||
| 55 | disabled={disabled || isLoading} | ||
| 56 | /> | ||
| 57 | <button | ||
| 58 | onClick={handleSend} | ||
| 59 | disabled={disabled || isLoading || !input.trim()} | ||
| 60 | > | ||
| 61 | Send | ||
| 62 | </button> | ||
| 63 | </div> | ||
| 64 | </div> | ||
| 65 | ); | ||
| 66 | } | ||
diff --git a/renderer/src/components/DocumentPane.tsx b/renderer/src/components/DocumentPane.tsx new file mode 100644 index 0000000..e44b0fb --- /dev/null +++ b/renderer/src/components/DocumentPane.tsx | |||
| @@ -0,0 +1,104 @@ | |||
| 1 | import React, { useState, useMemo } from "react"; | ||
| 2 | import type { Phase } from "../types"; | ||
| 3 | |||
| 4 | interface DocumentPaneProps { | ||
| 5 | content: string; | ||
| 6 | onChange: (content: string) => void; | ||
| 7 | phase: Phase; | ||
| 8 | disabled: boolean; | ||
| 9 | } | ||
| 10 | |||
| 11 | function renderMarkdown(md: string): string { | ||
| 12 | return ( | ||
| 13 | md | ||
| 14 | // Headers | ||
| 15 | .replace(/^### (.*$)/gm, "<h3>$1</h3>") | ||
| 16 | .replace(/^## (.*$)/gm, "<h2>$1</h2>") | ||
| 17 | .replace(/^# (.*$)/gm, "<h1>$1</h1>") | ||
| 18 | // Bold/italic | ||
| 19 | .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") | ||
| 20 | .replace(/\*([^*]+)\*/g, "<em>$1</em>") | ||
| 21 | // Code blocks | ||
| 22 | .replace( | ||
| 23 | /```(\w*)\n([\s\S]*?)```/g, | ||
| 24 | '<pre><code class="language-$1">$2</code></pre>' | ||
| 25 | ) | ||
| 26 | .replace(/`([^`]+)`/g, "<code>$1</code>") | ||
| 27 | // Lists | ||
| 28 | .replace(/^- \[x\] (.*$)/gm, '<li class="task done">☑ $1</li>') | ||
| 29 | .replace(/^- \[ \] (.*$)/gm, '<li class="task">☐ $1</li>') | ||
| 30 | .replace(/^- (.*$)/gm, "<li>$1</li>") | ||
| 31 | // Review comments (highlight them) | ||
| 32 | .replace(/(\/\/ REVIEW:.*$)/gm, '<mark class="review">$1</mark>') | ||
| 33 | .replace(/(\/\/ NOTE:.*$)/gm, '<mark class="note">$1</mark>') | ||
| 34 | // Paragraphs | ||
| 35 | .replace(/\n\n/g, "</p><p>") | ||
| 36 | .replace(/^(.+)$/gm, "<p>$1</p>") | ||
| 37 | // Clean up | ||
| 38 | .replace(/<p><\/p>/g, "") | ||
| 39 | .replace(/<p>(<h[1-3]>)/g, "$1") | ||
| 40 | .replace(/(<\/h[1-3]>)<\/p>/g, "$1") | ||
| 41 | .replace(/<p>(<pre>)/g, "$1") | ||
| 42 | .replace(/(<\/pre>)<\/p>/g, "$1") | ||
| 43 | .replace(/<p>(<li)/g, "$1") | ||
| 44 | .replace(/(<\/li>)<\/p>/g, "$1") | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function DocumentPane({ | ||
| 49 | content, | ||
| 50 | onChange, | ||
| 51 | phase, | ||
| 52 | disabled, | ||
| 53 | }: DocumentPaneProps) { | ||
| 54 | const [isEditing, setIsEditing] = useState(false); | ||
| 55 | const renderedHtml = useMemo(() => renderMarkdown(content), [content]); | ||
| 56 | |||
| 57 | if (phase === "implement") { | ||
| 58 | return ( | ||
| 59 | <div className="document-pane"> | ||
| 60 | <div className="document-header"> | ||
| 61 | <span>plan.md</span> | ||
| 62 | <span className="badge">Implementing...</span> | ||
| 63 | </div> | ||
| 64 | <div | ||
| 65 | className="document-content rendered" | ||
| 66 | dangerouslySetInnerHTML={{ __html: renderedHtml }} | ||
| 67 | /> | ||
| 68 | </div> | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | |||
| 72 | const filename = phase === "research" ? "research.md" : "plan.md"; | ||
| 73 | |||
| 74 | return ( | ||
| 75 | <div className="document-pane"> | ||
| 76 | <div className="document-header"> | ||
| 77 | <span>{filename}</span> | ||
| 78 | <button onClick={() => setIsEditing(!isEditing)}> | ||
| 79 | {isEditing ? "Preview" : "Edit"} | ||
| 80 | </button> | ||
| 81 | </div> | ||
| 82 | |||
| 83 | {isEditing ? ( | ||
| 84 | <textarea | ||
| 85 | className="document-content editing" | ||
| 86 | value={content} | ||
| 87 | onChange={(e) => onChange(e.target.value)} | ||
| 88 | disabled={disabled} | ||
| 89 | placeholder={`${filename} will appear here...`} | ||
| 90 | /> | ||
| 91 | ) : ( | ||
| 92 | <div | ||
| 93 | className="document-content rendered" | ||
| 94 | dangerouslySetInnerHTML={{ | ||
| 95 | __html: | ||
| 96 | renderedHtml || | ||
| 97 | '<p class="empty">Document will appear here after Claude generates it...</p>', | ||
| 98 | }} | ||
| 99 | onClick={() => !disabled && setIsEditing(true)} | ||
| 100 | /> | ||
| 101 | )} | ||
| 102 | </div> | ||
| 103 | ); | ||
| 104 | } | ||
diff --git a/renderer/src/components/Header.tsx b/renderer/src/components/Header.tsx new file mode 100644 index 0000000..3dcbba9 --- /dev/null +++ b/renderer/src/components/Header.tsx | |||
| @@ -0,0 +1,99 @@ | |||
| 1 | import React from "react"; | ||
| 2 | import type { Project, Session, Phase } from "../types"; | ||
| 3 | |||
| 4 | interface HeaderProps { | ||
| 5 | projects: Project[]; | ||
| 6 | sessions: Session[]; | ||
| 7 | selectedProject: Project | null; | ||
| 8 | selectedSession: Session | null; | ||
| 9 | onSelectProject: (project: Project | null) => void; | ||
| 10 | onSelectSession: (session: Session | null) => void; | ||
| 11 | onCreateProject: () => void; | ||
| 12 | onCreateSession: () => void; | ||
| 13 | } | ||
| 14 | |||
| 15 | const phaseLabels: Record<Phase, string> = { | ||
| 16 | research: "Research", | ||
| 17 | plan: "Plan", | ||
| 18 | implement: "Implement", | ||
| 19 | }; | ||
| 20 | |||
| 21 | const phases: Phase[] = ["research", "plan", "implement"]; | ||
| 22 | |||
| 23 | export function Header({ | ||
| 24 | projects, | ||
| 25 | sessions, | ||
| 26 | selectedProject, | ||
| 27 | selectedSession, | ||
| 28 | onSelectProject, | ||
| 29 | onSelectSession, | ||
| 30 | onCreateProject, | ||
| 31 | onCreateSession, | ||
| 32 | }: HeaderProps) { | ||
| 33 | return ( | ||
| 34 | <header className="header"> | ||
| 35 | <div className="header-left"> | ||
| 36 | <select | ||
| 37 | value={selectedProject?.id || ""} | ||
| 38 | onChange={(e) => { | ||
| 39 | const project = projects.find((p) => p.id === e.target.value); | ||
| 40 | onSelectProject(project || null); | ||
| 41 | onSelectSession(null); | ||
| 42 | }} | ||
| 43 | > | ||
| 44 | <option value="">Select Project...</option> | ||
| 45 | {projects.map((p) => ( | ||
| 46 | <option key={p.id} value={p.id}> | ||
| 47 | {p.name} | ||
| 48 | </option> | ||
| 49 | ))} | ||
| 50 | </select> | ||
| 51 | <button onClick={onCreateProject}>+ Project</button> | ||
| 52 | |||
| 53 | {selectedProject && ( | ||
| 54 | <> | ||
| 55 | <select | ||
| 56 | value={selectedSession?.id || ""} | ||
| 57 | onChange={(e) => { | ||
| 58 | const session = sessions.find((s) => s.id === e.target.value); | ||
| 59 | onSelectSession(session || null); | ||
| 60 | }} | ||
| 61 | > | ||
| 62 | <option value="">Select Session...</option> | ||
| 63 | {sessions.map((s) => ( | ||
| 64 | <option key={s.id} value={s.id}> | ||
| 65 | {s.name} | ||
| 66 | </option> | ||
| 67 | ))} | ||
| 68 | </select> | ||
| 69 | <button onClick={onCreateSession}>+ Session</button> | ||
| 70 | </> | ||
| 71 | )} | ||
| 72 | </div> | ||
| 73 | |||
| 74 | <div className="header-right"> | ||
| 75 | {selectedSession && ( | ||
| 76 | <div className="phase-indicator"> | ||
| 77 | {phases.map((phase) => { | ||
| 78 | const phaseIndex = phases.indexOf(phase); | ||
| 79 | const currentIndex = phases.indexOf(selectedSession.phase); | ||
| 80 | const isComplete = phaseIndex < currentIndex; | ||
| 81 | const isActive = phase === selectedSession.phase; | ||
| 82 | |||
| 83 | return ( | ||
| 84 | <span | ||
| 85 | key={phase} | ||
| 86 | className={`phase-step ${isActive ? "active" : ""} ${ | ||
| 87 | isComplete ? "complete" : "" | ||
| 88 | }`} | ||
| 89 | > | ||
| 90 | {phaseLabels[phase]} | ||
| 91 | </span> | ||
| 92 | ); | ||
| 93 | })} | ||
| 94 | </div> | ||
| 95 | )} | ||
| 96 | </div> | ||
| 97 | </header> | ||
| 98 | ); | ||
| 99 | } | ||
