     1→"""
     2→Assistant Chat Session
     3→======================
     4→
     5→Manages read-only conversational assistant sessions for projects.
     6→The assistant can answer questions about the codebase and features
     7→but cannot modify any files.
     8→"""
     9→
    10→import json
    11→import logging
    12→import os
    13→import shutil
    14→import sys
    15→import threading
    16→from datetime import datetime
    17→from pathlib import Path
    18→from typing import AsyncGenerator, Optional
    19→
    20→from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
    21→
    22→from .assistant_database import (
    23→    add_message,
    24→    create_conversation,
    25→)
    26→
    27→logger = logging.getLogger(__name__)
    28→
    29→# Root directory of the project
    30→ROOT_DIR = Path(__file__).parent.parent.parent
    31→
    32→# Read-only feature MCP tools (no mark_passing, skip, create_bulk)
    33→READONLY_FEATURE_MCP_TOOLS = [
    34→    "mcp__features__feature_get_stats",
    35→    "mcp__features__feature_get_next",
    36→    "mcp__features__feature_get_for_regression",
    37→]
    38→
    39→# Read-only built-in tools (no Write, Edit, Bash)
    40→READONLY_BUILTIN_TOOLS = [
    41→    "Read",
    42→    "Glob",
    43→    "Grep",
    44→    "WebFetch",
    45→    "WebSearch",
    46→]
    47→
    48→
    49→def get_system_prompt(project_name: str, project_dir: Path) -> str:
    50→    """Generate the system prompt for the assistant with project context."""
    51→    # Try to load app_spec.txt for context
    52→    app_spec_content = ""
    53→    app_spec_path = project_dir / "prompts" / "app_spec.txt"
    54→    if app_spec_path.exists():
    55→        try:
    56→            app_spec_content = app_spec_path.read_text(encoding="utf-8")
    57→            # Truncate if too long
    58→            if len(app_spec_content) > 5000:
    59→                app_spec_content = app_spec_content[:5000] + "\n... (truncated)"
    60→        except Exception as e:
    61→            logger.warning(f"Failed to read app_spec.txt: {e}")
    62→
    63→    return f"""You are a helpful project assistant for the "{project_name}" project.
    64→
    65→Your role is to help users understand the codebase, answer questions about features, and explain how code works. You have READ-ONLY access to the project files.
    66→
    67→IMPORTANT: You CANNOT modify any files. You can only:
    68→- Read and analyze source code files
    69→- Search for patterns in the codebase
    70→- Look up documentation online
    71→- Check feature progress and status
    72→
    73→If the user asks you to make changes, politely explain that you're a read-only assistant and they should use the main coding agent for modifications.
    74→
    75→## Project Specification
    76→
    77→{app_spec_content if app_spec_content else "(No app specification found)"}
    78→
    79→## Available Tools
    80→
    81→You have access to these read-only tools:
    82→- **Read**: Read file contents
    83→- **Glob**: Find files by pattern (e.g., "**/*.tsx")
    84→- **Grep**: Search file contents with regex
    85→- **WebFetch/WebSearch**: Look up documentation online
    86→- **feature_get_stats**: Get feature completion progress
    87→- **feature_get_next**: See the next pending feature
    88→- **feature_get_for_regression**: See passing features
    89→
    90→## Guidelines
    91→
    92→1. Be concise and helpful
    93→2. When explaining code, reference specific file paths and line numbers
    94→3. Use the feature tools to answer questions about project progress
    95→4. Search the codebase to find relevant information before answering
    96→5. If you're unsure, say so rather than guessing"""
    97→
    98→
    99→class AssistantChatSession:
   100→    """
   101→    Manages a read-only assistant conversation for a project.
   102→
   103→    Uses Claude Opus 4.5 with only read-only tools enabled.
   104→    Persists conversation history to SQLite.
   105→    """
   106→
   107→    def __init__(self, project_name: str, project_dir: Path, conversation_id: Optional[int] = None):
   108→        """
   109→        Initialize the session.
   110→
   111→        Args:
   112→            project_name: Name of the project
   113→            project_dir: Absolute path to the project directory
   114→            conversation_id: Optional existing conversation ID to resume
   115→        """
   116→        self.project_name = project_name
   117→        self.project_dir = project_dir
   118→        self.conversation_id = conversation_id
   119→        self.client: Optional[ClaudeSDKClient] = None
   120→        self._client_entered: bool = False
   121→        self.created_at = datetime.now()
   122→
   123→    async def close(self) -> None:
   124→        """Clean up resources and close the Claude client."""
   125→        if self.client and self._client_entered:
   126→            try:
   127→                await self.client.__aexit__(None, None, None)
   128→            except Exception as e:
   129→                logger.warning(f"Error closing Claude client: {e}")
   130→            finally:
   131→                self._client_entered = False
   132→                self.client = None
   133→
   134→    async def start(self) -> AsyncGenerator[dict, None]:
   135→        """
   136→        Initialize session with the Claude client.
   137→
   138→        Creates a new conversation if none exists, then sends an initial greeting.
   139→        Yields message chunks as they stream in.
   140→        """
   141→        # Create a new conversation if we don't have one
   142→        if self.conversation_id is None:
   143→            conv = create_conversation(self.project_dir, self.project_name)
   144→            self.conversation_id = conv.id
   145→            yield {"type": "conversation_created", "conversation_id": self.conversation_id}
   146→
   147→        # Build permissions list for read-only access
   148→        permissions_list = [
   149→            "Read(./**)",
   150→            "Glob(./**)",
   151→            "Grep(./**)",
   152→            "WebFetch",
   153→            "WebSearch",
   154→            *READONLY_FEATURE_MCP_TOOLS,
   155→        ]
   156→
   157→        # Create security settings file
   158→        security_settings = {
   159→            "sandbox": {"enabled": False},  # No bash, so sandbox not needed
   160→            "permissions": {
   161→                "defaultMode": "bypassPermissions",  # Read-only, no dangerous ops
   162→                "allow": permissions_list,
   163→            },
   164→        }
   165→        settings_file = self.project_dir / ".claude_assistant_settings.json"
   166→        with open(settings_file, "w") as f:
   167→            json.dump(security_settings, f, indent=2)
   168→
   169→        # Build MCP servers config - only features MCP for read-only access
   170→        mcp_servers = {
   171→            "features": {
   172→                "command": sys.executable,
   173→                "args": ["-m", "mcp_server.feature_mcp"],
   174→                "env": {
   175→                    **os.environ,
   176→                    "PROJECT_DIR": str(self.project_dir.resolve()),
   177→                    "PYTHONPATH": str(ROOT_DIR.resolve()),
   178→                },
   179→            },
   180→        }
   181→
   182→        # Get system prompt with project context
   183→        system_prompt = get_system_prompt(self.project_name, self.project_dir)
   184→
   185→        # Use system Claude CLI
   186→        system_cli = shutil.which("claude")
   187→
   188→        try:
   189→            self.client = ClaudeSDKClient(
   190→                options=ClaudeAgentOptions(
   191→                    model="claude-opus-4-5-20251101",
   192→                    cli_path=system_cli,
   193→                    system_prompt=system_prompt,
   194→                    allowed_tools=[*READONLY_BUILTIN_TOOLS, *READONLY_FEATURE_MCP_TOOLS],
   195→                    mcp_servers=mcp_servers,
   196→                    permission_mode="bypassPermissions",
   197→                    max_turns=100,
   198→                    cwd=str(self.project_dir.resolve()),
   199→                    settings=str(settings_file.resolve()),
   200→                )
   201→            )
   202→            await self.client.__aenter__()
   203→            self._client_entered = True
   204→        except Exception as e:
   205→            logger.exception("Failed to create Claude client")
   206→            yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
   207→            return
   208→
   209→        # Send initial greeting
   210→        try:
   211→            greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"
   212→
   213→            # Store the greeting in the database
   214→            add_message(self.project_dir, self.conversation_id, "assistant", greeting)
   215→
   216→            yield {"type": "text", "content": greeting}
   217→            yield {"type": "response_done"}
   218→        except Exception as e:
   219→            logger.exception("Failed to send greeting")
   220→            yield {"type": "error", "content": f"Failed to start conversation: {str(e)}"}
   221→
   222→    async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
   223→        """
   224→        Send user message and stream Claude's response.
   225→
   226→        Args:
   227→            user_message: The user's message
   228→
   229→        Yields:
   230→            Message chunks:
   231→            - {"type": "text", "content": str}
   232→            - {"type": "tool_call", "tool": str, "input": dict}
   233→            - {"type": "response_done"}
   234→            - {"type": "error", "content": str}
   235→        """
   236→        if not self.client:
   237→            yield {"type": "error", "content": "Session not initialized. Call start() first."}
   238→            return
   239→
   240→        if self.conversation_id is None:
   241→            yield {"type": "error", "content": "No conversation ID set."}
   242→            return
   243→
   244→        # Store user message in database
   245→        add_message(self.project_dir, self.conversation_id, "user", user_message)
   246→
   247→        try:
   248→            async for chunk in self._query_claude(user_message):
   249→                yield chunk
   250→            yield {"type": "response_done"}
   251→        except Exception as e:
   252→            logger.exception("Error during Claude query")
   253→            yield {"type": "error", "content": f"Error: {str(e)}"}
   254→
   255→    async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
   256→        """
   257→        Internal method to query Claude and stream responses.
   258→
   259→        Handles tool calls and text responses.
   260→        """
   261→        if not self.client:
   262→            return
   263→
   264→        # Send message to Claude
   265→        await self.client.query(message)
   266→
   267→        full_response = ""
   268→
   269→        # Stream the response
   270→        async for msg in self.client.receive_response():
   271→            msg_type = type(msg).__name__
   272→
   273→            if msg_type == "AssistantMessage" and hasattr(msg, "content"):
   274→                for block in msg.content:
   275→                    block_type = type(block).__name__
   276→
   277→                    if block_type == "TextBlock" and hasattr(block, "text"):
   278→                        text = block.text
   279→                        if text:
   280→                            full_response += text
   281→                            yield {"type": "text", "content": text}
   282→
   283→                    elif block_type == "ToolUseBlock" and hasattr(block, "name"):
   284→                        tool_name = block.name
   285→                        tool_input = getattr(block, "input", {})
   286→                        yield {
   287→                            "type": "tool_call",
   288→                            "tool": tool_name,
   289→                            "input": tool_input,
   290→                        }
   291→
   292→        # Store the complete response in the database
   293→        if full_response and self.conversation_id:
   294→            add_message(self.project_dir, self.conversation_id, "assistant", full_response)
   295→
   296→    def get_conversation_id(self) -> Optional[int]:
   297→        """Get the current conversation ID."""
   298→        return self.conversation_id
   299→
   300→
   301→# Session registry with thread safety
   302→_sessions: dict[str, AssistantChatSession] = {}
   303→_sessions_lock = threading.Lock()
   304→
   305→
   306→def get_session(project_name: str) -> Optional[AssistantChatSession]:
   307→    """Get an existing session for a project."""
   308→    with _sessions_lock:
   309→        return _sessions.get(project_name)
   310→
   311→
   312→async def create_session(
   313→    project_name: str,
   314→    project_dir: Path,
   315→    conversation_id: Optional[int] = None
   316→) -> AssistantChatSession:
   317→    """
   318→    Create a new session for a project, closing any existing one.
   319→
   320→    Args:
   321→        project_name: Name of the project
   322→        project_dir: Absolute path to the project directory
   323→        conversation_id: Optional conversation ID to resume
   324→    """
   325→    old_session: Optional[AssistantChatSession] = None
   326→
   327→    with _sessions_lock:
   328→        old_session = _sessions.pop(project_name, None)
   329→        session = AssistantChatSession(project_name, project_dir, conversation_id)
   330→        _sessions[project_name] = session
   331→
   332→    if old_session:
   333→        try:
   334→            await old_session.close()
   335→        except Exception as e:
   336→            logger.warning(f"Error closing old session for {project_name}: {e}")
   337→
   338→    return session
   339→
   340→
   341→async def remove_session(project_name: str) -> None:
   342→    """Remove and close a session."""
   343→    session: Optional[AssistantChatSession] = None
   344→
   345→    with _sessions_lock:
   346→        session = _sessions.pop(project_name, None)
   347→
   348→    if session:
   349→        try:
   350→            await session.close()
   351→        except Exception as e:
   352→            logger.warning(f"Error closing session for {project_name}: {e}")
   353→
   354→
   355→def list_sessions() -> list[str]:
   356→    """List all active session project names."""
   357→    with _sessions_lock:
   358→        return list(_sessions.keys())
   359→
   360→
   361→async def cleanup_all_sessions() -> None:
   362→    """Close all active sessions. Called on server shutdown."""
   363→    sessions_to_close: list[AssistantChatSession] = []
   364→
   365→    with _sessions_lock:
   366→        sessions_to_close = list(_sessions.values())
   367→        _sessions.clear()
   368→
   369→    for session in sessions_to_close:
   370→        try:
   371→            await session.close()
   372→        except Exception as e:
   373→            logger.warning(f"Error closing session {session.project_name}: {e}")
   374→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Assistant Chat Session
