Release: CI/CD Pipeline & Architecture Updates #177

Merged
jason.woltje merged 173 commits from develop into main 2026-02-01 19:18:48 +00:00
8 changed files with 1463 additions and 0 deletions
Showing only changes of commit d54714ea06 - Show all commits

118
MINDMAP_MIGRATION.md Normal file
View File

@@ -0,0 +1,118 @@
# Mindmap Components Migration - Phase 3
**Status:** ✅ Complete (with notes)
**Commit:** `aa267b5` - "feat: add mindmap components from jarvis frontend"
**Branch:** `feature/jarvis-fe-migration`
## Completed Tasks
### 1. ✅ Directory Structure Created
```
apps/web/src/components/mindmap/
├── controls/
│ ├── ExportButton.tsx
│ └── NodeCreateModal.tsx
├── hooks/
│ └── useGraphData.ts
├── nodes/
│ ├── BaseNode.tsx
│ ├── ConceptNode.tsx
│ ├── IdeaNode.tsx
│ ├── ProjectNode.tsx
│ └── TaskNode.tsx
├── index.ts
├── MermaidViewer.tsx
├── MindmapViewer.tsx
└── ReactFlowEditor.tsx
```
### 2. ✅ Components Copied
All mindmap components have been successfully migrated:
- **Main viewers:** ReactFlowEditor, MindmapViewer, MermaidViewer
- **Node types:** BaseNode, ConceptNode, TaskNode, IdeaNode, ProjectNode
- **Controls:** NodeCreateModal, ExportButton
- **Hooks:** useGraphData (with KnowledgeNode, KnowledgeEdge types)
### 3. ✅ Barrel Export Created
`components/mindmap/index.ts` exports all components and types for clean imports
### 4. ✅ Route Created
- Created `/mindmap` page at `apps/web/src/app/mindmap/page.tsx`
- Includes proper metadata and layout
### 5. ✅ Dependencies Added
- Copied `lib/auth-client.ts` (BetterAuth integration)
- Created `lib/api.ts` (session management utilities)
## Import Updates
No `@jarvis/*` imports were present in the mindmap components - they were already using relative paths and `@/lib/*` aliases, which are compatible with the Mosaic structure.
## Type Adaptations
The mindmap uses its own `KnowledgeNode` and `KnowledgeEdge` types, which are specific to the knowledge graph feature and not part of the general Mosaic entity types (Task, Project, etc. from `@mosaic/shared`). This is correct as the mindmap represents a different data model.
## Known Issues & Next Steps
### Missing Package Dependencies
The build currently fails due to missing packages required by `auth-client.ts`:
```
better-auth
better-auth/react
better-auth-credentials-plugin
better-auth-credentials-plugin/client
```
**Resolution:** These packages need to be added to the workspace:
```bash
pnpm add better-auth better-auth-credentials-plugin
```
### ReactFlow Dependencies
Verify that `@xyflow/react` is installed:
```bash
pnpm add @xyflow/react
```
### Mermaid Dependency
Verify that `mermaid` is installed:
```bash
pnpm add mermaid
```
## Testing Checklist
Once dependencies are installed:
- [ ] Build completes without errors
- [ ] Navigate to `/mindmap` route
- [ ] Create a knowledge node
- [ ] Verify ReactFlow interactive editor renders
- [ ] Test Mermaid diagram view
- [ ] Test export functionality
- [ ] Verify node type rendering (Concept, Task, Idea, Project)
## API Integration
The mindmap components expect a backend knowledge graph API at:
- Base URL: `process.env.NEXT_PUBLIC_API_URL` (default: http://localhost:8000)
- Endpoints:
- `GET /api/v1/knowledge/graph` - Fetch graph data
- `GET /api/v1/knowledge/mermaid` - Fetch Mermaid diagram
- `POST /api/v1/knowledge/nodes` - Create node
- `PUT /api/v1/knowledge/nodes/:id` - Update node
- `DELETE /api/v1/knowledge/nodes/:id` - Delete node
- `POST /api/v1/knowledge/edges` - Create edge
- `DELETE /api/v1/knowledge/edges` - Delete edge
- `GET /api/v1/knowledge/graph/statistics` - Get statistics
## Files Changed
- 15 files added
- 1,758 insertions
- No deletions
## Git Info
```
Branch: feature/jarvis-fe-migration
Commit: aa267b5
Pushed: Yes
```

View File

@@ -0,0 +1,99 @@
"use client";
import { useRef, useState } from "react";
import { Chat, type ChatRef, ConversationSidebar, type ConversationSidebarRef } from "@/components/chat";
/**
* Chat Page
*
* Placeholder route for the chat interface migrated from jarvis-fe.
*
* TODO:
* - Integrate with authentication
* - Connect to brain API endpoints (/api/brain/query)
* - Implement conversation persistence
* - Add project/workspace integration
* - Wire up actual hooks (useAuth, useProjects, useConversations, useApi)
*/
export default function ChatPage() {
const chatRef = useRef<ChatRef>(null);
const sidebarRef = useRef<ConversationSidebarRef>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
const handleConversationChange = (conversationId: string | null) => {
setCurrentConversationId(conversationId);
// TODO: Update sidebar when conversation changes
};
const handleSelectConversation = (conversationId: string | null) => {
// TODO: Load conversation from backend
console.log("Select conversation:", conversationId);
setCurrentConversationId(conversationId);
};
const handleNewConversation = (projectId?: string | null) => {
chatRef.current?.startNewConversation(projectId);
setCurrentConversationId(null);
};
return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "rgb(var(--color-background))" }}>
{/* Conversation Sidebar */}
<ConversationSidebar
ref={sidebarRef}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(!sidebarOpen)}
currentConversationId={currentConversationId}
onSelectConversation={handleSelectConversation}
onNewConversation={handleNewConversation}
/>
{/* Main Chat Area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<header
className="border-b px-4 py-3 flex items-center gap-3"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
>
{/* Toggle Sidebar Button */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 rounded-lg transition-colors hover:bg-[rgb(var(--surface-1))]"
aria-label="Toggle sidebar"
title="Toggle conversation history"
>
<svg
className="h-5 w-5"
style={{ color: "rgb(var(--text-muted))" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<div className="flex-1">
<h1 className="text-lg font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
AI Chat
</h1>
<p className="text-xs" style={{ color: "rgb(var(--text-muted))" }}>
Migrated from Jarvis - Connect to brain API for full functionality
</p>
</div>
</header>
{/* Chat Component */}
<Chat
ref={chatRef}
onConversationChange={handleConversationChange}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState, useEffect } from "react";
/**
* Banner that displays when the backend is unavailable.
* Shows error message, countdown to next retry, and manual retry button.
*
* TODO: Integrate with actual backend status checking hook
*/
export function BackendStatusBanner() {
const [isAvailable, setIsAvailable] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryIn, setRetryIn] = useState(0);
// TODO: Replace with actual useBackendStatus hook
// const { isAvailable, error, retryIn, manualRetry } = useBackendStatus();
const manualRetry = () => {
// TODO: Implement manual retry logic
console.log("Manual retry triggered");
};
const handleSignOut = async () => {
try {
// TODO: Implement signOut
// await signOut();
} catch (error) {
console.warn("Sign-out failed during backend unavailability:", error);
}
window.location.href = "/login";
};
// Don't render if backend is available
if (isAvailable) {
return null;
}
return (
<div
className="flex items-center justify-between gap-4 px-4 py-2 text-sm"
style={{
backgroundColor: "#fef3c7", // amber-100
borderBottom: "1px solid #fcd34d", // amber-300
color: "#92400e", // amber-800
}}
role="alert"
aria-live="polite"
>
<div className="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 flex-shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<span>
{error || "Backend temporarily unavailable."}
{retryIn > 0 && (
<span className="ml-1">
Retrying in {retryIn}s...
</span>
)}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={manualRetry}
className="rounded px-3 py-1 text-xs font-medium transition-colors hover:opacity-80"
style={{
backgroundColor: "#fcd34d", // amber-300
color: "#92400e", // amber-800
}}
>
Retry Now
</button>
<button
onClick={handleSignOut}
className="rounded px-3 py-1 text-xs font-medium transition-colors hover:opacity-80"
style={{
backgroundColor: "transparent",
color: "#92400e", // amber-800
border: "1px solid #fcd34d", // amber-300
}}
>
Sign in again
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,378 @@
"use client";
import { useCallback, useEffect, useRef, useState, useMemo, forwardRef, useImperativeHandle } from "react";
// TODO: These hooks will need to be created or adapted
// import { useAuth } from "@/lib/hooks/useAuth";
// import { useProjects } from "@/lib/hooks/useProjects";
// import { useConversations } from "@/lib/hooks/useConversations";
// import { useApi } from "@/lib/hooks/useApi";
import { MessageList } from "./MessageList";
import { ChatInput } from "./ChatInput";
// TODO: Import types need to be created
// import type { ConversationDetail } from "@/lib/hooks/useConversations";
// import { handleSessionExpired, isSessionExpiring } from "@/lib/api";
// import type { LLMModel, DefaultModel } from "@/lib/api";
// Placeholder types until the actual types are created
type ConversationDetail = any;
type LLMModel = any;
type DefaultModel = any;
export interface Message {
id: string;
role: "user" | "assistant" | "system";
content: string;
thinking?: string; // Chain of thought reasoning from thinking models
createdAt: string;
model?: string; // LLM model used for this response
provider?: string; // LLM provider (ollama, claude, etc.)
// Token usage info
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
// Friendly waiting messages (shown after a few seconds of loading)
const WAITING_QUIPS = [
"The AI is warming up... give it a moment.",
"Loading the neural pathways...",
"Waking up the LLM. It's not a morning model.",
"Brewing some thoughts...",
"The AI is stretching its parameters...",
"Summoning intelligence from the void...",
"Teaching electrons to think...",
"Consulting the silicon oracle...",
"The hamsters are spinning up the GPU...",
"Defragmenting the neural networks...",
];
// Error messages for actual timeouts
const TIMEOUT_QUIPS = [
"The AI got lost in thought. Literally. Try again?",
"That took too long, even by AI standards. Give it another go?",
"The model wandered off. Let's try to find it again.",
"Response timed out. The AI may have fallen asleep. Retry?",
"The LLM took an unexpected vacation. One more attempt?",
];
// Error messages for connection failures
const CONNECTION_QUIPS = [
"I seem to have misplaced the server. Check your connection?",
"The server and I are having communication issues. It's not you, it's us.",
"Connection lost. Either the internet is down, or the server is playing hide and seek.",
"Unable to reach the mothership. The tubes appear to be clogged.",
"The server isn't responding. Perhaps it's giving us the silent treatment.",
];
const getRandomQuip = (quips: string[]) => quips[Math.floor(Math.random() * quips.length)];
const WELCOME_MESSAGE: Message = {
id: "welcome",
role: "assistant",
content: "Hello. I'm your AI assistant. How can I help you today?",
createdAt: new Date().toISOString(),
};
export interface ChatRef {
loadConversation: (conversation: ConversationDetail) => void;
startNewConversation: (projectId?: string | null) => void;
getCurrentConversationId: () => string | null;
}
export interface NewConversationData {
id: string;
title: string | null;
project_id: string | null;
created_at: string;
updated_at: string;
}
interface ChatProps {
onConversationChange?: (conversationId: string | null, conversationData?: NewConversationData) => void;
onProjectChange?: () => void;
initialProjectId?: string | null;
onInitialProjectHandled?: () => void;
}
export const Chat = forwardRef<ChatRef, ChatProps>(function Chat({
onConversationChange,
onProjectChange: _onProjectChange,
initialProjectId,
onInitialProjectHandled,
}, ref) {
void _onProjectChange; // Kept for potential future use
// TODO: Replace with actual hooks once they're created
const accessToken = null;
const isLoading = false;
const authLoading = false;
const authError = null;
const projects: any[] = [];
// const { accessToken, isLoading: authLoading, error: authError } = useAuth();
// const { projects } = useProjects();
// const { updateConversationProject } = useConversations();
// const api = useApi();
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
const [isChatLoading, setIsChatLoading] = useState(false);
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null);
const [conversationTitle, setConversationTitle] = useState<string | null>(null);
const [conversationProjectId, setConversationProjectId] = useState<string | null>(null);
const [pendingProjectId, setPendingProjectId] = useState<string | null>(null);
const [showProjectMenu, setShowProjectMenu] = useState(false);
const [showModelMenu, setShowModelMenu] = useState(false);
const [showFooterProjectMenu, setShowFooterProjectMenu] = useState(false);
const [showFooterModelMenu, setShowFooterModelMenu] = useState(false);
const [isMovingProject, setIsMovingProject] = useState(false);
const [availableModels, setAvailableModels] = useState<LLMModel[]>([]);
const [defaultModel, setDefaultModel] = useState<DefaultModel | null>(null);
const [selectedModel, setSelectedModel] = useState<LLMModel | null>(null);
const [modelLoadError, setModelLoadError] = useState<string | null>(null);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [useReasoning, setUseReasoning] = useState(false); // Toggle for reasoning/thinking mode
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const projectMenuRef = useRef<HTMLDivElement>(null);
const modelMenuRef = useRef<HTMLDivElement>(null);
const footerProjectMenuRef = useRef<HTMLDivElement>(null);
const footerModelMenuRef = useRef<HTMLDivElement>(null);
// Track conversation ID in ref to prevent stale closure issues
const conversationIdRef = useRef<string | null>(conversationId);
// Expose methods to parent via ref
useImperativeHandle(ref, () => ({
loadConversation: (conversation: ConversationDetail) => {
// TODO: Implement once ConversationDetail type is available
console.log("loadConversation called with:", conversation);
},
startNewConversation: (projectId?: string | null) => {
setConversationId(null);
setConversationTitle(null);
setConversationProjectId(null);
setMessages([WELCOME_MESSAGE]);
setError(null);
setPendingProjectId(projectId || null);
setShowProjectMenu(false);
onConversationChange?.(null);
},
getCurrentConversationId: () => conversationId,
}));
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// Keep conversationIdRef in sync with state to prevent stale closures
useEffect(() => {
conversationIdRef.current = conversationId;
}, [conversationId]);
// Handle auth errors
useEffect(() => {
if (authError === "RefreshAccessTokenError") {
setError("Your session has expired. Please sign in again.");
}
}, [authError]);
// Global keyboard shortcut: Ctrl+/ to focus input
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// TODO: Implement click outside handlers for menus
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim() || isChatLoading) {
return;
}
// Add user message immediately
const userMessage: Message = {
id: `user-${Date.now()}`,
role: "user",
content: content.trim(),
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setIsChatLoading(true);
setLoadingQuip(null);
setError(null);
// Show a witty loading message after 3 seconds
const quipTimerId = setTimeout(() => {
setLoadingQuip(getRandomQuip(WAITING_QUIPS));
}, 3000);
// Change quip every 5 seconds if still waiting
const quipIntervalId = setInterval(() => {
setLoadingQuip(getRandomQuip(WAITING_QUIPS));
}, 5000);
try {
// TODO: Implement actual API call to /api/brain/query
const requestBody: {
message: string;
conversation_id: string | null;
project_id?: string;
provider_instance_id?: string;
provider?: string;
model?: string;
use_reasoning?: boolean;
} = {
message: content.trim(),
conversation_id: conversationId,
};
// Placeholder response for now
await new Promise(resolve => setTimeout(resolve, 1000));
const assistantMessage: Message = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: "This is a placeholder response. The chat API integration is not yet complete.",
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMessage]);
// Clear quip timers on success
clearTimeout(quipTimerId);
clearInterval(quipIntervalId);
setLoadingQuip(null);
} catch (err) {
// Clear quip timers on error
clearTimeout(quipTimerId);
clearInterval(quipIntervalId);
setLoadingQuip(null);
console.error("Failed to send message:", err);
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
setError(errorMsg);
const errorMessage: Message = {
id: `error-${Date.now()}`,
role: "assistant",
content: errorMsg,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsChatLoading(false);
}
},
[conversationId, isChatLoading]
);
const dismissError = useCallback(() => {
setError(null);
}, []);
// Show loading state while auth is loading
if (authLoading) {
return (
<div className="flex flex-1 items-center justify-center" style={{ backgroundColor: "rgb(var(--color-background))" }}>
<div className="flex items-center gap-3">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-t-transparent" style={{ borderColor: "rgb(var(--accent-primary))", borderTopColor: "transparent" }} />
<span style={{ color: "rgb(var(--text-secondary))" }}>Loading...</span>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col" style={{ backgroundColor: "rgb(var(--color-background))" }}>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
<MessageList messages={messages} isLoading={isChatLoading} loadingQuip={loadingQuip} />
<div ref={messagesEndRef} />
</div>
</div>
{/* Error Alert */}
{error && (
<div className="mx-4 mb-2 lg:mx-auto lg:max-w-4xl lg:px-8">
<div
className="flex items-center justify-between rounded-lg border px-4 py-3"
style={{
backgroundColor: "rgb(var(--semantic-error-light))",
borderColor: "rgb(var(--semantic-error) / 0.3)",
}}
role="alert"
>
<div className="flex items-center gap-3">
<svg
className="h-4 w-4 flex-shrink-0"
style={{ color: "rgb(var(--semantic-error))" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span
className="text-sm"
style={{ color: "rgb(var(--semantic-error-dark))" }}
>
{error}
</span>
</div>
<button
onClick={dismissError}
className="rounded p-1 transition-colors hover:bg-black/5"
aria-label="Dismiss error"
>
<svg
className="h-4 w-4"
style={{ color: "rgb(var(--semantic-error))" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
{/* Input Area */}
<div
className="sticky bottom-0 border-t"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
>
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
<ChatInput
onSend={sendMessage}
disabled={isChatLoading || !accessToken}
inputRef={inputRef}
/>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,189 @@
"use client";
import { useCallback, useState, useEffect, KeyboardEvent, RefObject } from "react";
interface ChatInputProps {
onSend: (message: string) => void;
disabled?: boolean;
inputRef?: RefObject<HTMLTextAreaElement | null>;
}
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps) {
const [message, setMessage] = useState("");
const [version, setVersion] = useState<string | null>(null);
// Fetch version from static version.json (generated at build time)
useEffect(() => {
fetch("/version.json")
.then((res) => res.json())
.then((data) => {
if (data.version) {
// Format as "version+commit" for full build identification
const fullVersion = data.commit
? `${data.version}+${data.commit}`
: data.version;
setVersion(fullVersion);
}
})
.catch(() => {
// Silently fail - version display is non-critical
});
}, []);
const handleSubmit = useCallback(() => {
if (message.trim() && !disabled) {
onSend(message);
setMessage("");
}
}, [message, onSend, disabled]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Enter to send (without Shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
// Ctrl/Cmd + Enter to send (alternative)
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
const characterCount = message.length;
const maxCharacters = 4000;
const isNearLimit = characterCount > maxCharacters * 0.9;
const isOverLimit = characterCount > maxCharacters;
return (
<div className="space-y-3">
{/* Input Container */}
<div
className="relative rounded-lg border transition-all duration-150"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: disabled
? "rgb(var(--border-default))"
: "rgb(var(--border-strong))",
}}
>
<textarea
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={disabled}
rows={1}
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
style={{
color: "rgb(var(--text-primary))",
minHeight: "48px",
maxHeight: "200px",
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height = Math.min(target.scrollHeight, 200) + "px";
}}
aria-label="Message input"
aria-describedby="input-help"
/>
{/* Send Button */}
<div className="absolute bottom-2 right-2 flex items-center gap-2">
<button
onClick={handleSubmit}
disabled={disabled || !message.trim() || isOverLimit}
className="btn-primary btn-sm rounded-md"
style={{
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
}}
aria-label="Send message"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span className="hidden sm:inline">Send</span>
</button>
</div>
</div>
{/* Footer: Keyboard shortcuts + character count */}
<div
className="flex items-center justify-between text-xs"
style={{ color: "rgb(var(--text-muted))" }}
id="input-help"
>
{/* Keyboard Shortcuts */}
<div className="hidden items-center gap-4 sm:flex">
<div className="flex items-center gap-1.5">
<span className="kbd">Enter</span>
<span>to send</span>
</div>
<div className="flex items-center gap-1.5">
<span className="kbd">Shift</span>
<span>+</span>
<span className="kbd">Enter</span>
<span>for new line</span>
</div>
</div>
{/* Mobile hint */}
<div className="sm:hidden">
Tap send or press Enter
</div>
{/* Character Count */}
<div
className="flex items-center gap-2"
style={{
color: isOverLimit
? "rgb(var(--semantic-error))"
: isNearLimit
? "rgb(var(--semantic-warning))"
: "rgb(var(--text-muted))",
}}
>
{characterCount > 0 && (
<>
<span>
{characterCount.toLocaleString()}/{maxCharacters.toLocaleString()}
</span>
{isOverLimit && (
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)}
</>
)}
</div>
</div>
{/* AI Disclaimer & Version */}
<div
className="flex items-center justify-center gap-2 text-xs"
style={{ color: "rgb(var(--text-muted))" }}
>
<span>AI may produce inaccurate information. Verify important details.</span>
{version && (
<>
<span>·</span>
<span>v{version}</span>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,245 @@
"use client";
import { useState, forwardRef, useImperativeHandle } from "react";
// import Link from "next/link";
// TODO: Import hooks when they're created
// import { useConversations, ConversationSummary } from "@/lib/hooks/useConversations";
// import { useProjects } from "@/lib/hooks/useProjects";
// import type { IsolationMode } from "@/lib/api";
// Placeholder types
type ConversationSummary = {
id: string;
title: string | null;
project_id: string | null;
updated_at: string;
message_count: number;
};
export interface ConversationSidebarRef {
refresh: () => void;
addConversation: (conversation: ConversationSummary) => void;
}
interface ConversationSidebarProps {
isOpen: boolean;
onClose: () => void;
currentConversationId: string | null;
onSelectConversation: (conversationId: string | null) => void;
onNewConversation: (projectId?: string | null) => void;
}
export const ConversationSidebar = forwardRef<ConversationSidebarRef, ConversationSidebarProps>(function ConversationSidebar({
isOpen,
onClose,
currentConversationId,
onSelectConversation,
onNewConversation,
}, ref) {
const [searchQuery, setSearchQuery] = useState("");
// Placeholder data
const conversations: ConversationSummary[] = [];
const projects: any[] = [];
// Expose methods to parent via ref
useImperativeHandle(ref, () => ({
refresh: () => {
// TODO: Implement refresh logic
console.log("Refresh called");
},
addConversation: (conversation: ConversationSummary) => {
// TODO: Implement addConversation logic
console.log("Add conversation called:", conversation);
},
}));
const filteredConversations = conversations.filter((conv) => {
if (!searchQuery.trim()) return true;
const title = conv.title || "Untitled conversation";
return title.toLowerCase().includes(searchQuery.toLowerCase());
});
const formatRelativeTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
const truncateTitle = (title: string | null, maxLength = 32) => {
const displayTitle = title || "Untitled conversation";
if (displayTitle.length <= maxLength) return displayTitle;
return displayTitle.substring(0, maxLength - 1) + "…";
};
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 z-40 md:hidden animate-fade-in"
style={{ backgroundColor: "rgb(0 0 0 / 0.6)" }}
onClick={onClose}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`
fixed left-0 top-0 z-50 h-screen transform border-r transition-all duration-200 ease-out flex flex-col
md:sticky md:top-0 md:z-auto md:h-screen md:transform-none md:transition-[width]
${isOpen ? "translate-x-0 w-72" : "-translate-x-full md:translate-x-0 md:w-16"}
`}
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
aria-label="Conversation history"
>
{/* Collapsed view - TODO: Implement */}
{!isOpen && (
<div className="hidden md:flex flex-col items-center py-3 h-full">
<button
onClick={() => onNewConversation()}
className="p-3 rounded-lg transition-colors"
title="New Conversation"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
)}
{/* Full sidebar content */}
{isOpen && (
<>
{/* Header */}
<div
className="flex items-center justify-between border-b px-4 py-3"
style={{ borderColor: "rgb(var(--border-default))" }}
>
<div className="flex items-center gap-2">
<svg
className="h-5 w-5"
style={{ color: "rgb(var(--text-muted))" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="text-sm font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
Conversations
</span>
</div>
<button onClick={onClose} className="btn-ghost rounded-md p-1.5" aria-label="Close sidebar">
<svg className="h-5 w-5" style={{ color: "rgb(var(--text-muted))" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* New Chat Button */}
<div className="px-3 pt-3">
<button
onClick={() => onNewConversation()}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-dashed py-2.5 text-sm font-medium transition-all duration-150 hover:border-solid"
style={{
borderColor: "rgb(var(--border-strong))",
color: "rgb(var(--text-secondary))",
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M12 4v16m8-8H4" />
</svg>
<span>New Conversation</span>
</button>
</div>
{/* Search */}
<div className="px-3 pt-3">
<div className="relative">
<svg
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
style={{ color: "rgb(var(--text-muted))" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search conversations..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input pl-9 pr-10 py-2 text-sm"
style={{ backgroundColor: "rgb(var(--surface-1))" }}
/>
</div>
</div>
{/* Conversations List */}
<div className="flex-1 overflow-y-auto px-3 pt-3 pb-3 space-y-1">
{filteredConversations.length === 0 ? (
<div className="text-center py-8" style={{ color: "rgb(var(--text-muted))" }}>
<p className="text-sm">No conversations yet</p>
<p className="text-xs mt-1">Start a new chat to begin</p>
</div>
) : (
filteredConversations.map((conv) => (
<button
key={conv.id}
onClick={() => onSelectConversation(conv.id)}
className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
conv.id === currentConversationId
? "bg-[rgb(var(--accent-primary-light))]"
: "hover:bg-[rgb(var(--surface-2))]"
}`}
>
<p
className="text-sm font-medium truncate"
style={{
color: conv.id === currentConversationId
? "rgb(var(--accent-primary))"
: "rgb(var(--text-primary))",
}}
>
{truncateTitle(conv.title)}
</p>
<div className="flex items-center gap-2 mt-0.5" style={{ color: "rgb(var(--text-muted))" }}>
<span className="text-xs">{formatRelativeTime(conv.updated_at)}</span>
{conv.message_count > 0 && (
<>
<span className="text-xs">·</span>
<span className="text-xs">
{conv.message_count} msg{conv.message_count !== 1 ? "s" : ""}
</span>
</>
)}
</div>
</button>
))
)}
</div>
</>
)}
</aside>
</>
);
});

View File

@@ -0,0 +1,319 @@
"use client";
import { useCallback, useState } from "react";
import type { Message } from "./Chat";
interface MessageListProps {
messages: Message[];
isLoading: boolean;
loadingQuip?: string | null;
}
/**
* Parse thinking content from message.
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
*/
function parseThinking(content: string): { thinking: string | null; response: string } {
// Match <thinking>...</thinking> or <think>...</think> blocks
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
const matches = content.match(thinkingRegex);
if (!matches) {
return { thinking: null, response: content };
}
// Extract thinking content
let thinking = "";
for (const match of matches) {
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
thinking += innerContent.trim() + "\n";
}
// Remove thinking blocks from response
const response = content.replace(thinkingRegex, "").trim();
return {
thinking: thinking.trim() || null,
response,
};
}
export function MessageList({ messages, isLoading, loadingQuip }: MessageListProps) {
return (
<div className="space-y-6" role="log" aria-label="Chat messages">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isLoading && <LoadingIndicator quip={loadingQuip} />}
</div>
);
}
function MessageBubble({ message }: { message: Message }) {
const isUser = message.role === "user";
const [copied, setCopied] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
// Parse thinking from content (or use pre-parsed thinking field)
const { thinking, response } = message.thinking
? { thinking: message.thinking, response: message.content }
: parseThinking(message.content);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(response);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}, [response]);
return (
<div
className={`group flex gap-4 message-animate ${isUser ? "flex-row-reverse" : ""}`}
role="article"
aria-label={`${isUser ? "Your" : "AI Assistant"} message`}
>
{/* Avatar */}
<div
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
style={{
backgroundColor: isUser
? "rgb(var(--surface-2))"
: "rgb(var(--accent-primary))",
color: isUser ? "rgb(var(--text-secondary))" : "white",
}}
aria-hidden="true"
>
{isUser ? "You" : "AI"}
</div>
{/* Message Content */}
<div className={`flex max-w-[85%] flex-col gap-1.5 ${isUser ? "items-end" : "items-start"}`}>
{/* Message Header */}
<div
className={`flex items-center gap-2 text-xs ${isUser ? "flex-row-reverse" : ""}`}
style={{ color: "rgb(var(--text-muted))" }}
>
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
{isUser ? "You" : "AI Assistant"}
</span>
{/* Model indicator for assistant messages */}
{!isUser && message.model && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: "rgb(var(--surface-2))",
color: "rgb(var(--text-tertiary))",
}}
title={message.provider ? `Provider: ${message.provider}` : undefined}
>
{message.model}
</span>
)}
{/* Token usage indicator for assistant messages */}
{!isUser && message.totalTokens !== undefined && message.totalTokens > 0 && (
<span
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: "rgb(var(--surface-2))",
color: "rgb(var(--text-muted))",
}}
title={`Prompt: ${message.promptTokens?.toLocaleString() || 0} tokens, Completion: ${message.completionTokens?.toLocaleString() || 0} tokens`}
>
{formatTokenCount(message.totalTokens)} tokens
</span>
)}
<span aria-label={`Sent at ${formatTime(message.createdAt)}`}>
{formatTime(message.createdAt)}
</span>
</div>
{/* Thinking Section - Collapsible */}
{thinking && !isUser && (
<div
className="rounded-lg overflow-hidden"
style={{
backgroundColor: "rgb(var(--surface-1))",
border: "1px solid rgb(var(--border-default))",
}}
>
<button
onClick={() => setThinkingExpanded(!thinkingExpanded)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
style={{ color: "rgb(var(--text-secondary))" }}
aria-expanded={thinkingExpanded}
>
<svg
className={`h-3.5 w-3.5 transition-transform duration-200 ${thinkingExpanded ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="m9 18 6-6-6-6" />
</svg>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span>Thinking</span>
<span
className="ml-auto text-xs"
style={{ color: "rgb(var(--text-muted))" }}
>
{thinkingExpanded ? "Hide" : "Show"} reasoning
</span>
</button>
{thinkingExpanded && (
<div
className="px-3 pb-3 text-xs leading-relaxed whitespace-pre-wrap font-mono"
style={{
color: "rgb(var(--text-secondary))",
borderTop: "1px solid rgb(var(--border-default))",
}}
>
{thinking}
</div>
)}
</div>
)}
{/* Message Body */}
<div
className="relative rounded-lg px-4 py-3"
style={{
backgroundColor: isUser
? "rgb(var(--accent-primary))"
: "rgb(var(--surface-0))",
color: isUser ? "white" : "rgb(var(--text-primary))",
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
}}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{response}
</p>
{/* Copy Button - appears on hover */}
<button
onClick={handleCopy}
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
}}
aria-label={copied ? "Copied!" : "Copy message"}
title={copied ? "Copied!" : "Copy to clipboard"}
>
{copied ? (
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
</div>
</div>
</div>
);
}
function LoadingIndicator({ quip }: { quip?: string | null }) {
return (
<div className="flex gap-4 message-animate" role="status" aria-label="AI is typing">
{/* Avatar */}
<div
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
style={{ backgroundColor: "rgb(var(--accent-primary))" }}
aria-hidden="true"
>
<span className="text-white">AI</span>
</div>
{/* Loading Content */}
<div className="flex flex-col gap-1.5">
<div
className="flex items-center gap-2 text-xs"
style={{ color: "rgb(var(--text-muted))" }}
>
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
AI Assistant
</span>
<span>thinking...</span>
</div>
<div
className="rounded-lg border px-4 py-3"
style={{
backgroundColor: "rgb(var(--surface-0))",
borderColor: "rgb(var(--border-default))",
}}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<span
className="h-1.5 w-1.5 rounded-full animate-bounce"
style={{
backgroundColor: "rgb(var(--accent-primary))",
animationDelay: "0ms",
}}
/>
<span
className="h-1.5 w-1.5 rounded-full animate-bounce"
style={{
backgroundColor: "rgb(var(--accent-primary))",
animationDelay: "150ms",
}}
/>
<span
className="h-1.5 w-1.5 rounded-full animate-bounce"
style={{
backgroundColor: "rgb(var(--accent-primary))",
animationDelay: "300ms",
}}
/>
</div>
{quip && (
<span
className="text-sm italic animate-fade-in"
style={{ color: "rgb(var(--text-muted))" }}
>
{quip}
</span>
)}
</div>
</div>
</div>
</div>
);
}
function formatTime(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
}
function formatTokenCount(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
}

View File

@@ -0,0 +1,17 @@
/**
* Chat Components
*
* Migrated from jarvis-fe. These components provide the chat interface
* for interacting with the AI brain service.
*
* Usage:
* ```tsx
* import { Chat, MessageList, ChatInput } from '@/components/chat';
* ```
*/
export { Chat, type ChatRef, type Message, type NewConversationData } from './Chat';
export { ChatInput } from './ChatInput';
export { MessageList } from './MessageList';
export { ConversationSidebar, type ConversationSidebarRef } from './ConversationSidebar';
export { BackendStatusBanner } from './BackendStatusBanner';