Compare commits

...

13 Commits

Author SHA1 Message Date
Matthew Lloyd
23801e9cc1 fix: add support for Codex in the shell (#424)
* fix: add support for Codex in the shell

* fix: harden codex shell session resume command
2026-02-23 23:36:58 +01:00
simosmik
4f6ff9260d Release 1.20.1 2026-02-23 22:23:33 +00:00
viper151
49061bc7a3 Update DEFAULT model version to gpt-5.3-codex (#426) 2026-02-23 23:13:50 +01:00
simosmik
50e097d4ac feat: migrate legacy database to new location and improve last login update handling 2026-02-23 22:12:00 +00:00
simosmik
f986004319 feat: implement install mode detection and update commands in version upgrade process 2026-02-23 21:55:53 +00:00
simosmik
f488a346ef Release 1.19.1 2026-02-23 21:29:06 +00:00
simosmik
82efac4704 fix: add prepublishOnly script to build before publishing 2026-02-23 21:27:45 +00:00
viper151
81697d0e73 Update DEFAULT model version to gpt-5.3-codex (#417) 2026-02-23 16:51:29 +01:00
simosmik
27bf09b0c1 Release 1.19.0 2026-02-23 11:56:33 +00:00
Haileyesus
7ccbc8d92d chore: update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json (#410) 2026-02-23 07:09:07 +01:00
Vadim Trunov
597e9c54b7 fix: slash commands with arguments bypass command execution (#392)
* fix: intercept slash commands in handleSubmit to pass arguments correctly

When a user types a slash command with arguments (e.g. `/feature implement dark mode`)
and presses Enter, the command was not being intercepted as a slash command. Instead,
the raw text was sent as a regular message to the Claude API, which responded with
"Unknown skill: feature".

Root cause: the command autocomplete menu (useSlashCommands) detects commands via the
regex `/(^|\s)\/(\S*)$/` which only matches when the cursor is right after the command
name with no spaces. As soon as the user types a space to add arguments, the pattern
stops matching, the menu closes, and pressing Enter falls through to handleSubmit which
sends the text as a plain message — completely bypassing command execution.

This fix adds a slash command interceptor at the top of handleSubmit:
- Checks if the trimmed input starts with `/`
- Extracts the command name (text before the first space)
- Looks up the command in the loaded slashCommands list
- If found, delegates to executeCommand() which properly extracts arguments
  via regex and sends them to the backend /api/commands/execute endpoint
- The backend then replaces $ARGUMENTS, $1, $2 etc. in the command template

Changes:
- Added `slashCommands` to the destructured return of useSlashCommands hook
- Added slash command interception logic in handleSubmit before message dispatch
- Added `executeCommand` and `slashCommands` to handleSubmit dependency array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review — pass rawInput param and cleanup UI state

- Pass trimmedInput to executeCommand to avoid stale closure reads
- Add UI cleanup (images, command menu, textarea) before early return
- Update executeCommand signature to accept optional rawInput parameter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:25:02 +03:00
mjfork
cccd915c33 feat: add HOST environment variable for configurable bind address (#360)
* feat: add HOST environment variable for configurable bind address

Allow users to specify which host/IP address the servers should bind to
via the HOST environment variable. Defaults to 0.0.0.0 (all interfaces)
to maintain backward compatibility.

This enables users to restrict the server to localhost only (127.0.0.1)
for security purposes or bind to a specific network interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use correct proxy host when HOST is set to specific interface

When HOST is set to a specific interface (e.g., 192.168.1.100), the Vite
proxy needs to connect to that same host, not localhost. This fix:

- Adds proxyHost logic that uses localhost when HOST=0.0.0.0, otherwise
  uses the specific HOST value for proxy targets
- Adds DISPLAY_HOST to show a user-friendly URL in console output
  (0.0.0.0 isn't a connectable address)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Michael Fork <mjfork@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-02-20 21:23:10 +03:00
viper151
0207a1f3a3 Feat: subagent tool grouping (#398)
* fix(mobile): prevent bottom padding removal on input focus

* fix: change subagent rendering

* fix: subagent task name
2026-02-19 17:32:45 +01:00
36 changed files with 637 additions and 65 deletions

View File

@@ -21,6 +21,10 @@ 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

37
CHANGELOG.md Normal file
View File

@@ -0,0 +1,37 @@
# 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.18.2", "version": "1.20.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.18.2", "version": "1.20.1",
"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.71", "version": "0.1.77",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.71.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.77.tgz",
"integrity": "sha512-O34SQCEsdU11Z2uy30GaJGRLdRbEwEvaQs8APywHVOW/EdIGE0rS/AE4V6p9j45/j5AFwh2USZWlbz5NTlLnrw==", "integrity": "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==",
"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.24.1 || ^4.0.0" "zod": "^3.25.0 || ^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.18.2", "version": "1.20.1",
"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,6 +32,7 @@
"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,7 +250,13 @@ function getAllSessions() {
* @returns {Object} Transformed message ready for WebSocket * @returns {Object} Transformed message ready for WebSocket
*/ */
function transformMessage(sdkMessage) { function transformMessage(sdkMessage) {
// Pass-through; SDK messages match frontend format. // Extract parent_tool_use_id for subagent tool grouping
if (sdkMessage.parent_tool_use_id) {
return {
...sdkMessage,
parentToolUseId: sdkMessage.parent_tool_use_id
};
}
return sdkMessage; return sdkMessage;
} }

View File

@@ -40,6 +40,22 @@ 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);
@@ -128,12 +144,12 @@ const userDb = {
} }
}, },
// Update last login time // Update last login time (non-fatal — logged but not thrown)
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) {
throw err; console.warn('Failed to update last login:', err.message);
} }
}, },