======================

Manages read-only conversational assistant sessions for projects.
The assistant can answer questions about the codebase and features
but cannot modify any files.
"""

import json
import logging
import os
import shutil
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator, Optional

from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient

from .assistant_database import (
    add_message,
    create_conversation,
)

logger = logging.getLogger(__name__)

# Root directory of the project
ROOT_DIR = Path(__file__).parent.parent.parent

# Read-only feature MCP tools (no mark_passing, skip, create_bulk)
READONLY_FEATURE_MCP_TOOLS = [
    "mcp__features__feature_get_stats",
    "mcp__features__feature_get_next",
    "mcp__features__feature_get_for_regression",
]

# Read-only built-in tools (no Write, Edit, Bash)
READONLY_BUILTIN_TOOLS = [
    "Read",
    "Glob",
    "Grep",
    "WebFetch",
    "WebSearch",
]

def get_system_prompt(project_name: str, project_dir: Path) -> str:
    """Generate the system prompt for the assistant with project context."""
    # Try to load app_spec.txt for context
    app_spec_content = ""
    app_spec_path = project_dir / "prompts" / "app_spec.txt"
    if app_spec_path.exists():
        try:
            app_spec_content = app_spec_path.read_text(encoding="utf-8")
            # Truncate if too long
            if len(app_spec_content) > 5000:
                app_spec_content = app_spec_content[:5000] + "\n... (truncated)"
        except Exception as e:
            logger.warning(f"Failed to read app_spec.txt: {e}")

    return f"""You are a helpful project assistant for the "{project_name}" project.

Your role is to help users understand the codebase, answer questions about features, and explain how code works. You have READ-ONLY access to the project files.

IMPORTANT: You CANNOT modify any files. You can only:
- Read and analyze source code files
- Search for patterns in the codebase
- Look up documentation online
- Check feature progress and status

If the user asks you to make changes, politely explain that you're a read-only assistant and they should use the main coding agent for modifications.

## Project Specification

{app_spec_content if app_spec_content else "(No app specification found)"}

## Available Tools

You have access to these read-only tools:
- **Read**: Read file contents
- **Glob**: Find files by pattern (e.g., "**/*.tsx")
- **Grep**: Search file contents with regex
- **WebFetch/WebSearch**: Look up documentation online
- **feature_get_stats**: Get feature completion progress
- **feature_get_next**: See the next pending feature
- **feature_get_for_regression**: See passing features

## Guidelines

1. Be concise and helpful
2. When explaining code, reference specific file paths and line numbers
3. Use the feature tools to answer questions about project progress
4. Search the codebase to find relevant information before answering
5. If you're unsure, say so rather than guessing"""

