Compare commits

..

1 Commits

Author SHA1 Message Date
simosmik
f670f552ac fix: mobile viewport 2026-02-20 08:10:22 +00:00
36 changed files with 83 additions and 635 deletions

View File

@@ -21,10 +21,6 @@ PORT=3001
#Frontend port #Frontend port
VITE_PORT=5173 VITE_PORT=5173
# Host/IP to bind servers to (default: 0.0.0.0 for all interfaces)
# Use 127.0.0.1 to restrict to localhost only
HOST=0.0.0.0
# Uncomment the following line if you have a custom claude cli path other than the default "claude" # Uncomment the following line if you have a custom claude cli path other than the default "claude"
# CLAUDE_CLI_PATH=claude # CLAUDE_CLI_PATH=claude

View File

@@ -1,37 +0,0 @@
# Changelog
All notable changes to CloudCLI UI will be documented in this file.
## [1.20.1](https://github.com/siteboon/claudecodeui/compare/v1.19.1...v1.20.1) (2026-02-23)
### New Features
* implement install mode detection and update commands in version upgrade process ([f986004](https://github.com/siteboon/claudecodeui/commit/f986004319207b068431f9f6adf338a8ce8decfc))
* migrate legacy database to new location and improve last login update handling ([50e097d](https://github.com/siteboon/claudecodeui/commit/50e097d4ac498aa9f1803ef3564843721833dc19))
## [1.19.1](https://github.com/siteboon/claudecodeui/compare/v1.19.0...v1.19.1) (2026-02-23)
### Bug Fixes
* add prepublishOnly script to build before publishing ([82efac4](https://github.com/siteboon/claudecodeui/commit/82efac4704cab11ed8d1a05fe84f41312140b223))
## [1.19.0](https://github.com/siteboon/claudecodeui/compare/v1.18.2...v1.19.0) (2026-02-23)
### New Features
* add HOST environment variable for configurable bind address ([#360](https://github.com/siteboon/claudecodeui/issues/360)) ([cccd915](https://github.com/siteboon/claudecodeui/commit/cccd915c336192216b6e6f68e2b5f3ece0ccf966))
* subagent tool grouping ([#398](https://github.com/siteboon/claudecodeui/issues/398)) ([0207a1f](https://github.com/siteboon/claudecodeui/commit/0207a1f3a3c87f1c6c1aee8213be999b23289386))
### Bug Fixes
* **macos:** fix node-pty posix_spawnp error with postinstall script ([#347](https://github.com/siteboon/claudecodeui/issues/347)) ([38a593c](https://github.com/siteboon/claudecodeui/commit/38a593c97fdb2bb7f051e09e8e99c16035448655)), closes [#284](https://github.com/siteboon/claudecodeui/issues/284)
* slash commands with arguments bypass command execution ([#392](https://github.com/siteboon/claudecodeui/issues/392)) ([597e9c5](https://github.com/siteboon/claudecodeui/commit/597e9c54b76e7c6cd1947299c668c78d24019cab))
### Refactoring
* **releases:** Create a contributing guide and proper release notes using a release-it plugin ([fc369d0](https://github.com/siteboon/claudecodeui/commit/fc369d047e13cba9443fe36c0b6bb2ce3beaf61c))
### Maintenance
* update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json ([#410](https://github.com/siteboon/claudecodeui/issues/410)) ([7ccbc8d](https://github.com/siteboon/claudecodeui/commit/7ccbc8d92d440e18c157b656c9ea2635044a64f6))

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.20.1", "version": "1.18.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.20.1", "version": "1.18.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
@@ -114,9 +114,9 @@
} }
}, },
"node_modules/@anthropic-ai/claude-agent-sdk": { "node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.1.77", "version": "0.1.71",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.77.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.71.tgz",
"integrity": "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==", "integrity": "sha512-O34SQCEsdU11Z2uy30GaJGRLdRbEwEvaQs8APywHVOW/EdIGE0rS/AE4V6p9j45/j5AFwh2USZWlbz5NTlLnrw==",
"license": "SEE LICENSE IN README.md", "license": "SEE LICENSE IN README.md",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@@ -132,7 +132,7 @@
"@img/sharp-win32-x64": "^0.33.5" "@img/sharp-win32-x64": "^0.33.5"
}, },
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.24.1 || ^4.0.0"
} }
}, },
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-arm64": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.20.1", "version": "1.18.2",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",
@@ -32,7 +32,6 @@
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"start": "npm run build && npm run server", "start": "npm run build && npm run server",
"release": "./release.sh", "release": "./release.sh",
"prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js" "postinstall": "node scripts/fix-node-pty.js"
}, },
"keywords": [ "keywords": [

View File

@@ -250,13 +250,7 @@ function getAllSessions() {
* @returns {Object} Transformed message ready for WebSocket * @returns {Object} Transformed message ready for WebSocket
*/ */
function transformMessage(sdkMessage) { function transformMessage(sdkMessage) {
// Extract parent_tool_use_id for subagent tool grouping // Pass-through; SDK messages match frontend format.
if (sdkMessage.parent_tool_use_id) {
return {
...sdkMessage,
parentToolUseId: sdkMessage.parent_tool_use_id
};
}
return sdkMessage; return sdkMessage;
} }

View File

@@ -40,22 +40,6 @@ if (process.env.DATABASE_PATH) {
} }
} }
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
try {
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
for (const suffix of ['-wal', '-shm']) {
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
}
}
} catch (err) {
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
}
}
// Create database connection // Create database connection
const db = new Database(DB_PATH); const db = new Database(DB_PATH);
@@ -144,12 +128,12 @@ const userDb = {
} }
}, },
// Update last login time (non-fatal — logged but not thrown) // Update last login time
updateLastLogin: (userId) => { updateLastLogin: (userId) => {
try { try {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId); db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
} catch (err) { } catch (err) {
console.warn('Failed to update last login:', err.message); throw err;
} }
}, },

View File

@@ -9,8 +9,6 @@ import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
// ANSI color codes for terminal output // ANSI color codes for terminal output
const colors = { const colors = {
reset: '\x1b[0m', reset: '\x1b[0m',
@@ -335,8 +333,7 @@ app.use(express.urlencoded({ limit: '50mb', extended: true }));
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
installMode
}); });
}); });
@@ -413,13 +410,11 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
console.log('Starting system update from directory:', projectRoot); console.log('Starting system update from directory:', projectRoot);
// Run the update command based on install mode // Run the update command
const updateCommand = installMode === 'git' const updateCommand = 'git checkout main && git pull && npm install';
? 'git checkout main && git pull && npm install'
: 'npm install -g @siteboon/claude-code-ui@latest';
const child = spawn('sh', ['-c', updateCommand], { const child = spawn('sh', ['-c', updateCommand], {
cwd: installMode === 'git' ? projectRoot : os.homedir(), cwd: projectRoot,
env: process.env env: process.env
}); });
@@ -1138,7 +1133,7 @@ function handleShellConnection(ws) {
if (isPlainShell) { if (isPlainShell) {
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`; welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
} else { } else {
const providerName = provider === 'cursor' ? 'Cursor' : provider === 'codex' ? 'Codex' : 'Claude'; const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
welcomeMsg = hasSession ? welcomeMsg = hasSession ?
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` : `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
@@ -1174,23 +1169,6 @@ function handleShellConnection(ws) {
shellCommand = `cd "${projectPath}" && cursor-agent`; shellCommand = `cd "${projectPath}" && cursor-agent`;
} }
} }
} else if (provider === 'codex') {
// Use codex command
if (os.platform() === 'win32') {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
}
} else {
if (hasSession && sessionId) {
// Try to resume session, but with fallback to a new session if it fails
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
} else {
shellCommand = `cd "${projectPath}" && codex`;
}
}
} else { } else {
// Use claude command (default) or initialCommand if provided // Use claude command (default) or initialCommand if provided
const command = initialCommand || 'claude'; const command = initialCommand || 'claude';
@@ -1908,9 +1886,6 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
} }
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
// Show localhost in URL when binding to all interfaces (0.0.0.0 isn't a connectable address)
const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST;
// Initialize database and start server // Initialize database and start server
async function startServer() { async function startServer() {
@@ -1930,7 +1905,7 @@ async function startServer() {
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`); console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
} }
server.listen(PORT, HOST, async () => { server.listen(PORT, '0.0.0.0', async () => {
const appInstallPath = path.join(__dirname, '..'); const appInstallPath = path.join(__dirname, '..');
console.log(''); console.log('');
@@ -1938,7 +1913,7 @@ async function startServer() {
console.log(` ${c.bright('Claude Code UI Server - Ready')}`); console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
console.log(c.dim('═'.repeat(63))); console.log(c.dim('═'.repeat(63)));
console.log(''); console.log('');
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + PORT)}`); console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`); console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`); console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
console.log(''); console.log('');

View File

@@ -1,6 +1,5 @@
// Load environment variables from .env before other imports execute. // Load environment variables from .env before other imports execute.
import fs from 'fs'; import fs from 'fs';
import os from 'os';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
@@ -23,7 +22,3 @@ try {
} catch (e) { } catch (e) {
console.log('No .env file found or error reading it:', e.message); console.log('No .env file found or error reading it:', e.message);
} }
if (!process.env.DATABASE_PATH) {
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
}

View File

@@ -889,80 +889,21 @@ async function parseJsonlSessions(filePath) {
} }
} }
// Parse an agent JSONL file and extract tool uses
async function parseAgentTools(filePath) {
const tools = [];
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
// Look for assistant messages with tool_use
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
for (const part of entry.message.content) {
if (part.type === 'tool_use') {
tools.push({
toolId: part.id,
toolName: part.name,
toolInput: part.input,
timestamp: entry.timestamp
});
}
}
}
// Look for tool results
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
for (const part of entry.message.content) {
if (part.type === 'tool_result') {
// Find the matching tool and add result
const tool = tools.find(t => t.toolId === part.tool_use_id);
if (tool) {
tool.toolResult = {
content: typeof part.content === 'string' ? part.content :
Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') :
JSON.stringify(part.content),
isError: Boolean(part.is_error)
};
}
}
}
}
} catch (parseError) {
// Skip malformed lines
}
}
}
} catch (error) {
console.warn(`Error parsing agent file ${filePath}:`, error.message);
}
return tools;
}
// Get messages for a specific session with pagination support // Get messages for a specific session with pagination support
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) { async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try { try {
const files = await fs.readdir(projectDir); const files = await fs.readdir(projectDir);
// agent-*.jsonl files contain subagent tool history - we'll process them separately // agent-*.jsonl files contain session start data at this point. This needs to be revisited
// periodically to make sure only accurate data is there and no new functionality is added there
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-'));
if (jsonlFiles.length === 0) { if (jsonlFiles.length === 0) {
return { messages: [], total: 0, hasMore: false }; return { messages: [], total: 0, hasMore: false };
} }
const messages = []; const messages = [];
// Map of agentId -> tools for subagent tool grouping
const agentToolsCache = new Map();
// Process all JSONL files to find messages for this session // Process all JSONL files to find messages for this session
for (const file of jsonlFiles) { for (const file of jsonlFiles) {
@@ -987,35 +928,6 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
} }
} }
// Collect agentIds from Task tool results
const agentIds = new Set();
for (const message of messages) {
if (message.toolUseResult?.agentId) {
agentIds.add(message.toolUseResult.agentId);
}
}
// Load agent tools for each agentId found
for (const agentId of agentIds) {
const agentFileName = `agent-${agentId}.jsonl`;
if (agentFiles.includes(agentFileName)) {
const agentFilePath = path.join(projectDir, agentFileName);
const tools = await parseAgentTools(agentFilePath);
agentToolsCache.set(agentId, tools);
}
}
// Attach agent tools to their parent Task messages
for (const message of messages) {
if (message.toolUseResult?.agentId) {
const agentId = message.toolUseResult.agentId;
const agentTools = agentToolsCache.get(agentId);
if (agentTools && agentTools.length > 0) {
message.subagentTools = agentTools;
}
}
}
// Sort messages by timestamp // Sort messages by timestamp
const sortedMessages = messages.sort((a, b) => const sortedMessages = messages.sort((a, b) =>
new Date(a.timestamp || 0) - new Date(b.timestamp || 0) new Date(a.timestamp || 0) - new Date(b.timestamp || 0)

View File

@@ -53,11 +53,11 @@ router.post('/register', async (req, res) => {
// Generate token // Generate token
const token = generateToken(user); const token = generateToken(user);
db.prepare('COMMIT').run(); // Update last login
// Update last login (non-fatal, outside transaction)
userDb.updateLastLogin(user.id); userDb.updateLastLogin(user.id);
db.prepare('COMMIT').run();
res.json({ res.json({
success: true, success: true,
user: { id: user.id, username: user.username }, user: { id: user.id, username: user.username },

View File

@@ -63,5 +63,5 @@ export const CODEX_MODELS = {
{ value: 'o4-mini', label: 'O4-mini' } { value: 'o4-mini', label: 'O4-mini' }
], ],
DEFAULT: 'gpt-5.3-codex' DEFAULT: 'gpt-5.2'
}; };

View File

@@ -162,7 +162,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path, projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id, sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current, hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || localStorage.getItem('selected-provider') || 'claude'), provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
cols: terminal.current.cols, cols: terminal.current.cols,
rows: terminal.current.rows, rows: terminal.current.rows,
initialCommand: initialCommandRef.current, initialCommand: initialCommandRef.current,

View File

@@ -106,7 +106,7 @@ export default function AppContent() {
</div> </div>
)} )}
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}> <div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
<MainContent <MainContent
selectedProject={selectedProject} selectedProject={selectedProject}
selectedSession={selectedSession} selectedSession={selectedSession}

View File

@@ -271,14 +271,13 @@ export function useChatComposerState({
}, [setChatMessages]); }, [setChatMessages]);
const executeCommand = useCallback( const executeCommand = useCallback(
async (command: SlashCommand, rawInput?: string) => { async (command: SlashCommand) => {
if (!command || !selectedProject) { if (!command || !selectedProject) {
return; return;
} }
try { try {
const effectiveInput = rawInput ?? input; const commandMatch = input.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
const args = const args =
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : []; commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
@@ -352,7 +351,6 @@ export function useChatComposerState({
); );
const { const {
slashCommands,
slashCommandsCount, slashCommandsCount,
filteredCommands, filteredCommands,
frequentCommands, frequentCommands,
@@ -475,28 +473,6 @@ export function useChatComposerState({
return; return;
} }
// Intercept slash commands: if input starts with /commandName, execute as command with args
const trimmedInput = currentInput.trim();
if (trimmedInput.startsWith('/')) {
const firstSpace = trimmedInput.indexOf(' ');
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
if (matchedCommand) {
executeCommand(matchedCommand, trimmedInput);
setInput('');
inputValueRef.current = '';
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
resetCommandMenuState();
setIsTextareaExpanded(false);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
return;
}
}
let messageContent = currentInput; let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode); const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) { if (selectedThinkingMode && selectedThinkingMode.prefix) {
@@ -663,7 +639,6 @@ export function useChatComposerState({
codexModel, codexModel,
currentSessionId, currentSessionId,
cursorModel, cursorModel,
executeCommand,
isLoading, isLoading,
onSessionActive, onSessionActive,
pendingViewSessionRef, pendingViewSessionRef,
@@ -679,7 +654,6 @@ export function useChatComposerState({
setClaudeStatus, setClaudeStatus,
setIsLoading, setIsLoading,
setIsUserScrolledUp, setIsUserScrolledUp,
slashCommands,
thinkingMode, thinkingMode,
], ],
); );
@@ -929,11 +903,8 @@ export function useChatComposerState({
[sendMessage, setClaudeStatus, setPendingPermissionRequests], [sendMessage, setClaudeStatus, setPendingPermissionRequests],
); );
const [isInputFocused, setIsInputFocused] = useState(false);
const handleInputFocusChange = useCallback( const handleInputFocusChange = useCallback(
(focused: boolean) => { (focused: boolean) => {
setIsInputFocused(focused);
onInputFocusChange?.(focused); onInputFocusChange?.(focused);
}, },
[onInputFocusChange], [onInputFocusChange],
@@ -982,6 +953,5 @@ export function useChatComposerState({
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
isInputFocused,
}; };
} }

View File

@@ -336,43 +336,9 @@ export function useChatRealtimeHandlers({
} }
if (structuredMessageData && Array.isArray(structuredMessageData.content)) { if (structuredMessageData && Array.isArray(structuredMessageData.content)) {
const parentToolUseId = rawStructuredData?.parentToolUseId;
structuredMessageData.content.forEach((part: any) => { structuredMessageData.content.forEach((part: any) => {
if (part.type === 'tool_use') { if (part.type === 'tool_use') {
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ''; const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
// Check if this is a child tool from a subagent
if (parentToolUseId) {
setChatMessages((previous) =>
previous.map((message) => {
if (message.toolId === parentToolUseId && message.isSubagentContainer) {
const childTool = {
toolId: part.id,
toolName: part.name,
toolInput: part.input,
toolResult: null,
timestamp: new Date(),
};
const existingChildren = message.subagentState?.childTools || [];
return {
...message,
subagentState: {
childTools: [...existingChildren, childTool],
currentToolIndex: existingChildren.length,
isComplete: false,
},
};
}
return message;
}),
);
return;
}
// Check if this is a Task tool (subagent container)
const isSubagentContainer = part.name === 'Task';
setChatMessages((previous) => [ setChatMessages((previous) => [
...previous, ...previous,
{ {
@@ -384,10 +350,6 @@ export function useChatRealtimeHandlers({
toolInput, toolInput,
toolId: part.id, toolId: part.id,
toolResult: null, toolResult: null,
isSubagentContainer,
subagentState: isSubagentContainer
? { childTools: [], currentToolIndex: -1, isComplete: false }
: undefined,
}, },
]); ]);
return; return;
@@ -420,8 +382,6 @@ export function useChatRealtimeHandlers({
} }
if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) { if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) {
const parentToolUseId = rawStructuredData?.parentToolUseId;
structuredMessageData.content.forEach((part: any) => { structuredMessageData.content.forEach((part: any) => {
if (part.type !== 'tool_result') { if (part.type !== 'tool_result') {
return; return;
@@ -429,32 +389,8 @@ export function useChatRealtimeHandlers({
setChatMessages((previous) => setChatMessages((previous) =>
previous.map((message) => { previous.map((message) => {
// Handle child tool results (route to parent's subagentState)
if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) {
return {
...message,
subagentState: {
...message.subagentState!,
childTools: message.subagentState!.childTools.map((child) => {
if (child.toolId === part.tool_use_id) {
return {
...child,
toolResult: {
content: part.content,
isError: part.is_error,
timestamp: new Date(),
},
};
}
return child;
}),
},
};
}
// Handle normal tool results (including parent Task tool completion)
if (message.isToolUse && message.toolId === part.tool_use_id) { if (message.isToolUse && message.toolId === part.tool_use_id) {
const result = { return {
...message, ...message,
toolResult: { toolResult: {
content: part.content, content: part.content,
@@ -462,14 +398,6 @@ export function useChatRealtimeHandlers({
timestamp: new Date(), timestamp: new Date(),
}, },
}; };
// Mark subagent as complete when parent Task receives its result
if (message.isSubagentContainer && message.subagentState) {
result.subagentState = {
...message.subagentState,
isComplete: true,
};
}
return result;
} }
return message; return message;
}), }),

View File

@@ -22,7 +22,7 @@ interface UseSlashCommandsOptions {
input: string; input: string;
setInput: Dispatch<SetStateAction<string>>; setInput: Dispatch<SetStateAction<string>>;
textareaRef: RefObject<HTMLTextAreaElement>; textareaRef: RefObject<HTMLTextAreaElement>;
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>; onExecuteCommand: (command: SlashCommand) => void | Promise<void>;
} }
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`; const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;

View File

@@ -1,8 +1,7 @@
import React, { memo, useMemo, useCallback } from 'react'; import React, { memo, useMemo, useCallback } from 'react';
import { getToolConfig } from './configs/toolConfigs'; import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components'; import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
type DiffLine = { type DiffLine = {
type: string; type: string;
@@ -22,12 +21,6 @@ interface ToolRendererProps {
autoExpandTools?: boolean; autoExpandTools?: boolean;
showRawParameters?: boolean; showRawParameters?: boolean;
rawToolInput?: string; rawToolInput?: string;
isSubagentContainer?: boolean;
subagentState?: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
} }
function getToolCategory(toolName: string): string { function getToolCategory(toolName: string): string {
@@ -57,24 +50,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
selectedProject, selectedProject,
autoExpandTools = false, autoExpandTools = false,
showRawParameters = false, showRawParameters = false,
rawToolInput, rawToolInput
isSubagentContainer,
subagentState
}) => { }) => {
// Route subagent containers to dedicated component
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
return (
<SubagentContainer
toolInput={toolInput}
toolResult={toolResult}
subagentState={subagentState}
/>
);
}
const config = getToolConfig(toolName); const config = getToolConfig(toolName);
const displayConfig: any = mode === 'input' ? config.input : config.result; const displayConfig: any = mode === 'input' ? config.input : config.result;

View File

@@ -1,180 +0,0 @@
import React from 'react';
import { CollapsibleSection } from './CollapsibleSection';
import type { SubagentChildTool } from '../../types/types';
interface SubagentContainerProps {
toolInput: unknown;
toolResult?: { content?: unknown; isError?: boolean } | null;
subagentState: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
}
const getCompactToolDisplay = (toolName: string, toolInput: unknown): string => {
const input = typeof toolInput === 'string' ? (() => {
try { return JSON.parse(toolInput); } catch { return {}; }
})() : (toolInput || {});
switch (toolName) {
case 'Read':
case 'Write':
case 'Edit':
case 'ApplyPatch':
return input.file_path?.split('/').pop() || input.file_path || '';
case 'Grep':
case 'Glob':
return input.pattern || '';
case 'Bash':
const cmd = input.command || '';
return cmd.length > 40 ? `${cmd.slice(0, 40)}...` : cmd;
case 'Task':
return input.description || input.subagent_type || '';
case 'WebFetch':
case 'WebSearch':
return input.url || input.query || '';
default:
return '';
}
};
export const SubagentContainer: React.FC<SubagentContainerProps> = ({
toolInput,
toolResult,
subagentState,
}) => {
const parsedInput = typeof toolInput === 'string' ? (() => {
try { return JSON.parse(toolInput); } catch { return {}; }
})() : (toolInput || {});
const subagentType = parsedInput?.subagent_type || 'Agent';
const description = parsedInput?.description || 'Running task';
const prompt = parsedInput?.prompt || '';
const { childTools, currentToolIndex, isComplete } = subagentState;
const currentTool = currentToolIndex >= 0 ? childTools[currentToolIndex] : null;
const title = `Subagent / ${subagentType}: ${description}`;
return (
<div className="border-l-2 border-l-purple-500 dark:border-l-purple-400 pl-3 py-0.5 my-1">
<CollapsibleSection
title={title}
toolName="Task"
open={false}
>
{/* Prompt/request to the subagent */}
{prompt && (
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 whitespace-pre-wrap break-words line-clamp-4">
{prompt}
</div>
)}
{/* Current tool indicator (while running) */}
{currentTool && !isComplete && (
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mt-1">
<span className="animate-pulse w-1.5 h-1.5 rounded-full bg-purple-500 dark:bg-purple-400 flex-shrink-0" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<>
<span className="text-gray-300 dark:text-gray-600">/</span>
<span className="font-mono truncate text-gray-500 dark:text-gray-400">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span>
</>
)}
</div>
)}
{/* Completion status */}
{isComplete && (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-1">
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>
</div>
)}
{/* Tool history (collapsed) */}
{childTools.length > 0 && (
<details className="mt-2 group/history">
<summary className="cursor-pointer text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1">
<svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/history:rotate-90 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>View tool history ({childTools.length})</span>
</summary>
<div className="mt-1 pl-3 border-l border-gray-200 dark:border-gray-700 space-y-0.5">
{childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span className="text-gray-400 dark:text-gray-500 w-4 text-right flex-shrink-0">{index + 1}.</span>
<span className="font-medium">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="font-mono truncate text-gray-400 dark:text-gray-500">
{getCompactToolDisplay(child.toolName, child.toolInput)}
</span>
)}
{child.toolResult?.isError && (
<span className="text-red-500 flex-shrink-0">(error)</span>
)}
</div>
))}
</div>
</details>
)}
{/* Final result */}
{isComplete && toolResult && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{(() => {
let content = toolResult.content;
// Handle JSON string that needs parsing
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
// Extract text from array format like [{"type":"text","text":"..."}]
const textParts = parsed
.filter((p: any) => p.type === 'text' && p.text)
.map((p: any) => p.text);
if (textParts.length > 0) {
content = textParts.join('\n');
}
}
} catch {
// Not JSON, use as-is
}
} else if (Array.isArray(content)) {
// Direct array format
const textParts = content
.filter((p: any) => p.type === 'text' && p.text)
.map((p: any) => p.text);
if (textParts.length > 0) {
content = textParts.join('\n');
}
}
return typeof content === 'string' ? (
<div className="whitespace-pre-wrap break-words line-clamp-6">
{content}
</div>
) : content ? (
<pre className="whitespace-pre-wrap break-words line-clamp-6 font-mono text-[11px]">
{JSON.stringify(content, null, 2)}
</pre>
) : null;
})()}
</div>
)}
</CollapsibleSection>
</div>
);
};

