From d5009ceeca5c968d14c10667fb792b1b93fde1be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:24:38 +0000 Subject: [PATCH 01/27] Initial plan From dbdea5362bc6e40dcb9918c1a39dc85b778e7b6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:39:05 +0000 Subject: [PATCH 02/27] Add browser mode infrastructure with WebSocket support Co-authored-by: ClickerMonkey <421233+ClickerMonkey@users.noreply.github.com> --- .gitignore | 1 + packages/cletus/esbuild.browser.cjs | 45 +++ packages/cletus/package.json | 10 +- packages/cletus/src/browser/app.tsx | 96 +++++ .../src/browser/components/ChatHeader.tsx | 26 ++ .../src/browser/components/ChatInput.tsx | 147 +++++++ .../src/browser/components/ChatList.tsx | 90 +++++ .../src/browser/components/Collapsible.tsx | 27 ++ .../src/browser/components/MessageItem.tsx | 47 +++ .../src/browser/components/MessageList.tsx | 41 ++ .../browser/components/OperationDisplay.tsx | 81 ++++ .../src/browser/components/SettingsView.tsx | 84 ++++ .../cletus/src/browser/components/Sidebar.tsx | 50 +++ packages/cletus/src/browser/index.html | 87 ++++ .../cletus/src/browser/pages/ChatPage.tsx | 78 ++++ .../cletus/src/browser/pages/MainPage.tsx | 39 ++ packages/cletus/src/browser/server.ts | 371 ++++++++++++++++++ packages/cletus/src/browser/styles.css | 212 ++++++++++ packages/cletus/src/index.tsx | 28 +- 19 files changed, 1554 insertions(+), 6 deletions(-) create mode 100644 packages/cletus/esbuild.browser.cjs create mode 100644 packages/cletus/src/browser/app.tsx create mode 100644 packages/cletus/src/browser/components/ChatHeader.tsx create mode 100644 packages/cletus/src/browser/components/ChatInput.tsx create mode 100644 packages/cletus/src/browser/components/ChatList.tsx create mode 100644 packages/cletus/src/browser/components/Collapsible.tsx create mode 100644 packages/cletus/src/browser/components/MessageItem.tsx create mode 100644 packages/cletus/src/browser/components/MessageList.tsx create mode 100644 packages/cletus/src/browser/components/OperationDisplay.tsx create mode 100644 packages/cletus/src/browser/components/SettingsView.tsx create mode 100644 packages/cletus/src/browser/components/Sidebar.tsx create mode 100644 packages/cletus/src/browser/index.html create mode 100644 packages/cletus/src/browser/pages/ChatPage.tsx create mode 100644 packages/cletus/src/browser/pages/MainPage.tsx create mode 100644 packages/cletus/src/browser/server.ts create mode 100644 packages/cletus/src/browser/styles.css diff --git a/.gitignore b/.gitignore index 2a68d65..bcd791e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build outputs dist/ +dist-browser/ *.tsbuildinfo # Build artifacts in source directories packages/*/src/**/*.js diff --git a/packages/cletus/esbuild.browser.cjs b/packages/cletus/esbuild.browser.cjs new file mode 100644 index 0000000..b1097f7 --- /dev/null +++ b/packages/cletus/esbuild.browser.cjs @@ -0,0 +1,45 @@ +const esbuild = require('esbuild'); +const fs = require('fs'); +const path = require('path'); + +// Build the browser client +esbuild.build({ + entryPoints: ['src/browser/app.tsx'], + bundle: true, + platform: 'browser', + target: 'es2020', + outfile: 'dist-browser/app.js', + format: 'esm', + minify: true, + sourcemap: true, + logLevel: 'info', + define: { + 'process.env.NODE_ENV': '"production"' + }, + loader: { + '.css': 'css', + }, +}).then(() => { + // Copy static files + const distDir = path.join(__dirname, 'dist-browser'); + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); + } + + // Copy HTML file + fs.copyFileSync( + path.join(__dirname, 'src/browser/index.html'), + path.join(distDir, 'index.html') + ); + + // Copy CSS file (it's imported in app.tsx but needs to be available) + fs.copyFileSync( + path.join(__dirname, 'src/browser/styles.css'), + path.join(distDir, 'styles.css') + ); + + console.log('✓ Browser client built successfully'); +}).catch((error) => { + console.error('Browser build failed:', error); + process.exit(1); +}); diff --git a/packages/cletus/package.json b/packages/cletus/package.json index b2c38df..e109f10 100644 --- a/packages/cletus/package.json +++ b/packages/cletus/package.json @@ -9,10 +9,10 @@ "cletus": "dist/index.js" }, "scripts": { - "build": "npm run clean && node esbuild.config.cjs", + "build": "npm run clean && node esbuild.config.cjs && node esbuild.browser.cjs", "dev": "tsx watch src/index.tsx", "start": "tsx --conditions=source src/index.tsx", - "clean": "rimraf dist tsconfig.tsbuildinfo", + "clean": "rimraf dist dist-browser tsconfig.tsbuildinfo", "typecheck": "tsc --noEmit", "test": "jest", "profile": "node --inspect dist/index.js", @@ -49,7 +49,9 @@ "ink-text-input": "^6.0.0", "mic": "^2.1.2", "react": "^19.2.0", - "sharp": "^0.34.3" + "react-dom": "^19.2.0", + "sharp": "^0.34.3", + "ws": "^8.18.0" }, "optionalDependencies": { "puppeteer": "^24.31.0" @@ -73,8 +75,10 @@ "@types/node": "^24.9.1", "@types/pdf-parse": "^1.1.5", "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.0", "@types/uuid": "^10.0.0", "@types/wav": "^1.0.4", + "@types/ws": "^8.5.13", "babel-jest": "^29.7.0", "diff": "^8.0.2", "esbuild": "^0.26.0", diff --git a/packages/cletus/src/browser/app.tsx b/packages/cletus/src/browser/app.tsx new file mode 100644 index 0000000..f61ee3c --- /dev/null +++ b/packages/cletus/src/browser/app.tsx @@ -0,0 +1,96 @@ +import React, { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ConfigFile, configExists } from '../file-manager'; +import { InkInitWizard } from '../components/InkInitWizard'; +import { MainPage } from './pages/MainPage'; +import { ChatPage } from './pages/ChatPage'; +import './styles.css'; + +type AppView = 'loading' | 'init' | 'main' | 'chat'; + +const App: React.FC = () => { + const [view, setView] = useState('loading'); + const [config, setConfig] = useState(null); + const [selectedChatId, setSelectedChatId] = useState(null); + + useEffect(() => { + async function checkConfig() { + const exists = await configExists(); + if (exists) { + const cfg = new ConfigFile(); + await cfg.load(); + setConfig(cfg); + setView('main'); + } else { + setView('init'); + } + } + checkConfig(); + }, []); + + if (view === 'loading') { + return ( +
+
+

