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
(limited to 'renderer')
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) => (
+
+ ))}
+ {isLoading && (
+
+ )}
+
+
+
+
+ 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 ? (
+