From 6d70c5f8a3ed90564b08616a3fb041409916059c Mon Sep 17 00:00:00 2001 From: Clawd Date: Sat, 28 Feb 2026 07:30:40 -0800 Subject: Phase 4: React UI - Add renderer/src/types.ts with Project, Session, Message, Phase types - Add renderer/src/styles/globals.css with full styling - Dark theme with accent colors - Header, document pane, chat pane, action bar layouts - Phase indicator, token usage bar, buttons - Add renderer/src/components/Header.tsx - Project/session dropdowns with create buttons - Phase indicator showing current workflow state - Add renderer/src/components/DocumentPane.tsx - Markdown viewer/editor with toggle - Syntax highlighting for review comments - Task checkbox rendering - Add renderer/src/components/ChatPane.tsx - Message list with auto-scroll - Input field with Enter to send - Loading state indicator - Add renderer/src/components/ActionBar.tsx - Token usage bar with color coding - Review/Submit buttons for workflow - Permission mode toggle for implement phase - Add renderer/src/App.tsx - Full state management for projects, sessions, messages - Claude message subscription - Workflow handlers (review, submit, phase advance) - Update renderer/src/main.tsx to render App --- renderer/src/App.tsx | 241 +++++++++++++++++++ renderer/src/components/ActionBar.tsx | 97 ++++++++ renderer/src/components/ChatPane.tsx | 66 ++++++ renderer/src/components/DocumentPane.tsx | 104 ++++++++ renderer/src/components/Header.tsx | 99 ++++++++ renderer/src/main.tsx | 11 +- renderer/src/styles/globals.css | 391 +++++++++++++++++++++++++++++++ renderer/src/types.ts | 35 +++ 8 files changed, 1041 insertions(+), 3 deletions(-) create mode 100644 renderer/src/App.tsx create mode 100644 renderer/src/components/ActionBar.tsx create mode 100644 renderer/src/components/ChatPane.tsx create mode 100644 renderer/src/components/DocumentPane.tsx create mode 100644 renderer/src/components/Header.tsx create mode 100644 renderer/src/styles/globals.css create mode 100644 renderer/src/types.ts diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx new file mode 100644 index 0000000..7a8c378 --- /dev/null +++ b/renderer/src/App.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Header } from "./components/Header"; +import { DocumentPane } from "./components/DocumentPane"; +import { ChatPane } from "./components/ChatPane"; +import { ActionBar } from "./components/ActionBar"; +import type { Project, Session, Message, Phase, TokenUsage } from "./types"; +import "./styles/globals.css"; + +const api = window.api; + +export function App() { + const [projects, setProjects] = useState([]); + const [sessions, setSessions] = useState([]); + const [selectedProject, setSelectedProject] = useState(null); + const [selectedSession, setSelectedSession] = useState(null); + const [messages, setMessages] = useState([]); + const [documentContent, setDocumentContent] = useState(""); + const [originalContent, setOriginalContent] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [tokenUsage, setTokenUsage] = useState({ + inputTokens: 0, + outputTokens: 0, + }); + + const hasChanges = documentContent !== originalContent; + + // Load projects on mount + useEffect(() => { + api.listProjects().then(setProjects); + }, []); + + // Load sessions when project changes + useEffect(() => { + if (selectedProject) { + api.listSessions(selectedProject.id).then(setSessions); + } else { + setSessions([]); + } + }, [selectedProject]); + + // Load messages and artifact when session changes + useEffect(() => { + if (selectedSession && selectedProject) { + // Load messages + api.listMessages(selectedSession.id).then(setMessages); + + // Load artifact + const filename = + selectedSession.phase === "research" ? "research.md" : "plan.md"; + api.readArtifact(selectedProject.path, filename).then((content) => { + const text = content || ""; + setDocumentContent(text); + setOriginalContent(text); + }); + } else { + setMessages([]); + setDocumentContent(""); + setOriginalContent(""); + } + }, [selectedSession?.id, selectedSession?.phase, selectedProject]); + + // Subscribe to Claude messages + useEffect(() => { + const unsubscribe = api.onClaudeMessage((sessionId, msg) => { + if (sessionId !== selectedSession?.id) return; + + if (msg.type === "result" && msg.subtype === "success") { + setIsLoading(false); + if (msg.usage) { + setTokenUsage({ + inputTokens: msg.usage.input_tokens, + outputTokens: msg.usage.output_tokens, + cacheHits: msg.usage.cache_read_input_tokens, + }); + } + // Reload artifact after Claude updates it + if (selectedProject) { + const filename = + selectedSession.phase === "research" ? "research.md" : "plan.md"; + api.readArtifact(selectedProject.path, filename).then((content) => { + const text = content || ""; + setDocumentContent(text); + setOriginalContent(text); + }); + } + } + + if (msg.type === "assistant") { + const content = ( + msg.message.content as Array<{ type: string; text?: string }> + ) + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text!) + .join("\n"); + + if (content) { + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === "assistant") { + // Update existing assistant message + return [...prev.slice(0, -1), { ...last, content }]; + } + // Add new assistant message + return [ + ...prev, + { + id: crypto.randomUUID(), + session_id: sessionId, + role: "assistant", + content, + created_at: Date.now() / 1000, + }, + ]; + }); + } + } + }); + + return unsubscribe; + }, [selectedSession?.id, selectedSession?.phase, selectedProject]); + + const handleSendMessage = async (message: string) => { + if (!selectedSession) return; + setIsLoading(true); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + session_id: selectedSession.id, + role: "user", + content: message, + created_at: Date.now() / 1000, + }, + ]); + await api.sendMessage(selectedSession.id, message); + }; + + const handleReview = async () => { + if (!selectedSession || !selectedProject) return; + // Save user edits first + const filename = + selectedSession.phase === "research" ? "research.md" : "plan.md"; + await api.writeArtifact(selectedProject.path, filename, documentContent); + setOriginalContent(documentContent); + setIsLoading(true); + await api.triggerReview(selectedSession.id); + }; + + const handleSubmit = async () => { + if (!selectedSession || !selectedProject) return; + // Save any pending edits + const filename = + selectedSession.phase === "research" ? "research.md" : "plan.md"; + await api.writeArtifact(selectedProject.path, filename, documentContent); + + const nextPhase = await api.advancePhase(selectedSession.id); + if (nextPhase) { + setSelectedSession({ ...selectedSession, phase: nextPhase }); + // Trigger initial message for next phase + setIsLoading(true); + const initialMsg = + nextPhase === "plan" + ? "Create a detailed implementation plan based on the research." + : "Begin implementing the plan."; + await api.sendMessage(selectedSession.id, initialMsg); + } + }; + + const handleCreateProject = async () => { + const path = await api.selectDirectory(); + if (!path) return; + const name = path.split("/").pop() || "New Project"; + const project = await api.createProject(name, path); + setProjects((prev) => [project, ...prev]); + setSelectedProject(project); + }; + + const handleCreateSession = async () => { + if (!selectedProject) return; + const name = `Session ${sessions.length + 1}`; + const session = await api.createSession(selectedProject.id, name); + setSessions((prev) => [session, ...prev]); + setSelectedSession(session); + setMessages([]); + setDocumentContent(""); + setOriginalContent(""); + }; + + return ( +
+
+ +
+ + + +
+ + { + if (selectedSession) { + api.setPermissionMode(selectedSession.id, mode); + setSelectedSession({ ...selectedSession, permission_mode: mode }); + } + }} + disabled={!selectedSession} + /> +
+ ); +} 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 @@ +import React from "react"; +import type { Phase, PermissionMode, TokenUsage } from "../types"; + +interface ActionBarProps { + phase: Phase; + hasChanges: boolean; + isLoading: boolean; + tokenUsage: TokenUsage; + permissionMode: PermissionMode; + onReview: () => void; + onSubmit: () => void; + onPermissionModeChange: (mode: PermissionMode) => void; + disabled: boolean; +} + +export function ActionBar({ + phase, + hasChanges, + isLoading, + tokenUsage, + permissionMode, + onReview, + onSubmit, + onPermissionModeChange, + disabled, +}: ActionBarProps) { + const totalTokens = tokenUsage.inputTokens + tokenUsage.outputTokens; + const maxTokens = 200000; + const usagePercent = Math.min((totalTokens / maxTokens) * 100, 100); + + const getBarColor = () => { + if (usagePercent > 80) return "#ef4444"; + if (usagePercent > 50) return "#f59e0b"; + return "#10b981"; + }; + + return ( +
+
+
+
+
+
+ + {(totalTokens / 1000).toFixed(1)}k / 200k + +
+ + {phase === "implement" && ( + + )} +
+ +
+ {phase !== "implement" && ( + <> + + + + )} + {phase === "implement" && isLoading && ( + Implementing... + )} +
+
+ ); +} 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 @@ +import React, { useState, useRef, useEffect } from "react"; +import type { Message } from "../types"; + +interface ChatPaneProps { + messages: Message[]; + onSend: (message: string) => void; + isLoading: boolean; + disabled: boolean; + placeholder: string; +} + +export function ChatPane({ + messages, + onSend, + isLoading, + disabled, + placeholder, +}: ChatPaneProps) { + const [input, setInput] = useState(""); + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const handleSend = () => { + if (!input.trim() || isLoading || disabled) return; + onSend(input.trim()); + setInput(""); + }; + + return ( +
+
+ {messages.map((msg) => ( +
+
{msg.content}
+
+ ))} + {isLoading && ( +
+
Thinking...
+
+ )} +
+
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()} + placeholder={placeholder} + disabled={disabled || isLoading} + /> + +
+
+ ); +} 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 @@ +import React, { useState, useMemo } from "react"; +import type { Phase } from "../types"; + +interface DocumentPaneProps { + content: string; + onChange: (content: string) => void; + phase: Phase; + disabled: boolean; +} + +function renderMarkdown(md: string): string { + return ( + md + // Headers + .replace(/^### (.*$)/gm, "

$1

") + .replace(/^## (.*$)/gm, "

$1

") + .replace(/^# (.*$)/gm, "

$1

") + // Bold/italic + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + // Code blocks + .replace( + /```(\w*)\n([\s\S]*?)```/g, + '
$2
' + ) + .replace(/`([^`]+)`/g, "$1") + // Lists + .replace(/^- \[x\] (.*$)/gm, '
  • ☑ $1
  • ') + .replace(/^- \[ \] (.*$)/gm, '
  • ☐ $1
  • ') + .replace(/^- (.*$)/gm, "
  • $1
  • ") + // Review comments (highlight them) + .replace(/(\/\/ REVIEW:.*$)/gm, '$1') + .replace(/(\/\/ NOTE:.*$)/gm, '$1') + // Paragraphs + .replace(/\n\n/g, "

    ") + .replace(/^(.+)$/gm, "

    $1

    ") + // Clean up + .replace(/

    <\/p>/g, "") + .replace(/

    ()/g, "$1") + .replace(/(<\/h[1-3]>)<\/p>/g, "$1") + .replace(/

    (

    )/g, "$1")
    +      .replace(/(<\/pre>)<\/p>/g, "$1")
    +      .replace(/

    ()<\/p>/g, "$1") + ); +} + +export function DocumentPane({ + content, + onChange, + phase, + disabled, +}: DocumentPaneProps) { + const [isEditing, setIsEditing] = useState(false); + const renderedHtml = useMemo(() => renderMarkdown(content), [content]); + + if (phase === "implement") { + return ( +

    +
    + plan.md + Implementing... +
    +
    +
    + ); + } + + const filename = phase === "research" ? "research.md" : "plan.md"; + + return ( +
    +
    + {filename} + +
    + + {isEditing ? ( +