View File

@@ -9,6 +9,8 @@ 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',
@@ -333,7 +335,8 @@ 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
}); });
}); });
@@ -410,11 +413,13 @@ 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 // Run the update command based on install mode
const updateCommand = 'git checkout main && git pull && npm install'; const updateCommand = installMode === 'git'
? '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: projectRoot, cwd: installMode === 'git' ? projectRoot : os.homedir(),
env: process.env env: process.env
}); });
@@ -1133,7 +1138,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' : 'Claude'; const providerName = provider === 'cursor' ? 'Cursor' : provider === 'codex' ? 'Codex' : '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`;
@@ -1169,6 +1174,23 @@ 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';
@@ -1886,6 +1908,9 @@ 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() {
@@ -1905,7 +1930,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, '0.0.0.0', async () => { server.listen(PORT, HOST, async () => {
const appInstallPath = path.join(__dirname, '..'); const appInstallPath = path.join(__dirname, '..');
console.log(''); console.log('');
@@ -1913,7 +1938,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://0.0.0.0:' + PORT)}`); console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + 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,5 +1,6 @@
// 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';
@@ -22,3 +23,7 @@ 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,21 +889,80 @@ 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 session start data at this point. This needs to be revisited // agent-*.jsonl files contain subagent tool history - we'll process them separately
// 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) {
@@ -928,6 +987,35 @@ 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);
// Update last login
userDb.updateLastLogin(user.id);
db.prepare('COMMIT').run(); db.prepare('COMMIT').run();
// Update last login (non-fatal, outside transaction)
userDb.updateLastLogin(user.id);
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.2' DEFAULT: 'gpt-5.3-codex'
}; };

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 || 'claude'), provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || localStorage.getItem('selected-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 && !isInputFocused ? 'pb-mobile-nav' : ''}`}> <div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}>
<MainContent <MainContent
selectedProject={selectedProject} selectedProject={selectedProject}
selectedSession={selectedSession} selectedSession={selectedSession}

View File

@@ -271,13 +271,14 @@ export function useChatComposerState({
}, [setChatMessages]); }, [setChatMessages]);
const executeCommand = useCallback( const executeCommand = useCallback(
async (command: SlashCommand) => { async (command: SlashCommand, rawInput?: string) => {
if (!command || !selectedProject) { if (!command || !selectedProject) {
return; return;
} }
try { try {
const commandMatch = input.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`)); const effectiveInput = rawInput ?? input;
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+/) : [];
@@ -351,6 +352,7 @@ export function useChatComposerState({
); );
const { const {
slashCommands,
slashCommandsCount, slashCommandsCount,
filteredCommands, filteredCommands,
frequentCommands, frequentCommands,
@@ -473,6 +475,28 @@ 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) {
@@ -639,6 +663,7 @@ export function useChatComposerState({
codexModel, codexModel,
currentSessionId, currentSessionId,
cursorModel, cursorModel,
executeCommand,
isLoading, isLoading,
onSessionActive, onSessionActive,
pendingViewSessionRef, pendingViewSessionRef,
@@ -654,6 +679,7 @@ export function useChatComposerState({
setClaudeStatus, setClaudeStatus,
setIsLoading, setIsLoading,
setIsUserScrolledUp, setIsUserScrolledUp,
slashCommands,
thinkingMode, thinkingMode,
], ],
); );
@@ -903,8 +929,11 @@ 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],
@@ -953,5 +982,6 @@ export function useChatComposerState({
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
isInputFocused,
}; };
} }

View File

@@ -336,9 +336,43 @@ 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,
{ {
@@ -350,6 +384,10 @@ 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;
@@ -382,6 +420,8 @@ 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;
@@ -389,8 +429,32 @@ export function useChatRealtimeHandlers({
setChatMessages((previous) => setChatMessages((previous) =>
previous.map((message) => { previous.map((message) => {
if (message.isToolUse && message.toolId === part.tool_use_id) { // Handle child tool results (route to parent's subagentState)
if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) {
return { 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) {
const result = {
...message, ...message,
toolResult: { toolResult: {
content: part.content, content: part.content,
@@ -398,6 +462,14 @@ 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) => void | Promise<void>; onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
} }
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`; const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;

View File

@@ -1,7 +1,8 @@
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 } from './components'; import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } 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;
@@ -21,6 +22,12 @@ 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 {
@@ -50,8 +57,24 @@ 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

@@ -24,7 +24,7 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
}) => { }) => {
return ( return (
<details className={`relative group/details ${className}`} open={open}> <details className={`relative group/details ${className}`} open={open}>
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none"> <summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:bg-background group-open/details:-mx-1 group-open/details:px-1">
<svg <svg
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0" className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0"
fill="none" fill="none"

View File

@@ -0,0 +1,180 @@
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,5 +2,6 @@ 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: true, defaultOpen: false,
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,14 +424,8 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
}, },
result: { result: {
type: 'collapsible', type: 'collapsible',
title: (result) => { title: 'Subagent result',
// Check if result has content with type array (agent results often have this structure) defaultOpen: false,
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,6 +17,14 @@ 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;
@@ -32,6 +40,12 @@ 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 } { content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] }
>(); >();
rawMessages.forEach((message) => { rawMessages.forEach((message) => {
@@ -368,6 +368,7 @@ 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,
}); });
}); });
} }
@@ -484,6 +485,22 @@ 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: '',
@@ -491,6 +508,7 @@ 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:
@@ -503,6 +521,14 @@ 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,6 +163,7 @@ function ChatInterface({
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
isInputFocused,
} = useChatComposerState({ } = useChatComposerState({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -373,6 +374,7 @@ 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,6 +87,7 @@ 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;
@@ -143,6 +144,7 @@ export default function ChatComposer({
onTextareaScrollSync, onTextareaScrollSync,
onTextareaInput, onTextareaInput,
onInputFocusChange, onInputFocusChange,
isInputFocused,
placeholder, placeholder,
isTextareaExpanded, isTextareaExpanded,
sendByCtrlEnter, sendByCtrlEnter,
@@ -162,8 +164,13 @@ 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"> <div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasQuestionPanel && ( {!hasQuestionPanel && (
<div className="flex-1"> <div className="flex-1">
<ClaudeStatus <ClaudeStatus

View File

@@ -184,6 +184,8 @@ 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

@@ -62,8 +62,8 @@ export default function MainContentTitle({
</div> </div>
) : showChatNewSession ? ( ) : showChatNewSession ? (
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground leading-tight">{t('mainContent.newSession')}</h2> <h2 className="text-base font-semibold text-foreground leading-tight">{t('mainContent.newSession')}</h2>
<div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div> <div className="text-xs text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div> </div>
) : ( ) : (
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -2,6 +2,7 @@ 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;
@@ -9,6 +10,7 @@ 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({
@@ -16,9 +18,13 @@ 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('');
@@ -150,7 +156,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">
git checkout main && git pull && npm install {upgradeCommand}
</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">
@@ -171,7 +177,7 @@ export default function VersionUpgradeModal({
<> <>
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText('git checkout main && git pull && npm install'); navigator.clipboard.writeText(upgradeCommand);
}} }}
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 } = useVersionCheck( const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(
'siteboon', 'siteboon',
'claudecodeui', 'claudecodeui',
); );
@@ -200,6 +200,7 @@ function Sidebar({
releaseInfo={releaseInfo} releaseInfo={releaseInfo}
currentVersion={currentVersion} currentVersion={currentVersion}
latestVersion={latestVersion} latestVersion={latestVersion}
installMode={installMode}
t={t} t={t}
/> />

View File

@@ -8,6 +8,7 @@ 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';
@@ -30,6 +31,7 @@ type SidebarModalsProps = {
releaseInfo: ReleaseInfo | null; releaseInfo: ReleaseInfo | null;
currentVersion: string; currentVersion: string;
latestVersion: string | null; latestVersion: string | null;
installMode: InstallMode;
t: TFunction; t: TFunction;
}; };
@@ -65,6 +67,7 @@ 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.
@@ -199,6 +202,7 @@ export default function SidebarModals({
releaseInfo={releaseInfo} releaseInfo={releaseInfo}
currentVersion={currentVersion} currentVersion={currentVersion}
latestVersion={latestVersion} latestVersion={latestVersion}
installMode={installMode}
/> />
</> </>
); );

View File

@@ -21,10 +21,28 @@ 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 () => {
@@ -66,5 +84,5 @@ export const useVersionCheck = (owner: string, repo: string) => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [owner, repo]); }, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo }; return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode };
}; };

View File

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

View File

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

View File

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

View File

@@ -5,19 +5,25 @@ 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://localhost:${env.PORT || 3001}`, '/api': `http://${proxyHost}:${port}`,
'/ws': { '/ws': {
target: `ws://localhost:${env.PORT || 3001}`, target: `ws://${proxyHost}:${port}`,
ws: true ws: true
}, },
'/shell': { '/shell': {
target: `ws://localhost:${env.PORT || 3001}`, target: `ws://${proxyHost}:${port}`,
ws: true ws: true
} }
} }