diff --git a/backend/ai/prompts.py b/backend/ai/prompts.py
index 6838ad8..8ae5916 100644
--- a/backend/ai/prompts.py
+++ b/backend/ai/prompts.py
@@ -27,7 +27,7 @@
## First Step: Read the Index
Before exploring the wiki, read `agents/index.md` to understand the wiki structure.
-This index contains page descriptions, tags, and navigation tips.
+For creating TSX views or CSV data files, read `agents/data-views.md` for examples.
## Your Capabilities
- **Search**: Use grep_pages for content search (regex), glob_pages for path patterns
@@ -59,6 +59,7 @@
## First Step: Read the Index
Start by reading `agents/index.md` to understand wiki structure and page locations.
+For TSX views or CSV data, read `agents/data-views.md` for patterns and examples.
Always use the file path (e.g., 'home.md', 'agents/index.md') when referencing pages.
Use list_pages() first to see exact file paths. Never use display titles like "Home".
diff --git a/backend/main.py b/backend/main.py
index 78a31d9..35c210d 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -73,6 +73,11 @@ async def startup_event():
print(f"โ
Wiki repository loaded successfully ({WIKI_REPO_PATH})")
print(f"๐ Found {len(wiki.list_pages())} pages")
+ # Initialize template files (examples for agents)
+ created = wiki.ensure_templates()
+ if created:
+ print(f"๐ Initialized {len(created)} template files")
+
# Initialize thread support (sets up .gitignore, cleans orphaned worktrees)
git_ops.init_thread_support(wiki)
print("๐งต Thread support initialized")
diff --git a/backend/storage/git_wiki.py b/backend/storage/git_wiki.py
index ebebce5..079c498 100644
--- a/backend/storage/git_wiki.py
+++ b/backend/storage/git_wiki.py
@@ -2,45 +2,30 @@
Git-based wiki storage backend.
Replaces SQLite database with git repository for version control.
-Pages are plain markdown files - no frontmatter.
+Supports multiple file types:
+- Markdown (.md) - wiki pages
+- CSV (.csv) - data files for interactive views
+- TSX (.tsx) - React view components
+
Metadata (author, dates) comes from git history.
Navigation and tags are in agents/index.md.
"""
import re
+import csv
+import io
+import shutil
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Optional, Any
from git import Repo, GitCommandError, Actor
-# Default content for agents/index.md when initializing a new wiki
-DEFAULT_AGENTS_INDEX = """# Wiki Index
-
-Quick reference for navigating this wiki. **Agents should read this first** when starting a task.
-
-## Pages
-
-(Add page entries here as the wiki grows)
+# Templates directory (for auto-instantiating agent examples)
+TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
-## Folders
-- **agents/** - Agent configuration and wiki metadata (this folder)
- - `index.md` - Navigation hub (you are here)
-
-## Navigation Tips
-
-1. Use `list_pages` to see all pages (sorted alphabetically)
-2. Use `grep_pages` to search content across all pages
-3. Use `glob_pages` to find pages by path pattern
-4. Read this index first to understand wiki structure before making changes
-
-## Adding New Pages
-
-When creating a new page:
-1. Use a descriptive filename (e.g., `api-reference.md`, `getting-started.md`)
-2. Update this index.md to add a navigation entry
-3. Pages are sorted alphabetically - add number prefixes if specific order is needed
-"""
+# Supported file extensions for wiki content
+SUPPORTED_EXTENSIONS = {'.md', '.csv', '.tsx'}
@@ -87,26 +72,64 @@ def __init__(self, repo_path: str):
def _ensure_agents_folder(self):
"""
- Ensure agents/ folder exists with default index.md.
+ Ensure agents/ folder exists.
- Creates index.md if missing, providing a starting point for
- wiki navigation.
+ The actual content (index.md, examples, etc.) is handled by
+ ensure_templates() which copies from backend/templates/.
"""
agents_dir = self.pages_dir / "agents"
agents_dir.mkdir(exist_ok=True)
- index_path = agents_dir / "index.md"
+ def ensure_templates(self) -> List[str]:
+ """
+ Copy template files to wiki if they don't exist.
+
+ Templates are stored in backend/templates/ and get auto-instantiated
+ into the wiki pages/ folder on startup. This provides example files
+ for agents to learn from.
+
+ Returns:
+ List of created page paths
+ """
+ if not TEMPLATES_DIR.exists():
+ return []
+
+ created = []
+
+ for template_path in TEMPLATES_DIR.rglob("*"):
+ if not template_path.is_file():
+ continue
+
+ # Skip hidden files
+ if template_path.name.startswith('.'):
+ continue
+
+ # Get relative path from templates dir
+ rel_path = template_path.relative_to(TEMPLATES_DIR)
+ wiki_path = self.pages_dir / rel_path
- if not index_path.exists():
- index_path.write_text(DEFAULT_AGENTS_INDEX, encoding='utf-8')
+ # Only copy if doesn't exist
+ if not wiki_path.exists():
+ # Create parent directories
+ wiki_path.parent.mkdir(parents=True, exist_ok=True)
+ # Copy file
+ shutil.copy(template_path, wiki_path)
+ created.append(str(rel_path))
+
+ # Commit all created files
+ if created:
try:
- self.repo.index.add(["pages/agents/index.md"])
+ for rel_path in created:
+ self.repo.index.add([f"pages/{rel_path}"])
self.repo.index.commit(
- "Initialize agents/ folder",
+ f"Initialize templates: {', '.join(created[:3])}{'...' if len(created) > 3 else ''}",
author=self._create_author("System")
)
- except Exception:
- pass # Ignore commit errors on init
+ print(f"Initialized {len(created)} template files: {', '.join(created)}")
+ except Exception as e:
+ print(f"Warning: Failed to commit templates: {e}")
+
+ return created
@staticmethod
def _create_author(author_name: str) -> Actor:
@@ -177,18 +200,39 @@ def _parse_order_from_filename(filename: str) -> tuple:
# No order prefix - return 0 and full name
return 0, name
+ @staticmethod
+ def _get_file_type(filepath: Path) -> str:
+ """
+ Determine file type from extension.
+
+ Args:
+ filepath: Path to file
+
+ Returns:
+ 'markdown', 'csv', or 'tsx'
+ """
+ ext = filepath.suffix.lower()
+ if ext == '.csv':
+ return 'csv'
+ elif ext == '.tsx':
+ return 'tsx'
+ else:
+ return 'markdown'
+
def _get_page_path(self, title: str) -> Path:
"""Get full filesystem path for a page by title or path.
Expects full path for files in subdirectories (e.g., "agents/index.md").
+ Supports .md, .csv, and .tsx files.
"""
# Direct path lookup - this is the expected case
- if title.endswith('.md') or '/' in title:
+ has_extension = any(title.endswith(ext) for ext in SUPPORTED_EXTENSIONS)
+ if has_extension or '/' in title:
direct_path = self.pages_dir / title
if direct_path.exists():
return direct_path
- # Try exact filename match
+ # Try exact filename match (for .md files)
filename = self.title_to_filename(title)
exact_path = self.pages_dir / filename
if exact_path.exists():
@@ -199,29 +243,50 @@ def _get_page_path(self, title: str) -> Path:
def _parse_page(self, filepath: Path) -> Dict[str, Any]:
"""
- Parse a markdown file into a page dict.
+ Parse a file into a page dict.
- Reads raw markdown content. Strips frontmatter if present (for migration).
- Title is the filename.
+ Handles different file types:
+ - Markdown (.md): Strips frontmatter if present
+ - CSV (.csv): Parses into headers and rows
+ - TSX (.tsx): Returns raw content
Args:
- filepath: Path to the markdown file
+ filepath: Path to the file
Returns:
- Dictionary with page data (path, content only)
+ Dictionary with page data (path, content, file_type, and type-specific fields)
"""
raw_content = filepath.read_text(encoding='utf-8')
+ file_type = self._get_file_type(filepath)
- # Strip frontmatter if present (for backwards compatibility during migration)
- content = self._strip_frontmatter(raw_content)
-
- return {
+ base_result = {
"path": str(filepath.relative_to(self.pages_dir)),
"title": filepath.name,
- "content": content,
+ "file_type": file_type,
"has_conflicts": "<<<<<<" in raw_content or "=======" in raw_content
}
+ if file_type == 'csv':
+ # Parse CSV into headers and rows
+ base_result["content"] = raw_content
+ try:
+ reader = csv.DictReader(io.StringIO(raw_content))
+ rows = list(reader)
+ base_result["headers"] = reader.fieldnames or []
+ base_result["rows"] = rows
+ except Exception:
+ # Fallback if CSV parsing fails
+ base_result["headers"] = []
+ base_result["rows"] = []
+ elif file_type == 'tsx':
+ # TSX: Return raw content as-is
+ base_result["content"] = raw_content
+ else:
+ # Markdown: Strip frontmatter if present
+ base_result["content"] = self._strip_frontmatter(raw_content)
+
+ return base_result
+
def _strip_frontmatter(self, content: str) -> str:
"""
Strip YAML frontmatter from content if present.
@@ -471,15 +536,16 @@ def rename_page(self, old_path: str, new_name: str, author: str = "User") -> Dic
return self._parse_page(new_filepath)
- def list_pages(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
+ def list_pages(self, limit: Optional[int] = None, file_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""
List all pages in the wiki.
Args:
limit: Maximum number of pages to return (optional)
+ file_type: Filter by file type ('markdown', 'csv', 'tsx') or None for all
Returns:
- List of page dictionaries (path and title only)
+ List of page dictionaries (path, title, file_type)
"""
pages = []
@@ -487,11 +553,23 @@ def list_pages(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
# Skip directories and hidden files
if not filepath.is_file() or filepath.name.startswith('.'):
continue
+
+ # Only include supported file types
+ if filepath.suffix.lower() not in SUPPORTED_EXTENSIONS:
+ continue
+
try:
rel_path = str(filepath.relative_to(self.pages_dir))
+ ft = self._get_file_type(filepath)
+
+ # Filter by file type if specified
+ if file_type and ft != file_type:
+ continue
+
pages.append({
"path": rel_path,
- "title": filepath.name
+ "title": filepath.name,
+ "file_type": ft
})
except Exception as e:
print(f"Warning: Failed to parse {filepath}: {e}")
@@ -554,8 +632,8 @@ def _manual_search(self, query: str, limit: int) -> List[Dict[str, Any]]:
pages = []
query_lower = query.lower()
- for filepath in self.pages_dir.rglob("*.md"):
- if filepath.is_file():
+ for filepath in self.pages_dir.rglob("*"):
+ if filepath.is_file() and filepath.suffix.lower() in SUPPORTED_EXTENSIONS:
try:
content = filepath.read_text(encoding='utf-8').lower()
if query_lower in content:
@@ -649,7 +727,7 @@ def get_page_tree(self) -> List[Dict[str, Any]]:
Get hierarchical tree of all pages and folders.
Returns:
- List of tree items, each with id, title, path, type, children
+ List of tree items, each with id, title, path, type, file_type, children
Sorted alphabetically.
"""
def build_tree(directory: Path, parent_path: Optional[str] = None) -> List[Dict]:
@@ -673,16 +751,18 @@ def build_tree(directory: Path, parent_path: Optional[str] = None) -> List[Dict]
"children": build_tree(entry, rel_path)
}
items.append(item)
- elif entry.is_file():
+ elif entry.is_file() and entry.suffix.lower() in SUPPORTED_EXTENSIONS:
try:
rel_path = str(entry.relative_to(self.pages_dir))
- # Remove extension for id (if .md)
- item_id = rel_path.replace('.md', '') if rel_path.endswith('.md') else rel_path
+ file_type = GitWiki._get_file_type(entry)
+ # Remove extension for id
+ item_id = rel_path.rsplit('.', 1)[0] if '.' in rel_path else rel_path
item = {
"id": item_id,
"title": entry.name,
"path": rel_path,
"type": "page",
+ "file_type": file_type,
"parent_path": parent_path,
"children": None
}
@@ -1227,7 +1307,7 @@ def get_diff_stats_by_page(self, base: str, target: str) -> Dict[str, Dict[str,
target: Target ref (e.g., "thread/feature-x")
Returns:
- Dictionary mapping page paths to {additions, deletions}
+ Dictionary mapping page paths to {additions, deletions, file_type}
"""
try:
result = self.repo.git.diff('--numstat', base, target)
@@ -1239,14 +1319,17 @@ def get_diff_stats_by_page(self, base: str, target: str) -> Dict[str, Dict[str,
parts = line.split('\t')
if len(parts) == 3:
adds, dels, path = parts
- # Only include pages (files in pages/ directory)
- if path.startswith('pages/') and path.endswith('.md'):
- # Convert path to page identifier
- page_path = path[6:] # Remove 'pages/' prefix
- stats[page_path] = {
- "additions": int(adds) if adds != '-' else 0,
- "deletions": int(dels) if dels != '-' else 0
- }
+ # Only include pages (files in pages/ directory with supported extensions)
+ if path.startswith('pages/'):
+ ext = '.' + path.rsplit('.', 1)[-1] if '.' in path else ''
+ if ext.lower() in SUPPORTED_EXTENSIONS:
+ # Convert path to page identifier
+ page_path = path[6:] # Remove 'pages/' prefix
+ stats[page_path] = {
+ "additions": int(adds) if adds != '-' else 0,
+ "deletions": int(dels) if dels != '-' else 0,
+ "file_type": self._get_file_type(Path(path))
+ }
return stats
except GitCommandError:
@@ -1286,14 +1369,18 @@ def search_pages_regex(
except regex_module.error as e:
return [{"error": f"Invalid regex pattern: {e}"}]
- for filepath in self.pages_dir.rglob("*.md"):
- if not filepath.is_file():
+ for filepath in self.pages_dir.rglob("*"):
+ if not filepath.is_file() or filepath.suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
try:
raw_content = filepath.read_text(encoding='utf-8')
- # Strip frontmatter if present
- page_content = self._strip_frontmatter(raw_content)
+ file_type = self._get_file_type(filepath)
+ # Strip frontmatter if present (for markdown files)
+ if file_type == 'markdown':
+ page_content = self._strip_frontmatter(raw_content)
+ else:
+ page_content = raw_content
page_path = str(filepath.relative_to(self.pages_dir))
lines = page_content.split('\n')
@@ -1333,16 +1420,17 @@ def glob_pages(self, pattern: str, limit: int = 50) -> List[Dict[str, Any]]:
- ? matches single character
Args:
- pattern: Glob pattern (e.g., "docs/*", "**/*api*")
+ pattern: Glob pattern (e.g., "docs/*", "**/*api*", "*.csv")
limit: Maximum results to return
Returns:
- List of matching pages with title, path, updated_at
+ List of matching pages with title, path, file_type
"""
import fnmatch
- # Normalize pattern - ensure it ends with .md for file matching
- if not pattern.endswith('.md') and not pattern.endswith('*'):
+ # Normalize pattern - add wildcard if no extension specified
+ has_extension = any(pattern.endswith(ext) for ext in SUPPORTED_EXTENSIONS)
+ if not has_extension and not pattern.endswith('*'):
search_pattern = pattern + '*'
else:
search_pattern = pattern
@@ -1355,13 +1443,13 @@ def glob_pages(self, pattern: str, limit: int = 50) -> List[Dict[str, Any]]:
results = []
- for filepath in self.pages_dir.rglob("*.md"):
- if not filepath.is_file():
+ for filepath in self.pages_dir.rglob("*"):
+ if not filepath.is_file() or filepath.suffix.lower() not in SUPPORTED_EXTENSIONS:
continue
rel_path = str(filepath.relative_to(self.pages_dir))
- # Remove .md for matching against pattern
- rel_path_no_ext = rel_path.replace('.md', '')
+ # Remove extension for matching against pattern
+ rel_path_no_ext = rel_path.rsplit('.', 1)[0] if '.' in rel_path else rel_path
# Match against both with and without extension
if fnmatch.fnmatch(rel_path, search_pattern) or \
@@ -1372,6 +1460,7 @@ def glob_pages(self, pattern: str, limit: int = 50) -> List[Dict[str, Any]]:
results.append({
"title": filepath.name,
"path": rel_path,
+ "file_type": self._get_file_type(filepath),
"updated_at": None # Legacy field
})
diff --git a/backend/templates/agents/components/DataTable.tsx b/backend/templates/agents/components/DataTable.tsx
new file mode 100644
index 0000000..df7741c
--- /dev/null
+++ b/backend/templates/agents/components/DataTable.tsx
@@ -0,0 +1,74 @@
+// Reusable DataTable component - can be imported via useComponent
+// Usage: const DataTable = useComponent('agents/components/DataTable.tsx');
+
+export default function DataTable({
+ title,
+ headers,
+ rows,
+ onIncrement,
+ onDecrement,
+ valueColumn
+}) {
+ return (
+
+
+ {title}
+
+
+
+
+
+ {headers.map((header, i) => (
+ {header}
+ ))}
+ {(onIncrement || onDecrement) && (
+ Actions
+ )}
+
+
+
+ {rows.map((row, rowIndex) => (
+
+ {headers.map((header, colIndex) => {
+ const key = header.toLowerCase();
+ return (
+
+ {row[key] !== undefined ? String(row[key]) : ''}
+
+ );
+ })}
+ {(onIncrement || onDecrement) && (
+
+
+ {onDecrement && (
+ onDecrement(rowIndex)}
+ >
+ -
+
+ )}
+ {onIncrement && (
+ onIncrement(rowIndex)}
+ >
+ +
+
+ )}
+
+
+ )}
+
+ ))}
+
+
+
+ {rows.length} items
+
+
+
+ );
+}
diff --git a/backend/templates/agents/data-views.md b/backend/templates/agents/data-views.md
new file mode 100644
index 0000000..32d3911
--- /dev/null
+++ b/backend/templates/agents/data-views.md
@@ -0,0 +1,63 @@
+# Data Views Reference
+
+The wiki supports interactive TSX views and CSV data files.
+
+## File Types
+
+| Extension | Purpose |
+|-----------|---------|
+| `.md` | Markdown pages |
+| `.csv` | Tabular data |
+| `.tsx` | Interactive views |
+
+## Creating CSV Files
+
+CSV files store tabular data. Example structure:
+
+```csv
+name,email,role
+Alice,alice@example.com,admin
+Bob,bob@example.com,user
+```
+
+## Creating TSX Views
+
+TSX views are React components compiled at runtime. Export a default function component.
+
+### Available Hooks
+
+- `useCSV('file.csv')` - Load and edit CSV data
+ - Returns: `{ rows, headers, updateCell, addRow, deleteRow, isLoaded }`
+- `usePage('file.md')` - Load any text file content
+ - Returns: `{ content, setContent, isLoaded }`
+- `useComponent('path.tsx')` - Reuse other TSX components
+ - Returns: The component or null if loading
+
+### Available Components
+
+UI components available in scope:
+- `Button` - Clickable button with variants
+- `Card`, `CardContent`, `CardHeader`, `CardTitle` - Card layout
+- `Badge` - Status/count badges
+- `Table`, `TableHead`, `TableBody`, `TableRow`, `TableCell`, `TableHeader` - Table layout
+
+### React Hooks
+
+Standard React hooks available:
+- `useState`, `useEffect`, `useMemo`, `useCallback`, `memo`, `useRef`
+
+## Examples
+
+See these pages for working examples:
+
+- [agents/examples/simple-view.tsx](page:agents/examples/simple-view.tsx) - Minimal view with state
+- [agents/examples/data-view.tsx](page:agents/examples/data-view.tsx) - CSV data manipulation
+- [agents/examples/kanban.tsx](page:agents/examples/kanban.tsx) - Kanban board with drag-and-drop
+- [agents/components/DataTable.tsx](page:agents/components/DataTable.tsx) - Reusable component
+
+## Tips
+
+1. Always check `isLoaded` before rendering data
+2. CSV cell values are strings - parse numbers with `parseInt()`/`parseFloat()`
+3. Use `useComponent` to share UI between views
+4. Tailwind CSS classes work for styling
diff --git a/backend/templates/agents/examples/data-view.tsx b/backend/templates/agents/examples/data-view.tsx
new file mode 100644
index 0000000..93c037c
--- /dev/null
+++ b/backend/templates/agents/examples/data-view.tsx
@@ -0,0 +1,54 @@
+// CSV data manipulation example - demonstrates useCSV hook
+export default function DataView() {
+ const tasks = useCSV('agents/examples/tasks.csv');
+
+ if (!tasks.isLoaded) {
+ return Loading... ;
+ }
+
+ const toggleComplete = (index) => {
+ const current = tasks.rows[index].completed === 'true';
+ tasks.updateCell(index, 'completed', String(!current));
+ };
+
+ const addTask = () => {
+ tasks.addRow({ task: 'New task', completed: 'false' });
+ };
+
+ return (
+
+
+ Task List
+
+
+
+
+
+ Task
+ Done
+
+
+
+ {tasks.rows.map((row, i) => (
+
+ {row.task}
+
+ toggleComplete(i)}
+ >
+ {row.completed === 'true' ? 'โ' : 'โ'}
+
+
+
+ ))}
+
+
+
+ Add Task
+
+
+
+ );
+}
diff --git a/backend/templates/agents/examples/kanban-tasks.csv b/backend/templates/agents/examples/kanban-tasks.csv
new file mode 100644
index 0000000..4cbab76
--- /dev/null
+++ b/backend/templates/agents/examples/kanban-tasks.csv
@@ -0,0 +1,9 @@
+title,description,status,priority
+Design database schema,Define tables and relationships for user data,done,high
+Implement authentication,Add login and signup with JWT tokens,done,high
+Create API endpoints,REST endpoints for CRUD operations,in-progress,high
+Write unit tests,Test coverage for core modules,in-progress,medium
+Setup CI/CD pipeline,Automated testing and deployment,todo,medium
+Add dark mode,Theme toggle for better UX,todo,low
+Performance optimization,Profile and optimize slow queries,review,medium
+Update documentation,API docs and README updates,review,low
diff --git a/backend/templates/agents/examples/kanban.tsx b/backend/templates/agents/examples/kanban.tsx
new file mode 100644
index 0000000..c384e5f
--- /dev/null
+++ b/backend/templates/agents/examples/kanban.tsx
@@ -0,0 +1,167 @@
+// Kanban board with drag-and-drop task management
+// Demonstrates useCSV for data, useState for drag state, and dynamic columns
+
+export default function Kanban() {
+ const tasks = useCSV('agents/examples/kanban-tasks.csv');
+ const [draggedTask, setDraggedTask] = useState(null);
+ const [dragOverColumn, setDragOverColumn] = useState(null);
+
+ const columns = ['todo', 'in-progress', 'review', 'done'];
+ const columnLabels = {
+ 'todo': 'To Do',
+ 'in-progress': 'In Progress',
+ 'review': 'Review',
+ 'done': 'Done'
+ };
+ const columnColors = {
+ 'todo': 'bg-slate-100',
+ 'in-progress': 'bg-blue-50',
+ 'review': 'bg-amber-50',
+ 'done': 'bg-green-50'
+ };
+
+ if (!tasks.isLoaded) {
+ return Loading tasks... ;
+ }
+
+ const getTasksByStatus = (status) => {
+ return tasks.rows
+ .map((row, index) => ({ ...row, _index: index }))
+ .filter(row => row.status === status);
+ };
+
+ const handleDragStart = (e, task) => {
+ setDraggedTask(task);
+ e.dataTransfer.effectAllowed = 'move';
+ };
+
+ const handleDragOver = (e, column) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ setDragOverColumn(column);
+ };
+
+ const handleDragLeave = () => {
+ setDragOverColumn(null);
+ };
+
+ const handleDrop = (e, newStatus) => {
+ e.preventDefault();
+ if (draggedTask && draggedTask.status !== newStatus) {
+ tasks.updateCell(draggedTask._index, 'status', newStatus);
+ }
+ setDraggedTask(null);
+ setDragOverColumn(null);
+ };
+
+ const handleDragEnd = () => {
+ setDraggedTask(null);
+ setDragOverColumn(null);
+ };
+
+ const addTask = () => {
+ const title = prompt('Task title:');
+ if (title) {
+ tasks.addRow({
+ title,
+ description: '',
+ status: 'todo',
+ priority: 'medium'
+ });
+ }
+ };
+
+ const deleteTask = (index) => {
+ if (confirm('Delete this task?')) {
+ tasks.deleteRow(index);
+ }
+ };
+
+ const getPriorityColor = (priority) => {
+ switch (priority) {
+ case 'high': return 'border-l-red-500';
+ case 'medium': return 'border-l-amber-500';
+ case 'low': return 'border-l-green-500';
+ default: return 'border-l-slate-300';
+ }
+ };
+
+ return (
+
+
+
Kanban Board
+ + Add Task
+
+
+
+ {columns.map(column => (
+
handleDragOver(e, column)}
+ onDragLeave={handleDragLeave}
+ onDrop={(e) => handleDrop(e, column)}
+ >
+
+
+ {columnLabels[column]}
+
+ {getTasksByStatus(column).length}
+
+
+
+ {getTasksByStatus(column).map(task => (
+
handleDragStart(e, task)}
+ onDragEnd={handleDragEnd}
+ className={`cursor-grab active:cursor-grabbing border-l-4 ${getPriorityColor(task.priority)} ${
+ draggedTask?._index === task._index ? 'opacity-50' : ''
+ }`}
+ >
+
+
+
+
{task.title}
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+
deleteTask(task._index)}
+ >
+ ร
+
+
+
+
+ {task.priority}
+
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+
+ Drag tasks between columns to change status. Tasks are saved automatically.
+
+
+
+ );
+}
diff --git a/backend/templates/agents/examples/simple-view.tsx b/backend/templates/agents/examples/simple-view.tsx
new file mode 100644
index 0000000..ff74e9a
--- /dev/null
+++ b/backend/templates/agents/examples/simple-view.tsx
@@ -0,0 +1,19 @@
+// Minimal TSX view example - demonstrates useState and UI components
+export default function SimpleView() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+
+ Simple Counter
+
+
+
+ setCount(c => c - 1)}>-
+ {count}
+ setCount(c => c + 1)}>+
+
+
+
+ );
+}
diff --git a/backend/templates/agents/examples/tasks.csv b/backend/templates/agents/examples/tasks.csv
new file mode 100644
index 0000000..688d7f8
--- /dev/null
+++ b/backend/templates/agents/examples/tasks.csv
@@ -0,0 +1,4 @@
+task,completed
+Read the documentation,true
+Create a view,false
+Test the data hook,false
diff --git a/backend/templates/agents/index.md b/backend/templates/agents/index.md
new file mode 100644
index 0000000..73afbdf
--- /dev/null
+++ b/backend/templates/agents/index.md
@@ -0,0 +1,38 @@
+# Wiki Index
+
+Quick reference for navigating this wiki. **Agents should read this first** when starting a task.
+
+You can edit it but keep this page structure unchanged.
+
+## Pages
+
+(Add page entries here as the wiki grows)
+
+## Folders
+
+- **agents/** - Agent configuration and wiki metadata (this folder)
+ - `index.md` - Navigation hub (you are here)
+ - `data-views.md` - Reference for TSX views and CSV data
+ - `examples/` - Working code examples
+ - `components/` - Reusable TSX components
+
+## Navigation Tips
+
+1. Use `list_pages` to see all pages (sorted alphabetically)
+2. Use `grep_pages` to search content across all pages
+3. Use `glob_pages` to find pages by path pattern
+4. Read this index first to understand wiki structure before making changes
+
+## Adding New Pages
+
+When creating a new page:
+1. Use a descriptive filename (e.g., `api-reference.md`, `getting-started.md`)
+2. Update this index.md to add a navigation entry
+3. Pages are sorted alphabetically - add number prefixes if specific order is needed
+
+## Creating Interactive Views
+
+For TSX views or CSV data files, see [agents/data-views.md](page:agents/data-views.md) for:
+- Available hooks (`useCSV`, `usePage`, `useComponent`)
+- UI components (Button, Card, Table, etc.)
+- Working examples in `agents/examples/`
diff --git a/etoneto-wiki b/etoneto-wiki
index d11bd08..53654ee 160000
--- a/etoneto-wiki
+++ b/etoneto-wiki
@@ -1 +1 @@
-Subproject commit d11bd0842266c61b1f63af344f57c352ad40f71f
+Subproject commit 53654ee4a1b1a15825764168aa0a4ba23fcbbc21
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..2cee974
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,5 @@
+# Backend API host address (without protocol)
+# Examples:
+# localhost:8000 (local development)
+# api.example.com (production)
+VITE_API_HOST=localhost:8000
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf3..1603971 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -12,6 +12,11 @@ dist
dist-ssr
*.local
+# Environment files (keep .env.example as template)
+.env
+.env.local
+.env.*.local
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 622a29c..aa8d9c9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -50,6 +50,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.14.0",
+ "sucrase": "^3.35.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.1",
diff --git a/frontend/package.json b/frontend/package.json
index a7ceca9..8e0a417 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -56,6 +56,7 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.14.0",
+ "sucrase": "^3.35.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.1.1",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 44db772..109e298 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import MainLayout from './components/layout/MainLayout';
import { AppProvider } from './contexts/AppContext';
import { AuthProvider } from './contexts/AuthContext';
diff --git a/frontend/src/components/agents/AgentSelector.tsx b/frontend/src/components/agents/AgentSelector.tsx
index ed7efe0..7088342 100644
--- a/frontend/src/components/agents/AgentSelector.tsx
+++ b/frontend/src/components/agents/AgentSelector.tsx
@@ -1,7 +1,7 @@
/**
* AgentSelector - dropdown to select between ChatAgent (default) and autonomous agents
*/
-import React, { useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
import {
Select,
SelectContent,
diff --git a/frontend/src/components/agents/BranchDiffPanel.tsx b/frontend/src/components/agents/BranchDiffPanel.tsx
index d5d6ae1..10e49b3 100644
--- a/frontend/src/components/agents/BranchDiffPanel.tsx
+++ b/frontend/src/components/agents/BranchDiffPanel.tsx
@@ -1,7 +1,7 @@
/**
* Branch Diff Panel - displays branch diff and merge options in center panel
*/
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import { GitAPI } from '../../services/git-api';
import { useAppContext } from '../../contexts/AppContext';
import { DiffViewer } from './DiffViewer';
diff --git a/frontend/src/components/agents/DiffViewer.tsx b/frontend/src/components/agents/DiffViewer.tsx
index a933e5e..6d4f680 100644
--- a/frontend/src/components/agents/DiffViewer.tsx
+++ b/frontend/src/components/agents/DiffViewer.tsx
@@ -1,7 +1,6 @@
/**
* DiffViewer - displays unified diff with basic syntax highlighting
*/
-import React from 'react';
interface DiffViewerProps {
diff: string;
diff --git a/frontend/src/components/chat/MessageList.tsx b/frontend/src/components/chat/MessageList.tsx
index dc7beda..93d97a4 100644
--- a/frontend/src/components/chat/MessageList.tsx
+++ b/frontend/src/components/chat/MessageList.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
-import { ChatMessage } from '../../types/chat';
+import type { ChatMessage } from '../../types/chat';
interface MessageListProps {
messages: ChatMessage[];
diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx
index fcbbc2e..397daa6 100644
--- a/frontend/src/components/common/ErrorBoundary.tsx
+++ b/frontend/src/components/common/ErrorBoundary.tsx
@@ -1,4 +1,5 @@
-import React, { Component, ErrorInfo, ReactNode } from 'react';
+import { Component } from 'react';
+import type { ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
diff --git a/frontend/src/components/editor/CSVEditor.tsx b/frontend/src/components/editor/CSVEditor.tsx
new file mode 100644
index 0000000..7a73551
--- /dev/null
+++ b/frontend/src/components/editor/CSVEditor.tsx
@@ -0,0 +1,200 @@
+/**
+ * Collaborative CSV table editor with Yjs backing.
+ * Provides inline cell editing with real-time sync.
+ */
+
+import { useCallback, useState } from 'react';
+import { useCSV } from '../../hooks/useCSV';
+import type { DataRow } from '../../hooks/useCSV';
+import { Button } from '../ui/button';
+import { Plus, Trash2, Loader2 } from 'lucide-react';
+
+interface CSVEditorProps {
+ pagePath: string;
+ initialHeaders?: string[];
+ editable?: boolean;
+ className?: string;
+ onSaveStatusChange?: (status: 'saved' | 'saving' | 'dirty') => void;
+}
+
+export const CSVEditor: React.FC = ({
+ pagePath,
+ initialHeaders,
+ editable = true,
+ className = '',
+ onSaveStatusChange: _onSaveStatusChange,
+}) => {
+ const {
+ rows,
+ headers,
+ updateCell,
+ addRow,
+ deleteRow,
+ isLoaded,
+ connectionStatus,
+ } = useCSV(pagePath, initialHeaders);
+
+ const [editingCell, setEditingCell] = useState<{ row: number; col: string } | null>(null);
+ const [editValue, setEditValue] = useState('');
+
+ const handleCellClick = useCallback((rowIndex: number, column: string, value: string) => {
+ if (!editable) return;
+ setEditingCell({ row: rowIndex, col: column });
+ setEditValue(String(value ?? ''));
+ }, [editable]);
+
+ const handleCellBlur = useCallback(() => {
+ if (editingCell) {
+ updateCell(editingCell.row, editingCell.col, editValue);
+ setEditingCell(null);
+ }
+ }, [editingCell, editValue, updateCell]);
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleCellBlur();
+ } else if (e.key === 'Escape') {
+ setEditingCell(null);
+ } else if (e.key === 'Tab' && editingCell) {
+ e.preventDefault();
+ handleCellBlur();
+ // Move to next cell
+ const currentColIndex = headers.indexOf(editingCell.col);
+ if (currentColIndex < headers.length - 1) {
+ const nextCol = headers[currentColIndex + 1];
+ const currentValue = rows[editingCell.row]?.[nextCol];
+ setEditingCell({ row: editingCell.row, col: nextCol });
+ setEditValue(String(currentValue ?? ''));
+ } else if (editingCell.row < rows.length - 1) {
+ // Move to first column of next row
+ const nextCol = headers[0];
+ const currentValue = rows[editingCell.row + 1]?.[nextCol];
+ setEditingCell({ row: editingCell.row + 1, col: nextCol });
+ setEditValue(String(currentValue ?? ''));
+ }
+ }
+ }, [editingCell, editValue, handleCellBlur, headers, rows]);
+
+ const handleAddRow = useCallback(() => {
+ const newRow: DataRow = {};
+ headers.forEach(h => { newRow[h] = ''; });
+ addRow(newRow);
+ }, [addRow, headers]);
+
+ const handleDeleteRow = useCallback((rowIndex: number) => {
+ deleteRow(rowIndex);
+ }, [deleteRow]);
+
+ if (!isLoaded) {
+ return (
+
+
+ Loading data...
+
+ );
+ }
+
+ if (headers.length === 0) {
+ return (
+
+
No data yet. Add some columns to get started.
+ {editable && (
+
+
+ Add Row
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {/* Connection status indicator */}
+ {connectionStatus !== 'connected' && (
+
+ {connectionStatus === 'connecting' ? 'Connecting...' : 'Disconnected'}
+
+ )}
+
+
+
+ {editable && (
+
+
+ Add Row
+
+ )}
+
+ );
+};
+
+export default CSVEditor;
diff --git a/frontend/src/components/editor/CodeMirrorDiffView.tsx b/frontend/src/components/editor/CodeMirrorDiffView.tsx
index 5866aea..5b10fc1 100644
--- a/frontend/src/components/editor/CodeMirrorDiffView.tsx
+++ b/frontend/src/components/editor/CodeMirrorDiffView.tsx
@@ -6,9 +6,11 @@ import { EditorView, basicSetup } from 'codemirror';
import { EditorState, RangeSetBuilder } from '@codemirror/state';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
-import { Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view';
+import { Decoration, ViewPlugin } from '@codemirror/view';
+import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { diffLines } from 'diff';
import { cn } from '@/lib/utils';
+import { getApiUrl } from '../../utils/url';
interface CodeMirrorDiffViewProps {
pagePath: string;
@@ -43,10 +45,10 @@ export function CodeMirrorDiffView({
// Fetch both main and branch content via API (no checkout needed)
useEffect(() => {
Promise.all([
- fetch(`http://localhost:8000/api/pages/${encodeURIComponent(pagePath)}/at-ref?ref=main`)
+ fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pagePath)}/at-ref?ref=main`)
.then(r => r.ok ? r.json() : { content: '' })
.then(data => data.content || ''),
- fetch(`http://localhost:8000/api/pages/${encodeURIComponent(pagePath)}/at-ref?ref=${encodeURIComponent(branchName)}`)
+ fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pagePath)}/at-ref?ref=${encodeURIComponent(branchName)}`)
.then(r => r.ok ? r.json() : { content: '' })
.then(data => data.content || '')
])
@@ -63,7 +65,7 @@ export function CodeMirrorDiffView({
}, [pagePath, branchName, refreshTrigger]);
// Compute unified diff content with markers
- const { diffContent, lineTypes, hasChanges } = useMemo(() => {
+ const { diffContent, lineTypes } = useMemo(() => {
if (mainContent === null || branchContent === null) {
return { diffContent: '', lineTypes: new Map(), hasChanges: false };
}
diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx
index 35f8321..d8ab328 100644
--- a/frontend/src/components/editor/EditorToolbar.tsx
+++ b/frontend/src/components/editor/EditorToolbar.tsx
@@ -1,7 +1,7 @@
import { useEffect, useReducer } from 'react';
import { EditorView } from 'prosemirror-view';
import type { Command } from 'prosemirror-state';
-import { toggleMark, setBlockType, lift } from 'prosemirror-commands';
+import { toggleMark, setBlockType } from 'prosemirror-commands';
import { wrapInList, liftListItem } from 'prosemirror-schema-list';
import type { MarkType, NodeType } from 'prosemirror-model';
import { Button } from '@/components/ui/button';
diff --git a/frontend/src/components/layout/CenterPanel.tsx b/frontend/src/components/layout/CenterPanel.tsx
index fcfeca5..4743350 100644
--- a/frontend/src/components/layout/CenterPanel.tsx
+++ b/frontend/src/components/layout/CenterPanel.tsx
@@ -1,21 +1,24 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react';
+import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useAppContext } from '../../contexts/AppContext';
import { useAuth } from '../../contexts/AuthContext';
import LoadingSpinner from '../common/LoadingSpinner';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Card, CardContent } from '@/components/ui/card';
-import { AlertCircle, FileText, GitBranch, Code, Eye, Cloud, CloudOff, CloudUpload, AlertTriangle, Loader2, Pencil, Check, X } from 'lucide-react';
+import { AlertCircle, FileText, GitBranch, Code, Eye, Cloud, CloudOff, CloudUpload, AlertTriangle, Loader2, Pencil } from 'lucide-react';
import { RevisionHistory } from '../revisions/RevisionHistory';
-import type { PageRevision } from '../../types/page';
+import type { PageRevision, FileType } from '../../types/page';
import { ProseMirrorEditor } from '../editor/ProseMirrorEditor';
import { CollaborativeEditor, type CollabStatus } from '../editor/CollaborativeEditor';
import { CollaborativeCodeMirrorEditor } from '../editor/CollaborativeCodeMirrorEditor';
import { CodeMirrorEditor } from '../editor/CodeMirrorEditor';
import { CodeMirrorDiffView } from '../editor/CodeMirrorDiffView';
+import { CSVEditor } from '../editor/CSVEditor';
+import { ViewRuntime } from '../views/ViewRuntime';
import { stringToColor, getInitials } from '../../utils/colors';
import { setEditingState } from '../../services/collab';
import { ThreadsAPI, type ThreadFile } from '../../services/threads-api';
+import { getApiUrl } from '../../utils/url';
// Render filename with dimmed extension
const FileName: React.FC<{ name: string; className?: string }> = ({ name, className }) => {
@@ -68,6 +71,17 @@ const CenterPanel: React.FC = () => {
const currentThread = threads.find(t => t.branch === currentBranch);
const isResolving = currentThread?.status === 'resolving';
+ // Determine file type from current page
+ const fileType: FileType = useMemo(() => {
+ if (!currentPage) return 'markdown';
+ if (currentPage.file_type) return currentPage.file_type;
+ // Fallback to extension-based detection
+ const path = currentPage.path;
+ if (path.endsWith('.csv')) return 'csv';
+ if (path.endsWith('.tsx')) return 'tsx';
+ return 'markdown';
+ }, [currentPage?.path, currentPage?.file_type]);
+
// Handle collab status changes from editors
const handleCollabStatusChange = useCallback((status: CollabStatus) => {
setCollabStatus(status);
@@ -218,7 +232,7 @@ const CenterPanel: React.FC = () => {
if (currentPage) {
try {
const response = await fetch(
- `http://localhost:8000/api/pages/${encodeURIComponent(currentPage.path)}/at-ref?ref=${revision.sha}`
+ `${getApiUrl()}/api/pages/${encodeURIComponent(currentPage.path)}/at-ref?ref=${revision.sha}`
);
if (response.ok) {
const data = await response.json();
@@ -282,7 +296,7 @@ const CenterPanel: React.FC = () => {
setTitleSaving(true);
titleSavingRef.current = true;
const response = await fetch(
- `http://localhost:8000/api/pages/${encodeURIComponent(currentPage.path)}/rename`,
+ `${getApiUrl()}/api/pages/${encodeURIComponent(currentPage.path)}/rename`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -449,30 +463,63 @@ const CenterPanel: React.FC = () => {
)}
- {/* Single collaborative editor for both View and Edit modes - prevents remounting */}
+ {/* File-type aware editor rendering for View and Edit modes */}
{(mainTab === 'view' || mainTab === 'edit') && !viewingRevision && (
- {formatMode === 'raw' ? (
-
+ ) : fileType === 'tsx' ? (
+ /* TSX: View renders component, Edit shows code */
+ mainTab === 'view' ? (
+
+
+
+ ) : (
+
+ )
) : (
-
+ /* Markdown: Formatted or raw mode */
+ formatMode === 'raw' ? (
+
+ ) : (
+
+ )
)}
diff --git a/frontend/src/components/pages/FolderItem.tsx b/frontend/src/components/pages/FolderItem.tsx
index f85c7fe..3da9f15 100644
--- a/frontend/src/components/pages/FolderItem.tsx
+++ b/frontend/src/components/pages/FolderItem.tsx
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { ChevronRight, ChevronDown, Folder, FolderOpen, Trash2, Check, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
-import { TreeItem } from '../../types/page';
+import type { TreeItem } from '../../types/page';
interface FolderItemProps {
folder: TreeItem;
diff --git a/frontend/src/components/pages/PageTree.tsx b/frontend/src/components/pages/PageTree.tsx
index 2609993..e8051e0 100644
--- a/frontend/src/components/pages/PageTree.tsx
+++ b/frontend/src/components/pages/PageTree.tsx
@@ -1,17 +1,15 @@
-import React, { useMemo, useState, useRef, useEffect } from 'react';
+import React, { useMemo, useState, useEffect } from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
- DragStartEvent,
- DragEndEvent,
- DragOverEvent,
useDraggable,
useDroppable
} from '@dnd-kit/core';
-import { TreeItem, Page } from '../../types/page';
+import type { DragStartEvent, DragEndEvent, DragOverEvent } from '@dnd-kit/core';
+import type { TreeItem, Page } from '../../types/page';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
@@ -22,9 +20,10 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
-import { Plus, FileText, FolderPlus, GripVertical, ChevronRight, ChevronDown, Folder, FolderOpen, Trash2, LogOut, LogIn } from 'lucide-react';
+import { Plus, FileText, FileSpreadsheet, FileCode, FolderPlus, GripVertical, ChevronRight, ChevronDown, Folder, FolderOpen, Trash2, LogOut, LogIn } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '../../contexts/AuthContext';
+import { getApiUrl } from '../../utils/url';
// Render filename with dimmed extension
const FileName: React.FC<{ name: string }> = ({ name }) => {
@@ -42,6 +41,13 @@ const FileName: React.FC<{ name: string }> = ({ name }) => {
);
};
+// Get file icon based on path extension
+const getFileIcon = (path: string) => {
+ if (path.endsWith('.csv')) return FileSpreadsheet;
+ if (path.endsWith('.tsx')) return FileCode;
+ return FileText;
+};
+
// Per-page diff stats (matches backend format)
interface PageDiffStats {
[pagePath: string]: {
@@ -165,7 +171,7 @@ const PageTree: React.FC = ({
}
// Fetch per-page diff stats with explicit head branch
- fetch(`http://localhost:8000/api/git/diff-stats-by-page?base=main&head=${encodeURIComponent(currentBranch)}`)
+ fetch(`${getApiUrl()}/api/git/diff-stats-by-page?base=main&head=${encodeURIComponent(currentBranch)}`)
.then(r => r.ok ? r.json() : { stats: {} })
.then(data => setDiffStats(data.stats || {}))
.catch(() => setDiffStats({}));
@@ -362,6 +368,7 @@ const PageTree: React.FC = ({
// Page item
const pageStats = diffStats[item.path];
+ const FileIcon = getFileIcon(item.path);
return (
= ({
onClick={() => page && onPageSelect(page)}
>
-
+
{pageStats && (pageStats.additions > 0 || pageStats.deletions > 0) && (
@@ -455,7 +462,7 @@ const PageTree: React.FC = ({
onDragCancel={handleDragCancel}
>
- {items.map((entry, index) => {
+ {items.map((entry) => {
if (entry.type === 'dropzone') {
// Only show drop zones when dragging
if (!draggedId) return null;
@@ -485,7 +492,10 @@ const PageTree: React.FC
= ({
{draggedItem.type === 'folder' ? (
) : (
-
+ (() => {
+ const DragIcon = getFileIcon(draggedItem.path);
+ return ;
+ })()
)}
{draggedItem.type === 'folder' ? draggedItem.title : }
@@ -525,14 +535,14 @@ const PageTree: React.FC = ({
{isGuest ? (
<>
window.location.href = 'http://localhost:8000/auth/google'}
+ onClick={() => window.location.href = `${getApiUrl()}/auth/google`}
className="cursor-pointer"
>
Login with Google
window.location.href = 'http://localhost:8000/auth/github'}
+ onClick={() => window.location.href = `${getApiUrl()}/auth/github`}
className="cursor-pointer"
>
diff --git a/frontend/src/components/threads/ThreadChangesView.tsx b/frontend/src/components/threads/ThreadChangesView.tsx
index 990c3ac..1f5faae 100644
--- a/frontend/src/components/threads/ThreadChangesView.tsx
+++ b/frontend/src/components/threads/ThreadChangesView.tsx
@@ -7,7 +7,7 @@
* - baseBranch changes
* - refreshTrigger changes (for when main branch is updated)
*/
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import { ChevronDown, ChevronRight, FilePlus, FileEdit, Trash2 } from 'lucide-react';
import { GitAPI } from '../../services/git-api';
import { DiffViewer } from '../agents/DiffViewer';
diff --git a/frontend/src/components/threads/ThreadSelector.tsx b/frontend/src/components/threads/ThreadSelector.tsx
index 3c80675..d5c1d6f 100644
--- a/frontend/src/components/threads/ThreadSelector.tsx
+++ b/frontend/src/components/threads/ThreadSelector.tsx
@@ -29,12 +29,18 @@ interface ThreadSelectorProps {
const StatusIcon: React.FC<{ status: ThreadStatus }> = ({ status }) => {
switch (status) {
+ case "active":
+ return ;
+ case "archived":
+ return ;
case "working":
return ;
case "need_help":
return ;
case "review":
return ;
+ case "resolving":
+ return ;
case "accepted":
return ;
case "rejected":
@@ -43,9 +49,12 @@ const StatusIcon: React.FC<{ status: ThreadStatus }> = ({ status }) => {
};
const statusLabel: Record = {
+ active: "Active",
+ archived: "Archived",
working: "Working",
need_help: "Needs help",
review: "Ready for review",
+ resolving: "Resolving conflicts",
accepted: "Accepted",
rejected: "Rejected",
};
diff --git a/frontend/src/components/ui/message.tsx b/frontend/src/components/ui/message.tsx
index 069fcec..6ef731a 100644
--- a/frontend/src/components/ui/message.tsx
+++ b/frontend/src/components/ui/message.tsx
@@ -6,7 +6,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
-import { Markdown, MarkdownLinkHandler } from "./markdown";
+import { Markdown } from "./markdown";
+import type { MarkdownLinkHandler } from "./markdown";
export type MessageProps = {
children: React.ReactNode;
diff --git a/frontend/src/components/views/ViewRuntime.tsx b/frontend/src/components/views/ViewRuntime.tsx
new file mode 100644
index 0000000..8e9a82d
--- /dev/null
+++ b/frontend/src/components/views/ViewRuntime.tsx
@@ -0,0 +1,490 @@
+/**
+ * Runtime TSX compilation and execution using Sucrase.
+ * Provides a sandboxed environment for user-defined views.
+ * Supports component reuse via useComponent hook.
+ */
+
+import React, { useState, useEffect, useMemo, useCallback, memo, createContext, useContext, useRef } from 'react';
+import type { ErrorInfo } from 'react';
+import { transform } from 'sucrase';
+import { useCSV } from '../../hooks/useCSV';
+import type { DataRow, SaveStatus } from '../../hooks/useCSV';
+import { usePage } from '../../hooks/usePage';
+import { useAuth } from '../../contexts/AuthContext';
+import type { CollabStatus } from '../editor/CollaborativeCodeMirrorEditor';
+import { getApiUrl } from '../../utils/url';
+
+// UI components available in view scope
+import { Button } from '../ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
+import { Badge } from '../ui/badge';
+
+// Cache for compiled components (shared across all ViewRuntime instances)
+const componentCache = new Map | null;
+ error: Error | null;
+ loading: boolean;
+ promise: Promise | null;
+}>();
+
+// Context for providing component loader to user views
+interface ComponentRegistryContextValue {
+ getComponent: (path: string) => React.ComponentType | null;
+ isLoading: (path: string) => boolean;
+ getError: (path: string) => Error | null;
+ loadComponent: (path: string) => void;
+}
+
+interface ViewRuntimeProps {
+ /** TSX source code to compile and render */
+ tsxCode: string;
+ /** Path to the view file (for error context) */
+ pagePath: string;
+ /** Callback when compilation/runtime error occurs */
+ onError?: (error: Error) => void;
+ /** Additional props to pass to the view component */
+ viewProps?: Record;
+ /** Callback for collab status changes (save status) */
+ onCollabStatusChange?: (status: CollabStatus) => void;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+}
+
+// Context for tracking save status across multiple useCSV calls
+interface SaveStatusContextValue {
+ registerStatus: (id: string, status: SaveStatus) => void;
+ unregisterStatus: (id: string) => void;
+}
+
+const SaveStatusContext = createContext(null);
+const ComponentRegistryContext = createContext(null);
+
+/**
+ * Compile TSX source code to a React component.
+ * Uses the same compilation logic as the main ViewRuntime.
+ */
+function compileTSXToComponent(
+ tsxCode: string,
+ scope: Record
+): React.ComponentType {
+ const transformed = transform(tsxCode, {
+ transforms: ['typescript', 'jsx', 'imports'],
+ jsxRuntime: 'classic',
+ jsxPragma: 'React.createElement',
+ jsxFragmentPragma: 'React.Fragment',
+ });
+
+ const scopeKeys = Object.keys(scope);
+ const scopeValues = Object.values(scope);
+
+ const wrappedCode = `
+ "use strict";
+ const exports = {};
+ const module = { exports: {} };
+ ${transformed.code}
+ if (exports.default) return exports.default;
+ if (module.exports.default) return module.exports.default;
+ if (Object.keys(module.exports).length > 0) return module.exports;
+ return exports;
+ `;
+
+ const fn = new Function(...scopeKeys, wrappedCode);
+ const result = fn(...scopeValues);
+
+ if (typeof result === 'function') {
+ return result;
+ } else if (result && typeof result.default === 'function') {
+ return result.default;
+ }
+ throw new Error('Component must export a React component as default export');
+}
+
+/**
+ * Wrapper hook that tracks save status and reports to context.
+ */
+function useCSVWithStatus(
+ pageId: string,
+ initialHeaders?: string[]
+) {
+ const result = useCSV(pageId, initialHeaders);
+ const context = useContext(SaveStatusContext);
+ const idRef = useRef(`${pageId}-${Math.random()}`);
+
+ useEffect(() => {
+ if (context) {
+ context.registerStatus(idRef.current, result.saveStatus);
+ }
+ return () => {
+ if (context) {
+ context.unregisterStatus(idRef.current);
+ }
+ };
+ }, [context, result.saveStatus]);
+
+ return result;
+}
+
+/**
+ * Hook for loading and using other TSX components.
+ * Returns the component if loaded, null if loading, or throws if error.
+ *
+ * Usage in views:
+ * const DataTable = useComponent('components/DataTable.tsx');
+ * if (!DataTable) return Loading...
;
+ * return ;
+ */
+function createUseComponentHook(_forceUpdate: () => void) {
+ return function useComponent(componentPath: string): React.ComponentType | null {
+ const registry = useContext(ComponentRegistryContext);
+
+ useEffect(() => {
+ if (registry) {
+ registry.loadComponent(componentPath);
+ }
+ }, [componentPath, registry]);
+
+ if (!registry) {
+ console.warn('useComponent: ComponentRegistryContext not available');
+ return null;
+ }
+
+ const error = registry.getError(componentPath);
+ if (error) {
+ throw error;
+ }
+
+ return registry.getComponent(componentPath);
+ };
+}
+
+/**
+ * Error boundary for catching runtime errors in user views.
+ */
+class ViewErrorBoundary extends React.Component<
+ { children: React.ReactNode; onError?: (error: Error) => void },
+ ErrorBoundaryState
+> {
+ constructor(props: { children: React.ReactNode; onError?: (error: Error) => void }) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ console.error('View runtime error:', error, errorInfo);
+ this.props.onError?.(error);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
Runtime Error
+
+ {this.state.error?.message}
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+/**
+ * Create the scope object available to user views.
+ * Includes React, hooks, UI components, useCSV, usePage, and useComponent.
+ */
+const createScope = (
+ useCSVHook: typeof useCSV,
+ usePageHook: typeof usePage,
+ useComponentHook: (path: string) => React.ComponentType | null
+) => ({
+ // React
+ React,
+ useState,
+ useEffect,
+ useMemo,
+ useCallback,
+ memo,
+ useRef,
+
+ // Auth hook (needed by useCSV)
+ useAuth,
+
+ // CSV data hook (wrapped to track save status)
+ useCSV: useCSVHook,
+
+ // Generic text page hook
+ usePage: usePageHook,
+
+ // Legacy alias for backwards compatibility
+ useDataPage: useCSVHook,
+
+ // Component loader hook (for reusing other TSX components)
+ useComponent: useComponentHook,
+
+ // UI components
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ Badge,
+
+ // Basic HTML table components (for data display)
+ Table: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => (
+
+ ),
+ TableHead: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => (
+ {children}
+ ),
+ TableBody: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => (
+ {children}
+ ),
+ TableRow: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => (
+ {children}
+ ),
+ TableCell: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => (
+ {children}
+ ),
+ TableHeader: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => (
+ {children}
+ ),
+});
+
+/**
+ * ViewRuntime component - compiles and renders TSX code at runtime.
+ */
+export const ViewRuntime: React.FC = ({
+ tsxCode,
+ pagePath,
+ onError,
+ viewProps = {},
+ onCollabStatusChange,
+}) => {
+ const [Component, setComponent] = useState | null>(null);
+ const [error, setError] = useState(null);
+ const [saveStatuses, setSaveStatuses] = useState>(new Map());
+ const [, forceUpdate] = useState({});
+ // Track loaded components to trigger re-renders when they're loaded
+ const [_loadedComponents, setLoadedComponents] = useState | null>>(new Map());
+ void _loadedComponents; // Used to trigger re-renders
+
+ // Create useComponent hook with forceUpdate
+ const useComponentHook = useMemo(() => createUseComponentHook(() => forceUpdate({})), []);
+
+ // Aggregate save status from all useCSV hooks
+ const aggregatedSaveStatus = useMemo((): SaveStatus => {
+ if (saveStatuses.size === 0) return 'saved';
+ const statuses = Array.from(saveStatuses.values());
+ if (statuses.includes('saving')) return 'saving';
+ if (statuses.includes('dirty')) return 'dirty';
+ return 'saved';
+ }, [saveStatuses]);
+
+ // Notify parent of save status changes
+ useEffect(() => {
+ if (onCollabStatusChange) {
+ onCollabStatusChange({
+ connectionStatus: 'connected',
+ remoteUsers: [],
+ saveStatus: aggregatedSaveStatus,
+ });
+ }
+ }, [aggregatedSaveStatus, onCollabStatusChange]);
+
+ // Context value for tracking save statuses
+ const saveStatusContextValue = useMemo((): SaveStatusContextValue => ({
+ registerStatus: (id: string, status: SaveStatus) => {
+ setSaveStatuses(prev => {
+ const next = new Map(prev);
+ next.set(id, status);
+ return next;
+ });
+ },
+ unregisterStatus: (id: string) => {
+ setSaveStatuses(prev => {
+ const next = new Map(prev);
+ next.delete(id);
+ return next;
+ });
+ },
+ }), []);
+
+ // Component registry context value for loading other TSX components
+ const componentRegistryValue = useMemo((): ComponentRegistryContextValue => ({
+ getComponent: (path: string) => {
+ const cached = componentCache.get(path);
+ return cached?.component || null;
+ },
+ isLoading: (path: string) => {
+ const cached = componentCache.get(path);
+ return cached?.loading || false;
+ },
+ getError: (path: string) => {
+ const cached = componentCache.get(path);
+ return cached?.error || null;
+ },
+ loadComponent: async (path: string) => {
+ // Check if already cached or loading
+ const existing = componentCache.get(path);
+ if (existing) {
+ return;
+ }
+
+ // Mark as loading
+ componentCache.set(path, {
+ component: null,
+ error: null,
+ loading: true,
+ promise: null,
+ });
+
+ try {
+ // Fetch the TSX source from API
+ const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(path)}`);
+ if (!response.ok) {
+ throw new Error(`Failed to load component: ${path}`);
+ }
+ const page = await response.json();
+ const tsxSource = page.content || '';
+
+ // Compile the component using the same scope
+ // Note: This creates a simplified scope for reusable components
+ const componentScope = createScope(useCSVWithStatus, usePage, useComponentHook);
+ const compiled = compileTSXToComponent(tsxSource, componentScope);
+
+ componentCache.set(path, {
+ component: compiled,
+ error: null,
+ loading: false,
+ promise: null,
+ });
+
+ // Update local state to trigger re-render
+ setLoadedComponents(prev => {
+ const next = new Map(prev);
+ next.set(path, compiled);
+ return next;
+ });
+
+ console.log(`Loaded component: ${path}`);
+ } catch (e) {
+ const err = e instanceof Error ? e : new Error(String(e));
+ console.error(`Failed to compile component ${path}:`, err);
+ componentCache.set(path, {
+ component: null,
+ error: err,
+ loading: false,
+ promise: null,
+ });
+
+ // Update local state to trigger re-render
+ setLoadedComponents(prev => {
+ const next = new Map(prev);
+ next.set(path, null);
+ return next;
+ });
+ }
+ },
+ }), [useComponentHook]);
+
+ useEffect(() => {
+ if (!tsxCode.trim()) {
+ setComponent(null);
+ setError(null);
+ return;
+ }
+
+ try {
+ // Transform TSX to JS using Sucrase
+ // Include 'imports' to convert ES modules to CommonJS
+ const transformed = transform(tsxCode, {
+ transforms: ['typescript', 'jsx', 'imports'],
+ jsxRuntime: 'classic',
+ jsxPragma: 'React.createElement',
+ jsxFragmentPragma: 'React.Fragment',
+ });
+
+ // Create function from transformed code
+ // Use the wrapped useCSV that tracks save status
+ const scope = createScope(useCSVWithStatus, usePage, useComponentHook);
+ const scopeKeys = Object.keys(scope);
+ const scopeValues = Object.values(scope);
+
+ // Wrap in function that returns the default export
+ // Sucrase with 'imports' transform sets exports.default for default exports
+ const wrappedCode = `
+ "use strict";
+ const exports = {};
+ const module = { exports: {} };
+ ${transformed.code}
+ // Check exports.default first (Sucrase uses this for default exports)
+ if (exports.default) return exports.default;
+ if (module.exports.default) return module.exports.default;
+ // Fall back to module.exports if it has properties
+ if (Object.keys(module.exports).length > 0) return module.exports;
+ return exports;
+ `;
+
+ // Create and execute function with scope
+ const fn = new Function(...scopeKeys, wrappedCode);
+ const result = fn(...scopeValues);
+
+ if (typeof result === 'function') {
+ setComponent(() => result);
+ setError(null);
+ } else if (result && typeof result.default === 'function') {
+ setComponent(() => result.default);
+ setError(null);
+ } else {
+ throw new Error('View must export a React component as default export');
+ }
+ } catch (e) {
+ const err = e instanceof Error ? e : new Error(String(e));
+ console.error('View compilation error:', err);
+ setError(err);
+ setComponent(null);
+ onError?.(err);
+ }
+ }, [tsxCode, onError, useComponentHook]);
+
+ if (error) {
+ return (
+
+
Compilation Error
+
File: {pagePath}
+
+ {error.message}
+
+
+ );
+ }
+
+ if (!Component) {
+ return (
+
+ Loading view...
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default ViewRuntime;
diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx
index 54b1eee..16f757d 100644
--- a/frontend/src/contexts/AppContext.tsx
+++ b/frontend/src/contexts/AppContext.tsx
@@ -6,6 +6,7 @@ import { treeApi } from '../services/tree-api';
import type { Thread, ThreadMessage, ThreadStatus } from '../types/thread';
import { useAuth } from './AuthContext';
import { invalidateSessions } from '../services/collab';
+import { getApiUrl } from '../utils/url';
interface AppState {
pages: Page[];
@@ -286,6 +287,9 @@ interface AppContextType {
moveItem: (sourcePath: string, targetParentPath: string | null, newOrder: number) => Promise;
createFolder: (name: string, parentPath?: string) => Promise;
deleteFolder: (path: string) => Promise;
+ // Branch actions
+ refreshBranches: () => Promise;
+ checkoutBranch: (branch: string) => Promise;
};
websocket: {
sendMessage: (message: unknown) => void;
@@ -564,7 +568,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
try {
- const response = await fetch('http://localhost:8000/api/pages');
+ const response = await fetch(`${getApiUrl()}/api/pages`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -583,7 +587,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
// The page content will be updated when the user navigates or refreshes
if (!currentPageRef.current && backendPages.length > 0) {
try {
- const pageResponse = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(backendPages[0].path)}`);
+ const pageResponse = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(backendPages[0].path)}`);
if (pageResponse.ok) {
const fullPage = await pageResponse.json();
currentPageRef.current = fullPage; // Update ref immediately to prevent duplicate dispatches
@@ -614,7 +618,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
try {
- const pageResponse = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(pageTitle)}`);
+ const pageResponse = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pageTitle)}`);
if (pageResponse.ok) {
const fullPage = await pageResponse.json();
// Only update if content actually changed (compare by content length and first/last chars as quick check)
@@ -637,7 +641,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
try {
- const pageResponse = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(currentPageRef.current.path)}`);
+ const pageResponse = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(currentPageRef.current.path)}`);
if (pageResponse.ok) {
const fullPage = await pageResponse.json();
currentPageRef.current = fullPage;
@@ -717,7 +721,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
dispatch({ type: 'SET_ERROR', payload: null });
try {
- const response = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(title)}`, {
+ const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(title)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -741,7 +745,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
dispatch({ type: 'SET_ERROR', payload: null });
try {
- const response = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(path)}?author=user`, {
+ const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(path)}?author=user`, {
method: 'DELETE',
});
@@ -769,7 +773,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
dispatch({ type: 'SET_ERROR', payload: null });
try {
- const response = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(page.path)}`);
+ const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(page.path)}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -796,7 +800,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
dispatch({ type: 'SET_ERROR', payload: null });
try {
- const response = await fetch('http://localhost:8000/api/pages', {
+ const response = await fetch(`${getApiUrl()}/api/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -906,7 +910,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
await treeApi.moveItem({ sourcePath, targetParentPath, newOrder });
const tree = await treeApi.getTree();
dispatch({ type: 'SET_PAGE_TREE', payload: tree });
- const response = await fetch('http://localhost:8000/api/pages');
+ const response = await fetch(`${getApiUrl()}/api/pages`);
if (response.ok) {
const data = await response.json();
dispatch({ type: 'SET_PAGES', payload: data.pages });
@@ -943,6 +947,41 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
console.error('Failed to delete folder:', err);
dispatch({ type: 'SET_ERROR', payload: 'Failed to delete folder' });
}
+ },
+
+ // Branch actions
+ refreshBranches: async () => {
+ // Just fetch to verify endpoint is accessible - branch list is managed elsewhere
+ try {
+ await fetch(`${getApiUrl()}/api/git/branches`);
+ } catch (err) {
+ console.error('Failed to refresh branches:', err);
+ }
+ },
+
+ checkoutBranch: async (branch: string) => {
+ try {
+ const response = await fetch(`${getApiUrl()}/api/git/checkout`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ branch }),
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to checkout branch: ${response.statusText}`);
+ }
+ dispatch({ type: 'SET_CURRENT_BRANCH', payload: branch });
+ // Reload pages and tree after checkout
+ const pagesResponse = await fetch(`${getApiUrl()}/api/pages`);
+ if (pagesResponse.ok) {
+ const data = await pagesResponse.json();
+ dispatch({ type: 'SET_PAGES', payload: data.pages });
+ }
+ const tree = await treeApi.getTree();
+ dispatch({ type: 'SET_PAGE_TREE', payload: tree });
+ } catch (err) {
+ console.error('Failed to checkout branch:', err);
+ dispatch({ type: 'SET_ERROR', payload: 'Failed to checkout branch' });
+ }
}
};
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
index 12315bf..dba8341 100644
--- a/frontend/src/contexts/AuthContext.tsx
+++ b/frontend/src/contexts/AuthContext.tsx
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
-import { User, initAuth, clearGuestId, getStoredGuestId } from '../services/auth-api';
+import type { User } from '../services/auth-api';
+import { initAuth, clearGuestId, getStoredGuestId } from '../services/auth-api';
interface AuthContextType {
user: User | null;
diff --git a/frontend/src/hooks/useCSV.ts b/frontend/src/hooks/useCSV.ts
new file mode 100644
index 0000000..18232a1
--- /dev/null
+++ b/frontend/src/hooks/useCSV.ts
@@ -0,0 +1,463 @@
+/**
+ * Hook for collaborative CSV data editing using Yjs.
+ * Provides access to CSV data via Y.Array with real-time sync.
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import * as Y from 'yjs';
+import { WebsocketProvider } from 'y-websocket';
+import { useAuth } from '../contexts/AuthContext';
+import { stringToColor, getInitials, getDisplayName } from '../utils/colors';
+import { getWsUrl, getApiUrl } from '../utils/url';
+import { updatePage } from '../services/api';
+
+// Debounce delay for auto-save (milliseconds)
+const SAVE_DEBOUNCE_MS = 2000;
+
+export interface DataRow {
+ [key: string]: string | number | boolean;
+}
+
+export type SaveStatus = 'saved' | 'saving' | 'dirty';
+
+export interface UseCSVResult {
+ /** Current rows from the data file */
+ rows: T[];
+ /** Column headers (from first row or explicitly set) */
+ headers: string[];
+ /** Update a specific cell value */
+ updateCell: (rowIndex: number, column: keyof T, value: string) => void;
+ /** Add a new row at the end */
+ addRow: (row: T) => void;
+ /** Delete a row by index */
+ deleteRow: (rowIndex: number) => void;
+ /** Insert a row at a specific index */
+ insertRow: (index: number, row: T) => void;
+ /** Whether the data has been loaded */
+ isLoaded: boolean;
+ /** Whether the data is currently syncing */
+ isSyncing: boolean;
+ /** Connection status */
+ connectionStatus: 'connecting' | 'connected' | 'disconnected';
+ /** Save status for the data */
+ saveStatus: SaveStatus;
+}
+
+// Save status listeners per page
+type SaveStatusListener = (status: SaveStatus) => void;
+const saveStatusListeners = new Map>();
+
+function notifySaveStatus(pageId: string, status: SaveStatus): void {
+ const listeners = saveStatusListeners.get(pageId);
+ if (listeners) {
+ listeners.forEach(listener => listener(status));
+ }
+}
+
+// Active data sessions by page path
+const activeDataSessions = new Map>;
+ headers: string[];
+ saveTimer: ReturnType | null;
+ lastSavedContent: string;
+ isInitialLoad: boolean;
+ saveStatus: SaveStatus;
+}>();
+
+/**
+ * Serialize Y.Array to CSV and save to backend.
+ */
+async function saveCSVData(pageId: string): Promise {
+ const session = activeDataSessions.get(pageId);
+ if (!session) return;
+
+ const content = serializeDataToCSV(session.yArray);
+
+ // Skip if content hasn't changed
+ if (content === session.lastSavedContent) {
+ session.saveStatus = 'saved';
+ notifySaveStatus(pageId, 'saved');
+ return;
+ }
+
+ session.saveStatus = 'saving';
+ notifySaveStatus(pageId, 'saving');
+
+ try {
+ await updatePage(pageId, {
+ content,
+ author: 'collaborative',
+ });
+ session.lastSavedContent = content;
+ session.saveStatus = 'saved';
+ notifySaveStatus(pageId, 'saved');
+ console.log(`Saved CSV data: ${pageId}`);
+ } catch (error) {
+ console.error(`Failed to save CSV ${pageId}:`, error);
+ // Revert to dirty so it will retry
+ session.saveStatus = 'dirty';
+ notifySaveStatus(pageId, 'dirty');
+ }
+}
+
+/**
+ * Schedule a debounced save for CSV data.
+ */
+function scheduleSaveCSV(pageId: string): void {
+ const session = activeDataSessions.get(pageId);
+ if (!session || session.isInitialLoad) return;
+
+ // Mark as dirty
+ session.saveStatus = 'dirty';
+ notifySaveStatus(pageId, 'dirty');
+
+ // Clear existing timer
+ if (session.saveTimer) {
+ clearTimeout(session.saveTimer);
+ }
+
+ // Schedule new save
+ session.saveTimer = setTimeout(() => {
+ saveCSVData(pageId);
+ session.saveTimer = null;
+ }, SAVE_DEBOUNCE_MS);
+}
+
+/**
+ * Hook for collaborative CSV data editing.
+ * Syncs CSV data via Yjs Y.Array.
+ *
+ * @param pageId - The path to the CSV file (e.g., "data/tasks.csv")
+ * @param initialHeaders - Optional initial headers if creating new file
+ */
+export function useCSV(
+ pageId: string,
+ initialHeaders?: string[]
+): UseCSVResult {
+ const { userId } = useAuth();
+ const [rows, setRows] = useState([]);
+ const [headers, setHeaders] = useState(initialHeaders || []);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
+ const [saveStatus, setSaveStatus] = useState('saved');
+ const [session, setSession] = useState ? V : never>();
+
+ useEffect(() => {
+ if (!userId || !pageId || !pageId.endsWith('.csv')) {
+ return;
+ }
+
+ // Check for existing session
+ let existingSession = activeDataSessions.get(pageId);
+ let isNewSession = false;
+
+ if (!existingSession) {
+ isNewSession = true;
+ // Create new Yjs document and provider
+ const doc = new Y.Doc();
+ const wsUrl = getWsUrl('/ws/collab');
+
+ const provider = new WebsocketProvider(wsUrl, pageId, doc, {
+ connect: true,
+ params: { userId, clientId: userId },
+ });
+
+ // Set up awareness
+ provider.awareness.setLocalStateField('user', {
+ id: userId,
+ name: getDisplayName(userId),
+ color: stringToColor(userId),
+ initials: getInitials(userId),
+ });
+
+ // Get Y.Array for data
+ const yArray = doc.getArray>('data');
+
+ existingSession = {
+ doc,
+ provider,
+ yArray,
+ headers: initialHeaders || [],
+ saveTimer: null,
+ lastSavedContent: '',
+ isInitialLoad: true,
+ saveStatus: 'saved' as SaveStatus,
+ };
+ activeDataSessions.set(pageId, existingSession);
+
+ // Set up auto-save observer
+ yArray.observeDeep(() => {
+ scheduleSaveCSV(pageId);
+ });
+
+ // Mark initial load complete after sync settles
+ provider.on('sync', (synced: boolean) => {
+ if (synced) {
+ setTimeout(() => {
+ const session = activeDataSessions.get(pageId);
+ if (session) {
+ session.isInitialLoad = false;
+ session.lastSavedContent = serializeDataToCSV(session.yArray);
+ console.log(`CSV initial load complete: ${pageId}`);
+ }
+ }, 500);
+ }
+ });
+ }
+
+ const { provider, yArray, doc } = existingSession;
+
+ // Handle connection status - always set up for this hook instance
+ const handleStatus = (event: { status: string }) => {
+ setConnectionStatus(
+ event.status === 'connected' ? 'connected' :
+ event.status === 'connecting' ? 'connecting' :
+ 'disconnected'
+ );
+ };
+ provider.on('status', handleStatus);
+
+ // Handle sync - fetch and initialize CSV content if empty
+ const handleSync = async (synced: boolean) => {
+ if (synced) {
+ setIsSyncing(false);
+
+ // If Y.Array is empty, fetch CSV content from server and initialize
+ if (yArray.length === 0) {
+ try {
+ const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pageId)}`);
+ if (response.ok) {
+ const page = await response.json();
+ if (page.content) {
+ initializeDataFromCSV(pageId, page.content, doc);
+ }
+ }
+ } catch (err) {
+ console.error('Failed to fetch CSV content:', err);
+ }
+ }
+
+ setIsLoaded(true);
+ }
+ };
+ provider.on('sync', handleSync);
+
+ // If reusing an existing session that's already synced, mark as loaded immediately
+ if (!isNewSession && provider.synced) {
+ setIsLoaded(true);
+ setConnectionStatus('connected');
+ }
+
+ setSession(existingSession);
+
+ // Convert Y.Array to plain array
+ const updateRows = () => {
+ const yArray = existingSession!.yArray;
+ const newRows: T[] = yArray.toArray().map(yMap => {
+ const obj: any = {};
+ yMap.forEach((value, key) => {
+ obj[key] = value;
+ });
+ return obj as T;
+ });
+ setRows(newRows);
+
+ // Extract headers from first row if not set
+ if (newRows.length > 0 && headers.length === 0) {
+ const firstRowHeaders = Object.keys(newRows[0]);
+ setHeaders(firstRowHeaders);
+ existingSession!.headers = firstRowHeaders;
+ }
+ };
+
+ // Initial load
+ updateRows();
+
+ // Observe changes - use observeDeep to catch Y.Map updates inside the array
+ existingSession.yArray.observeDeep(updateRows);
+
+ // Subscribe to save status changes
+ if (!saveStatusListeners.has(pageId)) {
+ saveStatusListeners.set(pageId, new Set());
+ }
+ const handleSaveStatus = (status: SaveStatus) => {
+ setSaveStatus(status);
+ };
+ saveStatusListeners.get(pageId)!.add(handleSaveStatus);
+
+ // Set initial save status from session
+ setSaveStatus(existingSession.saveStatus);
+
+ return () => {
+ existingSession?.yArray.unobserveDeep(updateRows);
+ // Remove event handlers to avoid duplicates
+ provider.off('status', handleStatus);
+ provider.off('sync', handleSync);
+ // Remove save status listener
+ saveStatusListeners.get(pageId)?.delete(handleSaveStatus);
+ // Don't destroy session - let it persist for view switching
+ };
+ }, [pageId, userId, initialHeaders]);
+
+ const updateCell = useCallback((rowIndex: number, column: keyof T, value: string) => {
+ if (!session?.yArray) return;
+ const yMap = session.yArray.get(rowIndex);
+ if (yMap) {
+ yMap.set(column as string, value);
+ }
+ }, [session]);
+
+ const addRow = useCallback((row: T) => {
+ if (!session?.yArray) return;
+ const yMap = new Y.Map();
+ Object.entries(row).forEach(([key, value]) => {
+ yMap.set(key, value);
+ });
+ session.yArray.push([yMap]);
+ }, [session]);
+
+ const deleteRow = useCallback((rowIndex: number) => {
+ if (!session?.yArray) return;
+ session.yArray.delete(rowIndex, 1);
+ }, [session]);
+
+ const insertRow = useCallback((index: number, row: T) => {
+ if (!session?.yArray) return;
+ const yMap = new Y.Map();
+ Object.entries(row).forEach(([key, value]) => {
+ yMap.set(key, value);
+ });
+ session.yArray.insert(index, [yMap]);
+ }, [session]);
+
+ return {
+ rows,
+ headers,
+ updateCell,
+ addRow,
+ deleteRow,
+ insertRow,
+ isLoaded,
+ isSyncing,
+ connectionStatus,
+ saveStatus,
+ };
+}
+
+/**
+ * Initialize a data page from CSV content.
+ * Call this when first loading a CSV file to populate the Y.Array.
+ */
+export function initializeDataFromCSV(
+ _pageId: string,
+ csvContent: string,
+ doc: Y.Doc
+): void {
+ const yArray = doc.getArray>('data');
+
+ // Don't reinitialize if already has data
+ if (yArray.length > 0) return;
+
+ const lines = csvContent.split('\n').filter(l => l.trim());
+ if (lines.length === 0) return;
+
+ // Parse headers
+ const headers = parseCSVLine(lines[0]);
+
+ // Parse rows in a transaction
+ doc.transact(() => {
+ lines.slice(1).forEach(line => {
+ const values = parseCSVLine(line);
+ const yMap = new Y.Map();
+ headers.forEach((h, i) => {
+ yMap.set(h, values[i] ?? '');
+ });
+ yArray.push([yMap]);
+ });
+ });
+}
+
+/**
+ * Serialize Y.Array data to CSV string.
+ */
+export function serializeDataToCSV(yArray: Y.Array>): string {
+ const rows = yArray.toArray();
+ if (rows.length === 0) return '';
+
+ // Get headers from first row
+ const headers: string[] = [];
+ rows[0].forEach((_, key) => headers.push(key));
+
+ // Build CSV
+ const lines = [headers.join(',')];
+ rows.forEach(yMap => {
+ const values = headers.map(h => {
+ const v = yMap.get(h);
+ // Escape and quote if needed
+ const str = String(v ?? '');
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+ });
+ lines.push(values.join(','));
+ });
+
+ return lines.join('\n');
+}
+
+/**
+ * Parse a CSV line into values.
+ */
+function parseCSVLine(line: string): string[] {
+ const result: string[] = [];
+ let current = '';
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+ if (char === '"') {
+ if (inQuotes && line[i + 1] === '"') {
+ current += '"';
+ i++; // Skip next quote
+ } else {
+ inQuotes = !inQuotes;
+ }
+ } else if (char === ',' && !inQuotes) {
+ result.push(current);
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+ result.push(current);
+ return result;
+}
+
+/**
+ * Clean up a data session when navigating away.
+ */
+export function destroyDataSession(pageId: string): void {
+ const session = activeDataSessions.get(pageId);
+ if (session) {
+ // Clear pending save timer
+ if (session.saveTimer) {
+ clearTimeout(session.saveTimer);
+ }
+ session.provider.awareness.setLocalState(null);
+ session.provider.disconnect();
+ session.provider.destroy();
+ session.doc.destroy();
+ activeDataSessions.delete(pageId);
+ }
+}
+
+/**
+ * Check if a data session exists.
+ */
+export function hasDataSession(pageId: string): boolean {
+ return activeDataSessions.has(pageId);
+}
diff --git a/frontend/src/hooks/usePage.ts b/frontend/src/hooks/usePage.ts
new file mode 100644
index 0000000..65d5f9f
--- /dev/null
+++ b/frontend/src/hooks/usePage.ts
@@ -0,0 +1,313 @@
+/**
+ * Hook for collaborative text page editing using Yjs.
+ * Provides access to any text file content with real-time sync.
+ * Works with .md, .txt, .json, .tsx, and any other text files.
+ */
+
+import { useState, useEffect, useCallback } from 'react';
+import * as Y from 'yjs';
+import { WebsocketProvider } from 'y-websocket';
+import { useAuth } from '../contexts/AuthContext';
+import { stringToColor, getInitials, getDisplayName } from '../utils/colors';
+import { getWsUrl, getApiUrl } from '../utils/url';
+import { updatePage } from '../services/api';
+
+// Debounce delay for auto-save (milliseconds)
+const SAVE_DEBOUNCE_MS = 2000;
+
+export type SaveStatus = 'saved' | 'saving' | 'dirty';
+
+export interface UsePageResult {
+ /** Current text content of the file */
+ content: string;
+ /** Update the content */
+ setContent: (content: string) => void;
+ /** Whether the content has been loaded */
+ isLoaded: boolean;
+ /** Whether the content is currently syncing */
+ isSyncing: boolean;
+ /** Connection status */
+ connectionStatus: 'connecting' | 'connected' | 'disconnected';
+ /** Save status for the content */
+ saveStatus: SaveStatus;
+}
+
+// Save status listeners per page
+type SaveStatusListener = (status: SaveStatus) => void;
+const saveStatusListeners = new Map>();
+
+function notifySaveStatus(pageId: string, status: SaveStatus): void {
+ const listeners = saveStatusListeners.get(pageId);
+ if (listeners) {
+ listeners.forEach(listener => listener(status));
+ }
+}
+
+// Active text sessions by page path
+const activeTextSessions = new Map | null;
+ lastSavedContent: string;
+ isInitialLoad: boolean;
+ saveStatus: SaveStatus;
+}>();
+
+/**
+ * Save text content to backend.
+ */
+async function saveTextContent(pageId: string): Promise {
+ const session = activeTextSessions.get(pageId);
+ if (!session) return;
+
+ const content = session.yText.toString();
+
+ // Skip if content hasn't changed
+ if (content === session.lastSavedContent) {
+ session.saveStatus = 'saved';
+ notifySaveStatus(pageId, 'saved');
+ return;
+ }
+
+ session.saveStatus = 'saving';
+ notifySaveStatus(pageId, 'saving');
+
+ try {
+ await updatePage(pageId, {
+ content,
+ author: 'collaborative',
+ });
+ session.lastSavedContent = content;
+ session.saveStatus = 'saved';
+ notifySaveStatus(pageId, 'saved');
+ console.log(`Saved text content: ${pageId}`);
+ } catch (error) {
+ console.error(`Failed to save text ${pageId}:`, error);
+ // Revert to dirty so it will retry
+ session.saveStatus = 'dirty';
+ notifySaveStatus(pageId, 'dirty');
+ }
+}
+
+/**
+ * Schedule a debounced save for text content.
+ */
+function scheduleSaveText(pageId: string): void {
+ const session = activeTextSessions.get(pageId);
+ if (!session || session.isInitialLoad) return;
+
+ // Mark as dirty
+ session.saveStatus = 'dirty';
+ notifySaveStatus(pageId, 'dirty');
+
+ // Clear existing timer
+ if (session.saveTimer) {
+ clearTimeout(session.saveTimer);
+ }
+
+ // Schedule new save
+ session.saveTimer = setTimeout(() => {
+ saveTextContent(pageId);
+ session.saveTimer = null;
+ }, SAVE_DEBOUNCE_MS);
+}
+
+/**
+ * Hook for collaborative text page editing.
+ * Syncs text content via Yjs Y.Text.
+ *
+ * @param pageId - The path to the text file (e.g., "notes.md", "config.json")
+ */
+export function usePage(pageId: string): UsePageResult {
+ const { userId } = useAuth();
+ const [content, setContentState] = useState('');
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
+ const [saveStatus, setSaveStatus] = useState('saved');
+ const [session, setSession] = useState ? V : never>();
+
+ useEffect(() => {
+ if (!userId || !pageId) {
+ return;
+ }
+
+ // Check for existing session
+ let existingSession = activeTextSessions.get(pageId);
+ let isNewSession = false;
+
+ if (!existingSession) {
+ isNewSession = true;
+ // Create new Yjs document and provider
+ const doc = new Y.Doc();
+ const wsUrl = getWsUrl('/ws/collab');
+
+ const provider = new WebsocketProvider(wsUrl, pageId, doc, {
+ connect: true,
+ params: { userId, clientId: userId },
+ });
+
+ // Set up awareness
+ provider.awareness.setLocalStateField('user', {
+ id: userId,
+ name: getDisplayName(userId),
+ color: stringToColor(userId),
+ initials: getInitials(userId),
+ });
+
+ // Get Y.Text for content
+ const yText = doc.getText('content');
+
+ existingSession = {
+ doc,
+ provider,
+ yText,
+ saveTimer: null,
+ lastSavedContent: '',
+ isInitialLoad: true,
+ saveStatus: 'saved' as SaveStatus,
+ };
+ activeTextSessions.set(pageId, existingSession);
+
+ // Set up auto-save observer
+ yText.observe(() => {
+ scheduleSaveText(pageId);
+ });
+
+ // Mark initial load complete after sync settles
+ provider.on('sync', (synced: boolean) => {
+ if (synced) {
+ setTimeout(() => {
+ const session = activeTextSessions.get(pageId);
+ if (session) {
+ session.isInitialLoad = false;
+ session.lastSavedContent = session.yText.toString();
+ console.log(`Text initial load complete: ${pageId}`);
+ }
+ }, 500);
+ }
+ });
+ }
+
+ const { provider, yText, doc } = existingSession;
+
+ // Handle connection status
+ const handleStatus = (event: { status: string }) => {
+ setConnectionStatus(
+ event.status === 'connected' ? 'connected' :
+ event.status === 'connecting' ? 'connecting' :
+ 'disconnected'
+ );
+ };
+ provider.on('status', handleStatus);
+
+ // Handle sync - fetch and initialize content if empty
+ const handleSync = async (synced: boolean) => {
+ if (synced) {
+ setIsSyncing(false);
+
+ // If Y.Text is empty, fetch content from server and initialize
+ if (yText.length === 0) {
+ try {
+ const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pageId)}`);
+ if (response.ok) {
+ const page = await response.json();
+ if (page.content) {
+ doc.transact(() => {
+ yText.insert(0, page.content);
+ });
+ }
+ }
+ } catch (err) {
+ console.error('Failed to fetch text content:', err);
+ }
+ }
+
+ setIsLoaded(true);
+ }
+ };
+ provider.on('sync', handleSync);
+
+ // If reusing an existing session that's already synced, mark as loaded immediately
+ if (!isNewSession && provider.synced) {
+ setIsLoaded(true);
+ setConnectionStatus('connected');
+ }
+
+ setSession(existingSession);
+
+ // Update content state when Y.Text changes
+ const updateContent = () => {
+ setContentState(existingSession!.yText.toString());
+ };
+
+ // Initial load
+ updateContent();
+
+ // Observe changes
+ existingSession.yText.observe(updateContent);
+
+ // Subscribe to save status changes
+ if (!saveStatusListeners.has(pageId)) {
+ saveStatusListeners.set(pageId, new Set());
+ }
+ const handleSaveStatus = (status: SaveStatus) => {
+ setSaveStatus(status);
+ };
+ saveStatusListeners.get(pageId)!.add(handleSaveStatus);
+
+ // Set initial save status from session
+ setSaveStatus(existingSession.saveStatus);
+
+ return () => {
+ existingSession?.yText.unobserve(updateContent);
+ provider.off('status', handleStatus);
+ provider.off('sync', handleSync);
+ saveStatusListeners.get(pageId)?.delete(handleSaveStatus);
+ };
+ }, [pageId, userId]);
+
+ const setContent = useCallback((newContent: string) => {
+ if (!session?.yText) return;
+
+ session.doc.transact(() => {
+ // Clear existing content and insert new
+ session.yText.delete(0, session.yText.length);
+ session.yText.insert(0, newContent);
+ });
+ }, [session]);
+
+ return {
+ content,
+ setContent,
+ isLoaded,
+ isSyncing,
+ connectionStatus,
+ saveStatus,
+ };
+}
+
+/**
+ * Clean up a text session when navigating away.
+ */
+export function destroyTextSession(pageId: string): void {
+ const session = activeTextSessions.get(pageId);
+ if (session) {
+ if (session.saveTimer) {
+ clearTimeout(session.saveTimer);
+ }
+ session.provider.awareness.setLocalState(null);
+ session.provider.disconnect();
+ session.provider.destroy();
+ session.doc.destroy();
+ activeTextSessions.delete(pageId);
+ }
+}
+
+/**
+ * Check if a text session exists.
+ */
+export function hasTextSession(pageId: string): boolean {
+ return activeTextSessions.has(pageId);
+}
diff --git a/frontend/src/hooks/usePages.ts b/frontend/src/hooks/usePages.ts
index 7f2a29e..602563e 100644
--- a/frontend/src/hooks/usePages.ts
+++ b/frontend/src/hooks/usePages.ts
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
-import { Page, PageTreeItem, ViewMode } from '../types/page';
+import type { Page, PageTreeItem, ViewMode } from '../types/page';
import { useAppContext } from '../contexts/AppContext';
+import { getApiUrl } from '../utils/url';
export interface UsePagesReturn {
pages: Page[];
@@ -12,8 +13,8 @@ export interface UsePagesReturn {
setCurrentPage: (page: Page | null) => void;
setViewMode: (mode: ViewMode) => void;
createPage: (title: string, content?: string) => Promise;
- updatePage: (id: number, updates: Partial) => Promise;
- deletePage: (id: number) => Promise;
+ updatePage: (path: string, updates: Partial) => Promise;
+ deletePage: (path: string) => Promise;
loadPages: () => Promise;
}
@@ -27,10 +28,37 @@ export const usePages = (): UsePagesReturn => {
// Generate page tree structure
const pageTree: PageTreeItem[] = pages.map(page => ({
- id: page.id,
- title: page.title
+ title: page.title,
+ path: page.path
}));
+ const loadPages = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await fetch(`${getApiUrl()}/api/pages`);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ const backendPages: Page[] = data.pages;
+
+ setPages(backendPages);
+
+ // Set current page to first page if none selected and pages exist
+ if (!currentPage && backendPages.length > 0) {
+ setCurrentPage(backendPages[0]);
+ }
+ } catch (err) {
+ setError('Failed to load pages');
+ console.error('Error loading pages:', err);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [currentPage]);
+
// Load pages from backend on initialization
useEffect(() => {
loadPages();
@@ -53,9 +81,10 @@ export const usePages = (): UsePagesReturn => {
try {
const newPage: Page = {
- id: Date.now(), // In real app, this would come from backend
+ path: title,
title,
content,
+ file_type: 'markdown',
author: 'user',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -73,7 +102,7 @@ export const usePages = (): UsePagesReturn => {
}
}, []);
- const updatePage = useCallback(async (id: number, updates: Partial) => {
+ const updatePage = useCallback(async (path: string, updates: Partial) => {
setIsLoading(true);
setError(null);
@@ -84,10 +113,10 @@ export const usePages = (): UsePagesReturn => {
};
setPages(prev => prev.map(page =>
- page.id === id ? { ...page, ...updatedPage } : page
+ page.path === path ? { ...page, ...updatedPage } : page
));
- if (currentPage?.id === id) {
+ if (currentPage?.path === path) {
setCurrentPage(prev => prev ? { ...prev, ...updatedPage } : null);
}
} catch (err) {
@@ -98,15 +127,15 @@ export const usePages = (): UsePagesReturn => {
}
}, [currentPage]);
- const deletePage = useCallback(async (id: number) => {
+ const deletePage = useCallback(async (path: string) => {
setIsLoading(true);
setError(null);
try {
- setPages(prev => prev.filter(page => page.id !== id));
+ setPages(prev => prev.filter(page => page.path !== path));
- if (currentPage?.id === id) {
- const remainingPages = pages.filter(page => page.id !== id);
+ if (currentPage?.path === path) {
+ const remainingPages = pages.filter(page => page.path !== path);
setCurrentPage(remainingPages.length > 0 ? remainingPages[0] : null);
}
} catch (err) {
@@ -117,33 +146,6 @@ export const usePages = (): UsePagesReturn => {
}
}, [currentPage, pages]);
- const loadPages = useCallback(async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- const response = await fetch('http://localhost:8000/api/pages');
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
- const backendPages: Page[] = data.pages;
-
- setPages(backendPages);
-
- // Set current page to first page if none selected and pages exist
- if (!currentPage && backendPages.length > 0) {
- setCurrentPage(backendPages[0]);
- }
- } catch (err) {
- setError('Failed to load pages');
- console.error('Error loading pages:', err);
- } finally {
- setIsLoading(false);
- }
- }, [currentPage]);
-
return {
pages,
currentPage,
@@ -158,4 +160,4 @@ export const usePages = (): UsePagesReturn => {
deletePage,
loadPages
};
-};
\ No newline at end of file
+};
diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts
index d648e07..316d654 100644
--- a/frontend/src/hooks/useWebSocket.ts
+++ b/frontend/src/hooks/useWebSocket.ts
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useCallback } from 'react';
-import { WebSocketService, createWebSocketService, WebSocketEventHandler, ConnectionStatusHandler } from '../services/websocket';
-import { WebSocketMessage } from '../types/chat';
+import { WebSocketService, createWebSocketService } from '../services/websocket';
+import type { WebSocketMessage } from '../types/chat';
export interface UseWebSocketReturn {
connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error';
diff --git a/frontend/src/services/agents-api.ts b/frontend/src/services/agents-api.ts
index 49d9288..a444f57 100644
--- a/frontend/src/services/agents-api.ts
+++ b/frontend/src/services/agents-api.ts
@@ -2,8 +2,9 @@
* API client for agent operations
*/
import type { Agent, AgentExecutionResult } from '../types/agent';
+import { getApiUrl } from '../utils/url';
-const API_BASE_URL = 'http://localhost:8000';
+const API_BASE_URL = getApiUrl();
export class AgentsAPI {
/**
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 197d667..16da5e5 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -1,6 +1,7 @@
-import { Page, PageRevision } from '../types/page';
+import type { Page, PageRevision } from '../types/page';
+import { getApiUrl } from '../utils/url';
-const API_BASE_URL = 'http://localhost:8000/api';
+const API_BASE_URL = `${getApiUrl()}/api`;
/**
* API service for interacting with the backend REST API
diff --git a/frontend/src/services/auth-api.ts b/frontend/src/services/auth-api.ts
index 22cb749..2488044 100644
--- a/frontend/src/services/auth-api.ts
+++ b/frontend/src/services/auth-api.ts
@@ -1,8 +1,9 @@
/**
* Authentication API service
*/
+import { getApiUrl } from '../utils/url';
-const API_BASE = 'http://localhost:8000';
+const API_BASE = getApiUrl();
const GUEST_ID_KEY = 'etoneto_guest_id';
export interface User {
diff --git a/frontend/src/services/collab.ts b/frontend/src/services/collab.ts
index 2091769..b9ee7d4 100644
--- a/frontend/src/services/collab.ts
+++ b/frontend/src/services/collab.ts
@@ -6,6 +6,7 @@
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { stringToColor, getInitials, getDisplayName } from '../utils/colors';
+import { getWsUrl, getApiUrl } from '../utils/url';
import { updatePage } from './api';
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
@@ -63,16 +64,57 @@ function notifySaveStatus(pagePath: string, status: SaveStatus): void {
}
}
+/**
+ * Serialize Y.Array data to CSV string.
+ */
+function serializeYArrayToCSV(yArray: Y.Array>): string {
+ const rows = yArray.toArray();
+ if (rows.length === 0) return '';
+
+ // Get headers from first row
+ const headers: string[] = [];
+ rows[0].forEach((_, key) => headers.push(key));
+
+ // Build CSV
+ const lines = [headers.join(',')];
+ rows.forEach(yMap => {
+ const values = headers.map(h => {
+ const v = yMap.get(h);
+ const str = String(v ?? '');
+ // Escape and quote if needed
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+ });
+ lines.push(values.join(','));
+ });
+
+ return lines.join('\n');
+}
+
/**
* Save document content to backend.
+ * Handles both markdown/tsx (Y.Text) and CSV (Y.Array) files.
*/
async function saveDocument(pagePath: string, pageTitle: string): Promise {
const session = activeSessions.get(pagePath);
const state = saveStates.get(pagePath);
if (!session || !state) return;
- const yText = session.doc.getText('content');
- const content = yText.toString();
+ // Determine content based on file type
+ let content: string;
+ const isCSV = pagePath.endsWith('.csv');
+
+ if (isCSV) {
+ // CSV: Serialize Y.Array to CSV format
+ const yArray = session.doc.getArray>('data');
+ content = serializeYArrayToCSV(yArray);
+ } else {
+ // Markdown/TSX: Use Y.Text
+ const yText = session.doc.getText('content');
+ content = yText.toString();
+ }
// Skip if content hasn't changed
if (content === state.lastSavedContent) {
@@ -185,8 +227,7 @@ export function createCollabSession(
const doc = new Y.Doc();
// Connect to backend collaboration WebSocket
- // URL format: ws://localhost:8000/ws/collab/{clientId}/{pagePath}
- const wsUrl = `ws://localhost:8000/ws/collab`;
+ const wsUrl = getWsUrl('/ws/collab');
const roomName = pagePath; // Use page path as room name
const provider = new WebsocketProvider(wsUrl, roomName, doc, {
@@ -243,20 +284,40 @@ export function createCollabSession(
};
saveStates.set(pagePath, saveState);
- // Set up Y.Text observer for auto-save
- const yText = doc.getText('content');
- yText.observe(() => {
- // Skip during initial load (sync from server)
- if (saveState.isInitialLoad) return;
- scheduleSave(pagePath, title);
- });
+ // Set up observer for auto-save (different for CSV vs text files)
+ const isCSV = pagePath.endsWith('.csv');
- // Mark initial load as complete after sync settles
- setTimeout(() => {
- saveState.isInitialLoad = false;
- saveState.lastSavedContent = yText.toString();
- console.log(`Initial load complete for: ${pagePath}`);
- }, 500);
+ if (isCSV) {
+ // CSV: Set up Y.Array observer
+ const yArray = doc.getArray>('data');
+ yArray.observeDeep(() => {
+ // Skip during initial load (sync from server)
+ if (saveState.isInitialLoad) return;
+ scheduleSave(pagePath, title);
+ });
+
+ // Mark initial load as complete after sync settles
+ setTimeout(() => {
+ saveState.isInitialLoad = false;
+ saveState.lastSavedContent = serializeYArrayToCSV(yArray);
+ console.log(`Initial load complete for CSV: ${pagePath}`);
+ }, 500);
+ } else {
+ // Markdown/TSX: Set up Y.Text observer
+ const yText = doc.getText('content');
+ yText.observe(() => {
+ // Skip during initial load (sync from server)
+ if (saveState.isInitialLoad) return;
+ scheduleSave(pagePath, title);
+ });
+
+ // Mark initial load as complete after sync settles
+ setTimeout(() => {
+ saveState.isInitialLoad = false;
+ saveState.lastSavedContent = yText.toString();
+ console.log(`Initial load complete for: ${pagePath}`);
+ }, 500);
+ }
// Create session object
const session: CollabSession = {
@@ -355,6 +416,71 @@ export function getSharedText(doc: Y.Doc): Y.Text {
return doc.getText('content');
}
+/**
+ * Get the shared Yjs Array for data (CSV files).
+ * Returns Y.Array where each Y.Map is a row with column keys.
+ */
+export function getSharedData(doc: Y.Doc): Y.Array> {
+ return doc.getArray>('data');
+}
+
+/**
+ * Initialize CSV data from content string.
+ * Parses CSV content and populates the Y.Array.
+ */
+export function initializeCSVData(doc: Y.Doc, csvContent: string): void {
+ const yArray = getSharedData(doc);
+
+ // Don't reinitialize if already has data
+ if (yArray.length > 0) return;
+
+ const lines = csvContent.split('\n').filter(l => l.trim());
+ if (lines.length === 0) return;
+
+ // Parse headers
+ const headers = parseCSVLine(lines[0]);
+
+ // Parse rows in a transaction
+ doc.transact(() => {
+ lines.slice(1).forEach(line => {
+ const values = parseCSVLine(line);
+ const yMap = new Y.Map();
+ headers.forEach((h, i) => {
+ yMap.set(h, values[i] ?? '');
+ });
+ yArray.push([yMap]);
+ });
+ });
+}
+
+/**
+ * Parse a CSV line into values.
+ */
+function parseCSVLine(line: string): string[] {
+ const result: string[] = [];
+ let current = '';
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+ if (char === '"') {
+ if (inQuotes && line[i + 1] === '"') {
+ current += '"';
+ i++; // Skip next quote
+ } else {
+ inQuotes = !inQuotes;
+ }
+ } else if (char === ',' && !inQuotes) {
+ result.push(current);
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+ result.push(current);
+ return result;
+}
+
/**
* Release a collaborative session.
* Sessions persist to allow seamless switching between formatted/raw views.
@@ -447,7 +573,7 @@ export async function setEditingState(
client_id: userId,
editing: String(editing),
});
- await fetch(`http://localhost:8000/api/collab/editing-state?${params}`, {
+ await fetch(`${getApiUrl()}/api/collab/editing-state?${params}`, {
method: 'POST',
});
console.log(`Editing state: ${pagePath} = ${editing}`);
diff --git a/frontend/src/services/git-api.ts b/frontend/src/services/git-api.ts
index a9836a7..69f52f6 100644
--- a/frontend/src/services/git-api.ts
+++ b/frontend/src/services/git-api.ts
@@ -1,9 +1,10 @@
/**
* API client for git operations
*/
-import type { DiffStats, BranchDiff } from '../types/agent';
+import type { DiffStats } from '../types/agent';
+import { getApiUrl } from '../utils/url';
-const API_BASE_URL = 'http://localhost:8000';
+const API_BASE_URL = getApiUrl();
export class GitAPI {
/**
@@ -75,4 +76,35 @@ export class GitAPI {
const data = await response.json();
return data.branch;
}
+
+ /**
+ * Delete a branch
+ */
+ static async deleteBranch(branch: string, force: boolean = false): Promise {
+ const url = `${API_BASE_URL}/api/git/branches/${encodeURIComponent(branch)}${force ? '?force=true' : ''}`;
+ const response = await fetch(url, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to delete branch: ${response.statusText}`);
+ }
+ }
+
+ /**
+ * Checkout a branch
+ */
+ static async checkoutBranch(branch: string): Promise {
+ const response = await fetch(`${API_BASE_URL}/api/git/checkout`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ branch }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to checkout branch: ${response.statusText}`);
+ }
+ }
}
diff --git a/frontend/src/services/threads-api.ts b/frontend/src/services/threads-api.ts
index 679431d..ea78fe8 100644
--- a/frontend/src/services/threads-api.ts
+++ b/frontend/src/services/threads-api.ts
@@ -1,8 +1,9 @@
/**
* API client for thread operations
*/
+import { getApiUrl } from '../utils/url';
-const API_BASE_URL = 'http://localhost:8000';
+const API_BASE_URL = getApiUrl();
export interface ThreadFile {
path: string;
diff --git a/frontend/src/services/tree-api.ts b/frontend/src/services/tree-api.ts
index 5b0caa2..f0568bc 100644
--- a/frontend/src/services/tree-api.ts
+++ b/frontend/src/services/tree-api.ts
@@ -1,6 +1,7 @@
-import { TreeItem, MoveOperation, FolderCreate } from '../types/page';
+import type { TreeItem, MoveOperation, FolderCreate } from '../types/page';
+import { getApiUrl } from '../utils/url';
-const API_BASE = 'http://localhost:8000';
+const API_BASE = getApiUrl();
export const treeApi = {
async getTree(): Promise {
diff --git a/frontend/src/services/websocket.ts b/frontend/src/services/websocket.ts
index 7a51aab..0159269 100644
--- a/frontend/src/services/websocket.ts
+++ b/frontend/src/services/websocket.ts
@@ -1,4 +1,5 @@
-import { WebSocketMessage, ChatMessage } from '../types/chat';
+import type { WebSocketMessage } from '../types/chat';
+import { getWsUrl } from '../utils/url';
export type WebSocketEventHandler = (message: WebSocketMessage) => void;
export type ConnectionStatusHandler = (status: 'connecting' | 'connected' | 'disconnected' | 'error') => void;
@@ -177,7 +178,7 @@ export const createWebSocketService = (clientId?: string) => {
// Create new instance
console.log(`Creating new WebSocket service for: ${id}`);
- const wsUrl = `ws://localhost:8000/ws`;
+ const wsUrl = getWsUrl('/ws');
const service = new WebSocketService(wsUrl, id);
serviceInstances.set(id, service);
diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts
index 0d57f6e..6b4ee26 100644
--- a/frontend/src/types/chat.ts
+++ b/frontend/src/types/chat.ts
@@ -25,13 +25,15 @@ export interface WebSocketMessage {
type:
| 'chat' | 'chat_response' | 'tool_call' | 'system' | 'system_prompt' | 'page_update' | 'error' | 'status' | 'success'
// Agent messages
- | 'agent_complete' | 'agent_selected'
+ | 'agent_complete' | 'agent_selected' | 'agent_start'
// Thread messages
| 'thread_created' | 'thread_status' | 'thread_deleted' | 'thread_list' | 'thread_selected' | 'thread_message'
// Branch messages
| 'branch_created' | 'branch_switched' | 'branch_deleted'
// Page messages
- | 'page_updated' | 'pages_changed';
+ | 'page_updated' | 'pages_changed' | 'pages_content_changed'
+ // Collab messages
+ | 'collab_room_change';
data?: any;
message?: string;
tool_name?: string;
diff --git a/frontend/src/types/page.ts b/frontend/src/types/page.ts
index bdb7aca..43a5ec4 100644
--- a/frontend/src/types/page.ts
+++ b/frontend/src/types/page.ts
@@ -1,13 +1,20 @@
+// Supported file types
+export type FileType = 'markdown' | 'csv' | 'tsx';
+
export interface Page {
- path: string; // Relative file path in git repo (e.g., "home.md", "agents/index.md")
- content: string; // Plain markdown content (no frontmatter)
- title: string; // Filename (e.g., "home.md")
+ path: string; // Relative file path in git repo (e.g., "home.md", "data.csv", "view.tsx")
+ content: string; // File content (markdown, CSV, or TSX source)
+ title: string; // Filename (e.g., "home.md", "tasks.csv")
+ file_type: FileType; // Type of file
+ // CSV-specific fields
+ headers?: string[]; // CSV column headers
+ rows?: Record[]; // CSV rows as objects
// Legacy fields for compatibility - metadata comes from git
author?: string;
created_at?: string;
updated_at?: string;
tags?: string[];
- content_json?: any; // ProseMirror document JSON
+ content_json?: any; // ProseMirror document JSON (markdown only)
}
export interface PageRevision {
@@ -28,9 +35,10 @@ export interface PageTreeItem {
// Enhanced tree item with folder support
export interface TreeItem {
id: string; // Unique identifier (full path without extension)
- title: string; // Filename (e.g., "home.md", "agents")
- path: string; // Full relative path (e.g., "home.md", "agents/index.md")
+ title: string; // Filename (e.g., "home.md", "tasks.csv", "agents")
+ path: string; // Full relative path (e.g., "home.md", "agents/index.md", "data.csv")
type: 'page' | 'folder';
+ file_type?: FileType; // Type of file (only for pages, not folders)
children?: TreeItem[] | null;
parent_path: string | null; // Parent folder path (null = root)
order?: number; // Legacy - items are now sorted alphabetically
diff --git a/frontend/src/utils/url.ts b/frontend/src/utils/url.ts
new file mode 100644
index 0000000..8cc90d3
--- /dev/null
+++ b/frontend/src/utils/url.ts
@@ -0,0 +1,37 @@
+/**
+ * URL utilities for API and WebSocket connections.
+ * Uses VITE_API_HOST environment variable, falls back to page location.
+ */
+
+/**
+ * Get the API host from environment or derive from page location.
+ */
+function getHost(): string {
+ // Use environment variable if set
+ if (import.meta.env.VITE_API_HOST) {
+ return import.meta.env.VITE_API_HOST;
+ }
+ // Fallback to current page host
+ return window.location.host;
+}
+
+/**
+ * Get the base API URL.
+ * Uses VITE_API_HOST env var in dev, or current page host in prod.
+ */
+export function getApiUrl(): string {
+ const host = getHost();
+ // Use https if page is https, otherwise http
+ const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
+ return `${protocol}//${host}`;
+}
+
+/**
+ * Get WebSocket URL for a given path.
+ * Automatically uses ws:// or wss:// based on page protocol.
+ */
+export function getWsUrl(path: string): string {
+ const host = getHost();
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${protocol}//${host}${path}`;
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index 11f02fe..f91e68f 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -1 +1,9 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_API_HOST?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index bdd682b..4f9319a 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,4 +1,3 @@
-///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
@@ -16,10 +15,4 @@ export default defineConfig({
usePolling: true,
},
},
- test: {
- globals: true,
- environment: 'jsdom',
- setupFiles: './src/test/setup.ts',
- exclude: ['**/node_modules/**', '**/e2e/**'],
- },
-})
+} as any)