View File

@@ -2,6 +2,5 @@ export { CollapsibleSection } from './CollapsibleSection';
export { DiffViewer } from './DiffViewer'; export { DiffViewer } from './DiffViewer';
export { OneLineDisplay } from './OneLineDisplay'; export { OneLineDisplay } from './OneLineDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay'; export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers'; export * from './ContentRenderers';
export * from './InteractiveRenderers'; export * from './InteractiveRenderers';

View File

@@ -383,7 +383,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
const description = input.description || 'Running task'; const description = input.description || 'Running task';
return `Subagent / ${subagentType}: ${description}`; return `Subagent / ${subagentType}: ${description}`;
}, },
defaultOpen: false, defaultOpen: true,
contentType: 'markdown', contentType: 'markdown',
getContentProps: (input) => { getContentProps: (input) => {
// If only prompt exists (and required fields), show just the prompt // If only prompt exists (and required fields), show just the prompt
@@ -424,8 +424,14 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
}, },
result: { result: {
type: 'collapsible', type: 'collapsible',
title: 'Subagent result', title: (result) => {
defaultOpen: false, // Check if result has content with type array (agent results often have this structure)
if (result && result.content && Array.isArray(result.content)) {
return 'Subagent Response';
}
return 'Subagent Result';
},
defaultOpen: true,
contentType: 'markdown', contentType: 'markdown',
getContentProps: (result) => { getContentProps: (result) => {
// Handle agent results which may have complex structure // Handle agent results which may have complex structure

View File

@@ -17,14 +17,6 @@ export interface ToolResult {
[key: string]: unknown; [key: string]: unknown;
} }
export interface SubagentChildTool {
toolId: string;
toolName: string;
toolInput: unknown;
toolResult?: ToolResult | null;
timestamp: Date;
}
export interface ChatMessage { export interface ChatMessage {
type: string; type: string;
content?: string; content?: string;
@@ -40,12 +32,6 @@ export interface ChatMessage {
toolResult?: ToolResult | null; toolResult?: ToolResult | null;
toolId?: string; toolId?: string;
toolCallId?: string; toolCallId?: string;
isSubagentContainer?: boolean;
subagentState?: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
[key: string]: unknown; [key: string]: unknown;
} }

View File

@@ -354,7 +354,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
const converted: ChatMessage[] = []; const converted: ChatMessage[] = [];
const toolResults = new Map< const toolResults = new Map<
string, string,
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] } { content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown }
>(); >();
rawMessages.forEach((message) => { rawMessages.forEach((message) => {
@@ -368,7 +368,6 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
isError: Boolean(part.is_error), isError: Boolean(part.is_error),
timestamp: new Date(message.timestamp || Date.now()), timestamp: new Date(message.timestamp || Date.now()),
toolUseResult: message.toolUseResult || null, toolUseResult: message.toolUseResult || null,
subagentTools: message.subagentTools,
}); });
}); });
} }
@@ -485,22 +484,6 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
if (part.type === 'tool_use') { if (part.type === 'tool_use') {
const toolResult = toolResults.get(part.id); const toolResult = toolResults.get(part.id);
const isSubagentContainer = part.name === 'Task';
// Build child tools from server-provided subagentTools data
const childTools: import('../types/types').SubagentChildTool[] = [];
if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) {
for (const tool of toolResult.subagentTools as any[]) {
childTools.push({
toolId: tool.toolId,
toolName: tool.toolName,
toolInput: tool.toolInput,
toolResult: tool.toolResult || null,
timestamp: new Date(tool.timestamp || Date.now()),
});
}
}
converted.push({ converted.push({
type: 'assistant', type: 'assistant',
content: '', content: '',
@@ -508,7 +491,6 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
isToolUse: true, isToolUse: true,
toolName: part.name, toolName: part.name,
toolInput: normalizeToolInput(part.input), toolInput: normalizeToolInput(part.input),
toolId: part.id,
toolResult: toolResult toolResult: toolResult
? { ? {
content: content:
@@ -521,14 +503,6 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
: null, : null,
toolError: toolResult?.isError || false, toolError: toolResult?.isError || false,
toolResultTimestamp: toolResult?.timestamp || new Date(), toolResultTimestamp: toolResult?.timestamp || new Date(),
isSubagentContainer,
subagentState: isSubagentContainer
? {
childTools,
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
isComplete: Boolean(toolResult),
}
: undefined,
}); });
} }
}); });

