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,267 @@
|
||||
/**
|
||||
* Approval Card Component
|
||||
* Interactive UI for approving/rejecting commands
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Approval card instance tracking
|
||||
let activeCards = new Map();
|
||||
|
||||
/**
|
||||
* Render approval card
|
||||
* @param {Object} approvalData - Approval request data
|
||||
* @returns {HTMLElement} - The approval card element
|
||||
*/
|
||||
function renderApprovalCard(approvalData) {
|
||||
// Check if card already exists
|
||||
if (activeCards.has(approvalData.id)) {
|
||||
const existingCard = activeCards.get(approvalData.id);
|
||||
if (existingCard && existingCard.isConnected) {
|
||||
return existingCard;
|
||||
}
|
||||
}
|
||||
|
||||
const cardId = `approval-card-${approvalData.id}`;
|
||||
|
||||
// Create card container
|
||||
const card = document.createElement('div');
|
||||
card.className = 'approval-card';
|
||||
card.id = cardId;
|
||||
card.dataset.approvalId = approvalData.id;
|
||||
|
||||
// Generate HTML
|
||||
card.innerHTML = `
|
||||
<div class="approval-card-header">
|
||||
<span class="approval-icon">🤖</span>
|
||||
<span class="approval-label">Executing:</span>
|
||||
<code class="approval-command">${escapeHtml(approvalData.command)}</code>
|
||||
</div>
|
||||
${approvalData.explanation ? `
|
||||
<div class="approval-explanation">
|
||||
<span class="explanation-icon">ℹ️</span>
|
||||
<span class="explanation-text">${escapeHtml(approvalData.explanation)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="approval-buttons">
|
||||
<button class="btn-approve" onclick="ApprovalCard.handleApprove('${approvalData.id}')">Approve</button>
|
||||
<button class="btn-custom" onclick="ApprovalCard.handleCustom('${approvalData.id}')" ${approvalData.explanation ? '' : 'style="display:none"'}>Custom Instructions</button>
|
||||
<button class="btn-reject" onclick="ApprovalCard.handleReject('${approvalData.id}')">Reject</button>
|
||||
</div>
|
||||
<div class="approval-custom" style="display:none;">
|
||||
<label class="custom-label">Custom command:</label>
|
||||
<input type="text" class="custom-input" id="${cardId}-custom-input" placeholder="Enter modified command..." />
|
||||
<div class="custom-buttons">
|
||||
<button class="btn-approve-small" onclick="ApprovalCard.executeCustom('${approvalData.id}')">Execute Custom</button>
|
||||
<button class="btn-cancel-small" onclick="ApprovalCard.closeCustom('${approvalData.id}')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store in active cards
|
||||
activeCards.set(approvalData.id, card);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approve button click
|
||||
* @param {string} approvalId - Approval ID
|
||||
*/
|
||||
function handleApprove(approvalId) {
|
||||
sendApprovalResponse(approvalId, true, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reject button click
|
||||
* @param {string} approvalId - Approval ID
|
||||
*/
|
||||
function handleReject(approvalId) {
|
||||
sendApprovalResponse(approvalId, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle custom instructions click
|
||||
* @param {string} approvalId - Approval ID
|
||||
*/
|
||||
function handleCustom(approvalId) {
|
||||
const card = activeCards.get(approvalId);
|
||||
if (!card) return;
|
||||
|
||||
const customSection = card.querySelector('.approval-custom');
|
||||
const customButton = card.querySelector('.btn-custom');
|
||||
|
||||
if (customSection.style.display === 'none') {
|
||||
// Show custom input
|
||||
customSection.style.display = 'block';
|
||||
const input = card.querySelector(`#${approvalId}-custom-input`);
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
if (customButton) {
|
||||
customButton.textContent = 'Close';
|
||||
customButton.onclick = () => ApprovalCard.closeCustom(approvalId);
|
||||
}
|
||||
} else {
|
||||
// Close custom input
|
||||
closeCustom(approvalId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute custom command
|
||||
* @param {string} approvalId - Approval ID
|
||||
*/
|
||||
function executeCustom(approvalId) {
|
||||
const card = activeCards.get(approvalId);
|
||||
if (!card) return;
|
||||
|
||||
const input = card.querySelector(`#${approvalId}-custom-input`);
|
||||
const customCommand = input ? input.value.trim() : '';
|
||||
|
||||
if (!customCommand) {
|
||||
// Show error
|
||||
const existingError = card.querySelector('.approval-custom-error');
|
||||
if (!existingError) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'approval-custom-error';
|
||||
errorDiv.textContent = 'Please enter a command';
|
||||
errorDiv.style.color = '#ff6b6b';
|
||||
errorDiv.style.marginTop = '5px';
|
||||
errorDiv.style.fontSize = '12px';
|
||||
card.querySelector('.approval-custom-buttons').insertBefore(errorDiv, card.querySelector('.approval-custom-buttons').firstChild);
|
||||
}
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
sendApprovalResponse(approvalId, true, customCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close custom input
|
||||
* @param {string} approvalId - Approval ID
|
||||
*/
|
||||
function closeCustom(approvalId) {
|
||||
const card = activeCards.get(approvalId);
|
||||
if (!card) return;
|
||||
|
||||
const customSection = card.querySelector('.approval-custom');
|
||||
const customButton = card.querySelector('.btn-custom');
|
||||
|
||||
customSection.style.display = 'none';
|
||||
customButton.textContent = 'Custom Instructions';
|
||||
customButton.onclick = () => ApprovalCard.handleCustom(approvalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send approval response to server
|
||||
* @param {string} approvalId - Approval ID
|
||||
* @param {boolean} approved - Whether user approved
|
||||
* @param {string|null} customCommand - Custom command if provided
|
||||
*/
|
||||
function sendApprovalResponse(approvalId, approved, customCommand) {
|
||||
// Remove card from UI
|
||||
const card = activeCards.get(approvalId);
|
||||
if (card && card.isConnected) {
|
||||
card.remove();
|
||||
}
|
||||
activeCards.delete(approvalId);
|
||||
|
||||
// Check if this is a server-initiated approval or AI-conversational approval
|
||||
const pendingApproval = window._pendingApprovals && window._pendingApprovals[approvalId];
|
||||
|
||||
if (pendingApproval) {
|
||||
// AI-conversational approval - send as chat message
|
||||
let responseMessage;
|
||||
|
||||
if (approved) {
|
||||
if (customCommand) {
|
||||
responseMessage = `Execute: ${customCommand}`;
|
||||
} else {
|
||||
responseMessage = 'yes';
|
||||
}
|
||||
} else {
|
||||
responseMessage = 'no';
|
||||
}
|
||||
|
||||
// Send as chat message
|
||||
if (typeof sendChatMessage === 'function') {
|
||||
sendChatMessage(responseMessage, 'webcontainer');
|
||||
} else if (window.sendMessageToSession) {
|
||||
window.sendMessageToSession(responseMessage);
|
||||
}
|
||||
|
||||
// Clean up pending approval
|
||||
delete window._pendingApprovals[approvalId];
|
||||
} else {
|
||||
// Server-initiated approval - send via WebSocket
|
||||
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
|
||||
window.ws.send(JSON.stringify({
|
||||
type: 'approval-response',
|
||||
id: approvalId,
|
||||
approved: approved,
|
||||
customCommand: customCommand,
|
||||
sessionId: window.attachedSessionId || window.chatSessionId
|
||||
}));
|
||||
} else {
|
||||
console.error('[ApprovalCard] WebSocket not connected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approval expired event
|
||||
* @param {string} approvalId - Approval ID
|
||||
*/
|
||||
function handleExpired(approvalId) {
|
||||
const card = activeCards.get(approvalId);
|
||||
if (card && card.isConnected) {
|
||||
const header = card.querySelector('.approval-card-header');
|
||||
if (header) {
|
||||
header.innerHTML = `
|
||||
<span class="approval-icon" style="color: #ff6b6b;">⏱️</span>
|
||||
<span class="approval-label">Expired:</span>
|
||||
<span class="approval-command" style="color: #ff6b6b;">This approval request has expired</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const buttons = card.querySelector('.approval-buttons');
|
||||
if (buttons) {
|
||||
buttons.style.display = 'none';
|
||||
}
|
||||
|
||||
const custom = card.querySelector('.approval-custom');
|
||||
if (custom) {
|
||||
custom.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} - Escaped text
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Export public API
|
||||
window.ApprovalCard = {
|
||||
render: renderApprovalCard,
|
||||
handleApprove,
|
||||
handleReject,
|
||||
handleCustom,
|
||||
executeCustom,
|
||||
closeCustom,
|
||||
sendApprovalResponse,
|
||||
handleExpired
|
||||
};
|
||||
|
||||
console.log('[ApprovalCard] Component loaded');
|
||||
})();
|
||||
@@ -0,0 +1,265 @@
|
||||
/* ============================================================
|
||||
Approval Card Component Styles
|
||||
============================================================ */
|
||||
|
||||
.approval-card {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
border: 1px solid #4a9eff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 12px 0;
|
||||
box-shadow: 0 4px 20px rgba(74, 158, 255, 0.2);
|
||||
animation: approvalCardSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes approvalCardSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header Section */
|
||||
.approval-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(74, 158, 255, 0.3);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.approval-icon {
|
||||
font-size: 20px;
|
||||
animation: approvalIconPulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes approvalIconPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.approval-label {
|
||||
font-weight: 600;
|
||||
color: #4a9eff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.approval-command {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #7dd3fc;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Explanation Section */
|
||||
.approval-explanation {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.explanation-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.explanation-text {
|
||||
color: #e0e0e0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Buttons Section */
|
||||
.approval-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.approval-buttons button {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-approve:hover {
|
||||
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.btn-custom {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-custom:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-reject:hover {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Custom Instructions Section */
|
||||
.approval-custom {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.custom-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-input:focus {
|
||||
border-color: #4a9eff;
|
||||
box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.custom-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.custom-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-approve-small,
|
||||
.btn-cancel-small {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-approve-small {
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-approve-small:hover {
|
||||
background: linear-gradient(135deg, #16a34a 0%, #15803d 100%);
|
||||
}
|
||||
|
||||
.btn-cancel-small {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.btn-cancel-small:hover {
|
||||
background: rgba(100, 116, 139, 0.3);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 600px) {
|
||||
.approval-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.approval-buttons button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading/Disabled States */
|
||||
.approval-card.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.approval-card.loading .approval-icon {
|
||||
animation: approvalIconSpin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes approvalIconSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Expired State */
|
||||
.approval-card.expired {
|
||||
opacity: 0.5;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Success State */
|
||||
.approval-card.success {
|
||||
border-color: #22c55e;
|
||||
animation: approvalCardSuccess 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes approvalCardSuccess {
|
||||
0% {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -172,13 +172,21 @@
|
||||
// Check if this is a server-initiated approval or AI-conversational approval
|
||||
const pendingApproval = window._pendingApprovals && window._pendingApprovals[approvalId];
|
||||
|
||||
// Get the session ID
|
||||
const sessionId = window.attachedSessionId || window.chatSessionId ||
|
||||
(pendingApproval && pendingApproval.sessionId);
|
||||
|
||||
if (pendingApproval) {
|
||||
// AI-conversational approval - send as chat message
|
||||
// AI-conversational approval - send as chat message to Claude
|
||||
// This is the Kimi-style flow: approval responses are sent as chat messages
|
||||
// Claude will continue execution upon receiving "yes"
|
||||
console.log('[ApprovalCard] Sending AI-conversational approval as chat message');
|
||||
|
||||
let responseMessage;
|
||||
|
||||
if (approved) {
|
||||
if (customCommand) {
|
||||
responseMessage = `Execute: ${customCommand}`;
|
||||
responseMessage = customCommand;
|
||||
} else {
|
||||
responseMessage = 'yes';
|
||||
}
|
||||
@@ -186,24 +194,46 @@
|
||||
responseMessage = 'no';
|
||||
}
|
||||
|
||||
// Send as chat message
|
||||
if (typeof sendChatMessage === 'function') {
|
||||
sendChatMessage(responseMessage, 'webcontainer');
|
||||
} else if (window.sendMessageToSession) {
|
||||
window.sendMessageToSession(responseMessage);
|
||||
// Send directly via WebSocket as a chat command
|
||||
if (window.ws && window.ws.readyState === WebSocket.OPEN && sessionId) {
|
||||
window.ws.send(JSON.stringify({
|
||||
type: 'command',
|
||||
sessionId: sessionId,
|
||||
command: responseMessage,
|
||||
metadata: {
|
||||
isApprovalResponse: true,
|
||||
approvalId: approvalId,
|
||||
originalCommand: pendingApproval.command || null
|
||||
}
|
||||
}));
|
||||
console.log('[ApprovalCard] Sent approval response via WebSocket:', responseMessage);
|
||||
} else {
|
||||
console.error('[ApprovalCard] WebSocket not connected for approval response');
|
||||
if (typeof appendSystemMessage === 'function') {
|
||||
appendSystemMessage('❌ Failed to send approval: WebSocket not connected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up pending approval
|
||||
delete window._pendingApprovals[approvalId];
|
||||
|
||||
// Show feedback
|
||||
if (typeof appendSystemMessage === 'function' && approved) {
|
||||
appendSystemMessage('✅ Approval sent - continuing execution...');
|
||||
}
|
||||
|
||||
} else {
|
||||
// Server-initiated approval - send via WebSocket
|
||||
console.log('[ApprovalCard] Sending server-initiated approval via WebSocket');
|
||||
|
||||
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
|
||||
window.ws.send(JSON.stringify({
|
||||
type: 'approval-response',
|
||||
id: approvalId,
|
||||
approved: approved,
|
||||
customCommand: customCommand,
|
||||
sessionId: window.attachedSessionId || window.chatSessionId
|
||||
sessionId: sessionId
|
||||
}));
|
||||
} else {
|
||||
console.error('[ApprovalCard] WebSocket not connected');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Enhanced Chat Input Component Styles
|
||||
* CodeNomad-style sophisticated prompt input
|
||||
*/
|
||||
|
||||
/* === Chat Input Container === */
|
||||
.chat-input-wrapper-enhanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* === Attachment Chips === */
|
||||
.attachment-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
max-height: 120px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #484f58 #161b22;
|
||||
}
|
||||
|
||||
.attachment-chips::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.attachment-chips::-webkit-scrollbar-track {
|
||||
background: #161b22;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-chips::-webkit-scrollbar-thumb {
|
||||
background: #484f58;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-chips::-webkit-scrollbar-thumb:hover {
|
||||
background: #6e7681;
|
||||
}
|
||||
|
||||
.attachment-chips:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* === Attachment Chip === */
|
||||
.attachment-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #c9d1d9;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-chip.image-chip {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.attachment-chip.image-chip img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.attachment-chip .chip-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.attachment-chip .chip-name,
|
||||
.attachment-chip .chip-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.attachment-chip .chip-info {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.attachment-chip .chip-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.attachment-chip .chip-remove:hover {
|
||||
background: #484f58;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* === Chat Input Wrapper === */
|
||||
.chat-input-wrapper-enhanced .chat-input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .input-actions-left,
|
||||
.chat-input-wrapper-enhanced .input-actions-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced textarea {
|
||||
flex: 1;
|
||||
min-height: 24px;
|
||||
max-height: 360px;
|
||||
padding: 8px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #c9d1d9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced textarea:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .btn-attach,
|
||||
.chat-input-wrapper-enhanced .btn-send {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .btn-attach {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .btn-attach:hover {
|
||||
background: #30363d;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .btn-send {
|
||||
background: #1f6feb;
|
||||
border: 1px solid #1f6feb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .btn-send:hover {
|
||||
background: #388bfd;
|
||||
}
|
||||
|
||||
/* === Input Info Bar === */
|
||||
.chat-input-wrapper-enhanced .input-info-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .token-count,
|
||||
.chat-input-wrapper-enhanced .char-count {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === Unified Picker === */
|
||||
.unified-picker {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px 8px 0 0;
|
||||
margin-bottom: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.unified-picker.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.picker-item:hover {
|
||||
background: #21262d;
|
||||
}
|
||||
|
||||
.picker-item .picker-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.picker-item .picker-description {
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* === Mobile Responsive === */
|
||||
@media (max-width: 640px) {
|
||||
.attachment-chips {
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
.attachment-chip {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.attachment-chip.image-chip img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.attachment-chip .chip-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .chat-input-wrapper {
|
||||
padding: 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced textarea {
|
||||
font-size: 16px; /* Prevent zoom on iOS */
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .btn-attach,
|
||||
.chat-input-wrapper-enhanced .btn-send {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper-enhanced .input-info-bar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Touch Targets === */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.attachment-chip .chip-remove {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Focus Styles === */
|
||||
.chat-input-wrapper-enhanced textarea:focus-visible,
|
||||
.chat-input-wrapper-enhanced .btn-attach:focus-visible,
|
||||
.chat-input-wrapper-enhanced .btn-send:focus-visible {
|
||||
outline: 2px solid #58a6ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* === Queued Message Indicator === */
|
||||
.queued-message-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #ff6b6b;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.queued-message-indicator .indicator-icon {
|
||||
font-size: 16px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.queued-message-indicator .indicator-count {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,660 @@
|
||||
/**
|
||||
* Monaco Editor Component
|
||||
* VS Code's editor in the browser with tab system
|
||||
*
|
||||
* Features:
|
||||
* - Tab-based multi-file editing
|
||||
* - Syntax highlighting for 100+ languages
|
||||
* - Auto-save on Ctrl+S
|
||||
* - Dirty state indicators
|
||||
* - Mobile responsive (CodeMirror fallback on touch devices)
|
||||
*/
|
||||
|
||||
class MonacoEditor {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
console.error('[MonacoEditor] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.editors = new Map(); // tabId -> editor instance
|
||||
this.models = new Map(); // tabId -> model instance
|
||||
this.tabs = [];
|
||||
this.activeTab = null;
|
||||
this.monaco = null;
|
||||
this.isMobile = this.detectMobile();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
detectMobile() {
|
||||
// Check for actual mobile device (not just touch-enabled laptop)
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
// Also check screen width as additional heuristic
|
||||
const isSmallScreen = window.innerWidth < 768;
|
||||
return isMobile || isSmallScreen;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (this.isMobile) {
|
||||
// Use CodeMirror for mobile (touch-friendly)
|
||||
console.log('[MonacoEditor] Mobile detected, using fallback');
|
||||
this.initializeFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Wrap AMD loader in promise
|
||||
await new Promise((resolve, reject) => {
|
||||
// Configure Monaco loader
|
||||
require.config({
|
||||
paths: {
|
||||
'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs'
|
||||
}
|
||||
});
|
||||
|
||||
// Load Monaco
|
||||
require(['vs/editor/editor.main'], (monaco) => {
|
||||
this.monaco = monaco;
|
||||
this.setupContainer();
|
||||
this.setupKeyboardShortcuts();
|
||||
this.loadPersistedTabs();
|
||||
this.initialized = true;
|
||||
console.log('[MonacoEditor] Initialized successfully');
|
||||
resolve();
|
||||
}, (error) => {
|
||||
console.error('[MonacoEditor] AMD loader error:', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MonacoEditor] Failed to initialize:', error);
|
||||
this.initializeFallback();
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
setupContainer() {
|
||||
this.container.innerHTML = `
|
||||
<div class="monaco-editor-container">
|
||||
<div class="editor-tabs-wrapper">
|
||||
<div class="editor-tabs" id="editor-tabs"></div>
|
||||
<div class="editor-tabs-actions">
|
||||
<button class="btn-icon" id="btn-save-current" title="Save (Ctrl+S)" style="display: none;">💾</button>
|
||||
<button class="btn-icon" id="btn-save-all" title="Save All (Ctrl+Shift+S)">💾</button>
|
||||
<button class="btn-icon" id="btn-close-all" title="Close All">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-content-wrapper">
|
||||
<div class="editor-content" id="editor-content">
|
||||
<div class="editor-placeholder">
|
||||
<div class="placeholder-icon">📄</div>
|
||||
<h2>No file open</h2>
|
||||
<p>Select a file from the sidebar to start editing</p>
|
||||
<p style="font-size: 0.9em; opacity: 0.7; margin-top: 8px;">Files are automatically editable</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-statusbar">
|
||||
<span class="statusbar-item" id="statusbar-cursor">Ln 1, Col 1</span>
|
||||
<span class="statusbar-item" id="statusbar-language">Plain Text</span>
|
||||
<span class="statusbar-item" id="statusbar-file">No file</span>
|
||||
<span class="statusbar-item" id="statusbar-editable" style="display: none;">✓ Editable</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
const saveCurrentBtn = this.container.querySelector('#btn-save-current');
|
||||
if (saveCurrentBtn) {
|
||||
saveCurrentBtn.addEventListener('click', () => this.saveCurrentFile());
|
||||
}
|
||||
|
||||
const saveAllBtn = this.container.querySelector('#btn-save-all');
|
||||
if (saveAllBtn) {
|
||||
saveAllBtn.addEventListener('click', () => this.saveAllFiles());
|
||||
}
|
||||
|
||||
const closeAllBtn = this.container.querySelector('#btn-close-all');
|
||||
if (closeAllBtn) {
|
||||
closeAllBtn.addEventListener('click', () => this.closeAllTabs());
|
||||
}
|
||||
}
|
||||
|
||||
setupKeyboardShortcuts() {
|
||||
// Ctrl+S to save
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
this.saveCurrentFile();
|
||||
}
|
||||
// Ctrl+W to close tab
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||
e.preventDefault();
|
||||
this.closeCurrentTab();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getLanguageFromFile(filePath) {
|
||||
const ext = filePath.split('.').pop().toLowerCase();
|
||||
|
||||
const languageMap = {
|
||||
'js': 'javascript',
|
||||
'jsx': 'javascript',
|
||||
'ts': 'typescript',
|
||||
'tsx': 'typescript',
|
||||
'py': 'python',
|
||||
'html': 'html',
|
||||
'htm': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'scss',
|
||||
'json': 'json',
|
||||
'md': 'markdown',
|
||||
'markdown': 'markdown',
|
||||
'xml': 'xml',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml',
|
||||
'sql': 'sql',
|
||||
'sh': 'shell',
|
||||
'bash': 'shell',
|
||||
'zsh': 'shell',
|
||||
'txt': 'plaintext'
|
||||
};
|
||||
|
||||
return languageMap[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
async openFile(filePath, content) {
|
||||
if (!this.initialized && !this.isMobile) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (this.isMobile) {
|
||||
this.openFileFallback(filePath, content);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already open
|
||||
const existingTab = this.tabs.find(tab => tab.path === filePath);
|
||||
if (existingTab) {
|
||||
this.activateTab(existingTab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new tab
|
||||
const tabId = `tab-${Date.now()}`;
|
||||
const tab = {
|
||||
id: tabId,
|
||||
path: filePath,
|
||||
name: filePath.split('/').pop(),
|
||||
dirty: false,
|
||||
originalContent: content || ''
|
||||
};
|
||||
|
||||
this.tabs.push(tab);
|
||||
|
||||
// Create Monaco model
|
||||
const language = this.getLanguageFromFile(filePath);
|
||||
const model = this.monaco.editor.createModel(content || '', language, monaco.Uri.parse(filePath));
|
||||
this.models.set(tabId, model);
|
||||
|
||||
// Create editor instance
|
||||
const contentArea = this.container.querySelector('#editor-content');
|
||||
|
||||
// Remove placeholder
|
||||
const placeholder = contentArea.querySelector('.editor-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
// Create editor container
|
||||
const editorContainer = document.createElement('div');
|
||||
editorContainer.className = 'monaco-editor-instance';
|
||||
editorContainer.style.display = 'none';
|
||||
contentArea.appendChild(editorContainer);
|
||||
|
||||
// Create editor
|
||||
const editor = this.monaco.editor.create(editorContainer, {
|
||||
model: model,
|
||||
theme: 'vs-dark',
|
||||
automaticLayout: true,
|
||||
fontSize: 14,
|
||||
fontFamily: "'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monaco",
|
||||
lineNumbers: 'on',
|
||||
minimap: { enabled: true },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'off',
|
||||
tabSize: 4,
|
||||
renderWhitespace: 'selection',
|
||||
cursorStyle: 'line',
|
||||
folding: true,
|
||||
bracketPairColorization: { enabled: true },
|
||||
guides: {
|
||||
indentation: true,
|
||||
bracketPairs: true
|
||||
}
|
||||
});
|
||||
|
||||
// Track cursor position
|
||||
editor.onDidChangeCursorPosition((e) => {
|
||||
this.updateCursorPosition(e.position);
|
||||
});
|
||||
|
||||
// Track content changes
|
||||
model.onDidChangeContent(() => {
|
||||
this.markDirty(tabId);
|
||||
});
|
||||
|
||||
this.editors.set(tabId, editor);
|
||||
|
||||
// Activate the new tab
|
||||
this.activateTab(tabId);
|
||||
|
||||
// Persist tabs
|
||||
this.saveTabsToStorage();
|
||||
|
||||
return tabId;
|
||||
}
|
||||
|
||||
activateTab(tabId) {
|
||||
if (!this.editors.has(tabId)) {
|
||||
console.error('[MonacoEditor] Tab not found:', tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide all editors
|
||||
this.editors.forEach((editor, id) => {
|
||||
const container = editor.getDomNode();
|
||||
if (container) {
|
||||
container.style.display = id === tabId ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
this.activeTab = tabId;
|
||||
this.renderTabs();
|
||||
this.updateStatusbar(tabId);
|
||||
|
||||
// Show save button for current file and editable indicator
|
||||
const tab = this.tabs.find(t => t.id === tabId);
|
||||
const saveCurrentBtn = this.container.querySelector('#btn-save-current');
|
||||
const editableIndicator = this.container.querySelector('#statusbar-editable');
|
||||
|
||||
if (saveCurrentBtn) {
|
||||
saveCurrentBtn.style.display = 'inline-flex';
|
||||
saveCurrentBtn.title = `Save ${tab?.name || 'file'} (Ctrl+S)`;
|
||||
}
|
||||
|
||||
if (editableIndicator) {
|
||||
editableIndicator.style.display = 'inline-flex';
|
||||
editableIndicator.textContent = tab?.dirty ? '● Unsaved changes' : '✓ Editable';
|
||||
editableIndicator.style.color = tab?.dirty ? '#f48771' : '#4ec9b0';
|
||||
}
|
||||
|
||||
// Focus the active editor and ensure it's not read-only
|
||||
const editor = this.editors.get(tabId);
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
editor.updateOptions({ readOnly: false });
|
||||
}
|
||||
}
|
||||
|
||||
closeTab(tabId) {
|
||||
const tab = this.tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
// Check for unsaved changes
|
||||
if (tab.dirty) {
|
||||
const shouldSave = confirm(`Save changes to ${tab.name} before closing?`);
|
||||
if (shouldSave) {
|
||||
this.saveFile(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispose editor and model
|
||||
const editor = this.editors.get(tabId);
|
||||
if (editor) {
|
||||
editor.dispose();
|
||||
this.editors.delete(tabId);
|
||||
}
|
||||
|
||||
const model = this.models.get(tabId);
|
||||
if (model) {
|
||||
model.dispose();
|
||||
this.models.delete(tabId);
|
||||
}
|
||||
|
||||
// Remove tab from list
|
||||
this.tabs = this.tabs.filter(t => t.id !== tabId);
|
||||
|
||||
// If we closed the active tab, activate another one
|
||||
if (this.activeTab === tabId) {
|
||||
if (this.tabs.length > 0) {
|
||||
this.activateTab(this.tabs[0].id);
|
||||
} else {
|
||||
this.activeTab = null;
|
||||
this.showPlaceholder();
|
||||
}
|
||||
}
|
||||
|
||||
this.renderTabs();
|
||||
this.saveTabsToStorage();
|
||||
}
|
||||
|
||||
closeCurrentTab() {
|
||||
if (this.activeTab) {
|
||||
this.closeTab(this.activeTab);
|
||||
}
|
||||
}
|
||||
|
||||
closeAllTabs() {
|
||||
if (this.tabs.length === 0) return;
|
||||
|
||||
const hasUnsaved = this.tabs.some(t => t.dirty);
|
||||
if (hasUnsaved) {
|
||||
const shouldSaveAll = confirm('Some files have unsaved changes. Save all before closing?');
|
||||
if (shouldSaveAll) {
|
||||
this.saveAllFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// Dispose all editors and models
|
||||
this.editors.forEach(editor => editor.dispose());
|
||||
this.models.forEach(model => model.dispose());
|
||||
|
||||
this.editors.clear();
|
||||
this.models.clear();
|
||||
this.tabs = [];
|
||||
this.activeTab = null;
|
||||
|
||||
this.renderTabs();
|
||||
this.showPlaceholder();
|
||||
this.saveTabsToStorage();
|
||||
}
|
||||
|
||||
async saveFile(tabId) {
|
||||
const tab = this.tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
const model = this.models.get(tabId);
|
||||
if (!model) return;
|
||||
|
||||
const content = model.getValue();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/claude/api/file/${encodeURIComponent(tab.path)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Update tab state
|
||||
tab.dirty = false;
|
||||
tab.originalContent = content;
|
||||
|
||||
this.renderTabs();
|
||||
|
||||
// Show success toast
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`✅ Saved ${tab.name}`, 'success', 2000);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[MonacoEditor] Error saving file:', error);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`❌ Failed to save ${tab.name}: ${error.message}`, 'error', 3000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async saveCurrentFile() {
|
||||
if (this.activeTab) {
|
||||
await this.saveFile(this.activeTab);
|
||||
}
|
||||
}
|
||||
|
||||
async saveAllFiles() {
|
||||
const dirtyTabs = this.tabs.filter(t => t.dirty);
|
||||
|
||||
if (dirtyTabs.length === 0) {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('No unsaved changes', 'info', 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let saved = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const tab of dirtyTabs) {
|
||||
const result = await this.saveFile(tab.id);
|
||||
if (result) {
|
||||
saved++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof showToast === 'function') {
|
||||
if (failed === 0) {
|
||||
showToast(`✅ Saved ${saved} file${saved > 1 ? 's' : ''}`, 'success', 2000);
|
||||
} else {
|
||||
showToast(`⚠️ Saved ${saved} file${saved > 1 ? 's' : ''}, ${failed} failed`, 'warning', 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markDirty(tabId) {
|
||||
const tab = this.tabs.find(t => t.id === tabId);
|
||||
if (tab && !tab.dirty) {
|
||||
tab.dirty = true;
|
||||
this.renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
updateCursorPosition(position) {
|
||||
const cursorEl = this.container.querySelector('#statusbar-cursor');
|
||||
if (cursorEl && position) {
|
||||
cursorEl.textContent = `Ln ${position.lineNumber}, Col ${position.column}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusbar(tabId) {
|
||||
const tab = this.tabs.find(t => t.id === tabId);
|
||||
if (!tab) return;
|
||||
|
||||
const fileEl = this.container.querySelector('#statusbar-file');
|
||||
const langEl = this.container.querySelector('#statusbar-language');
|
||||
|
||||
if (fileEl) {
|
||||
fileEl.textContent = tab.path;
|
||||
}
|
||||
|
||||
if (langEl) {
|
||||
const language = this.getLanguageFromFile(tab.path);
|
||||
langEl.textContent = language.charAt(0).toUpperCase() + language.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
renderTabs() {
|
||||
const tabsContainer = this.container.querySelector('#editor-tabs');
|
||||
if (!tabsContainer) return;
|
||||
|
||||
if (this.tabs.length === 0) {
|
||||
tabsContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tabsContainer.innerHTML = this.tabs.map(tab => `
|
||||
<div class="editor-tab ${tab.id === this.activeTab ? 'active' : ''} ${tab.dirty ? 'dirty' : ''}"
|
||||
data-tab-id="${tab.id}"
|
||||
title="${this.escapeHtml(tab.path)}">
|
||||
<span class="tab-name">${this.escapeHtml(tab.name)}</span>
|
||||
${tab.dirty ? '<span class="tab-dirty-indicator">●</span>' : ''}
|
||||
<button class="tab-close" title="Close tab">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Tab click handlers
|
||||
tabsContainer.querySelectorAll('.editor-tab').forEach(tabEl => {
|
||||
tabEl.addEventListener('click', (e) => {
|
||||
if (!e.target.classList.contains('tab-close')) {
|
||||
this.activateTab(tabEl.dataset.tabId);
|
||||
}
|
||||
});
|
||||
|
||||
const closeBtn = tabEl.querySelector('.tab-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeTab(tabEl.dataset.tabId);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showPlaceholder() {
|
||||
const contentArea = this.container.querySelector('#editor-content');
|
||||
if (contentArea) {
|
||||
contentArea.innerHTML = `
|
||||
<div class="editor-placeholder">
|
||||
<div class="placeholder-icon">📄</div>
|
||||
<h2>No file open</h2>
|
||||
<p>Select a file from the sidebar to start editing</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
saveTabsToStorage() {
|
||||
const tabsData = this.tabs.map(tab => ({
|
||||
path: tab.path,
|
||||
name: tab.name,
|
||||
dirty: tab.dirty,
|
||||
active: tab.id === this.activeTab
|
||||
}));
|
||||
|
||||
try {
|
||||
sessionStorage.setItem('monaco-tabs', JSON.stringify(tabsData));
|
||||
} catch (e) {
|
||||
console.error('[MonacoEditor] Failed to save tabs:', e);
|
||||
}
|
||||
}
|
||||
|
||||
loadPersistedTabs() {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('monaco-tabs');
|
||||
if (saved) {
|
||||
const tabsData = JSON.parse(saved);
|
||||
console.log('[MonacoEditor] Restoring tabs:', tabsData);
|
||||
// Note: Files will need to be reloaded from server
|
||||
// This just restores the tab list structure
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MonacoEditor] Failed to load tabs:', e);
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Fallback for mobile devices
|
||||
initializeFallback() {
|
||||
this.setupContainer();
|
||||
this.isMobile = true;
|
||||
this.initialized = true;
|
||||
|
||||
// Add message about mobile limitation
|
||||
const contentArea = this.container.querySelector('#editor-content');
|
||||
if (contentArea) {
|
||||
contentArea.innerHTML = `
|
||||
<div class="editor-placeholder">
|
||||
<div class="placeholder-icon">📱</div>
|
||||
<h2>Mobile View</h2>
|
||||
<p>Full code editing coming soon to mobile!</p>
|
||||
<p>For now, please use a desktop or tablet device.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
openFileFallback(filePath, content) {
|
||||
// Mobile fallback - show read-only content
|
||||
const contentArea = this.container.querySelector('#editor-content');
|
||||
if (contentArea) {
|
||||
const language = this.getLanguageFromFile(filePath);
|
||||
contentArea.innerHTML = `
|
||||
<div class="mobile-file-view">
|
||||
<div class="file-header">
|
||||
<h3>${this.escapeHtml(filePath)}</h3>
|
||||
<span class="language-badge">${language}</span>
|
||||
</div>
|
||||
<pre class="code-content"><code>${this.escapeHtml(content || '')}</code></pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Dispose all editors and models
|
||||
this.editors.forEach(editor => editor.dispose());
|
||||
this.models.forEach(model => model.dispose());
|
||||
this.editors.clear();
|
||||
this.models.clear();
|
||||
this.tabs = [];
|
||||
this.activeTab = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
let monacoEditor = null;
|
||||
|
||||
// Initialize when DOM is ready
|
||||
async function initMonacoEditor() {
|
||||
monacoEditor = new MonacoEditor('file-editor');
|
||||
await monacoEditor.initialize();
|
||||
return monacoEditor;
|
||||
}
|
||||
|
||||
// Export to window
|
||||
if (typeof window !== 'undefined') {
|
||||
window.MonacoEditor = MonacoEditor;
|
||||
|
||||
// Auto-initialize
|
||||
async function autoInit() {
|
||||
try {
|
||||
const editor = await initMonacoEditor();
|
||||
window.monacoEditor = editor;
|
||||
console.log('[MonacoEditor] Auto-initialization complete');
|
||||
} catch (error) {
|
||||
console.error('[MonacoEditor] Auto-initialization failed:', error);
|
||||
window.monacoEditor = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => autoInit());
|
||||
} else {
|
||||
autoInit();
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { MonacoEditor };
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Monaco Editor Component Styles
|
||||
* Mobile-first responsive design
|
||||
*/
|
||||
|
||||
/* === Monaco Editor Container === */
|
||||
.monaco-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Editor Header (Tabs + Actions) === */
|
||||
.editor-tabs-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
min-height: 35px;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #5a5a5a #252526;
|
||||
}
|
||||
|
||||
.editor-tabs::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.editor-tabs::-webkit-scrollbar-track {
|
||||
background: #252526;
|
||||
}
|
||||
|
||||
.editor-tabs::-webkit-scrollbar-thumb {
|
||||
background: #5a5a5a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-tabs::-webkit-scrollbar-thumb:hover {
|
||||
background: #6e6e6e;
|
||||
}
|
||||
|
||||
.editor-tabs-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
border-left: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
/* === Monaco Editor Tabs === */
|
||||
.editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid #3c3c3c;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #969696;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.editor-tab:hover {
|
||||
background: #2a2d2e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.editor-tab.active {
|
||||
background: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-top: 1px solid #007acc;
|
||||
}
|
||||
|
||||
.editor-tab.dirty .tab-name {
|
||||
color: #e3b341;
|
||||
}
|
||||
|
||||
.editor-tab.dirty .tab-dirty-indicator {
|
||||
color: #e3b341;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.tab-dirty-indicator {
|
||||
font-size: 10px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #969696;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: #3c3c3c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* === Editor Content Area === */
|
||||
.editor-content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.monaco-editor-instance {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Editor Placeholder === */
|
||||
.editor-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #6e6e6e;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.editor-placeholder h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.editor-placeholder p {
|
||||
font-size: 1rem;
|
||||
color: #6e6e6e;
|
||||
}
|
||||
|
||||
/* === Mobile File View (Fallback) === */
|
||||
.mobile-file-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mobile-file-view .file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: #252526;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mobile-file-view h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.language-badge {
|
||||
padding: 4px 8px;
|
||||
background: #007acc;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-file-view .code-content {
|
||||
background: #1e1e1e;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mobile-file-view code {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
/* === Editor Statusbar === */
|
||||
.editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 4px 12px;
|
||||
background: #007acc;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.statusbar-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === Action Buttons === */
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #969696;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #3c3c3c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-icon:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* === Mobile Responsive === */
|
||||
@media (max-width: 640px) {
|
||||
.editor-tabs-wrapper {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.editor-tabs-actions {
|
||||
border-left: none;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
padding: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor-tab {
|
||||
padding: 10px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.editor-placeholder h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.editor-placeholder p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.editor-statusbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Tablet Responsive === */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
.tab-name {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Touch Targets (Mobile) === */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.editor-tab {
|
||||
padding: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === File Error State === */
|
||||
.file-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
color: #f85149;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-error h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file-error p {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
/* === Loading Spinner === */
|
||||
.loading {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #3c3c3c;
|
||||
border-top-color: #007acc;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Focus Styles for Accessibility === */
|
||||
.editor-tab:focus-visible,
|
||||
.tab-close:focus-visible,
|
||||
.btn-icon:focus-visible {
|
||||
outline: 2px solid #007acc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* === Dark Mode Scrollbar === */
|
||||
.monaco-editor-instance ::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.monaco-editor-instance ::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.monaco-editor-instance ::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 7px;
|
||||
border: 3px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.monaco-editor-instance ::-webkit-scrollbar-thumb:hover {
|
||||
background: #4f4f4f;
|
||||
}
|
||||
|
||||
.monaco-editor-instance ::-webkit-scrollbar-corner {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
/* === Print Styles === */
|
||||
@media print {
|
||||
.editor-tabs-wrapper,
|
||||
.editor-statusbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Session Picker Component
|
||||
* Show modal on startup to select existing session or create new
|
||||
*
|
||||
* Features:
|
||||
* - Session picker modal on startup
|
||||
* - Recent sessions list
|
||||
* - Sessions grouped by project
|
||||
* - Create new session
|
||||
* - Session forking support
|
||||
*/
|
||||
|
||||
class SessionPicker {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.sessions = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Check URL params first
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sessionId = urlParams.get('session');
|
||||
const project = urlParams.get('project');
|
||||
|
||||
if (sessionId) {
|
||||
// Load specific session
|
||||
console.log('[SessionPicker] Loading session from URL:', sessionId);
|
||||
await this.loadSession(sessionId);
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (project) {
|
||||
// Create or load session for project
|
||||
console.log('[SessionPicker] Project context:', project);
|
||||
await this.ensureSessionForProject(project);
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// No session or project - show picker
|
||||
await this.showPicker();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async showPicker() {
|
||||
// Create modal
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.className = 'session-picker-modal';
|
||||
this.modal.innerHTML = `
|
||||
<div class="session-picker-content">
|
||||
<div class="picker-header">
|
||||
<h2>Select a Session</h2>
|
||||
<button class="btn-close" onclick="window.sessionPicker.close()">×</button>
|
||||
</div>
|
||||
|
||||
<div class="picker-tabs">
|
||||
<button class="picker-tab active" data-tab="recent" onclick="window.sessionPicker.switchTab('recent')">
|
||||
<span class="tab-icon">🕐</span>
|
||||
<span class="tab-label">Recent</span>
|
||||
</button>
|
||||
<button class="picker-tab" data-tab="projects" onclick="window.sessionPicker.switchTab('projects')">
|
||||
<span class="tab-icon">📁</span>
|
||||
<span class="tab-label">Projects</span>
|
||||
</button>
|
||||
<button class="picker-tab" data-tab="new" onclick="window.sessionPicker.switchTab('new')">
|
||||
<span class="tab-icon">➕</span>
|
||||
<span class="tab-label">New Session</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="picker-body">
|
||||
<div id="picker-recent" class="picker-tab-content active">
|
||||
<div class="loading">Loading recent sessions...</div>
|
||||
</div>
|
||||
<div id="picker-projects" class="picker-tab-content">
|
||||
<div class="loading">Loading projects...</div>
|
||||
</div>
|
||||
<div id="picker-new" class="picker-tab-content">
|
||||
<div class="new-session-form">
|
||||
<div class="form-group">
|
||||
<label>Session Name</label>
|
||||
<input type="text" id="new-session-name" placeholder="My Session" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Project (optional)</label>
|
||||
<input type="text" id="new-session-project" placeholder="my-project" />
|
||||
</div>
|
||||
<button class="btn-primary btn-block" onclick="window.sessionPicker.createNewSession()">
|
||||
Create Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(this.modal);
|
||||
document.body.style.overflow = 'hidden'; // Prevent scrolling
|
||||
|
||||
// Load recent sessions
|
||||
await this.loadRecentSessions();
|
||||
await this.loadProjects();
|
||||
}
|
||||
|
||||
async loadRecentSessions() {
|
||||
const container = document.getElementById('picker-recent');
|
||||
|
||||
try {
|
||||
const response = await fetch('/claude/api/claude/sessions');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.sessions = data.sessions || [];
|
||||
|
||||
if (this.sessions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">💬</div>
|
||||
<h3>No sessions yet</h3>
|
||||
<p>Create a new session to get started</p>
|
||||
<button class="btn-primary" onclick="window.sessionPicker.switchTab('new')">
|
||||
Create Session
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by last modified
|
||||
this.sessions.sort((a, b) => {
|
||||
const dateA = new Date(a.modified || a.created);
|
||||
const dateB = new Date(b.modified || b.created);
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Show last 10 sessions
|
||||
const recentSessions = this.sessions.slice(0, 10);
|
||||
|
||||
container.innerHTML = recentSessions.map(session => {
|
||||
const date = new Date(session.modified || session.created);
|
||||
const timeAgo = this.formatTimeAgo(date);
|
||||
const title = session.title || session.id;
|
||||
const project = session.project || 'General';
|
||||
|
||||
return `
|
||||
<div class="session-item" onclick="window.sessionPicker.selectSession('${session.id}')">
|
||||
<div class="session-icon">💬</div>
|
||||
<div class="session-info">
|
||||
<div class="session-title">${this.escapeHtml(title)}</div>
|
||||
<div class="session-meta">
|
||||
<span class="session-project">${this.escapeHtml(project)}</span>
|
||||
<span class="session-time">${timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-arrow">→</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SessionPicker] Failed to load sessions:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-state">
|
||||
<h3>Failed to load sessions</h3>
|
||||
<p>${error.message}</p>
|
||||
<button class="btn-secondary" onclick="window.sessionPicker.loadRecentSessions()">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async loadProjects() {
|
||||
const container = document.getElementById('picker-projects');
|
||||
|
||||
try {
|
||||
// Use the sessions endpoint to get projects
|
||||
const response = await fetch('/claude/api/claude/sessions');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Group sessions by project
|
||||
const projectMap = new Map();
|
||||
const allSessions = [
|
||||
...(data.active || []),
|
||||
...(data.historical || [])
|
||||
];
|
||||
|
||||
allSessions.forEach(session => {
|
||||
const projectName = session.metadata?.project || session.workingDir?.split('/').pop() || 'Untitled';
|
||||
if (!projectMap.has(projectName)) {
|
||||
projectMap.set(projectName, {
|
||||
name: projectName,
|
||||
sessionCount: 0,
|
||||
lastSession: session
|
||||
});
|
||||
}
|
||||
const project = projectMap.get(projectName);
|
||||
project.sessionCount++;
|
||||
});
|
||||
|
||||
const projects = Array.from(projectMap.values());
|
||||
|
||||
if (projects.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create a new project to organize your sessions</p>
|
||||
<button class="btn-primary" onclick="window.sessionPicker.switchTab('new')">
|
||||
New Session
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by session count (most used first)
|
||||
projects.sort((a, b) => b.sessionCount - a.sessionCount);
|
||||
|
||||
container.innerHTML = projects.map(project => {
|
||||
const sessionCount = project.sessionCount || 0;
|
||||
return `
|
||||
<div class="project-item" onclick="window.sessionPicker.selectProject('${this.escapeHtml(project.name)}')">
|
||||
<div class="project-icon">📁</div>
|
||||
<div class="project-info">
|
||||
<div class="project-name">${this.escapeHtml(project.name)}</div>
|
||||
<div class="project-meta">${sessionCount} session${sessionCount !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div class="project-arrow">→</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SessionPicker] Failed to load projects:', error);
|
||||
container.innerHTML = `
|
||||
<div class="error-state">
|
||||
<h3>Failed to load projects</h3>
|
||||
<p>${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async selectSession(sessionId) {
|
||||
await this.loadSession(sessionId);
|
||||
this.close();
|
||||
}
|
||||
|
||||
async selectProject(projectName) {
|
||||
await this.ensureSessionForProject(projectName);
|
||||
this.close();
|
||||
}
|
||||
|
||||
async loadSession(sessionId) {
|
||||
try {
|
||||
const response = await fetch(`/claude/api/claude/sessions/${sessionId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const session = await response.json();
|
||||
|
||||
// Attach to session
|
||||
if (typeof attachToSession === 'function') {
|
||||
attachToSession(sessionId);
|
||||
}
|
||||
|
||||
console.log('[SessionPicker] Loaded session:', sessionId);
|
||||
return session;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SessionPicker] Failed to load session:', error);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`Failed to load session: ${error.message}`, 'error', 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSessionForProject(projectName) {
|
||||
try {
|
||||
// Check if session exists for this project
|
||||
const response = await fetch('/claude/api/claude/sessions');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const sessions = data.sessions || [];
|
||||
|
||||
const projectSession = sessions.find(s => s.project === projectName);
|
||||
|
||||
if (projectSession) {
|
||||
return await this.loadSession(projectSession.id);
|
||||
}
|
||||
|
||||
// Create new session for project
|
||||
return await this.createNewSession(projectName);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SessionPicker] Failed to ensure session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async createNewSession(projectName = null) {
|
||||
const nameInput = document.getElementById('new-session-name');
|
||||
const projectInput = document.getElementById('new-session-project');
|
||||
|
||||
const name = nameInput?.value || projectName || 'Untitled Session';
|
||||
const project = projectInput?.value || projectName || '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/claude/api/claude/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: name,
|
||||
project: project,
|
||||
source: 'web-ide'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const session = await response.json();
|
||||
|
||||
// Attach to new session
|
||||
if (typeof attachToSession === 'function') {
|
||||
attachToSession(session.id);
|
||||
}
|
||||
|
||||
console.log('[SessionPicker] Created session:', session.id);
|
||||
this.close();
|
||||
return session;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SessionPicker] Failed to create session:', error);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`Failed to create session: ${error.message}`, 'error', 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switchTab(tabName) {
|
||||
// Update tab buttons
|
||||
this.modal.querySelectorAll('.picker-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
if (tab.dataset.tab === tabName) {
|
||||
tab.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update tab content
|
||||
this.modal.querySelectorAll('.picker-tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
const activeContent = document.getElementById(`picker-${tabName}`);
|
||||
if (activeContent) {
|
||||
activeContent.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.modal) {
|
||||
this.modal.remove();
|
||||
this.modal = null;
|
||||
}
|
||||
document.body.style.overflow = ''; // Restore scrolling
|
||||
}
|
||||
|
||||
formatTimeAgo(date) {
|
||||
const seconds = Math.floor((new Date() - date) / 1000);
|
||||
|
||||
if (seconds < 60) {
|
||||
return 'Just now';
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}m ago`;
|
||||
} else if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
return `${hours}h ago`;
|
||||
} else if (seconds < 604800) {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
return `${days}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
let sessionPicker = null;
|
||||
|
||||
// Auto-initialize
|
||||
if (typeof window !== 'undefined') {
|
||||
window.SessionPicker = SessionPicker;
|
||||
|
||||
// Create instance
|
||||
sessionPicker = new SessionPicker();
|
||||
window.sessionPicker = sessionPicker;
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
sessionPicker.initialize();
|
||||
});
|
||||
} else {
|
||||
sessionPicker.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { SessionPicker };
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Session Picker Component Styles
|
||||
* Modal for selecting or creating sessions
|
||||
*/
|
||||
|
||||
/* === Session Picker Modal === */
|
||||
.session-picker-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.session-picker-content {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* === Picker Header === */
|
||||
.picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.picker-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.picker-header .btn-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.picker-header .btn-close:hover {
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/* === Picker Tabs === */
|
||||
.picker-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.picker-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.picker-tab:hover {
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.picker-tab.active {
|
||||
background: #21262d;
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
.picker-tab .tab-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* === Picker Body === */
|
||||
.picker-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.picker-tab-content {
|
||||
display: none;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.picker-tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* === Session/Project Items === */
|
||||
.session-item,
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.session-item:hover,
|
||||
.project-item:hover {
|
||||
background: #21262d;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.session-icon,
|
||||
.project-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-info,
|
||||
.project-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-title,
|
||||
.project-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #c9d1d9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-meta,
|
||||
.project-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-project {
|
||||
padding: 2px 6px;
|
||||
background: #21262d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.session-time {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-arrow,
|
||||
.project-arrow {
|
||||
color: #8b949e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* === Empty State === */
|
||||
.empty-state,
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3,
|
||||
.error-state h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #c9d1d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state p,
|
||||
.error-state p {
|
||||
font-size: 0.875rem;
|
||||
color: #8b949e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* === New Session Form === */
|
||||
.new-session-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 8px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #c9d1d9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.1);
|
||||
}
|
||||
|
||||
/* === Buttons === */
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1f6feb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #388bfd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #30363d;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* === Loading === */
|
||||
.loading {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #30363d;
|
||||
border-top-color: #58a6ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Mobile Responsive === */
|
||||
@media (max-width: 640px) {
|
||||
.session-picker-modal {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.session-picker-content {
|
||||
max-height: 90vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.picker-header h2 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.picker-tabs {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.picker-tab {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picker-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.session-item,
|
||||
.project-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.session-icon,
|
||||
.project-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.session-title,
|
||||
.project-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Touch Targets === */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.picker-tab,
|
||||
.session-item,
|
||||
.project-item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-close,
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Print Styles === */
|
||||
@media print {
|
||||
.session-picker-modal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,32 @@ class SessionPicker {
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Check URL params first
|
||||
console.log('[SessionPicker] initialize() called');
|
||||
if (window.traceExecution) {
|
||||
window.traceExecution('session-picker', 'initialize() called', { pathname: window.location.pathname });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FIRST: Check URL path for session ID (route-based: /claude/ide/session/XXX)
|
||||
// This is the PRIMARY method for session attachment
|
||||
// ============================================================
|
||||
const pathname = window.location.pathname;
|
||||
const pathMatch = pathname.match(/\/claude\/ide\/session\/([^\/]+)$/);
|
||||
|
||||
if (pathMatch && pathMatch[1]) {
|
||||
const sessionId = pathMatch[1];
|
||||
console.log('[SessionPicker] Session ID in URL path, NOT showing picker:', sessionId);
|
||||
console.log('[SessionPicker] ide.js will handle attachment');
|
||||
if (window.traceExecution) {
|
||||
window.traceExecution('session-picker', 'URL path has session ID, NOT showing picker', { sessionId, pathname });
|
||||
}
|
||||
this.initialized = true;
|
||||
return; // Don't show picker, let ide.js handle it
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECOND: Check URL params (legacy format: ?session=XXX)
|
||||
// ============================================================
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sessionId = urlParams.get('session');
|
||||
const project = urlParams.get('project');
|
||||
@@ -28,6 +53,9 @@ class SessionPicker {
|
||||
if (sessionId) {
|
||||
// Load specific session
|
||||
console.log('[SessionPicker] Loading session from URL:', sessionId);
|
||||
if (window.traceExecution) {
|
||||
window.traceExecution('session-picker', 'Loading session from query param', { sessionId });
|
||||
}
|
||||
await this.loadSession(sessionId);
|
||||
this.initialized = true;
|
||||
return;
|
||||
@@ -36,12 +64,19 @@ class SessionPicker {
|
||||
if (project) {
|
||||
// Create or load session for project
|
||||
console.log('[SessionPicker] Project context:', project);
|
||||
if (window.traceExecution) {
|
||||
window.traceExecution('session-picker', 'Project context', { project });
|
||||
}
|
||||
await this.ensureSessionForProject(project);
|
||||
this.initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// No session or project - show picker
|
||||
console.log('[SessionPicker] No session found, showing picker modal');
|
||||
if (window.traceExecution) {
|
||||
window.traceExecution('session-picker', 'SHOWING PICKER MODAL', { pathname, search: window.location.search });
|
||||
}
|
||||
await this.showPicker();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user