class AssistantChatSession:
    """
    Manages a read-only assistant conversation for a project.

    Uses Claude Opus 4.5 with only read-only tools enabled.
    Persists conversation history to SQLite.
    """

    def __init__(self, project_name: str, project_dir: Path, conversation_id: Optional[int] = None):
        """
        Initialize the session.

        Args:
            project_name: Name of the project
            project_dir: Absolute path to the project directory
            conversation_id: Optional existing conversation ID to resume
        """
        self.project_name = project_name
        self.project_dir = project_dir
        self.conversation_id = conversation_id
        self.client: Optional[ClaudeSDKClient] = None
        self._client_entered: bool = False
        self.created_at = datetime.now()

    async def close(self) -> None:
        """Clean up resources and close the Claude client."""
        if self.client and self._client_entered:
            try:
                await self.client.__aexit__(None, None, None)
            except Exception as e:
                logger.warning(f"Error closing Claude client: {e}")
            finally:
                self._client_entered = False
                self.client = None

    async def start(self) -> AsyncGenerator[dict, None]:
        """
        Initialize session with the Claude client.

        Creates a new conversation if none exists, then sends an initial greeting.
        Yields message chunks as they stream in.
        """
        # Create a new conversation if we don't have one
        if self.conversation_id is None:
            conv = create_conversation(self.project_dir, self.project_name)
            self.conversation_id = conv.id
            yield {"type": "conversation_created", "conversation_id": self.conversation_id}

        # Build permissions list for read-only access
        permissions_list = [
            "Read(./**)",
            "Glob(./**)",
            "Grep(./**)",
            "WebFetch",
            "WebSearch",
            *READONLY_FEATURE_MCP_TOOLS,
        ]

        # Create security settings file
        security_settings = {
            "sandbox": {"enabled": False},  # No bash, so sandbox not needed
            "permissions": {
                "defaultMode": "bypassPermissions",  # Read-only, no dangerous ops
                "allow": permissions_list,
            },
        }
        settings_file = self.project_dir / ".claude_assistant_settings.json"
        with open(settings_file, "w") as f:
            json.dump(security_settings, f, indent=2)

        # Build MCP servers config - only features MCP for read-only access
        mcp_servers = {
            "features": {
                "command": sys.executable,
                "args": ["-m", "mcp_server.feature_mcp"],
                "env": {
                    **os.environ,
                    "PROJECT_DIR": str(self.project_dir.resolve()),
                    "PYTHONPATH": str(ROOT_DIR.resolve()),
                },
            },
        }

        # Get system prompt with project context
        system_prompt = get_system_prompt(self.project_name, self.project_dir)

        # Use system Claude CLI
        system_cli = shutil.which("claude")

        try:
            self.client = ClaudeSDKClient(
                options=ClaudeAgentOptions(
                    model="claude-opus-4-5-20251101",
                    cli_path=system_cli,
                    system_prompt=system_prompt,
                    allowed_tools=[*READONLY_BUILTIN_TOOLS, *READONLY_FEATURE_MCP_TOOLS],
                    mcp_servers=mcp_servers,
                    permission_mode="bypassPermissions",
                    max_turns=100,
                    cwd=str(self.project_dir.resolve()),
                    settings=str(settings_file.resolve()),
                )
            )
            await self.client.__aenter__()
            self._client_entered = True
        except Exception as e:
            logger.exception("Failed to create Claude client")
            yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
            return

        # Send initial greeting
        try:
            greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"

            # Store the greeting in the database
            add_message(self.project_dir, self.conversation_id, "assistant", greeting)

            yield {"type": "text", "content": greeting}
            yield {"type": "response_done"}
        except Exception as e:
            logger.exception("Failed to send greeting")
            yield {"type": "error", "content": f"Failed to start conversation: {str(e)}"}

    async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
        """
        Send user message and stream Claude's response.

        Args:
            user_message: The user's message

        Yields:
            Message chunks:
            - {"type": "text", "content": str}
            - {"type": "tool_call", "tool": str, "input": dict}
            - {"type": "response_done"}
            - {"type": "error", "content": str}
        """
        if not self.client:
            yield {"type": "error", "content": "Session not initialized. Call start() first."}
            return

        if self.conversation_id is None:
            yield {"type": "error", "content": "No conversation ID set."}
            return

        # Store user message in database
        add_message(self.project_dir, self.conversation_id, "user", user_message)

        try:
            async for chunk in self._query_claude(user_message):
                yield chunk
            yield {"type": "response_done"}
        except Exception as e:
            logger.exception("Error during Claude query")
            yield {"type": "error", "content": f"Error: {str(e)}"}

    async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
        """
        Internal method to query Claude and stream responses.

        Handles tool calls and text responses.
        """
        if not self.client:
            return

        # Send message to Claude
        await self.client.query(message)

        full_response = ""

        # Stream the response
        async for msg in self.client.receive_response():
            msg_type = type(msg).__name__

            if msg_type == "AssistantMessage" and hasattr(msg, "content"):
                for block in msg.content:
                    block_type = type(block).__name__

                    if block_type == "TextBlock" and hasattr(block, "text"):
                        text = block.text
                        if text:
                            full_response += text
                            yield {"type": "text", "content": text}

                    elif block_type == "ToolUseBlock" and hasattr(block, "name"):
                        tool_name = block.name
                        tool_input = getattr(block, "input", {})
                        yield {
                            "type": "tool_call",
                            "tool": tool_name,
                            "input": tool_input,
                        }

        # Store the complete response in the database
        if full_response and self.conversation_id:
            add_message(self.project_dir, self.conversation_id, "assistant", full_response)

    def get_conversation_id(self) -> Optional[int]:
        """Get the current conversation ID."""
        return self.conversation_id

# Session registry with thread safety
_sessions: dict[str, AssistantChatSession] = {}
_sessions_lock = threading.Lock()

def get_session(project_name: str) -> Optional[AssistantChatSession]:
    """Get an existing session for a project."""
    with _sessions_lock:
        return _sessions.get(project_name)

async def create_session(
    project_name: str,
    project_dir: Path,
    conversation_id: Optional[int] = None
) -> AssistantChatSession:
    """
    Create a new session for a project, closing any existing one.

    Args:
        project_name: Name of the project
        project_dir: Absolute path to the project directory
        conversation_id: Optional conversation ID to resume
    """
    old_session: Optional[AssistantChatSession] = None

    with _sessions_lock:
        old_session = _sessions.pop(project_name, None)
        session = AssistantChatSession(project_name, project_dir, conversation_id)
        _sessions[project_name] = session

    if old_session:
        try:
            await old_session.close()
        except Exception as e:
            logger.warning(f"Error closing old session for {project_name}: {e}")

    return session

async def remove_session(project_name: str) -> None:
    """Remove and close a session."""
    session: Optional[AssistantChatSession] = None

    with _sessions_lock:
        session = _sessions.pop(project_name, None)

    if session:
        try:
            await session.close()
        except Exception as e:
            logger.warning(f"Error closing session for {project_name}: {e}")

def list_sessions() -> list[str]:
    """List all active session project names."""
    with _sessions_lock:
        return list(_sessions.keys())

async def cleanup_all_sessions() -> None:
    """Close all active sessions. Called on server shutdown."""
    sessions_to_close: list[AssistantChatSession] = []

    with _sessions_lock:
        sessions_to_close = list(_sessions.values())
        _sessions.clear()

    for session in sessions_to_close:
        try:
            await session.close()
        except Exception as e:
            logger.warning(f"Error closing session {session.project_name}: {e}")


Now let me check the UI component structure and look for where cost/usage could be displayed:


Bash

find "C:\Users\MikeSwanson\claude-projects\ACPP\AutoCoderPlus4\..\..\Autocode-remix\Autocoder-2.0\ui\src" -name "*.tsx" -o -name "*.ts" | head -20


