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:
uroma
2026-01-22 14:43:05 +00:00
Unverified
parent b82837aa5f
commit 55aafbae9a
6463 changed files with 1115462 additions and 4486 deletions

View File

@@ -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 };
}