View File

@@ -163,7 +163,6 @@ function ChatInterface({
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
isInputFocused,
} = useChatComposerState({ } = useChatComposerState({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -374,7 +373,6 @@ function ChatInterface({
onTextareaScrollSync={syncInputOverlayScroll} onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput} onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange} onInputFocusChange={handleInputFocusChange}
isInputFocused={isInputFocused}
placeholder={t('input.placeholder', { placeholder={t('input.placeholder', {
provider: provider:
provider === 'cursor' provider === 'cursor'

View File

@@ -87,7 +87,6 @@ interface ChatComposerProps {
onTextareaScrollSync: (target: HTMLTextAreaElement) => void; onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void; onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
isInputFocused?: boolean;
placeholder: string; placeholder: string;
isTextareaExpanded: boolean; isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
@@ -144,7 +143,6 @@ export default function ChatComposer({
onTextareaScrollSync, onTextareaScrollSync,
onTextareaInput, onTextareaInput,
onInputFocusChange, onInputFocusChange,
isInputFocused,
placeholder, placeholder,
isTextareaExpanded, isTextareaExpanded,
sendByCtrlEnter, sendByCtrlEnter,
@@ -164,13 +162,8 @@ export default function ChatComposer({
(r) => r.toolName === 'AskUserQuestion' (r) => r.toolName === 'AskUserQuestion'
); );
// On mobile, when input is focused, float the input box at the bottom
const mobileFloatingClass = isInputFocused
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
: '';
return ( return (
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}> <div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
{!hasQuestionPanel && ( {!hasQuestionPanel && (
<div className="flex-1"> <div className="flex-1">
<ClaudeStatus <ClaudeStatus

View File

@@ -184,8 +184,6 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
autoExpandTools={autoExpandTools} autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined} rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
subagentState={message.subagentState}
/> />
)} )}

View File

@@ -2,7 +2,6 @@ import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { authenticatedFetch } from "../../utils/api"; import { authenticatedFetch } from "../../utils/api";
import { ReleaseInfo } from "../../types/sharedTypes"; import { ReleaseInfo } from "../../types/sharedTypes";
import type { InstallMode } from "../../hooks/useVersionCheck";
interface VersionUpgradeModalProps { interface VersionUpgradeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -10,7 +9,6 @@ interface VersionUpgradeModalProps {
releaseInfo: ReleaseInfo | null; releaseInfo: ReleaseInfo | null;
currentVersion: string; currentVersion: string;
latestVersion: string | null; latestVersion: string | null;
installMode: InstallMode;
} }
export default function VersionUpgradeModal({ export default function VersionUpgradeModal({
@@ -18,13 +16,9 @@ export default function VersionUpgradeModal({
onClose, onClose,
releaseInfo, releaseInfo,
currentVersion, currentVersion,
latestVersion, latestVersion
installMode
}: VersionUpgradeModalProps) { }: VersionUpgradeModalProps) {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const upgradeCommand = installMode === 'npm'
? t('versionUpdate.npmUpgradeCommand')
: 'git checkout main && git pull && npm install';
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState(''); const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState(''); const [updateError, setUpdateError] = useState('');
@@ -156,7 +150,7 @@ export default function VersionUpgradeModal({
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.manualUpgrade')}</h3> <h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.manualUpgrade')}</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border"> <div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono"> <code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
{upgradeCommand} git checkout main && git pull && npm install
</code> </code>
</div> </div>
<p className="text-xs text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-600 dark:text-gray-400">
@@ -177,7 +171,7 @@ export default function VersionUpgradeModal({
<> <>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText(upgradeCommand); navigator.clipboard.writeText('git checkout main && git pull && npm install');
}} }}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors" className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
> >

View File

@@ -38,7 +38,7 @@ function Sidebar({
}: SidebarProps) { }: SidebarProps) {
const { t } = useTranslation(['sidebar', 'common']); const { t } = useTranslation(['sidebar', 'common']);
const { isPWA } = useDeviceSettings({ trackMobile: false }); const { isPWA } = useDeviceSettings({ trackMobile: false });
const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck( const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck(
'siteboon', 'siteboon',
'claudecodeui', 'claudecodeui',
); );
@@ -200,7 +200,6 @@ function Sidebar({
releaseInfo={releaseInfo} releaseInfo={releaseInfo}
currentVersion={currentVersion} currentVersion={currentVersion}
latestVersion={latestVersion} latestVersion={latestVersion}
installMode={installMode}
t={t} t={t}
/> />

View File

@@ -8,7 +8,6 @@ import Settings from '../../../Settings';
import VersionUpgradeModal from '../../../modals/VersionUpgradeModal'; import VersionUpgradeModal from '../../../modals/VersionUpgradeModal';
import type { Project } from '../../../../types/app'; import type { Project } from '../../../../types/app';
import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ReleaseInfo } from '../../../../types/sharedTypes';
import type { InstallMode } from '../../../../hooks/useVersionCheck';
import { normalizeProjectForSettings } from '../../utils/utils'; import { normalizeProjectForSettings } from '../../utils/utils';
import type { DeleteProjectConfirmation, SessionDeleteConfirmation, SettingsProject } from '../../types/types'; import type { DeleteProjectConfirmation, SessionDeleteConfirmation, SettingsProject } from '../../types/types';
@@ -31,7 +30,6 @@ type SidebarModalsProps = {
releaseInfo: ReleaseInfo | null; releaseInfo: ReleaseInfo | null;
currentVersion: string; currentVersion: string;
latestVersion: string | null; latestVersion: string | null;
installMode: InstallMode;
t: TFunction; t: TFunction;
}; };
@@ -67,7 +65,6 @@ export default function SidebarModals({
releaseInfo, releaseInfo,
currentVersion, currentVersion,
latestVersion, latestVersion,
installMode,
t, t,
}: SidebarModalsProps) { }: SidebarModalsProps) {
// Settings expects project identity/path fields to be present for dropdown labels and local-scope MCP config. // Settings expects project identity/path fields to be present for dropdown labels and local-scope MCP config.
@@ -202,7 +199,6 @@ export default function SidebarModals({
releaseInfo={releaseInfo} releaseInfo={releaseInfo}
currentVersion={currentVersion} currentVersion={currentVersion}
latestVersion={latestVersion} latestVersion={latestVersion}
installMode={installMode}
/> />
</> </>
); );

View File

@@ -21,28 +21,10 @@ const compareVersions = (v1: string, v2: string) => {
return 0; return 0;
}; };
export type InstallMode = 'git' | 'npm';
export const useVersionCheck = (owner: string, repo: string) => { export const useVersionCheck = (owner: string, repo: string) => {
const [updateAvailable, setUpdateAvailable] = useState(false); const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestVersion, setLatestVersion] = useState<string | null>(null); const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null); const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);
const [installMode, setInstallMode] = useState<InstallMode>('git');
useEffect(() => {
const fetchInstallMode = async () => {
try {
const response = await fetch('/health');
const data = await response.json();
if (data.installMode === 'npm' || data.installMode === 'git') {
setInstallMode(data.installMode);
}
} catch {
// Default to git on error
}
};
fetchInstallMode();
}, []);
useEffect(() => { useEffect(() => {
const checkVersion = async () => { const checkVersion = async () => {
@@ -84,5 +66,5 @@ export const useVersionCheck = (owner: string, repo: string) => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [owner, repo]); }, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode }; return { updateAvailable, latestVersion, currentVersion: version, releaseInfo };
}; };

View File

@@ -200,7 +200,6 @@
"viewFullRelease": "View full release", "viewFullRelease": "View full release",
"updateProgress": "Update Progress:", "updateProgress": "Update Progress:",
"manualUpgrade": "Manual upgrade:", "manualUpgrade": "Manual upgrade:",
"npmUpgradeCommand": "npm install -g @siteboon/claude-code-ui@latest",
"manualUpgradeHint": "Or click \"Update Now\" to run the update automatically.", "manualUpgradeHint": "Or click \"Update Now\" to run the update automatically.",
"updateCompleted": "Update completed successfully!", "updateCompleted": "Update completed successfully!",
"restartServer": "Please restart the server to apply changes.", "restartServer": "Please restart the server to apply changes.",

View File

@@ -200,7 +200,6 @@
"viewFullRelease": "リリース全文を見る", "viewFullRelease": "リリース全文を見る",
"updateProgress": "アップデートの進捗:", "updateProgress": "アップデートの進捗:",
"manualUpgrade": "手動アップグレード:", "manualUpgrade": "手動アップグレード:",
"npmUpgradeCommand": "npm install -g @siteboon/claude-code-ui@latest",
"manualUpgradeHint": "または「今すぐ更新」をクリックして自動的にアップデートを実行できます。", "manualUpgradeHint": "または「今すぐ更新」をクリックして自動的にアップデートを実行できます。",
"updateCompleted": "アップデートが完了しました!", "updateCompleted": "アップデートが完了しました!",
"restartServer": "変更を適用するにはサーバーを再起動してください。", "restartServer": "変更を適用するにはサーバーを再起動してください。",

View File

@@ -200,7 +200,6 @@
"viewFullRelease": "전체 릴리스 보기", "viewFullRelease": "전체 릴리스 보기",
"updateProgress": "업데이트 진행 상황:", "updateProgress": "업데이트 진행 상황:",
"manualUpgrade": "수동 업그레이드:", "manualUpgrade": "수동 업그레이드:",
"npmUpgradeCommand": "npm install -g @siteboon/claude-code-ui@latest",
"manualUpgradeHint": "또는 \"지금 업데이트\"를 클릭하여 자동으로 업데이트합니다.", "manualUpgradeHint": "또는 \"지금 업데이트\"를 클릭하여 자동으로 업데이트합니다.",
"updateCompleted": "업데이트가 완료되었습니다!", "updateCompleted": "업데이트가 완료되었습니다!",
"restartServer": "변경사항을 적용하려면 서버를 재시작하세요.", "restartServer": "변경사항을 적용하려면 서버를 재시작하세요.",

View File

@@ -200,7 +200,6 @@
"viewFullRelease": "查看完整发布", "viewFullRelease": "查看完整发布",
"updateProgress": "更新进度:", "updateProgress": "更新进度:",
"manualUpgrade": "手动升级:", "manualUpgrade": "手动升级:",
"npmUpgradeCommand": "npm install -g @siteboon/claude-code-ui@latest",
"manualUpgradeHint": "或点击'立即更新'以自动运行更新。", "manualUpgradeHint": "或点击'立即更新'以自动运行更新。",
"updateCompleted": "更新成功完成!", "updateCompleted": "更新成功完成!",
"restartServer": "请重启服务器以应用更改。", "restartServer": "请重启服务器以应用更改。",

View File

@@ -62,6 +62,9 @@
--safe-area-inset-bottom: env(safe-area-inset-bottom); --safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-left: env(safe-area-inset-left); --safe-area-inset-left: env(safe-area-inset-left);
/* Virtual keyboard height (set by JS for iOS/Safari fallback) */
--keyboard-height: 0px;
/* Mobile navigation dimensions - Single source of truth */ /* Mobile navigation dimensions - Single source of truth */
/* Floating nav: ~52px bar + 8px bottom margin + 12px px-3 top spacing */ /* Floating nav: ~52px bar + 8px bottom margin + 12px px-3 top spacing */
--mobile-nav-height: 52px; --mobile-nav-height: 52px;
@@ -166,12 +169,16 @@
overflow: hidden; overflow: hidden;
} }
/* Virtual keyboard offset — works in both PWA and regular mobile browsers */
.fixed.inset-0 {
bottom: max(env(keyboard-inset-bottom, 0px), var(--keyboard-height, 0px));
}
/* Adjust fixed inset positioning in PWA mode */ /* Adjust fixed inset positioning in PWA mode */
body.pwa-mode .fixed.inset-0 { body.pwa-mode .fixed.inset-0 {
top: var(--header-total-padding); top: var(--header-total-padding);
left: var(--safe-area-inset-left); left: var(--safe-area-inset-left);
right: var(--safe-area-inset-right); right: var(--safe-area-inset-right);
bottom: 0;
} }
/* Global transition defaults */ /* Global transition defaults */

View File

@@ -7,6 +7,19 @@ import 'katex/dist/katex.min.css'
// Initialize i18n // Initialize i18n
import './i18n/config.js' import './i18n/config.js'
// Tell the browser to overlay the virtual keyboard instead of resizing the viewport (PWA)
if ('virtualKeyboard' in navigator) {
navigator.virtualKeyboard.overlaysContent = true;
} else if (window.visualViewport) {
// iOS/Safari fallback: track keyboard height via visualViewport
const viewport = window.visualViewport;
const updateKeyboardHeight = () => {
const keyboardHeight = Math.max(0, window.innerHeight - viewport.height);
document.documentElement.style.setProperty('--keyboard-height', `${keyboardHeight}px`);
};
viewport.addEventListener('resize', updateKeyboardHeight);
}
// Clean up stale service workers on app load to prevent caching issues after builds // Clean up stale service workers on app load to prevent caching issues after builds
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(registrations => { navigator.serviceWorker.getRegistrations().then(registrations => {

View File

@@ -5,25 +5,19 @@ export default defineConfig(({ command, mode }) => {
// Load env file based on `mode` in the current working directory. // Load env file based on `mode` in the current working directory.
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
const host = env.HOST || '0.0.0.0'
// When binding to all interfaces (0.0.0.0), proxy should connect to localhost
// Otherwise, proxy to the specific host the backend is bound to
const proxyHost = host === '0.0.0.0' ? 'localhost' : host
const port = env.PORT || 3001
return { return {
plugins: [react()], plugins: [react()],
server: { server: {
host,
port: parseInt(env.VITE_PORT) || 5173, port: parseInt(env.VITE_PORT) || 5173,
proxy: { proxy: {
'/api': `http://${proxyHost}:${port}`, '/api': `http://localhost:${env.PORT || 3001}`,
'/ws': { '/ws': {
target: `ws://${proxyHost}:${port}`, target: `ws://localhost:${env.PORT || 3001}`,
ws: true ws: true
}, },
'/shell': { '/shell': {
target: `ws://${proxyHost}:${port}`, target: `ws://localhost:${env.PORT || 3001}`,
ws: true ws: true
} }
} }