1→import { useState, useEffect, useCallback } from 'react'
     2→import { useProjects, useFeatures, useAgentStatus } from './hooks/useProjects'
     3→import { useProjectWebSocket } from './hooks/useWebSocket'
     4→import { useFeatureSound } from './hooks/useFeatureSound'
     5→import { useCelebration } from './hooks/useCelebration'
     6→
     7→const STORAGE_KEY = 'autocoder-selected-project'
     8→import { ProjectSelector } from './components/ProjectSelector'
     9→import { KanbanBoard } from './components/KanbanBoard'
    10→import { AgentControl } from './components/AgentControl'
    11→import { ProgressDashboard } from './components/ProgressDashboard'
    12→import { SetupWizard } from './components/SetupWizard'
    13→import { AddFeatureForm } from './components/AddFeatureForm'
    14→import { FeatureModal } from './components/FeatureModal'
    15→import { DebugLogViewer } from './components/DebugLogViewer'
    16→import { AgentThought } from './components/AgentThought'
    17→import { AssistantFAB } from './components/AssistantFAB'
    18→import { AssistantPanel } from './components/AssistantPanel'
    19→import { GitPanel } from './components/GitPanel'
    20→import { TerminalChat } from './components/TerminalChat'
    21→import { ReferenceFoldersPanel } from './components/ReferenceFoldersPanel'
    22→import { Plus, Loader2, GitBranch, Terminal, X, FolderSearch } from 'lucide-react'
    23→import type { Feature } from './lib/types'
    24→
    25→function App() {
    26→  // Initialize selected project from localStorage
    27→  const [selectedProject, setSelectedProject] = useState<string | null>(() => {
    28→    try {
    29→      return localStorage.getItem(STORAGE_KEY)
    30→    } catch {
    31→      return null
    32→    }
    33→  })
    34→  const [showAddFeature, setShowAddFeature] = useState(false)
    35→  const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null)
    36→  const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
    37→  const [debugOpen, setDebugOpen] = useState(false)
    38→  const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
    39→  const [assistantOpen, setAssistantOpen] = useState(false)
    40→  const [gitPanelOpen, setGitPanelOpen] = useState(false)
    41→  const [terminalOpen, setTerminalOpen] = useState(false)
    42→  const [referencesOpen, setReferencesOpen] = useState(false)
    43→
    44→  const { data: projects, isLoading: projectsLoading } = useProjects()
    45→  const { data: features } = useFeatures(selectedProject)
    46→  const { data: agentStatusData } = useAgentStatus(selectedProject)
    47→  const wsState = useProjectWebSocket(selectedProject)
    48→
    49→  // Play sounds when features move between columns
    50→  useFeatureSound(features)
    51→
    52→  // Celebrate when all features are complete
    53→  useCelebration(features, selectedProject)
    54→
    55→  // Persist selected project to localStorage
    56→  const handleSelectProject = useCallback((project: string | null) => {
    57→    setSelectedProject(project)
    58→    try {
    59→      if (project) {
    60→        localStorage.setItem(STORAGE_KEY, project)
    61→      } else {
    62→        localStorage.removeItem(STORAGE_KEY)
    63→      }
    64→    } catch {
    65→      // localStorage not available
    66→    }
    67→  }, [])
    68→
    69→  // Validate stored project exists (clear if project was deleted)
    70→  useEffect(() => {
    71→    if (selectedProject && projects && !projects.some(p => p.name === selectedProject)) {
    72→      handleSelectProject(null)
    73→    }
    74→  }, [selectedProject, projects, handleSelectProject])
    75→
    76→  // Keyboard shortcuts
    77→  useEffect(() => {
    78→    const handleKeyDown = (e: KeyboardEvent) => {
    79→      // Ignore if user is typing in an input
    80→      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
    81→        return
    82→      }
    83→
    84→      // D : Toggle debug window
    85→      if (e.key === 'd' || e.key === 'D') {
    86→        e.preventDefault()
    87→        setDebugOpen(prev => !prev)
    88→      }
    89→
    90→      // N : Add new feature (when project selected)
    91→      if ((e.key === 'n' || e.key === 'N') && selectedProject) {
    92→        e.preventDefault()
    93→        setShowAddFeature(true)
    94→      }
    95→
    96→      // A : Toggle assistant panel (when project selected)
    97→      if ((e.key === 'a' || e.key === 'A') && selectedProject) {
    98→        e.preventDefault()
    99→        setAssistantOpen(prev => !prev)
   100→      }
   101→
   102→      // G : Toggle git panel (when project selected)
   103→      if ((e.key === 'g' || e.key === 'G') && selectedProject) {
   104→        e.preventDefault()
   105→        setGitPanelOpen(prev => !prev)
   106→      }
   107→
   108→      // T : Toggle terminal panel (when project selected)
   109→      if ((e.key === 't' || e.key === 'T') && selectedProject) {
   110→        e.preventDefault()
   111→        setTerminalOpen(prev => !prev)
   112→      }
   113→
   114→      // R : Toggle references panel (when project selected)
   115→      if ((e.key === 'r' || e.key === 'R') && selectedProject) {
   116→        e.preventDefault()
   117→        setReferencesOpen(prev => !prev)
   118→      }
   119→
   120→      // Escape : Close modals
   121→      if (e.key === 'Escape') {
   122→        if (referencesOpen) {
   123→          setReferencesOpen(false)
   124→        } else if (terminalOpen) {
   125→          setTerminalOpen(false)
   126→        } else if (gitPanelOpen) {
   127→          setGitPanelOpen(false)
   128→        } else if (assistantOpen) {
   129→          setAssistantOpen(false)
   130→        } else if (showAddFeature) {
   131→          setShowAddFeature(false)
   132→        } else if (selectedFeature) {
   133→          setSelectedFeature(null)
   134→        } else if (debugOpen) {
   135→          setDebugOpen(false)
   136→        }
   137→      }
   138→    }
   139→
   140→    window.addEventListener('keydown', handleKeyDown)
   141→    return () => window.removeEventListener('keydown', handleKeyDown)
   142→  }, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen, gitPanelOpen, terminalOpen, referencesOpen])
   143→
   144→  // Combine WebSocket progress with feature data
   145→  const progress = wsState.progress.total > 0 ? wsState.progress : {
   146→    passing: features?.done.length ?? 0,
   147→    total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
   148→    percentage: 0,
   149→  }
   150→
   151→  if (progress.total > 0 && progress.percentage === 0) {
   152→    progress.percentage = Math.round((progress.passing / progress.total) * 100 * 10) / 10
   153→  }
   154→
   155→  if (!setupComplete) {
   156→    return <SetupWizard onComplete={() => setSetupComplete(true)} />
   157→  }
   158→
   159→  return (
   160→

   161→      {/* Header */}
   162→
   163→

   164→

   165→            {/* Logo and Title */}
   166→

   167→              AutoCoder
   168→

   169→
   170→            {/* Controls */}
   171→

   172→              <ProjectSelector
   173→                projects={projects ?? []}
   174→                selectedProject={selectedProject}
   175→                onSelectProject={handleSelectProject}
   176→                isLoading={projectsLoading}
   177→              />
   178→
   179→              {selectedProject && (
   180→                <>
   181→                  <button
   182→                    onClick={() => setShowAddFeature(true)}
   183→                    className="neo-btn neo-btn-primary text-sm"
   184→                    title="Press N"
   185→                  >
   186→                    <Plus size={18} />
   187→                    Add Feature
   188→
   189→                      N
   190→
   191→                  </button>
   192→
   193→                  <button
   194→                    onClick={() => setTerminalOpen(true)}
   195→                    className="neo-btn text-sm"
   196→                    title="Press T"
   197→                  >
   198→                    <Terminal size={18} />
   199→                    Terminal
   200→
   201→                      T
   202→
   203→                  </button>
   204→
   205→                  <button
   206→                    onClick={() => setReferencesOpen(true)}
   207→                    className="neo-btn text-sm"
   208→                    title="Press R"
   209→                  >
   210→                    <FolderSearch size={18} />
   211→                    Refs
   212→
   213→                      R
   214→
   215→                  </button>
   216→
   217→                  <button
   218→                    onClick={() => setGitPanelOpen(true)}
   219→                    className="neo-btn text-sm"
   220→                    title="Press G"
   221→                  >
   222→                    <GitBranch size={18} />
   223→                    Git
   224→
   225→                      G
   226→
   227→                  </button>
   228→
   229→                  <AgentControl
   230→                    projectName={selectedProject}
   231→                    status={wsState.agentStatus}
   232→                    yoloMode={agentStatusData?.yolo_mode ?? false}
   233→                  />
   234→                </>
   235→              )}
   236→

   237→

   238→

   239→
   240→
   241→      {/* Main Content */}
   242→
   246→        {!selectedProject ? (
   247→

   248→

   249→              Welcome to AutoCoder
   250→

   251→

   252→              Select a project from the dropdown above or create a new one to get started.
   253→

   254→

   255→        ) : (
   256→

   257→            {/* Progress Dashboard */}
   258→            <ProgressDashboard
   259→              passing={progress.passing}
   260→              total={progress.total}
   261→              percentage={progress.percentage}
   262→              isConnected={wsState.isConnected}
   263→            />
   264→
   265→            {/* Agent Thought - shows latest agent narrative */}
   266→            <AgentThought
   267→              logs={wsState.logs}
   268→              agentStatus={wsState.agentStatus}
   269→            />
   270→
   271→            {/* Initializing Features State - show when agent is running but no features yet */}
   272→            {features &&
   273→             features.pending.length === 0 &&
   274→             features.in_progress.length === 0 &&
   275→             features.done.length === 0 &&
   276→             wsState.agentStatus === 'running' && (
   277→

   278→                <Loader2 size={32} className="animate-spin mx-auto mb-4 text-[var(--color-neo-progress)]" />
   279→

   280→                  Initializing Features...
   281→

   282→

   283→                  The agent is reading your spec and creating features. This may take a moment.
   284→

   285→

   286→            )}
   287→
   288→            {/* Kanban Board */}
   289→            <KanbanBoard
   290→              features={features}
   291→              onFeatureClick={setSelectedFeature}
   292→            />
   293→

   294→        )}
   295→
   296→
   297→      {/* Add Feature Modal */}
   298→      {showAddFeature && selectedProject && (
   299→        <AddFeatureForm
   300→          projectName={selectedProject}
   301→          onClose={() => setShowAddFeature(false)}
   302→        />
   303→      )}
   304→
   305→      {/* Feature Detail Modal */}
   306→      {selectedFeature && selectedProject && (
   307→        <FeatureModal
   308→          feature={selectedFeature}
   309→          projectName={selectedProject}
   310→          onClose={() => setSelectedFeature(null)}
   311→        />
   312→      )}
   313→
   314→      {/* Debug Log Viewer - fixed to bottom */}
   315→      {selectedProject && (
   316→        <DebugLogViewer
   317→          logs={wsState.logs}
   318→          isOpen={debugOpen}
   319→          onToggle={() => setDebugOpen(!debugOpen)}
   320→          onClear={wsState.clearLogs}
   321→          onHeightChange={setDebugPanelHeight}
   322→        />
   323→      )}
   324→
   325→      {/* Assistant FAB and Panel */}
   326→      {selectedProject && (
   327→        <>
   328→          <AssistantFAB
   329→            onClick={() => setAssistantOpen(!assistantOpen)}
   330→            isOpen={assistantOpen}
   331→          />
   332→          <AssistantPanel
   333→            projectName={selectedProject}
   334→            isOpen={assistantOpen}
   335→            onClose={() => setAssistantOpen(false)}
   336→          />
   337→        </>
   338→      )}
   339→
   340→      {/* Git Panel */}
   341→      {selectedProject && (
   342→        <GitPanel
   343→          projectName={selectedProject}
   344→          isOpen={gitPanelOpen}
   345→          onClose={() => setGitPanelOpen(false)}
   346→        />
   347→      )}
   348→
   349→      {/* Terminal Panel */}
   350→      {selectedProject && (
   351→        <>
   352→          {/* Backdrop */}
   353→          {terminalOpen && (
   354→
 setTerminalOpen(false)}
   357→              aria-hidden="true"
   358→            />
   359→          )}
   360→          {/* Panel */}
   361→          <div
   362→            className={`
   363→              fixed right-0 top-0 bottom-0 z-50
   364→              w-[600px] max-w-[90vw]
   365→              bg-white
   366→              border-l-4 border-[var(--color-neo-border)]
   367→              shadow-[-8px_0_0px_rgba(0,0,0,1)]
   368→              transform transition-transform duration-300 ease-out
   369→              flex flex-col
   370→              ${terminalOpen ? 'translate-x-0' : 'translate-x-full'}
   371→            `}
   372→            role="dialog"
   373→            aria-label="Claude Terminal"
   374→            aria-hidden={!terminalOpen}
   375→          >
   376→            {/* Header */}
   377→

   378→

   379→

   380→                  <Terminal size={18} />
   381→

   382→

   383→
Claude Terminal

   384→
{selectedProject}

   385→

   386→

   387→              <button
   388→                onClick={() => setTerminalOpen(false)}
   389→                className="neo-btn neo-btn-ghost p-2 bg-white/20 border-white/40 hover:bg-white/30 text-white"
   390→                title="Close Terminal (Press T)"
   391→              >
   392→                <X size={18} />
   393→              </button>
   394→

   395→
   396→            {/* Terminal Chat area */}
   397→

   398→              {terminalOpen && <TerminalChat projectName={selectedProject} onClose={() => setTerminalOpen(false)} />}
   399→

   400→

   401→        </>
   402→      )}
   403→
   404→      {/* Reference Folders Panel */}
   405→      {selectedProject && (
   406→        <ReferenceFoldersPanel
   407→          projectName={selectedProject}
   408→          isOpen={referencesOpen}
   409→          onClose={() => setReferencesOpen(false)}
   410→        />
   411→      )}
   412→

   413→  )
   414→}
   415→
   416→export default App
   417→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import { useState, useEffect, useCallback } from 'react'