Loading...

+
+ ); + } + + if (view === 'init') { + return ( + { + setConfig(cfg); + setView('main'); + }} + /> + ); + } + + if (view === 'chat' && selectedChatId && config) { + return ( + setView('main')} + /> + ); + } + + if (view === 'main' && config) { + return ( + { + setSelectedChatId(chatId); + setView('chat'); + }} + onExit={() => { + if (typeof window !== 'undefined') { + window.close(); + } + }} + /> + ); + } + + return ( +
+
+

Loading...

+
+ ); +}; + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Root element not found'); +} + +const root = createRoot(container); +root.render( + + + +); diff --git a/packages/cletus/src/browser/components/ChatHeader.tsx b/packages/cletus/src/browser/components/ChatHeader.tsx new file mode 100644 index 0000000..8f8bc48 --- /dev/null +++ b/packages/cletus/src/browser/components/ChatHeader.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type { ChatMeta } from '../../schemas'; + +interface ChatHeaderProps { + chat: ChatMeta; + onBack: () => void; +} + +export const ChatHeader: React.FC = ({ chat, onBack }) => { + return ( +
+
+ +
+

{chat.name}

+
+ Mode: {chat.mode} + {chat.assistant && ` • Assistant: ${chat.assistant}`} +
+
+
+
+ ); +}; diff --git a/packages/cletus/src/browser/components/ChatInput.tsx b/packages/cletus/src/browser/components/ChatInput.tsx new file mode 100644 index 0000000..4b06188 --- /dev/null +++ b/packages/cletus/src/browser/components/ChatInput.tsx @@ -0,0 +1,147 @@ +import React, { useState, useRef, useEffect } from 'react'; +import type { ConfigFile } from '../../config'; +import type { ChatMeta } from '../../schemas'; + +interface ChatInputProps { + chatId: string; + chatMeta: ChatMeta; + config: ConfigFile; + onMessageSent: () => void; +} + +export const ChatInput: React.FC = ({ chatId, chatMeta, config, onMessageSent }) => { + const [input, setInput] = useState(''); + const [isConnecting, setIsConnecting] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [status, setStatus] = useState(''); + const wsRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + // Connect WebSocket + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${window.location.host}`); + + ws.onopen = () => { + setIsConnecting(false); + // Initialize chat + ws.send(JSON.stringify({ + type: 'init_chat', + data: { chatId }, + })); + }; + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + switch (message.type) { + case 'chat_initialized': + console.log('Chat initialized'); + break; + + case 'message_added': + onMessageSent(); + break; + + case 'status_update': + setStatus(message.data.status); + break; + + case 'messages_updated': + case 'response_complete': + setIsProcessing(false); + setStatus(''); + onMessageSent(); + break; + + case 'error': + console.error('WebSocket error:', message.data.message); + setIsProcessing(false); + setStatus('Error: ' + message.data.message); + break; + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + setIsConnecting(false); + setIsProcessing(false); + }; + + ws.onclose = () => { + setIsConnecting(false); + setIsProcessing(false); + }; + + wsRef.current = ws; + setIsConnecting(true); + + return () => { + ws.close(); + }; + }, [chatId]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!input.trim() || isProcessing || !wsRef.current) { + return; + } + + wsRef.current.send(JSON.stringify({ + type: 'send_message', + data: { content: input.trim() }, + })); + + setInput(''); + setIsProcessing(true); + setStatus('Processing...'); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + return ( +
+ {status && ( +
+ {status} +
+ )} + +
+