code-graph-cli 3.0.3

Code intelligence engine for TypeScript/JavaScript/Rust/Python/Go — query the dependency graph instead of reading source files.
<script lang="ts">
  import { onMount, tick } from 'svelte';
  import ChatMessage from './ChatMessage.svelte';
  import ChatInput from './ChatInput.svelte';
  import ProviderSelector from './ProviderSelector.svelte';
  import { sendChatMessage, getAuthStatus } from '../api';
  import type { ChatMessageData, AuthStatus } from '../types';

  interface Props {
    onCitationClick?: (file: string, line: number) => void;
    onClose?: () => void;
    onCollapse?: () => void;
  }

  let { onCitationClick, onClose, onCollapse }: Props = $props();

  let messages: ChatMessageData[] = $state([]);
  let sessionId: string | null = $state(null);
  let isThinking: boolean = $state(false);
  let authStatus: AuthStatus | null = $state(null);
  let messagesEndEl: HTMLElement | null = $state(null);
  let messagesContainerEl: HTMLElement | null = $state(null);

  onMount(async () => {
    try {
      authStatus = await getAuthStatus();
    } catch {
      // Backend may not be running — show default state
      authStatus = null;
    }
  });

  async function scrollToBottom() {
    await tick();
    messagesEndEl?.scrollIntoView({ behavior: 'smooth' });
  }

  async function sendMessage(text: string) {
    if (!text.trim() || isThinking) return;
    if (!authStatus?.configured) return;

    // Add user message immediately
    messages = [...messages, { role: 'user', content: text }];
    isThinking = true;
    await scrollToBottom();

    try {
      const response = await sendChatMessage(text, sessionId ?? undefined, authStatus?.provider);
      sessionId = response.session_id;

      // Add assistant response with citations and tools
      messages = [
        ...messages,
        {
          role: 'assistant',
          content: response.answer,
          citations: response.citations,
          toolsUsed: response.tools_used,
        },
      ];
    } catch (e) {
      // Show error as a system-style message
      const errorText = e instanceof Error ? e.message : 'An error occurred while contacting the AI.';
      messages = [
        ...messages,
        {
          role: 'assistant',
          content: `**Error:** ${errorText}\n\nPlease check that the backend server is running and an LLM provider is configured.`,
          citations: [],
          toolsUsed: [],
        },
      ];
    } finally {
      isThinking = false;
      await scrollToBottom();
    }
  }

  function handleProviderChange(provider: string, model: string) {
    if (authStatus) {
      authStatus = { ...authStatus, provider: provider as 'claude' | 'ollama', model };
    }
  }

  let headerLabel = $derived.by(() => {
    if (!authStatus) return 'Chat';
    return 'Chat';
  });
</script>

<div class="chat-panel">
  <!-- Header -->
  <div class="chat-header">
    <div class="chat-header-left">
      <span class="chat-title">{headerLabel}</span>
      <ProviderSelector bind:authStatus onProviderChange={handleProviderChange} />
    </div>
    <div class="chat-header-actions">
      <button
        class="header-btn"
        onclick={onCollapse}
        title="Collapse chat panel"
        aria-label="Collapse chat panel"
      >
        <!-- Chevron-right icon to collapse -->
        <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <polyline points="9 18 15 12 9 6"/>
        </svg>
      </button>
      <button
        class="header-btn"
        onclick={onClose}
        title="Close chat panel"
        aria-label="Close chat panel"
      >
        <!-- X icon to close -->
        <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
          <line x1="18" y1="6" x2="6" y2="18"/>
          <line x1="6" y1="6" x2="18" y2="18"/>
        </svg>
      </button>
    </div>
  </div>

  <!-- Message list -->
  <div class="messages-container" bind:this={messagesContainerEl}>
    {#if messages.length === 0}
      <div class="empty-state">
        <div class="empty-icon">
          <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
            <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
          </svg>
        </div>
        <div class="empty-title">Ask about your codebase</div>
        <div class="empty-hint">Try: "Where is authentication handled?" or "What calls the main function?"</div>
      </div>
    {/if}

    {#each messages as message}
      <ChatMessage {message} onCitationClick={onCitationClick} />
    {/each}

    {#if isThinking}
      <div class="thinking-indicator">
        <div class="thinking-dots">
          <span></span>
          <span></span>
          <span></span>
        </div>
        <span class="thinking-text">Thinking...</span>
      </div>
    {/if}

    <div bind:this={messagesEndEl}></div>
  </div>

  <!-- Input at bottom -->
  <ChatInput {isThinking} disabled={!authStatus?.configured} onSend={sendMessage} />
</div>

<style>
  .chat-panel {
    display: flex;
    flex-direction: column;
    height: 100%;
    overflow: hidden;
    background: var(--color-bg-surface);
  }

  /* Header */
  .chat-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 8px 10px;
    border-bottom: 1px solid var(--color-border);
    flex-shrink: 0;
    min-height: 42px;
  }

  .chat-header-left {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
    flex: 1;
  }

  .chat-title {
    font-size: 12px;
    font-weight: 600;
    color: var(--color-text-muted);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    flex-shrink: 0;
  }

  .chat-header-actions {
    display: flex;
    align-items: center;
    gap: 2px;
    flex-shrink: 0;
  }

  .header-btn {
    width: 26px;
    height: 26px;
    border-radius: 4px;
    border: none;
    background: transparent;
    cursor: pointer;
    color: var(--color-text-muted);
    display: flex;
    align-items: center;
    justify-content: center;
    transition: color 100ms ease, background 100ms ease;
  }

  .header-btn:hover {
    color: var(--color-text-primary);
    background: rgba(255, 255, 255, 0.06);
  }

  /* Messages */
  .messages-container {
    flex: 1;
    overflow-y: auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }

  /* Empty state */
  .empty-state {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 32px 24px;
    gap: 10px;
    text-align: center;
  }

  .empty-icon {
    color: var(--color-text-muted);
    opacity: 0.4;
  }

  .empty-title {
    font-size: 14px;
    font-weight: 600;
    color: var(--color-text-primary);
    opacity: 0.7;
  }

  .empty-hint {
    font-size: 12px;
    color: var(--color-text-muted);
    line-height: 1.5;
    max-width: 240px;
  }

  /* Thinking indicator */
  .thinking-indicator {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 16px;
    color: var(--color-text-muted);
    font-size: 12px;
    font-style: italic;
  }

  .thinking-dots {
    display: flex;
    gap: 4px;
    align-items: center;
  }

  .thinking-dots span {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--color-text-muted);
    animation: pulse 1.2s ease-in-out infinite;
  }

  .thinking-dots span:nth-child(2) {
    animation-delay: 0.2s;
  }

  .thinking-dots span:nth-child(3) {
    animation-delay: 0.4s;
  }

  @keyframes pulse {
    0%, 80%, 100% {
      opacity: 0.3;
      transform: scale(0.8);
    }
    40% {
      opacity: 1;
      transform: scale(1);
    }
  }
</style>