import { useProjects, useFeatures, useAgentStatus } from './hooks/useProjects'
import { useProjectWebSocket } from './hooks/useWebSocket'
import { useFeatureSound } from './hooks/useFeatureSound'
import { useCelebration } from './hooks/useCelebration'

const STORAGE_KEY = 'autocoder-selected-project'
import { ProjectSelector } from './components/ProjectSelector'
import { KanbanBoard } from './components/KanbanBoard'
import { AgentControl } from './components/AgentControl'
import { ProgressDashboard } from './components/ProgressDashboard'
import { SetupWizard } from './components/SetupWizard'
import { AddFeatureForm } from './components/AddFeatureForm'
import { FeatureModal } from './components/FeatureModal'
import { DebugLogViewer } from './components/DebugLogViewer'
import { AgentThought } from './components/AgentThought'
import { AssistantFAB } from './components/AssistantFAB'
import { AssistantPanel } from './components/AssistantPanel'
import { GitPanel } from './components/GitPanel'
import { TerminalChat } from './components/TerminalChat'
import { ReferenceFoldersPanel } from './components/ReferenceFoldersPanel'
import { Plus, Loader2, GitBranch, Terminal, X, FolderSearch } from 'lucide-react'
import type { Feature } from './lib/types'

function App() {
  // Initialize selected project from localStorage
  const [selectedProject, setSelectedProject] = useState<string | null>(() => {
    try {
      return localStorage.getItem(STORAGE_KEY)
    } catch {
      return null
    }
  })
  const [showAddFeature, setShowAddFeature] = useState(false)
  const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null)
  const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
  const [debugOpen, setDebugOpen] = useState(false)
  const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
  const [assistantOpen, setAssistantOpen] = useState(false)
  const [gitPanelOpen, setGitPanelOpen] = useState(false)
  const [terminalOpen, setTerminalOpen] = useState(false)
  const [referencesOpen, setReferencesOpen] = useState(false)

  const { data: projects, isLoading: projectsLoading } = useProjects()
  const { data: features } = useFeatures(selectedProject)
  const { data: agentStatusData } = useAgentStatus(selectedProject)
  const wsState = useProjectWebSocket(selectedProject)

  // Play sounds when features move between columns
  useFeatureSound(features)

  // Celebrate when all features are complete
  useCelebration(features, selectedProject)

  // Persist selected project to localStorage
  const handleSelectProject = useCallback((project: string | null) => {
    setSelectedProject(project)
    try {
      if (project) {
        localStorage.setItem(STORAGE_KEY, project)
      } else {
        localStorage.removeItem(STORAGE_KEY)
      }
    } catch {
      // localStorage not available
    }
  }, [])

  // Validate stored project exists (clear if project was deleted)
  useEffect(() => {
    if (selectedProject && projects && !projects.some(p => p.name === selectedProject)) {
      handleSelectProject(null)
    }
  }, [selectedProject, projects, handleSelectProject])

  // Keyboard shortcuts
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Ignore if user is typing in an input
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
        return
      }

      // D : Toggle debug window
      if (e.key === 'd' || e.key === 'D') {
        e.preventDefault()
        setDebugOpen(prev => !prev)
      }

      // N : Add new feature (when project selected)
      if ((e.key === 'n' || e.key === 'N') && selectedProject) {
        e.preventDefault()
        setShowAddFeature(true)
      }

      // A : Toggle assistant panel (when project selected)
      if ((e.key === 'a' || e.key === 'A') && selectedProject) {
        e.preventDefault()
        setAssistantOpen(prev => !prev)
      }

      // G : Toggle git panel (when project selected)
      if ((e.key === 'g' || e.key === 'G') && selectedProject) {
        e.preventDefault()
        setGitPanelOpen(prev => !prev)
      }

      // T : Toggle terminal panel (when project selected)
      if ((e.key === 't' || e.key === 'T') && selectedProject) {
        e.preventDefault()
        setTerminalOpen(prev => !prev)
      }

      // R : Toggle references panel (when project selected)
      if ((e.key === 'r' || e.key === 'R') && selectedProject) {
        e.preventDefault()
        setReferencesOpen(prev => !prev)
      }

      // Escape : Close modals
      if (e.key === 'Escape') {
        if (referencesOpen) {
          setReferencesOpen(false)
        } else if (terminalOpen) {
          setTerminalOpen(false)
        } else if (gitPanelOpen) {
          setGitPanelOpen(false)
        } else if (assistantOpen) {
          setAssistantOpen(false)
        } else if (showAddFeature) {
          setShowAddFeature(false)
        } else if (selectedFeature) {
          setSelectedFeature(null)
        } else if (debugOpen) {
          setDebugOpen(false)
        }
      }
    }

    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen, gitPanelOpen, terminalOpen, referencesOpen])

  // Combine WebSocket progress with feature data
  const progress = wsState.progress.total > 0 ? wsState.progress : {
    passing: features?.done.length ?? 0,
    total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
    percentage: 0,
  }

  if (progress.total > 0 && progress.percentage === 0) {
    progress.percentage = Math.round((progress.passing / progress.total) * 100 * 10) / 10
  }

  if (!setupComplete) {
    return <SetupWizard onComplete={() => setSetupComplete(true)} />
  }

  return (

      {/* Header */}

            {/* Logo and Title */}

              AutoCoder

            {/* Controls */}

              <ProjectSelector
                projects={projects ?? []}
                selectedProject={selectedProject}
                onSelectProject={handleSelectProject}
                isLoading={projectsLoading}
              />

              {selectedProject && (
                <>
                  <button
                    onClick={() => setShowAddFeature(true)}
                    className="neo-btn neo-btn-primary text-sm"
                    title="Press N"
                  >
                    <Plus size={18} />
                    Add Feature

                      N

                  </button>

                  <button
                    onClick={() => setTerminalOpen(true)}
                    className="neo-btn text-sm"
                    title="Press T"
                  >
                    <Terminal size={18} />
                    Terminal

                      T

                  </button>

                  <button
                    onClick={() => setReferencesOpen(true)}
                    className="neo-btn text-sm"
                    title="Press R"
                  >
                    <FolderSearch size={18} />
                    Refs

                      R

                  </button>

                  <button
                    onClick={() => setGitPanelOpen(true)}
                    className="neo-btn text-sm"
                    title="Press G"
                  >
                    <GitBranch size={18} />
                    Git

                      G

                  </button>

                  <AgentControl
                    projectName={selectedProject}
                    status={wsState.agentStatus}
                    yoloMode={agentStatusData?.yolo_mode ?? false}
                  />
                </>
              )}

      {/* Main Content */}

        {!selectedProject ? (

              Welcome to AutoCoder

              Select a project from the dropdown above or create a new one to get started.

        ) : (

            {/* Progress Dashboard */}
            <ProgressDashboard
              passing={progress.passing}
              total={progress.total}
              percentage={progress.percentage}
              isConnected={wsState.isConnected}
            />

            {/* Agent Thought - shows latest agent narrative */}
            <AgentThought
              logs={wsState.logs}
              agentStatus={wsState.agentStatus}
            />

            {/* Initializing Features State - show when agent is running but no features yet */}
            {features &&
             features.pending.length === 0 &&
             features.in_progress.length === 0 &&
             features.done.length === 0 &&
             wsState.agentStatus === 'running' && (

                <Loader2 size={32} className="animate-spin mx-auto mb-4 text-[var(--color-neo-progress)]" />

                  Initializing Features...

                  The agent is reading your spec and creating features. This may take a moment.

            )}

            {/* Kanban Board */}
            <KanbanBoard
              features={features}
              onFeatureClick={setSelectedFeature}
            />

        )}

      {/* Add Feature Modal */}
      {showAddFeature && selectedProject && (
        <AddFeatureForm
          projectName={selectedProject}
          onClose={() => setShowAddFeature(false)}
        />
      )}

      {/* Feature Detail Modal */}
      {selectedFeature && selectedProject && (
        <FeatureModal
          feature={selectedFeature}
          projectName={selectedProject}
          onClose={() => setSelectedFeature(null)}
        />
      )}

      {/* Debug Log Viewer - fixed to bottom */}
      {selectedProject && (
        <DebugLogViewer
          logs={wsState.logs}
          isOpen={debugOpen}
          onToggle={() => setDebugOpen(!debugOpen)}
          onClear={wsState.clearLogs}
          onHeightChange={setDebugPanelHeight}
        />
      )}

      {/* Assistant FAB and Panel */}
      {selectedProject && (
        <>
          <AssistantFAB
            onClick={() => setAssistantOpen(!assistantOpen)}
            isOpen={assistantOpen}
          />
          <AssistantPanel
            projectName={selectedProject}
            isOpen={assistantOpen}
            onClose={() => setAssistantOpen(false)}
          />
        </>
      )}

      {/* Git Panel */}
      {selectedProject && (
        <GitPanel
          projectName={selectedProject}
          isOpen={gitPanelOpen}
          onClose={() => setGitPanelOpen(false)}
        />
      )}

      {/* Terminal Panel */}
      {selectedProject && (
        <>
          {/* Backdrop */}
          {terminalOpen && (

 setTerminalOpen(false)}
              aria-hidden="true"
            />
          )}
          {/* Panel */}
          <div
            className={`
              fixed right-0 top-0 bottom-0 z-50
              w-[600px] max-w-[90vw]
              bg-white
              border-l-4 border-[var(--color-neo-border)]
              shadow-[-8px_0_0px_rgba(0,0,0,1)]
              transform transition-transform duration-300 ease-out
              flex flex-col
              ${terminalOpen ? 'translate-x-0' : 'translate-x-full'}
            `}
            role="dialog"
            aria-label="Claude Terminal"
            aria-hidden={!terminalOpen}
          >
            {/* Header */}

                  <Terminal size={18} />

Claude Terminal

{selectedProject}

              <button
                onClick={() => setTerminalOpen(false)}
                className="neo-btn neo-btn-ghost p-2 bg-white/20 border-white/40 hover:bg-white/30 text-white"
                title="Close Terminal (Press T)"
              >
                <X size={18} />
              </button>

            {/* Terminal Chat area */}

              {terminalOpen && <TerminalChat projectName={selectedProject} onClose={() => setTerminalOpen(false)} />}

        </>
      )}

      {/* Reference Folders Panel */}
      {selectedProject && (
        <ReferenceFoldersPanel
          projectName={selectedProject}
          isOpen={referencesOpen}
          onClose={() => setReferencesOpen(false)}
        />
      )}

  )
}

