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>
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user