i-self 0.4.3

Personal developer-companion CLI: scans your repos, indexes code semantically, watches your activity, and moves AI-agent sessions between tools (Claude Code, Aider, Goose, OpenAI Codex CLI, Continue.dev, OpenCode).
// i-self Dashboard JavaScript

// ---------------------------------------------------------------------------
// Auth bootstrap
//
// When the server is running with a bearer token configured, every API call
// must carry `Authorization: Bearer <token>`. The dashboard supports two
// ways of getting that token into the browser:
//
//   1. Open the dashboard with `?token=<token>` in the URL exactly once. The
//      block below grabs it, stashes it in localStorage, and strips it from
//      the URL via history.replaceState so it doesn't end up in browser
//      history or get sent in Referer headers.
//   2. Already have it in localStorage from a previous session — nothing to do.
//
// On loopback with no token configured, none of this matters — the server
// ignores Authorization entirely.
// ---------------------------------------------------------------------------
(function bootstrapAuth() {
    const params = new URLSearchParams(window.location.search);
    const fromQuery = params.get('token');
    if (fromQuery) {
        try { localStorage.setItem('iself_token', fromQuery); } catch (_) { /* private mode etc. */ }
        params.delete('token');
        const cleaned = params.toString();
        history.replaceState(
            null,
            '',
            window.location.pathname + (cleaned ? '?' + cleaned : '') + window.location.hash
        );
    }
})();

function authHeader() {
    try {
        const t = localStorage.getItem('iself_token');
        return t ? { 'Authorization': 'Bearer ' + t } : {};
    } catch (_) {
        return {};
    }
}

// Single-shot guard so a page-load with a dozen broken fetches doesn't pop a
// dozen prompts — only the first 401 prompts the user.
let promptOpen = false;

// Wrapper used in place of `fetch` for every API call. Adds the bearer header
// when a token is known. On 401:
//   1. Drop the stored token (it's wrong or stale).
//   2. Prompt the user to paste a fresh token.
//   3. If they provide one, save and reload — every initial fetch re-runs.
//   4. If they cancel, leave the page in its degraded state rather than
//      asking again on every subsequent fetch.
async function iselfFetch(url, options) {
    options = options || {};
    options.headers = Object.assign({}, options.headers || {}, authHeader());
    const resp = await fetch(url, options);
    if (resp.status === 401 && !promptOpen) {
        promptOpen = true;
        try { localStorage.removeItem('iself_token'); } catch (_) {}
        const newToken = window.prompt(
            'i-self API token required.\n\n' +
            'Paste your bearer token (from `ISELF_API_TOKEN` env or ~/.i-self/api_token):\n\n' +
            'Cancel to use the dashboard in degraded (no-API) mode.'
        );
        if (newToken && newToken.trim()) {
            try { localStorage.setItem('iself_token', newToken.trim()); } catch (_) {}
            window.location.reload();
        }
    }
    return resp;
}

// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
    btn.addEventListener('click', () => {
        // Remove active class from all tabs
        document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
        document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
        
        // Add active class to clicked tab
        btn.classList.add('active');
        const tabId = btn.getAttribute('data-tab');
        document.getElementById(tabId).classList.add('active');
    });
});

// Load initial data
document.addEventListener('DOMContentLoaded', () => {
    loadStats();
    loadProfile();
    loadTeams();
});

// Load stats
async function loadStats() {
    try {
        const response = await iselfFetch('/api/stats');
        const stats = await response.json();

        document.getElementById('profile-status').textContent =
            stats.has_profile ? '✓ Loaded' : '✗ Not found';

        // Show the active embedding backend. If we're on the hash-bucket
        // fallback, surface the warning banner so users notice they're in
        // degraded mode instead of getting silently mediocre search.
        const embedderEl = document.getElementById('embedder-id');
        const banner = document.getElementById('embedder-degraded-banner');
        if (embedderEl && stats.embedder) {
            embedderEl.textContent = stats.embedder;
            embedderEl.title = stats.embedder_is_semantic
                ? 'Real semantic embeddings'
                : 'Hash-bucket keyword fallback (not semantic)';
            if (banner) {
                banner.style.display = stats.embedder_is_semantic ? 'none' : 'block';
            }
        } else if (embedderEl) {
            embedderEl.textContent = '?';
        }
    } catch (error) {
        console.error('Failed to load stats:', error);
    }
}

// Load profile
async function loadProfile() {
    try {
        const response = await iselfFetch('/api/profile');
        if (!response.ok) return;
        
        const profile = await response.json();
        
        // Update repo count
        document.getElementById('repo-count').textContent = 
            profile.github.total_repositories;
        
        // Update language count
        document.getElementById('language-count').textContent = 
            profile.github.primary_languages.length;
        
        // Load search stats
        loadSearchStats();
        
    } catch (error) {
        console.error('Failed to load profile:', error);
    }
}

// Load search stats
async function loadSearchStats() {
    try {
        const response = await iselfFetch('/api/search/stats');
        if (!response.ok) {
            document.getElementById('embedding-count').textContent = '0';
            return;
        }
        
        const stats = await response.json();
        document.getElementById('embedding-count').textContent = 
            stats.total_embeddings || 0;
    } catch (error) {
        document.getElementById('embedding-count').textContent = '0';
    }
}