export default App


1→import { Wifi, WifiOff } from 'lucide-react'
     2→
     3→interface ProgressDashboardProps {
     4→  passing: number
     5→  total: number
     6→  percentage: number
     7→  isConnected: boolean
     8→}
     9→
    10→export function ProgressDashboard({
    11→  passing,
    12→  total,
    13→  percentage,
    14→  isConnected,
    15→}: ProgressDashboardProps) {
    16→  return (
    17→

    18→

    19→

    20→          Progress
    21→

    22→

    23→          {isConnected ? (
    24→            <>
    25→              <Wifi size={16} className="text-[var(--color-neo-done)]" />
    26→              Live
    27→            </>
    28→          ) : (
    29→            <>
    30→              <WifiOff size={16} className="text-[var(--color-neo-danger)]" />
    31→              Offline
    32→            </>
    33→          )}
    34→

    35→

    36→
    37→      {/* Large Percentage */}
    38→

    39→
    40→          {percentage.toFixed(1)}
    41→
    42→
    43→          %
    44→
    45→

    46→
    47→      {/* Progress Bar */}
    48→

    49→        <div
    50→          className="neo-progress-fill"
    51→          style={{ width: `${percentage}%` }}
    52→        />
    53→

    54→
    55→      {/* Stats */}
    56→

    57→

    58→
    59→            {passing}
    60→
    61→
    62→            Passing
    63→
    64→

    65→
/

    66→

    67→
    68→            {total}
    69→
    70→
    71→            Total
    72→
    73→

    74→

    75→

    76→  )
    77→}
    78→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import { Wifi, WifiOff } from 'lucide-react'

interface ProgressDashboardProps {
  passing: number
  total: number
  percentage: number
  isConnected: boolean
}

