Files
SuperCharged-Claude-Code-Up…/public/claude-ide/components/enhanced-chat-input-enhanced-chat-input.js-1769008703817.js
uroma 55aafbae9a Fix project isolation: Make loadChatHistory respect active project sessions
- Modified loadChatHistory() to check for active project before fetching all sessions
- When active project exists, use project.sessions instead of fetching from API
- Added detailed console logging to debug session filtering
- This prevents ALL sessions from appearing in every project's sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 14:43:05 +00:00

628 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Enhanced Chat Input Component
* CodeNomad-style sophisticated prompt input
*
* Features:
* - Expandable textarea (2-15 lines desktop, 2-4 mobile)
* - Attachment system (files, images, long text paste)
* - Draft persistence (session-aware localStorage)
* - History navigation (↑↓ arrows)
* - Unified picker (@files, /commands)
* - Shell mode (! prefix)
* - Token/char count
*/
class EnhancedChatInput {
constructor(containerId) {
this.container = document.getElementById(containerId);
if (!this.container) {
console.error('[ChatInput] Container not found:', containerId);
return;
}
this.state = {
value: '',
attachments: [],
drafts: new Map(),
history: [],
historyIndex: -1,
shellMode: false,
isMobile: this.detectMobile()
};
this.loadDrafts();
this.loadHistory();
this.initialize();
}
detectMobile() {
return window.innerWidth < 640 || 'ontouchstart' in window;
}
initialize() {
// Get existing textarea
const existingInput = this.container.querySelector('#chat-input');
if (!existingInput) {
console.error('[ChatInput] #chat-input not found');
return;
}
// Wrap existing input with enhanced UI
const wrapper = existingInput.parentElement;
wrapper.className = 'chat-input-wrapper-enhanced';
// Insert attachment chips container before the input
const chipsContainer = document.createElement('div');
chipsContainer.className = 'attachment-chips';
chipsContainer.id = 'attachment-chips';
wrapper.insertBefore(chipsContainer, existingInput);
// Update textarea attributes
existingInput.setAttribute('rows', '1');
existingInput.setAttribute('data-auto-expand', 'true');
this.textarea = existingInput;
this.chipsContainer = chipsContainer;
// Mobile viewport state
this.state.viewportHeight = window.innerHeight;
this.state.keyboardVisible = false;
this.state.initialViewportHeight = window.innerHeight;
this.setupEventListeners();
this.setupKeyboardDetection();
this.loadCurrentDraft();
}
setupKeyboardDetection() {
if (!this.state.isMobile) return;
// Detect virtual keyboard by tracking viewport changes
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
this.handleViewportChange();
}, 100);
});
// Also listen to visual viewport API (better for mobile keyboards)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
this.handleViewportChange();
});
}
}
handleViewportChange() {
const currentHeight = window.innerHeight;
const initialHeight = this.state.initialViewportHeight;
const heightDiff = initialHeight - currentHeight;
// If viewport shrank by more than 150px, keyboard is likely visible
const keyboardVisible = heightDiff > 150;
if (keyboardVisible !== this.state.keyboardVisible) {
this.state.keyboardVisible = keyboardVisible;
console.log(`[ChatInput] Keyboard ${keyboardVisible ? 'visible' : 'hidden'}`);
// Re-calculate max lines when keyboard state changes
this.autoExpand();
}
this.state.viewportHeight = currentHeight;
}
calculateMaxLines() {
if (!this.state.isMobile) {
return 15; // Desktop default
}
// Mobile: Calculate based on available viewport height
const viewportHeight = this.state.viewportHeight;
const keyboardHeight = this.state.keyboardVisible
? (this.state.initialViewportHeight - viewportHeight)
: 0;
// Available height for input area (rough estimate)
// Leave space for: header (~60px), tabs (~50px), messages area, attachments
const availableHeight = viewportHeight - keyboardHeight - 200; // 200px for UI chrome
// Line height is approximately 24px
const lineHeight = 24;
const maxLines = Math.floor(availableHeight / lineHeight);
// Clamp between 2 and 4 lines for mobile
return Math.max(2, Math.min(4, maxLines));
}
setupEventListeners() {
if (!this.textarea) return;
// Auto-expand on input
this.textarea.addEventListener('input', () => {
this.autoExpand();
this.saveDraft();
this.checkTriggers();
this.updateCharCount();
});
// Handle paste events
this.textarea.addEventListener('paste', (e) => this.handlePaste(e));
// Handle keyboard shortcuts
this.textarea.addEventListener('keydown', (e) => {
// History navigation with ↑↓
if (e.key === 'ArrowUp' && !e.shiftKey) {
this.navigateHistory(-1);
e.preventDefault();
} else if (e.key === 'ArrowDown' && !e.shiftKey) {
this.navigateHistory(1);
e.preventDefault();
}
// Send with Enter (Shift+Enter for newline)
else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.send();
}
// Detect shell mode (!)
else if (e.key === '!' && this.textarea.selectionStart === 0) {
this.state.shellMode = true;
this.updatePlaceholder();
}
});
// Handle file attachment button
const attachBtn = this.container.querySelector('.btn-icon[title="Attach file"], .btn-attach');
if (attachBtn) {
attachBtn.addEventListener('click', () => this.attachFile());
}
}
autoExpand() {
if (!this.textarea) return;
const maxLines = this.calculateMaxLines();
const lineHeight = 24; // pixels
const padding = 12; // padding
this.textarea.style.height = 'auto';
const newHeight = this.textarea.scrollHeight;
const minHeight = lineHeight + padding * 2;
const maxHeight = lineHeight * maxLines + padding * 2;
if (newHeight < minHeight) {
this.textarea.style.height = `${minHeight}px`;
} else if (newHeight > maxHeight) {
this.textarea.style.height = `${maxHeight}px`;
this.textarea.style.overflowY = 'auto';
} else {
this.textarea.style.height = `${newHeight}px`;
}
}
handlePaste(event) {
const items = event.clipboardData?.items;
if (!items) return;
// Check for images
for (const item of items) {
if (item.type.startsWith('image/')) {
event.preventDefault();
const file = item.getAsFile();
this.attachImageFile(file);
return;
}
}
// Check for long text paste
const pastedText = event.clipboardData.getData('text');
if (pastedText) {
const lines = pastedText.split('\n').length;
const chars = pastedText.length;
if (chars > 150 || lines > 3) {
event.preventDefault();
this.addPastedText(pastedText);
}
}
}
attachFile() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '*/*';
input.onchange = async (e) => {
const files = e.target.files;
for (const file of files) {
if (file.type.startsWith('image/')) {
await this.attachImageFile(file);
} else {
await this.attachTextFile(file);
}
}
};
input.click();
}
async attachImageFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
const attachment = {
id: Date.now() + Math.random(),
type: 'image',
name: file.name,
size: file.size,
data: e.target.result
};
this.state.attachments.push(attachment);
this.renderAttachments();
this.saveDraft();
};
reader.readAsDataURL(file);
}
async attachTextFile(file) {
const text = await file.text();
const attachment = {
id: Date.now() + Math.random(),
type: 'file',
name: file.name,
size: file.size,
content: text
};
this.state.attachments.push(attachment);
this.renderAttachments();
this.saveDraft();
}
addPastedText(text) {
const attachment = {
id: Date.now() + Math.random(),
type: 'pasted',
label: `pasted #${this.state.attachments.filter(a => a.type === 'pasted').length + 1}`,
content: text,
chars: text.length,
lines: text.split('\n').length
};
this.state.attachments.push(attachment);
this.renderAttachments();
this.saveDraft();
}
removeAttachment(id) {
this.state.attachments = this.state.attachments.filter(a => a.id !== id);
this.renderAttachments();
this.saveDraft();
}
renderAttachments() {
if (!this.chipsContainer) return;
if (this.state.attachments.length === 0) {
this.chipsContainer.innerHTML = '';
return;
}
this.chipsContainer.innerHTML = this.state.attachments.map(a => {
if (a.type === 'image') {
return `
<div class="attachment-chip image-chip" data-id="${a.id}">
<img src="${a.data}" alt="${a.name}" />
<button class="chip-remove" title="Remove">×</button>
</div>
`;
} else if (a.type === 'file') {
return `
<div class="attachment-chip file-chip" data-id="${a.id}">
<span class="chip-icon">📄</span>
<span class="chip-name">${this.escapeHtml(a.name)}</span>
<button class="chip-remove" title="Remove">×</button>
</div>
`;
} else if (a.type === 'pasted') {
return `
<div class="attachment-chip pasted-chip" data-id="${a.id}">
<span class="chip-icon">📋</span>
<span class="chip-label">${this.escapeHtml(a.label)}</span>
<span class="chip-info">${a.chars} chars, ${a.lines} lines</span>
<button class="chip-remove" title="Remove">×</button>
</div>
`;
}
return '';
}).join('');
// Add click handlers
this.chipsContainer.querySelectorAll('.chip-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
const chip = e.target.closest('.attachment-chip');
if (chip) {
this.removeAttachment(parseFloat(chip.dataset.id));
}
});
});
}
checkTriggers() {
if (!this.textarea) return;
const value = this.textarea.value;
const cursorPos = this.textarea.selectionStart;
// Check for @ trigger (file mentions)
const atMatch = value.substring(0, cursorPos).match(/@(\w*)$/);
if (atMatch && atMatch[0].length > 1) {
console.log('[ChatInput] File mention triggered:', atMatch[1]);
// TODO: Show file picker
}
// Check for / trigger (slash commands)
const slashMatch = value.substring(0, cursorPos).match(/\/(\w*)$/);
if (slashMatch && slashMatch[0].length > 1) {
console.log('[ChatInput] Command triggered:', slashMatch[1]);
// TODO: Show command picker
}
}
navigateHistory(direction) {
if (this.state.history.length === 0) return;
let newIndex;
if (direction === -1) {
newIndex = Math.min(this.state.historyIndex + 1, this.state.history.length - 1);
} else {
newIndex = Math.max(this.state.historyIndex - 1, -1);
}
this.state.historyIndex = newIndex;
if (newIndex === -1) {
this.textarea.value = this.state.value;
} else {
const index = this.state.history.length - 1 - newIndex;
this.textarea.value = this.state.history[index];
}
this.autoExpand();
}
// Session-aware draft storage
getDraftKey() {
const sessionId = this.getCurrentSessionId();
return `claude-ide.drafts.${sessionId}`;
}
saveDraft() {
const sessionId = this.getCurrentSessionId();
if (!sessionId) return;
const draft = {
value: this.textarea.value,
attachments: this.state.attachments,
timestamp: Date.now(),
sessionId: sessionId
};
this.state.drafts.set(sessionId, draft);
try {
localStorage.setItem(this.getDraftKey(), JSON.stringify(draft));
// Clean up old drafts from other sessions
this.cleanupOldDrafts(sessionId);
} catch (e) {
console.error('[ChatInput] Failed to save draft:', e);
}
}
cleanupOldDrafts(currentSessionId) {
try {
const allKeys = Object.keys(localStorage);
const draftKeys = allKeys.filter(k => k.startsWith('claude-ide.drafts.'));
// Keep only recent drafts (last 5 sessions)
const drafts = draftKeys.map(key => {
try {
return { key, data: JSON.parse(localStorage.getItem(key)) };
} catch {
return null;
}
}).filter(d => d && d.data.sessionId !== currentSessionId);
// Sort by timestamp
drafts.sort((a, b) => b.data.timestamp - a.data.timestamp);
// Remove old drafts beyond 5
drafts.slice(5).forEach(d => {
localStorage.removeItem(d.key);
});
} catch (e) {
console.error('[ChatInput] Failed to cleanup drafts:', e);
}
}
loadDrafts() {
try {
const allKeys = Object.keys(localStorage);
const draftKeys = allKeys.filter(k => k.startsWith('claude-ide.drafts.'));
draftKeys.forEach(key => {
try {
const draft = JSON.parse(localStorage.getItem(key));
if (draft && draft.sessionId) {
this.state.drafts.set(draft.sessionId, draft);
}
} catch (e) {
// Skip invalid drafts
}
});
} catch (e) {
console.error('[ChatInput] Failed to load drafts:', e);
}
}
loadCurrentDraft() {
const sessionId = this.getCurrentSessionId();
if (!sessionId) return;
const draft = this.state.drafts.get(sessionId);
if (draft) {
this.textarea.value = draft.value || '';
this.state.attachments = draft.attachments || [];
this.renderAttachments();
this.autoExpand();
// Show restore notification if draft is old (> 5 minutes)
const age = Date.now() - draft.timestamp;
if (age > 5 * 60 * 1000 && draft.value) {
this.showDraftRestoreNotification();
}
}
}
showDraftRestoreNotification() {
if (typeof showToast === 'function') {
showToast('Draft restored from previous session', 'info', 3000);
}
}
clearDraft() {
const sessionId = this.getCurrentSessionId();
if (sessionId) {
this.state.drafts.delete(sessionId);
localStorage.removeItem(this.getDraftKey());
}
}
saveHistory() {
const value = this.textarea.value.trim();
if (!value) return;
this.state.history.push(value);
this.state.historyIndex = -1;
// Limit history to 100 items
if (this.state.history.length > 100) {
this.state.history.shift();
}
localStorage.setItem('chat-history', JSON.stringify(this.state.history));
}
loadHistory() {
try {
const stored = localStorage.getItem('chat-history');
if (stored) {
this.state.history = JSON.parse(stored);
}
} catch (e) {
console.error('[ChatInput] Failed to load history:', e);
}
}
getCurrentSessionId() {
return window.attachedSessionId || window.currentSessionId || null;
}
updatePlaceholder() {
if (!this.textarea) return;
if (this.state.shellMode) {
this.textarea.placeholder = 'Shell mode: enter shell command... (Enter to send)';
} else {
this.textarea.placeholder = 'Type your message to Claude Code... (@ for files, / for commands, Enter to send)';
}
}
updateCharCount() {
const value = this.textarea.value;
const charCountEl = this.container.querySelector('#char-count');
if (charCountEl) {
charCountEl.textContent = `${value.length} chars`;
}
// Token count (rough estimation: 1 token ≈ 4 chars)
const tokenCountEl = this.container.querySelector('#token-usage');
if (tokenCountEl) {
const tokens = Math.ceil(value.length / 4);
tokenCountEl.textContent = `${tokens} tokens`;
}
}
send() {
const content = this.textarea.value.trim();
const hasAttachments = this.state.attachments.length > 0;
if (!content && !hasAttachments) return;
// Get the send button and trigger click
const sendBtn = this.container.querySelector('.btn-send, .btn-primary[onclick*="sendChatMessage"]');
if (sendBtn) {
sendBtn.click();
} else if (typeof sendChatMessage === 'function') {
// Call the function directly
sendChatMessage();
}
// Save to history
this.saveHistory();
// Clear input
this.textarea.value = '';
this.state.attachments = [];
this.state.shellMode = false;
this.renderAttachments();
this.clearDraft();
this.autoExpand();
this.updatePlaceholder();
this.updateCharCount();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
destroy() {
this.saveDraft();
this.state = null;
}
}
// Global instance
let enhancedChatInput = null;
// Initialize when DOM is ready
function initEnhancedChatInput() {
enhancedChatInput = new EnhancedChatInput('chat-input-container');
}
// Export to window
if (typeof window !== 'undefined') {
window.EnhancedChatInput = EnhancedChatInput;
window.enhancedChatInput = null;
// Auto-initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initEnhancedChatInput();
window.enhancedChatInput = enhancedChatInput;
});
} else {
initEnhancedChatInput();
window.enhancedChatInput = enhancedChatInput;
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = { EnhancedChatInput };
}