// Search functionality
document.getElementById('search-btn').addEventListener('click', performSearch);
document.getElementById('search-input').addEventListener('keypress', (e) => {
    if (e.key === 'Enter') performSearch();
});

async function performSearch() {
    const query = document.getElementById('search-input').value.trim();
    if (!query) return;
    
    const language = document.getElementById('language-filter').value;
    const resultsContainer = document.getElementById('search-results');
    
    resultsContainer.innerHTML = '<div class="loading"></div>';
    
    try {
        const response = await iselfFetch('/api/search', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ 
                query, 
                top_k: 10,
                language: language || undefined
            })
        });
        
        if (!response.ok) {
            throw new Error('Search failed');
        }
        
        const data = await response.json();
        displaySearchResults(data.results);
        
    } catch (error) {
        resultsContainer.innerHTML = `<p class="error">Error: ${error.message}</p>`;
    }
}

function displaySearchResults(results) {
    const container = document.getElementById('search-results');
    
    if (results.length === 0) {
        container.innerHTML = '<p>No results found.</p>';
        return;
    }
    
    container.innerHTML = results.map(result => `
        <div class="result-item">
            <div class="result-header">
                <span class="result-file">${result.source_file} (${result.language})</span>
                <span class="result-score">Score: ${(result.score * 100).toFixed(1)}%</span>
            </div>
            <div class="result-content">
                <pre><code>${escapeHtml(result.content.substring(0, 500))}${result.content.length > 500 ? '...' : ''}</code></pre>
            </div>
        </div>
    `).join('');
}

// AI Chat
document.getElementById('ai-send').addEventListener('click', sendMessage);
document.getElementById('ai-input').addEventListener('keypress', (e) => {
    if (e.key === 'Enter') sendMessage();
});

async function sendMessage() {
    const input = document.getElementById('ai-input');
    const message = input.value.trim();
    if (!message) return;
    
    // Add user message
    addMessage('user', message);
    input.value = '';
    
    try {
        const response = await iselfFetch('/api/ai/ask', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ question: message })
        });
        
        if (!response.ok) {
            throw new Error('AI request failed');
        }
        
        const data = await response.json();
        addMessage('assistant', data.answer);
        
    } catch (error) {
        addMessage('assistant', `Error: ${error.message}`);
    }
}

function addMessage(role, content) {
    const container = document.getElementById('chat-messages');
    const messageDiv = document.createElement('div');
    messageDiv.className = `message ${role}`;
    messageDiv.innerHTML = `<p>${escapeHtml(content)}</p>`;
    container.appendChild(messageDiv);
    container.scrollTop = container.scrollHeight;
}

// Quick action buttons
document.querySelectorAll('.action-btn').forEach(btn => {
    btn.addEventListener('click', () => {
        const action = btn.getAttribute('data-action');
        handleQuickAction(action);
    });
});

function handleQuickAction(action) {
    const input = document.getElementById('ai-input');
    
    switch(action) {
        case 'explain':
            input.value = 'Explain my typical coding style and patterns';
            break;
        case 'patterns':
            input.value = 'What are my most common design patterns?';
            break;
        case 'improve':
            input.value = 'How can I improve my code quality?';
            break;
        case 'generate':
            input.value = 'Generate a Rust function for error handling following my patterns';
            break;
    }
    
    sendMessage();
}

// Team functionality
async function loadTeams() {
    try {
        const response = await iselfFetch('/api/teams');
        if (!response.ok) return;
        
        const teams = await response.json();
        const select = document.getElementById('team-select');
        
        teams.forEach(team => {
            const option = document.createElement('option');
            option.value = team;
            option.textContent = team;
            select.appendChild(option);
        });
        
    } catch (error) {
        console.error('Failed to load teams:', error);
    }
}

document.getElementById('load-team').addEventListener('click', async () => {
    const teamName = document.getElementById('team-select').value;
    if (!teamName) return;
    
    try {
        const response = await iselfFetch(`/api/teams/${teamName}`);
        if (!response.ok) {
            throw new Error('Team not found');
        }
        
        const team = await response.json();
        displayTeam(team);
        
    } catch (error) {
        alert(`Error: ${error.message}`);
    }
});

function displayTeam(team) {
    document.getElementById('team-content').classList.remove('hidden');
    
    document.getElementById('team-member-count').textContent = 
        team.config.members.length;
    
    document.getElementById('skill-coverage').textContent = 
        Object.keys(team.language_coverage).length;
    
    document.getElementById('knowledge-silos').textContent = 
        team.knowledge_distribution.knowledge_silos.length;
    
    // Display recommendations
    const recsContainer = document.getElementById('team-recommendations');
    if (team.recommendations.length > 0) {
        recsContainer.innerHTML = team.recommendations.map(rec => `
            <div class="recommendation ${rec.priority.toLowerCase()}">
                <h4>${rec.title}</h4>
                <p>${rec.description}</p>
                <ul>
                    ${rec.action_items.map(item => `<li>${item}</li>`).join('')}
                </ul>
            </div>
        `).join('');
    } else {
        recsContainer.innerHTML = '<p>No recommendations at this time.</p>';
    }
}

// Utility functions
function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

// Auto-refresh stats every 30 seconds
setInterval(loadStats, 30000);