export function ProgressDashboard({
  passing,
  total,
  percentage,
  isConnected,
}: ProgressDashboardProps) {
  return (

          Progress

          {isConnected ? (
            <>
              <Wifi size={16} className="text-[var(--color-neo-done)]" />
              Live
            </>
          ) : (
            <>
              <WifiOff size={16} className="text-[var(--color-neo-danger)]" />
              Offline
            </>
          )}

      {/* Large Percentage */}

          {percentage.toFixed(1)}

          %

      {/* Progress Bar */}

        <div
          className="neo-progress-fill"
          style={{ width: `${percentage}%` }}
        />

      {/* Stats */}

            {passing}

            Passing

/

            {total}

            Total

  )
}


     1→"""
     2→Assistant Chat Router
     3→=====================
     4→
     5→WebSocket and REST endpoints for the read-only project assistant.
     6→"""
     7→
     8→import json
     9→import logging
    10→import re
    11→from pathlib import Path
    12→from typing import Optional
    13→
    14→from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
    15→from pydantic import BaseModel
    16→
    17→from ..services.assistant_chat_session import (
    18→    AssistantChatSession,
    19→    create_session,
    20→    get_session,
    21→    list_sessions,
    22→    remove_session,
    23→)
    24→from ..services.assistant_database import (
    25→    create_conversation,
    26→    delete_conversation,
    27→    get_conversation,
    28→    get_conversations,
    29→)
    30→
    31→logger = logging.getLogger(__name__)
    32→
    33→router = APIRouter(prefix="/api/assistant", tags=["assistant-chat"])
    34→
    35→# Root directory
    36→ROOT_DIR = Path(__file__).parent.parent.parent
    37→
    38→
    39→def _get_project_path(project_name: str) -> Optional[Path]:
    40→    """Get project path from registry."""
    41→    import sys
    42→    root = Path(__file__).parent.parent.parent
    43→    if str(root) not in sys.path:
    44→        sys.path.insert(0, str(root))
    45→
    46→    from registry import get_project_path
    47→    return get_project_path(project_name)
    48→
    49→
    50→def validate_project_name(name: str) -> bool:
    51→    """Validate project name to prevent path traversal."""
    52→    return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name))
    53→
    54→
    55→# ============================================================================
    56→# Pydantic Models
    57→# ============================================================================
    58→
    59→class ConversationSummary(BaseModel):
    60→    """Summary of a conversation."""
    61→    id: int
    62→    project_name: str
    63→    title: Optional[str]
    64→    created_at: Optional[str]
    65→    updated_at: Optional[str]
    66→    message_count: int
    67→
    68→
    69→class ConversationMessageModel(BaseModel):
    70→    """A message within a conversation."""
    71→    id: int
    72→    role: str
    73→    content: str
    74→    timestamp: Optional[str]
    75→
    76→
    77→class ConversationDetail(BaseModel):
    78→    """Full conversation with messages."""
    79→    id: int
    80→    project_name: str
    81→    title: Optional[str]
    82→    created_at: Optional[str]
    83→    updated_at: Optional[str]
    84→    messages: list[ConversationMessageModel]
    85→
    86→
    87→class SessionInfo(BaseModel):
    88→    """Active session information."""
    89→    project_name: str
    90→    conversation_id: Optional[int]
    91→    is_active: bool
    92→
    93→
    94→# ============================================================================
    95→# REST Endpoints - Conversation Management
    96→# ============================================================================
    97→
    98→@router.get("/conversations/{project_name}", response_model=list[ConversationSummary])
    99→async def list_project_conversations(project_name: str):
   100→    """List all conversations for a project."""
   101→    if not validate_project_name(project_name):
   102→        raise HTTPException(status_code=400, detail="Invalid project name")
   103→
   104→    project_dir = _get_project_path(project_name)
   105→    if not project_dir or not project_dir.exists():
   106→        raise HTTPException(status_code=404, detail="Project not found")
   107→
   108→    conversations = get_conversations(project_dir, project_name)
   109→    return [ConversationSummary(**c) for c in conversations]
   110→
   111→
   112→@router.get("/conversations/{project_name}/{conversation_id}", response_model=ConversationDetail)
   113→async def get_project_conversation(project_name: str, conversation_id: int):
   114→    """Get a specific conversation with all messages."""
   115→    if not validate_project_name(project_name):
   116→        raise HTTPException(status_code=400, detail="Invalid project name")
   117→
   118→    project_dir = _get_project_path(project_name)
   119→    if not project_dir or not project_dir.exists():
   120→        raise HTTPException(status_code=404, detail="Project not found")
   121→
   122→    conversation = get_conversation(project_dir, conversation_id)
   123→    if not conversation:
   124→        raise HTTPException(status_code=404, detail="Conversation not found")
   125→
   126→    return ConversationDetail(
   127→        id=conversation["id"],
   128→        project_name=conversation["project_name"],
   129→        title=conversation["title"],
   130→        created_at=conversation["created_at"],
   131→        updated_at=conversation["updated_at"],
   132→        messages=[ConversationMessageModel(**m) for m in conversation["messages"]],
   133→    )
   134→
   135→
   136→@router.post("/conversations/{project_name}", response_model=ConversationSummary)
   137→async def create_project_conversation(project_name: str):
   138→    """Create a new conversation for a project."""
   139→    if not validate_project_name(project_name):
   140→        raise HTTPException(status_code=400, detail="Invalid project name")
   141→
   142→    project_dir = _get_project_path(project_name)
   143→    if not project_dir or not project_dir.exists():
   144→        raise HTTPException(status_code=404, detail="Project not found")
   145→
   146→    conversation = create_conversation(project_dir, project_name)
   147→    return ConversationSummary(
   148→        id=conversation.id,
   149→        project_name=conversation.project_name,
   150→        title=conversation.title,
   151→        created_at=conversation.created_at.isoformat() if conversation.created_at else None,
   152→        updated_at=conversation.updated_at.isoformat() if conversation.updated_at else None,
   153→        message_count=0,
   154→    )
   155→
   156→
   157→@router.delete("/conversations/{project_name}/{conversation_id}")
   158→async def delete_project_conversation(project_name: str, conversation_id: int):
   159→    """Delete a conversation."""
   160→    if not validate_project_name(project_name):
   161→        raise HTTPException(status_code=400, detail="Invalid project name")
   162→
   163→    project_dir = _get_project_path(project_name)
   164→    if not project_dir or not project_dir.exists():
   165→        raise HTTPException(status_code=404, detail="Project not found")
   166→
   167→    success = delete_conversation(project_dir, conversation_id)
   168→    if not success:
   169→        raise HTTPException(status_code=404, detail="Conversation not found")
   170→
   171→    return {"success": True, "message": "Conversation deleted"}
   172→
   173→
   174→# ============================================================================
   175→# REST Endpoints - Session Management
   176→# ============================================================================
   177→
   178→@router.get("/sessions", response_model=list[str])
   179→async def list_active_sessions():
   180→    """List all active assistant sessions."""
   181→    return list_sessions()
   182→
   183→
   184→@router.get("/sessions/{project_name}", response_model=SessionInfo)
   185→async def get_session_info(project_name: str):
   186→    """Get information about an active session."""
   187→    if not validate_project_name(project_name):
   188→        raise HTTPException(status_code=400, detail="Invalid project name")
   189→
   190→    session = get_session(project_name)
   191→    if not session:
   192→        raise HTTPException(status_code=404, detail="No active session for this project")
   193→
   194→    return SessionInfo(
   195→        project_name=project_name,
   196→        conversation_id=session.get_conversation_id(),
   197→        is_active=True,
   198→    )
   199→
   200→
   201→@router.delete("/sessions/{project_name}")
   202→async def close_session(project_name: str):
   203→    """Close an active session."""
   204→    if not validate_project_name(project_name):
   205→        raise HTTPException(status_code=400, detail="Invalid project name")
   206→
   207→    session = get_session(project_name)
   208→    if not session:
   209→        raise HTTPException(status_code=404, detail="No active session for this project")
   210→
   211→    await remove_session(project_name)
   212→    return {"success": True, "message": "Session closed"}
   213→
   214→
   215→# ============================================================================
   216→# WebSocket Endpoint
   217→# ============================================================================
   218→
   219→@router.websocket("/ws/{project_name}")
   220→async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
   221→    """
   222→    WebSocket endpoint for assistant chat.
   223→
   224→    Message protocol:
   225→
   226→    Client -> Server:
   227→    - {"type": "start", "conversation_id": int | null} - Start/resume session
   228→    - {"type": "message", "content": "..."} - Send user message
   229→    - {"type": "ping"} - Keep-alive ping
   230→
   231→    Server -> Client:
   232→    - {"type": "conversation_created", "conversation_id": int} - New conversation created
   233→    - {"type": "text", "content": "..."} - Text chunk from Claude
   234→    - {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
   235→    - {"type": "response_done"} - Response complete
   236→    - {"type": "error", "content": "..."} - Error message
   237→    - {"type": "pong"} - Keep-alive pong
   238→    """
   239→    if not validate_project_name(project_name):
   240→        await websocket.close(code=4000, reason="Invalid project name")
   241→        return
   242→
   243→    project_dir = _get_project_path(project_name)
   244→    if not project_dir:
   245→        await websocket.close(code=4004, reason="Project not found in registry")
   246→        return
   247→
   248→    if not project_dir.exists():
   249→        await websocket.close(code=4004, reason="Project directory not found")
   250→        return
   251→
   252→    await websocket.accept()
   253→    logger.info(f"Assistant WebSocket connected for project: {project_name}")
   254→
   255→    session: Optional[AssistantChatSession] = None
   256→
   257→    try:
   258→        while True:
   259→            try:
   260→                data = await websocket.receive_text()
   261→                message = json.loads(data)
   262→                msg_type = message.get("type")
   263→                logger.info(f"Assistant received message type: {msg_type}")
   264→
   265→                if msg_type == "ping":
   266→                    await websocket.send_json({"type": "pong"})
   267→                    continue
   268→
   269→                elif msg_type == "start":
   270→                    # Get optional conversation_id to resume
   271→                    conversation_id = message.get("conversation_id")
   272→
   273→                    try:
   274→                        # Create a new session
   275→                        session = await create_session(
   276→                            project_name,
   277→                            project_dir,
   278→                            conversation_id=conversation_id,
   279→                        )
   280→
   281→                        # Stream the initial greeting
   282→                        async for chunk in session.start():
   283→                            await websocket.send_json(chunk)
   284→                    except Exception as e:
   285→                        logger.exception(f"Error starting assistant session for {project_name}")
   286→                        await websocket.send_json({
   287→                            "type": "error",
   288→                            "content": f"Failed to start session: {str(e)}"
   289→                        })
   290→
   291→                elif msg_type == "message":
   292→                    if not session:
   293→                        session = get_session(project_name)
   294→                        if not session:
   295→                            await websocket.send_json({
   296→                                "type": "error",
   297→                                "content": "No active session. Send 'start' first."
   298→                            })
   299→                            continue
   300→
   301→                    user_content = message.get("content", "").strip()
   302→                    if not user_content:
   303→                        await websocket.send_json({
   304→                            "type": "error",
   305→                            "content": "Empty message"
   306→                        })
   307→                        continue
   308→
   309→                    # Stream Claude's response
   310→                    async for chunk in session.send_message(user_content):
   311→                        await websocket.send_json(chunk)
   312→
   313→                else:
   314→                    await websocket.send_json({
   315→                        "type": "error",
   316→                        "content": f"Unknown message type: {msg_type}"
   317→                    })
   318→
   319→            except json.JSONDecodeError:
   320→                await websocket.send_json({
   321→                    "type": "error",
   322→                    "content": "Invalid JSON"
   323→                })
   324→
   325→    except WebSocketDisconnect:
   326→        logger.info(f"Assistant chat WebSocket disconnected for {project_name}")
   327→
   328→    except Exception as e:
   329→        logger.exception(f"Assistant chat WebSocket error for {project_name}")
   330→        try:
   331→            await websocket.send_json({
   332→                "type": "error",
   333→                "content": f"Server error: {str(e)}"
   334→            })
   335→        except Exception:
   336→            pass
   337→
   338→    finally:
   339→        # Don't remove session on disconnect - allow resume
   340→        pass
   341→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Assistant Chat Router
