feat: Add multi-host config and dynamic project scanning
Backend: - Load hosts from config/hosts.json - New /api/hosts endpoint listing available hosts - Dynamic project scanning with configurable depth - Support for local and SSH hosts (SSH execution coming next) Frontend (by Web-UI Claude): - Slash commands: /clear, /help, /export, /scroll, /new, /info - Chat export as Markdown Config: - hosts.json defines hosts with connection info and base paths - hosts.example.json as template (real config is gitignored) - Each host has name, description, color, icon, basePaths Next steps: - SSH command execution for remote hosts - Frontend host selector UI - Multi-agent collaboration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,8 +4,12 @@ node_modules/
|
|||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Config with credentials (separate per instance)
|
# Claude credentials (separate per instance)
|
||||||
config/
|
config/.claude/
|
||||||
|
config/.config/
|
||||||
|
|
||||||
|
# Hosts config with real IPs (use hosts.example.json as template)
|
||||||
|
config/hosts.json
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { spawn } from 'child_process';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join, basename } from 'path';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -14,27 +14,114 @@ app.use(express.json());
|
|||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
|
// Load hosts configuration
|
||||||
|
const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json';
|
||||||
|
let hostsConfig = { hosts: {}, defaults: { scanSubdirs: true, maxDepth: 1 } };
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
if (existsSync(CONFIG_PATH)) {
|
||||||
|
hostsConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
console.log('Loaded hosts config:', Object.keys(hostsConfig.hosts));
|
||||||
|
} else {
|
||||||
|
console.log('No hosts.json found, using defaults');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading config:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
// Store active Claude sessions
|
// Store active Claude sessions
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
// REST endpoint to list available projects
|
// Scan directory for projects
|
||||||
|
function scanProjects(basePath, depth = 0, maxDepth = 1) {
|
||||||
|
const projects = [];
|
||||||
|
|
||||||
|
if (!existsSync(basePath)) return projects;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(basePath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||||
|
const fullPath = join(basePath, entry.name);
|
||||||
|
projects.push({
|
||||||
|
path: fullPath,
|
||||||
|
name: entry.name,
|
||||||
|
type: 'directory'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recurse if not at max depth
|
||||||
|
if (depth < maxDepth - 1) {
|
||||||
|
projects.push(...scanProjects(fullPath, depth + 1, maxDepth));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error scanning ${basePath}:`, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST endpoint to list hosts
|
||||||
|
app.get('/api/hosts', (req, res) => {
|
||||||
|
const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({
|
||||||
|
id,
|
||||||
|
name: host.name,
|
||||||
|
description: host.description,
|
||||||
|
color: host.color,
|
||||||
|
icon: host.icon,
|
||||||
|
connectionType: host.connection.type,
|
||||||
|
isLocal: host.connection.type === 'local'
|
||||||
|
}));
|
||||||
|
res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// REST endpoint to list projects for a host
|
||||||
app.get('/api/projects', (req, res) => {
|
app.get('/api/projects', (req, res) => {
|
||||||
const baseDirs = [
|
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
|
||||||
{ path: '/projects', name: 'projects', description: 'Development projects' },
|
const host = hostsConfig.hosts[hostId];
|
||||||
{ path: '/docker', name: 'docker', description: 'Docker configurations' },
|
|
||||||
{ path: '/stacks', name: 'stacks', description: 'Production stacks' }
|
if (!host) {
|
||||||
];
|
return res.status(404).json({ error: `Host '${hostId}' not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only local hosts for now
|
||||||
|
if (host.connection.type !== 'local') {
|
||||||
|
return res.json({
|
||||||
|
projects: [],
|
||||||
|
host: hostId,
|
||||||
|
message: 'SSH hosts not yet supported for project listing'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const projects = [];
|
const projects = [];
|
||||||
for (const dir of baseDirs) {
|
const scanSubdirs = hostsConfig.defaults?.scanSubdirs ?? true;
|
||||||
if (existsSync(dir.path)) {
|
const maxDepth = hostsConfig.defaults?.maxDepth ?? 1;
|
||||||
projects.push({ ...dir, type: 'directory' });
|
|
||||||
|
for (const basePath of host.basePaths) {
|
||||||
|
// Add base path itself
|
||||||
|
if (existsSync(basePath)) {
|
||||||
|
projects.push({
|
||||||
|
path: basePath,
|
||||||
|
name: basename(basePath),
|
||||||
|
type: 'base',
|
||||||
|
isBase: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scan subdirectories if enabled
|
||||||
|
if (scanSubdirs) {
|
||||||
|
projects.push(...scanProjects(basePath, 0, maxDepth));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json(projects);
|
}
|
||||||
|
|
||||||
|
res.json({ projects, host: hostId, hostInfo: { name: host.name, color: host.color } });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
|
|||||||
37
config/hosts.example.json
Normal file
37
config/hosts.example.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"hosts": {
|
||||||
|
"local": {
|
||||||
|
"name": "Local",
|
||||||
|
"description": "Local machine",
|
||||||
|
"connection": {
|
||||||
|
"type": "local"
|
||||||
|
},
|
||||||
|
"basePaths": [
|
||||||
|
"/projects",
|
||||||
|
"/docker"
|
||||||
|
],
|
||||||
|
"color": "#f97316",
|
||||||
|
"icon": "cat"
|
||||||
|
},
|
||||||
|
"remote-example": {
|
||||||
|
"name": "Remote Server",
|
||||||
|
"description": "Example remote host via SSH",
|
||||||
|
"connection": {
|
||||||
|
"type": "ssh",
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"user": "username",
|
||||||
|
"port": 22
|
||||||
|
},
|
||||||
|
"basePaths": [
|
||||||
|
"/home/username/projects"
|
||||||
|
],
|
||||||
|
"color": "#22c55e",
|
||||||
|
"icon": "server"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"host": "local",
|
||||||
|
"scanSubdirs": true,
|
||||||
|
"maxDepth": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
# Separate config for WebUI Claude (NOT Neko's config!)
|
# Separate config for WebUI Claude (NOT Neko's config!)
|
||||||
- ./config/.claude:/home/node/.claude:rw
|
- ./config/.claude:/home/node/.claude:rw
|
||||||
- ./config/.config/claude:/home/node/.config/claude:rw
|
- ./config/.config/claude:/home/node/.config/claude:rw
|
||||||
|
# Hosts configuration
|
||||||
|
- ./config/hosts.json:/app/config/hosts.json:ro
|
||||||
# Project directories for Claude to work in
|
# Project directories for Claude to work in
|
||||||
- /home/sumdex/projects:/projects:rw
|
- /home/sumdex/projects:/projects:rw
|
||||||
- /home/sumdex/docker:/docker:rw
|
- /home/sumdex/docker:/docker:rw
|
||||||
|
|||||||
@@ -1,10 +1,89 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useClaudeSession } from './hooks/useClaudeSession';
|
import { useClaudeSession } from './hooks/useClaudeSession';
|
||||||
import { MessageList } from './components/MessageList';
|
import { MessageList } from './components/MessageList';
|
||||||
import { ChatInput } from './components/ChatInput';
|
import { ChatInput } from './components/ChatInput';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
|
|
||||||
|
// Slash command definitions
|
||||||
|
const SLASH_COMMANDS = {
|
||||||
|
clear: {
|
||||||
|
description: 'Clear chat history (UI only)',
|
||||||
|
execute: ({ clearMessages, addSystemMessage }) => {
|
||||||
|
clearMessages();
|
||||||
|
addSystemMessage('Chat cleared');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
description: 'Show available commands',
|
||||||
|
execute: ({ addSystemMessage }) => {
|
||||||
|
const helpText = Object.entries(SLASH_COMMANDS)
|
||||||
|
.map(([cmd, { description }]) => `/${cmd} - ${description}`)
|
||||||
|
.join('\n');
|
||||||
|
addSystemMessage(`Available commands:\n${helpText}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
description: 'Export chat as Markdown',
|
||||||
|
execute: ({ messages, addSystemMessage }) => {
|
||||||
|
const markdown = messages.map(m => {
|
||||||
|
if (m.type === 'user') return `**You:** ${m.content}`;
|
||||||
|
if (m.type === 'assistant') return `**Claude:** ${m.content}`;
|
||||||
|
if (m.type === 'tool_use') return `> Tool: ${m.tool}`;
|
||||||
|
if (m.type === 'system') return `_${m.content}_`;
|
||||||
|
return '';
|
||||||
|
}).filter(Boolean).join('\n\n');
|
||||||
|
|
||||||
|
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `claude-chat-${new Date().toISOString().slice(0,10)}.md`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
addSystemMessage('Chat exported as Markdown');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
description: 'Scroll to top or bottom (/scroll top|bottom)',
|
||||||
|
execute: ({ args, addSystemMessage }) => {
|
||||||
|
const direction = args[0] || 'bottom';
|
||||||
|
const container = document.querySelector('.overflow-y-auto');
|
||||||
|
if (container) {
|
||||||
|
if (direction === 'top') {
|
||||||
|
container.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addSystemMessage(`Scrolled to ${direction}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new: {
|
||||||
|
description: 'Start a new session (clears history)',
|
||||||
|
execute: ({ clearMessages, stopSession, startSession, selectedProject, addSystemMessage }) => {
|
||||||
|
stopSession();
|
||||||
|
clearMessages();
|
||||||
|
setTimeout(() => {
|
||||||
|
startSession(selectedProject, false); // false = don't resume
|
||||||
|
}, 500);
|
||||||
|
addSystemMessage('Starting new session...');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
description: 'Show session info',
|
||||||
|
execute: ({ connected, sessionActive, currentProject, messages, addSystemMessage }) => {
|
||||||
|
const info = [
|
||||||
|
`Connected: ${connected ? 'Yes' : 'No'}`,
|
||||||
|
`Session: ${sessionActive ? 'Active' : 'Inactive'}`,
|
||||||
|
`Project: ${currentProject || 'None'}`,
|
||||||
|
`Messages: ${messages.length}`
|
||||||
|
].join('\n');
|
||||||
|
addSystemMessage(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {
|
const {
|
||||||
connected,
|
connected,
|
||||||
@@ -17,21 +96,67 @@ function App() {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
stopSession,
|
stopSession,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
setError
|
setError,
|
||||||
|
setMessages
|
||||||
} = useClaudeSession();
|
} = useClaudeSession();
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
|
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [resumeSession, setResumeSession] = useState(true);
|
const [resumeSession, setResumeSession] = useState(true);
|
||||||
|
|
||||||
|
// Add system message helper
|
||||||
|
const addSystemMessage = useCallback((content) => {
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
type: 'system',
|
||||||
|
content,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}]);
|
||||||
|
}, [setMessages]);
|
||||||
|
|
||||||
const handleStartSession = () => {
|
const handleStartSession = () => {
|
||||||
startSession(selectedProject, resumeSession);
|
startSession(selectedProject, resumeSession);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = (message) => {
|
// Handle slash commands
|
||||||
if (message.trim()) {
|
const handleCommand = useCallback((command, args) => {
|
||||||
sendMessage(message);
|
const cmd = SLASH_COMMANDS[command.toLowerCase()];
|
||||||
|
if (cmd) {
|
||||||
|
cmd.execute({
|
||||||
|
clearMessages,
|
||||||
|
addSystemMessage,
|
||||||
|
messages,
|
||||||
|
stopSession,
|
||||||
|
startSession,
|
||||||
|
selectedProject,
|
||||||
|
connected,
|
||||||
|
sessionActive,
|
||||||
|
currentProject,
|
||||||
|
args
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}, [clearMessages, addSystemMessage, messages, stopSession, startSession, selectedProject, connected, sessionActive, currentProject]);
|
||||||
|
|
||||||
|
const handleSendMessage = (message) => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
|
||||||
|
// Check for slash command
|
||||||
|
if (message.startsWith('/')) {
|
||||||
|
const parts = message.slice(1).split(' ');
|
||||||
|
const command = parts[0];
|
||||||
|
const args = parts.slice(1);
|
||||||
|
|
||||||
|
if (handleCommand(command, args)) {
|
||||||
|
return; // Command handled
|
||||||
|
} else {
|
||||||
|
addSystemMessage(`Unknown command: /${command}. Type /help for available commands.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular message
|
||||||
|
sendMessage(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Send, Loader2 } from 'lucide-react';
|
import { Send, Loader2, Command } from 'lucide-react';
|
||||||
|
|
||||||
|
// Available slash commands for autocomplete
|
||||||
|
const COMMANDS = [
|
||||||
|
{ name: 'help', description: 'Show available commands' },
|
||||||
|
{ name: 'clear', description: 'Clear chat history' },
|
||||||
|
{ name: 'export', description: 'Export chat as Markdown' },
|
||||||
|
{ name: 'scroll', description: 'Scroll to top or bottom' },
|
||||||
|
{ name: 'new', description: 'Start a new session' },
|
||||||
|
{ name: 'info', description: 'Show session info' },
|
||||||
|
];
|
||||||
|
|
||||||
export function ChatInput({ onSend, disabled, placeholder }) {
|
export function ChatInput({ onSend, disabled, placeholder }) {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
@@ -14,15 +27,60 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
}
|
}
|
||||||
}, [message]);
|
}, [message]);
|
||||||
|
|
||||||
|
// Handle command filtering
|
||||||
|
useEffect(() => {
|
||||||
|
if (message.startsWith('/')) {
|
||||||
|
const query = message.slice(1).toLowerCase();
|
||||||
|
const filtered = COMMANDS.filter(cmd =>
|
||||||
|
cmd.name.toLowerCase().startsWith(query)
|
||||||
|
);
|
||||||
|
setFilteredCommands(filtered);
|
||||||
|
setShowCommands(filtered.length > 0 && message.length > 0);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
} else {
|
||||||
|
setShowCommands(false);
|
||||||
|
}
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (message.trim() && !disabled) {
|
if (message.trim() && !disabled) {
|
||||||
onSend(message);
|
onSend(message);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
setShowCommands(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectCommand = (cmdName) => {
|
||||||
|
setMessage(`/${cmdName} `);
|
||||||
|
setShowCommands(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
|
// Handle command selection
|
||||||
|
if (showCommands) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(i => Math.min(i + 1, filteredCommands.length - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(i => Math.max(i - 1, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab' || (e.key === 'Enter' && filteredCommands.length > 0)) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectCommand(filteredCommands[selectedIndex].name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setShowCommands(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit(e);
|
handleSubmit(e);
|
||||||
@@ -33,6 +91,25 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
<form onSubmit={handleSubmit} className="p-4 border-t border-dark-800 bg-dark-900">
|
<form onSubmit={handleSubmit} className="p-4 border-t border-dark-800 bg-dark-900">
|
||||||
<div className="flex gap-3 items-end max-w-4xl mx-auto">
|
<div className="flex gap-3 items-end max-w-4xl mx-auto">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
|
{/* Command autocomplete dropdown */}
|
||||||
|
{showCommands && (
|
||||||
|
<div className="absolute bottom-full left-0 right-0 mb-2 bg-dark-800 border border-dark-700 rounded-lg shadow-xl overflow-hidden z-10">
|
||||||
|
{filteredCommands.map((cmd, index) => (
|
||||||
|
<button
|
||||||
|
key={cmd.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectCommand(cmd.name)}
|
||||||
|
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors
|
||||||
|
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
|
||||||
|
>
|
||||||
|
<Command className="w-4 h-4 text-dark-500" />
|
||||||
|
<span className="font-medium">/{cmd.name}</span>
|
||||||
|
<span className="text-dark-500 text-sm">{cmd.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={message}
|
value={message}
|
||||||
@@ -50,7 +127,7 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
||||||
Shift+Enter for newline
|
{message.startsWith('/') ? 'Tab to complete' : 'Shift+Enter for newline'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export function useClaudeSession() {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
stopSession,
|
stopSession,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
setError
|
setError,
|
||||||
|
setMessages
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user