=====================

WebSocket and REST endpoints for the read-only project assistant.
"""

import json
import logging
import re
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel

from ..services.assistant_chat_session import (
    AssistantChatSession,
    create_session,
    get_session,
    list_sessions,
    remove_session,
)
from ..services.assistant_database import (
    create_conversation,
    delete_conversation,
    get_conversation,
    get_conversations,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/assistant", tags=["assistant-chat"])

# Root directory
ROOT_DIR = Path(__file__).parent.parent.parent

def _get_project_path(project_name: str) -> Optional[Path]:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

def validate_project_name(name: str) -> bool:
    """Validate project name to prevent path traversal."""
    return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name))

# ============================================================================
# Pydantic Models
# ============================================================================

class ConversationSummary(BaseModel):
    """Summary of a conversation."""
    id: int
    project_name: str
    title: Optional[str]
    created_at: Optional[str]
    updated_at: Optional[str]
    message_count: int

class ConversationMessageModel(BaseModel):
    """A message within a conversation."""
    id: int
    role: str
    content: str
    timestamp: Optional[str]

class ConversationDetail(BaseModel):
    """Full conversation with messages."""
    id: int
    project_name: str
    title: Optional[str]
    created_at: Optional[str]
    updated_at: Optional[str]
    messages: list[ConversationMessageModel]

class SessionInfo(BaseModel):
    """Active session information."""
    project_name: str
    conversation_id: Optional[int]
    is_active: bool

# ============================================================================
# REST Endpoints - Conversation Management
# ============================================================================

@router.get("/conversations/{project_name}", response_model=list[ConversationSummary])
async def list_project_conversations(project_name: str):
    """List all conversations for a project."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir or not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project not found")

    conversations = get_conversations(project_dir, project_name)
    return [ConversationSummary(**c) for c in conversations]

@router.get("/conversations/{project_name}/{conversation_id}", response_model=ConversationDetail)
async def get_project_conversation(project_name: str, conversation_id: int):
    """Get a specific conversation with all messages."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir or not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project not found")

    conversation = get_conversation(project_dir, conversation_id)
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversation not found")

    return ConversationDetail(
        id=conversation["id"],
        project_name=conversation["project_name"],
        title=conversation["title"],
        created_at=conversation["created_at"],
        updated_at=conversation["updated_at"],
        messages=[ConversationMessageModel(**m) for m in conversation["messages"]],
    )

@router.post("/conversations/{project_name}", response_model=ConversationSummary)
async def create_project_conversation(project_name: str):
    """Create a new conversation for a project."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir or not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project not found")

    conversation = create_conversation(project_dir, project_name)
    return ConversationSummary(
        id=conversation.id,
        project_name=conversation.project_name,
        title=conversation.title,
        created_at=conversation.created_at.isoformat() if conversation.created_at else None,
        updated_at=conversation.updated_at.isoformat() if conversation.updated_at else None,
        message_count=0,
    )

@router.delete("/conversations/{project_name}/{conversation_id}")
async def delete_project_conversation(project_name: str, conversation_id: int):
    """Delete a conversation."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir or not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project not found")

    success = delete_conversation(project_dir, conversation_id)
    if not success:
        raise HTTPException(status_code=404, detail="Conversation not found")

    return {"success": True, "message": "Conversation deleted"}

# ============================================================================
# REST Endpoints - Session Management
# ============================================================================

@router.get("/sessions", response_model=list[str])
async def list_active_sessions():
    """List all active assistant sessions."""
    return list_sessions()

@router.get("/sessions/{project_name}", response_model=SessionInfo)
async def get_session_info(project_name: str):
    """Get information about an active session."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    session = get_session(project_name)
    if not session:
        raise HTTPException(status_code=404, detail="No active session for this project")

    return SessionInfo(
        project_name=project_name,
        conversation_id=session.get_conversation_id(),
        is_active=True,
    )

@router.delete("/sessions/{project_name}")
async def close_session(project_name: str):
    """Close an active session."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    session = get_session(project_name)
    if not session:
        raise HTTPException(status_code=404, detail="No active session for this project")

    await remove_session(project_name)
    return {"success": True, "message": "Session closed"}

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@router.websocket("/ws/{project_name}")
async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
    """
    WebSocket endpoint for assistant chat.

    Message protocol:

    Client -> Server:
    - {"type": "start", "conversation_id": int | null} - Start/resume session
    - {"type": "message", "content": "..."} - Send user message
    - {"type": "ping"} - Keep-alive ping

    Server -> Client:
    - {"type": "conversation_created", "conversation_id": int} - New conversation created
    - {"type": "text", "content": "..."} - Text chunk from Claude
    - {"type": "tool_call", "tool": "...", "input": {...}} - Tool being called
    - {"type": "response_done"} - Response complete
    - {"type": "error", "content": "..."} - Error message
    - {"type": "pong"} - Keep-alive pong
    """
    if not validate_project_name(project_name):
        await websocket.close(code=4000, reason="Invalid project name")
        return

    project_dir = _get_project_path(project_name)
    if not project_dir:
        await websocket.close(code=4004, reason="Project not found in registry")
        return

    if not project_dir.exists():
        await websocket.close(code=4004, reason="Project directory not found")
        return

    await websocket.accept()
    logger.info(f"Assistant WebSocket connected for project: {project_name}")

    session: Optional[AssistantChatSession] = None

    try:
        while True:
            try:
                data = await websocket.receive_text()
                message = json.loads(data)
                msg_type = message.get("type")
                logger.info(f"Assistant received message type: {msg_type}")

                if msg_type == "ping":
                    await websocket.send_json({"type": "pong"})
                    continue

                elif msg_type == "start":
                    # Get optional conversation_id to resume
                    conversation_id = message.get("conversation_id")

                    try:
                        # Create a new session
                        session = await create_session(
                            project_name,
                            project_dir,
                            conversation_id=conversation_id,
                        )

                        # Stream the initial greeting
                        async for chunk in session.start():
                            await websocket.send_json(chunk)
                    except Exception as e:
                        logger.exception(f"Error starting assistant session for {project_name}")
                        await websocket.send_json({
                            "type": "error",
                            "content": f"Failed to start session: {str(e)}"
                        })

                elif msg_type == "message":
                    if not session:
                        session = get_session(project_name)
                        if not session:
                            await websocket.send_json({
                                "type": "error",
                                "content": "No active session. Send 'start' first."
                            })
                            continue

                    user_content = message.get("content", "").strip()
                    if not user_content:
                        await websocket.send_json({
                            "type": "error",
                            "content": "Empty message"
                        })
                        continue

                    # Stream Claude's response
                    async for chunk in session.send_message(user_content):
                        await websocket.send_json(chunk)

                else:
                    await websocket.send_json({
                        "type": "error",
                        "content": f"Unknown message type: {msg_type}"
                    })

            except json.JSONDecodeError:
                await websocket.send_json({
                    "type": "error",
                    "content": "Invalid JSON"
                })

    except WebSocketDisconnect:
        logger.info(f"Assistant chat WebSocket disconnected for {project_name}")

    except Exception as e:
        logger.exception(f"Assistant chat WebSocket error for {project_name}")
        try:
            await websocket.send_json({
                "type": "error",
                "content": f"Server error: {str(e)}"
            })
        except Exception:
            pass

    finally:
        # Don't remove session on disconnect - allow resume
        pass


Let me search for the Claude API response handling to understand what token metadata is available: