722 lines
21 KiB
Markdown
722 lines
21 KiB
Markdown
# Chat Interface + Task Orchestration Research Report
|
|
|
|
**Date:** 2026-03-01
|
|
**Focus:** Analysis of Mission Control and Clawtrol for Mosaic Stack feature development
|
|
**Goal:** Extract actionable design patterns for chat, task dispatch, and live event feeds
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
Both Mission Control and Clawtrol are OpenClaw-compatible dashboards with complementary strengths:
|
|
|
|
| Feature | Mission Control | Clawtrol | Mosaic Stack Gap |
|
|
|---------|----------------|----------|------------------|
|
|
| Chat with agents | ❌ No direct chat | ✅ Full session chat + send | **HIGH** - Stub exists, not wired |
|
|
| Task dispatch | ✅ AI planning + Kanban | ✅ Simple Kanban | Medium - Kanban exists |
|
|
| Live events | ✅ SSE-based feed | ❌ Polling only | Medium - SSE polling exists |
|
|
| Session viewer | ❌ No | ✅ Full transcript view | **HIGH** - Missing |
|
|
| Agent management | ✅ Auto-create agents | ❌ Basic list | Medium |
|
|
|
|
**Top 3 Quick Wins for Mosaic Stack:**
|
|
1. **Session chat interface** (< 4 hours) - Wire existing chat stub to OpenClaw API
|
|
2. **Session list view** (< 2 hours) - Read `sessions.json` + `.jsonl` transcripts
|
|
3. **Task card planning indicator** (< 1 hour) - Add purple pulse animation
|
|
|
|
---
|
|
|
|
## 1. Chat Interface Analysis
|
|
|
|
### Clawtrol Sessions Module (Best Reference)
|
|
|
|
**File:** `src/components/modules/SessionsModule/index.tsx`
|
|
|
|
**Key Architecture:**
|
|
```typescript
|
|
// Session list fetched from OpenClaw
|
|
const res = await fetch('/api/sessions');
|
|
const data = await res.json();
|
|
setSessions(data.sessions || []);
|
|
|
|
// Session detail with message history
|
|
const res = await fetch(`/api/sessions/${encodeURIComponent(session.key)}?limit=50`);
|
|
const data = await res.json();
|
|
setChatMessages(data.messages || []);
|
|
|
|
// Send message to session (via Telegram or direct)
|
|
await fetch('/api/sessions/send', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ sessionKey: selectedSession.key, message: msg }),
|
|
});
|
|
```
|
|
|
|
**UI Pattern - Two-Column Chat Layout:**
|
|
```tsx
|
|
// Session list view
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
{sessions.map(session => (
|
|
<div onClick={() => openSessionChat(session)}>
|
|
{/* Activity indicator */}
|
|
<div className="w-2 h-2 rounded-full"
|
|
style={{ background: activityColor, boxShadow: '0 0 6px ...' }} />
|
|
|
|
{/* Session metadata */}
|
|
<span>{session.messageCount} msgs · {session.totalTokens}k tokens</span>
|
|
<span>${session.estimatedCost.toFixed(2)}</span>
|
|
|
|
{/* Last message preview */}
|
|
<div className="truncate">
|
|
{session.lastMessages[0]?.text?.slice(0, 100)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
```
|
|
|
|
**Chat View Pattern:**
|
|
```tsx
|
|
// Messages container with auto-scroll
|
|
<div className="flex-1 overflow-auto p-4 space-y-3">
|
|
{chatMessages.map(msg => (
|
|
<div className={msg.role === 'user' ? 'justify-end' : 'justify-start'}>
|
|
<div className="max-w-[85%] rounded-lg px-3 py-2">
|
|
{/* Role badge */}
|
|
<span className="text-[9px] uppercase">
|
|
{msg.role === 'user' ? 'you' : 'assistant'}
|
|
</span>
|
|
|
|
{/* Markdown content */}
|
|
<div>{renderMarkdown(msg.text)}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div ref={chatEndRef} /> {/* Auto-scroll anchor */}
|
|
</div>
|
|
|
|
// Input with Enter to send
|
|
<input onKeyDown={e => e.key === 'Enter' && sendChatMessage()} />
|
|
```
|
|
|
|
**Session API Pattern (`/api/sessions/route.ts`):**
|
|
```typescript
|
|
// Priority: CLI > Index file > Direct file scan
|
|
const SESSIONS_INDEX = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions', 'sessions.json');
|
|
const SESSIONS_DIR = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions');
|
|
|
|
// Read sessions from index
|
|
const sessionsMap = JSON.parse(await readFile(SESSIONS_INDEX, 'utf-8'));
|
|
|
|
// Enrich with message count and last messages
|
|
for (const session of sessions) {
|
|
const [msgs, count] = await Promise.all([
|
|
getLastMessages(sessionFile, 3), // Last 3 messages
|
|
getMessageCount(sessionFile), // Total count
|
|
]);
|
|
}
|
|
|
|
// Parse JSONL for messages
|
|
function getLastMessages(sessionFile: string, count: number) {
|
|
const lines = data.trim().split('\n').filter(Boolean);
|
|
for (let i = lines.length - 1; i >= 0 && messages.length < count; i--) {
|
|
const parsed = JSON.parse(lines[i]);
|
|
if (parsed.type === 'message' && parsed.message) {
|
|
messages.unshift({
|
|
role: parsed.message.role,
|
|
text: extractTextFromContent(parsed.message.content),
|
|
timestamp: parsed.timestamp,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Message Send Pattern (`/api/sessions/send/route.ts`):**
|
|
```typescript
|
|
// Parse session key to determine target
|
|
function parseSessionKey(key: string): { chatId: string; topicId?: string } | null {
|
|
// agent:main:main → DM to owner
|
|
if (key === 'agent:main:main') {
|
|
return { chatId: await getDefaultChatId() };
|
|
}
|
|
|
|
// agent:main:telegram:group:<id>:topic:<id>
|
|
const topicMatch = key.match(/:group:(-?\d+):topic:(\d+)$/);
|
|
if (topicMatch) {
|
|
return { chatId: topicMatch[1], topicId: topicMatch[2] };
|
|
}
|
|
}
|
|
|
|
// Send via Telegram Bot API (or could use OpenClaw chat.send)
|
|
const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ chat_id: target.chatId, text: message }),
|
|
});
|
|
```
|
|
|
|
### Key Takeaways for Mosaic Stack
|
|
|
|
1. **Session key format:** `agent:main:telegram:group:<id>:topic:<id>` or `agent:main:main`
|
|
2. **JSONL parsing:** Read from `~/.openclaw/agents/main/sessions/<session-id>.jsonl`
|
|
3. **Cost estimation:**
|
|
```typescript
|
|
const isOpus = modelName.includes('opus');
|
|
const inputRate = isOpus ? 15 : 3;
|
|
const outputRate = isOpus ? 75 : 15;
|
|
const cost = (inputTokens / 1_000_000 * inputRate) + (outputTokens / 1_000_000 * outputRate);
|
|
```
|
|
4. **Activity color logic:**
|
|
```typescript
|
|
if (lastActivity > hourAgo) return 'green'; // Active
|
|
if (lastActivity > dayAgo) return 'yellow'; // Recent
|
|
return 'dim'; // Stale
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Task/Agent Dispatch Flow (Mission Control)
|
|
|
|
### AI Planning UX Pattern
|
|
|
|
**The Flow:**
|
|
```
|
|
CREATE → PLAN (AI Q&A) → ASSIGN (Auto-agent) → EXECUTE → DELIVER
|
|
```
|
|
|
|
**Status Columns:**
|
|
```
|
|
PLANNING → INBOX → ASSIGNED → IN PROGRESS → TESTING → REVIEW → DONE
|
|
```
|
|
|
|
**PlanningTab.tsx - Core Pattern:**
|
|
|
|
1. **Start Planning Button:**
|
|
```tsx
|
|
if (!state?.isStarted) {
|
|
return (
|
|
<button onClick={startPlanning} className="px-6 py-3 bg-mc-accent">
|
|
📋 Start Planning
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
2. **Question/Answer Loop:**
|
|
```tsx
|
|
// Current question display
|
|
<h3>{state.currentQuestion.question}</h3>
|
|
|
|
// Multiple choice options
|
|
{state.currentQuestion.options.map(option => (
|
|
<button
|
|
onClick={() => setSelectedOption(option.label)}
|
|
className={isSelected ? 'border-mc-accent bg-mc-accent/10' : 'border-mc-border'}
|
|
>
|
|
<span className="w-8 h-8">{option.id.toUpperCase()}</span>
|
|
<span>{option.label}</span>
|
|
{isSelected && <CheckCircle />}
|
|
</button>
|
|
))}
|
|
|
|
// "Other" option with text input
|
|
{isOther && isSelected && (
|
|
<input placeholder="Please specify..." value={otherText} />
|
|
)}
|
|
```
|
|
|
|
3. **Polling for AI Response:**
|
|
```typescript
|
|
// Poll every 2 seconds for next question
|
|
pollingIntervalRef.current = setInterval(() => {
|
|
pollForUpdates();
|
|
}, 2000);
|
|
|
|
// 90-second timeout
|
|
pollingTimeoutRef.current = setTimeout(() => {
|
|
setError('Taking too long to respond...');
|
|
}, 90000);
|
|
```
|
|
|
|
4. **Planning Complete - Spec Display:**
|
|
```tsx
|
|
if (state?.isComplete && state?.spec) {
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-2 text-green-400">
|
|
<Lock /> Planning Complete
|
|
</div>
|
|
|
|
{/* Generated spec */}
|
|
<div className="bg-mc-bg border rounded-lg p-4">
|
|
<h3>{state.spec.title}</h3>
|
|
<p>{state.spec.summary}</p>
|
|
<ul>{state.spec.deliverables.map(d => <li>{d}</li>)}</ul>
|
|
<ul>{state.spec.success_criteria.map(c => <li>{c}</li>)}</ul>
|
|
</div>
|
|
|
|
{/* Auto-created agents */}
|
|
{state.agents.map(agent => (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl">{agent.avatar_emoji}</span>
|
|
<div>
|
|
<p>{agent.name}</p>
|
|
<p className="text-sm">{agent.role}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Planning API Pattern
|
|
|
|
**POST `/api/tasks/[id]/planning` - Start Planning:**
|
|
```typescript
|
|
// Create session key
|
|
const sessionKey = `agent:main:planning:${taskId}`;
|
|
|
|
// Build planning prompt
|
|
const planningPrompt = `
|
|
PLANNING REQUEST
|
|
|
|
Task Title: ${task.title}
|
|
Task Description: ${task.description}
|
|
|
|
Generate your FIRST question. Respond with ONLY valid JSON:
|
|
{
|
|
"question": "Your question here?",
|
|
"options": [
|
|
{"id": "A", "label": "First option"},
|
|
{"id": "B", "label": "Second option"},
|
|
{"id": "other", "label": "Other"}
|
|
]
|
|
}
|
|
`;
|
|
|
|
// Send to OpenClaw
|
|
await client.call('chat.send', {
|
|
sessionKey,
|
|
message: planningPrompt,
|
|
});
|
|
|
|
// Store in DB
|
|
UPDATE tasks SET planning_session_key = ?, planning_messages = ?, status = 'planning'
|
|
```
|
|
|
|
**Key Insight:** The AI doesn't just plan - it asks **multiple-choice questions** to clarify requirements. This is the "AI clarification before dispatch" pattern.
|
|
|
|
### Kanban Card with Planning Indicator
|
|
|
|
```tsx
|
|
// TaskCard.tsx
|
|
const isPlanning = task.status === 'planning';
|
|
|
|
<div className={isPlanning
|
|
? 'border-purple-500/40 hover:border-purple-500'
|
|
: 'border-mc-border/50 hover:border-mc-accent/40'}>
|
|
|
|
{isPlanning && (
|
|
<div className="flex items-center gap-2 py-2 px-3 bg-purple-500/10">
|
|
<div className="w-2 h-2 bg-purple-500 rounded-full animate-pulse" />
|
|
<span className="text-xs text-purple-400">Continue planning</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
```
|
|
|
|
### Auto-Dispatch Pattern
|
|
|
|
```typescript
|
|
// When task moves from PLANNING → INBOX (planning complete)
|
|
if (shouldTriggerAutoDispatch(oldStatus, newStatus, agentId)) {
|
|
await triggerAutoDispatch({
|
|
taskId,
|
|
taskTitle,
|
|
agentId,
|
|
agentName,
|
|
workspaceId,
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Live Event Feed
|
|
|
|
### Mission Control SSE Pattern
|
|
|
|
**`src/lib/events.ts`:**
|
|
```typescript
|
|
// In-memory client registry
|
|
const clients = new Set<ReadableStreamDefaultController>();
|
|
|
|
export function registerClient(controller) {
|
|
clients.add(controller);
|
|
}
|
|
|
|
export function broadcast(event: SSEEvent) {
|
|
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
const encoded = new TextEncoder().encode(data);
|
|
|
|
for (const client of Array.from(clients)) {
|
|
try {
|
|
client.enqueue(encoded);
|
|
} catch {
|
|
clients.delete(client);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**LiveFeed Component:**
|
|
```tsx
|
|
// Filter tabs
|
|
<div className="flex gap-1">
|
|
{['all', 'tasks', 'agents'].map(tab => (
|
|
<button className={filter === tab ? 'bg-mc-accent' : ''}>
|
|
{tab}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
// Event list with icons
|
|
{filteredEvents.map(event => (
|
|
<div className={`p-2 rounded border-l-2 ${
|
|
isHighlight ? 'bg-mc-bg-tertiary border-mc-accent-pink' : 'hover:bg-mc-bg-tertiary'
|
|
}`}>
|
|
<span>{getEventIcon(event.type)}</span>
|
|
<p>{event.message}</p>
|
|
<span className="text-xs">{formatDistanceToNow(event.created_at)}</span>
|
|
</div>
|
|
))}
|
|
|
|
// Event icons
|
|
function getEventIcon(type: string) {
|
|
switch (type) {
|
|
case 'task_created': return '📋';
|
|
case 'task_assigned': return '👤';
|
|
case 'task_completed': return '✅';
|
|
case 'message_sent': return '💬';
|
|
case 'agent_joined': return '🎉';
|
|
}
|
|
}
|
|
```
|
|
|
|
### SSE vs WebSocket Trade-off
|
|
|
|
| Aspect | SSE (Mission Control) | WebSocket (Clawtrol) |
|
|
|--------|----------------------|---------------------|
|
|
| Direction | Server → Client only | Bidirectional |
|
|
| Reconnect | Automatic browser handling | Manual implementation |
|
|
| Overhead | HTTP-based, lighter | Full TCP connection |
|
|
| Use case | Event feeds, notifications | Real-time terminal, chat |
|
|
|
|
**Recommendation:** Use SSE for event feeds (simpler), WebSocket for interactive terminals.
|
|
|
|
---
|
|
|
|
## 4. Session Viewer Pattern
|
|
|
|
### Clawtrol Session List
|
|
|
|
```tsx
|
|
// Session card with activity indicator
|
|
<div className="card-base cursor-pointer" onClick={() => openSessionChat(session)}>
|
|
{/* Activity dot */}
|
|
<div className="w-2 h-2 rounded-full"
|
|
style={{
|
|
background: activityColor,
|
|
boxShadow: activityColor === 'green' ? '0 0 6px rgba(0,255,106,0.5)' : undefined
|
|
}} />
|
|
|
|
{/* Session info */}
|
|
<h3 className="truncate">{session.label}</h3>
|
|
<div className="text-[9px]">
|
|
{session.messageCount} msgs · {session.totalTokens}k tokens
|
|
{session.estimatedCost > 0 && <span> · ${session.estimatedCost.toFixed(2)}</span>}
|
|
{session.model && <span> · {session.model}</span>}
|
|
</div>
|
|
|
|
{/* Last message preview */}
|
|
{session.lastMessages?.length > 0 && (
|
|
<div className="mt-2 p-2 rounded bg-secondary">
|
|
<span>{session.lastMessages[0]?.role === 'user' ? 'you: ' : 'assistant: '}</span>
|
|
<span className="truncate">{session.lastMessages[0]?.text?.slice(0, 100)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
```
|
|
|
|
### Session Label Mapping
|
|
|
|
```typescript
|
|
const TOPIC_NAMES: Record<string, string> = {
|
|
'1369': '🔖 Bookmarks',
|
|
'13': '🌴 Bali Trip',
|
|
'14': '💰 Expenses',
|
|
// ... user-defined topic labels
|
|
};
|
|
|
|
function getSessionLabel(key: string): string {
|
|
if (key === 'agent:main:main') return 'Main Session (DM)';
|
|
if (key.includes(':subagent:')) return `Subagent ${uuid.slice(0, 8)}`;
|
|
|
|
// Telegram topic
|
|
const topicMatch = key.match(/:topic:(\d+)$/);
|
|
if (topicMatch) {
|
|
return TOPIC_NAMES[topicMatch[1]] || `Topic ${topicMatch[1]}`;
|
|
}
|
|
|
|
return key.split(':').pop() || key;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. OpenClaw Client Integration
|
|
|
|
### WebSocket Client Pattern
|
|
|
|
**`src/lib/openclaw/client.ts`:**
|
|
```typescript
|
|
export class OpenClawClient extends EventEmitter {
|
|
private ws: WebSocket | null = null;
|
|
private pendingRequests = new Map<string, PromiseHandlers>();
|
|
private connected = false;
|
|
private authenticated = false;
|
|
|
|
async connect(): Promise<void> {
|
|
// Add token to URL for auth
|
|
const wsUrl = new URL(this.url);
|
|
wsUrl.searchParams.set('token', this.token);
|
|
|
|
this.ws = new WebSocket(wsUrl.toString());
|
|
|
|
this.ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
|
|
// Handle challenge-response auth
|
|
if (data.type === 'event' && data.event === 'connect.challenge') {
|
|
const response = {
|
|
type: 'req',
|
|
id: crypto.randomUUID(),
|
|
method: 'connect',
|
|
params: {
|
|
auth: { token: this.token },
|
|
role: 'operator',
|
|
scopes: ['operator.admin'],
|
|
}
|
|
};
|
|
this.ws.send(JSON.stringify(response));
|
|
return;
|
|
}
|
|
|
|
// Handle RPC responses
|
|
if (data.type === 'res') {
|
|
const pending = this.pendingRequests.get(data.id);
|
|
if (pending) {
|
|
data.ok ? pending.resolve(data.payload) : pending.reject(data.error);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
async call<T>(method: string, params?: object): Promise<T> {
|
|
const id = crypto.randomUUID();
|
|
const message = { type: 'req', id, method, params };
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this.pendingRequests.set(id, { resolve, reject });
|
|
this.ws.send(JSON.stringify(message));
|
|
|
|
// 30s timeout
|
|
setTimeout(() => {
|
|
if (this.pendingRequests.has(id)) {
|
|
this.pendingRequests.delete(id);
|
|
reject(new Error(`Timeout: ${method}`));
|
|
}
|
|
}, 30000);
|
|
});
|
|
}
|
|
|
|
// Convenience methods
|
|
async listSessions() { return this.call('sessions.list'); }
|
|
async sendMessage(sessionId: string, content: string) {
|
|
return this.call('sessions.send', { session_id: sessionId, content });
|
|
}
|
|
async listAgents() { return this.call('agents.list'); }
|
|
}
|
|
```
|
|
|
|
### Event Deduplication Pattern
|
|
|
|
```typescript
|
|
// Global dedup cache (survives Next.js hot reload)
|
|
const GLOBAL_EVENT_CACHE_KEY = '__openclaw_processed_events__';
|
|
const globalProcessedEvents = globalThis[GLOBAL_EVENT_CACHE_KEY] || new Map();
|
|
|
|
// Content-based event ID
|
|
function generateEventId(data: any): string {
|
|
const canonical = JSON.stringify({
|
|
type: data.type,
|
|
seq: data.seq,
|
|
runId: data.payload?.runId,
|
|
payloadHash: createHash('sha256').update(JSON.stringify(data.payload)).digest('hex').slice(0, 16),
|
|
});
|
|
return createHash('sha256').update(canonical).digest('hex').slice(0, 32);
|
|
}
|
|
|
|
// Skip duplicates
|
|
if (globalProcessedEvents.has(eventId)) return;
|
|
globalProcessedEvents.set(eventId, Date.now());
|
|
|
|
// LRU cleanup
|
|
if (globalProcessedEvents.size > MAX_EVENTS) {
|
|
// Remove oldest entries
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Feature Recommendations for Mosaic Stack
|
|
|
|
### Quick Wins (< 4 hours each)
|
|
|
|
| Feature | Effort | Impact | Source |
|
|
|---------|--------|--------|--------|
|
|
| **Session list page** | 2h | HIGH | Clawtrol |
|
|
| **Session chat interface** | 4h | HIGH | Clawtrol |
|
|
| **Planning indicator on task cards** | 1h | MEDIUM | Mission Control |
|
|
| **Activity dots (green/yellow/dim)** | 30m | MEDIUM | Clawtrol |
|
|
| **Token/cost display per session** | 1h | MEDIUM | Clawtrol |
|
|
| **Event feed filter tabs** | 1h | LOW | Mission Control |
|
|
|
|
### Medium Effort (4-16 hours)
|
|
|
|
| Feature | Effort | Impact | Description |
|
|
|---------|--------|--------|-------------|
|
|
| **AI planning flow** | 8h | HIGH | Multi-choice Q&A before dispatch |
|
|
| **OpenClaw WebSocket client** | 4h | HIGH | Real-time event streaming |
|
|
| **Session transcript viewer** | 4h | MEDIUM | JSONL parsing + display |
|
|
| **Auto-agent creation** | 8h | MEDIUM | Generate agents from planning spec |
|
|
|
|
### Architecture Recommendations
|
|
|
|
1. **Keep SSE for event feed** - Simpler than WebSocket for one-way updates
|
|
2. **Use OpenClaw `chat.send` for messages** - Don't implement Telegram API directly
|
|
3. **Store session metadata in PostgreSQL** - Mirror `sessions.json` for joins
|
|
4. **Implement planning as a state machine** - Clear states: idle → started → questioning → complete
|
|
|
|
---
|
|
|
|
## 7. Code Snippets to Reuse
|
|
|
|
### Session API Route (Clawtrol-style)
|
|
|
|
```typescript
|
|
// app/api/sessions/route.ts
|
|
import { readFile, readdir } from 'fs/promises';
|
|
import { join } from 'path';
|
|
import os from 'os';
|
|
|
|
const SESSIONS_DIR = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions');
|
|
|
|
export async function GET() {
|
|
// Try CLI first
|
|
try {
|
|
const { stdout } = await execAsync('openclaw sessions --json');
|
|
return NextResponse.json({ sessions: JSON.parse(stdout).sessions, source: 'cli' });
|
|
} catch {}
|
|
|
|
// Fallback to file
|
|
const index = await readFile(join(SESSIONS_DIR, 'sessions.json'), 'utf-8');
|
|
const sessionsMap = JSON.parse(index);
|
|
|
|
const sessions = await Promise.all(
|
|
Object.entries(sessionsMap).map(async ([key, data]) => ({
|
|
key,
|
|
label: getSessionLabel(key),
|
|
kind: getSessionKind(key),
|
|
lastActivity: new Date(data.updatedAt).toISOString(),
|
|
messageCount: await getMessageCount(key),
|
|
totalTokens: data.totalTokens || 0,
|
|
estimatedCost: calculateCost(data),
|
|
}))
|
|
);
|
|
|
|
return NextResponse.json({ sessions, source: 'file' });
|
|
}
|
|
```
|
|
|
|
### Activity Indicator Component
|
|
|
|
```tsx
|
|
// components/ActivityIndicator.tsx
|
|
export function ActivityIndicator({ lastActivity }: { lastActivity: Date }) {
|
|
const now = Date.now();
|
|
const hourAgo = now - 60 * 60 * 1000;
|
|
const dayAgo = now - 24 * 60 * 60 * 1000;
|
|
|
|
const color = lastActivity.getTime() > hourAgo
|
|
? 'bg-green-500'
|
|
: lastActivity.getTime() > dayAgo
|
|
? 'bg-yellow-500'
|
|
: 'bg-gray-500';
|
|
|
|
const glow = lastActivity.getTime() > hourAgo
|
|
? 'shadow-[0_0_6px_rgba(34,197,94,0.5)]'
|
|
: '';
|
|
|
|
return (
|
|
<div className={`w-2 h-2 rounded-full ${color} ${glow}`} />
|
|
);
|
|
}
|
|
```
|
|
|
|
### Cost Estimation Utility
|
|
|
|
```typescript
|
|
// lib/cost-estimation.ts
|
|
const RATES = {
|
|
opus: { input: 15, output: 75 },
|
|
sonnet: { input: 3, output: 15 },
|
|
haiku: { input: 0.25, output: 1.25 },
|
|
};
|
|
|
|
export function estimateCost(model: string, inputTokens: number, outputTokens: number): number {
|
|
const tier = model.includes('opus') ? 'opus'
|
|
: model.includes('sonnet') ? 'sonnet'
|
|
: 'haiku';
|
|
|
|
const rates = RATES[tier];
|
|
return (inputTokens / 1_000_000 * rates.input) +
|
|
(outputTokens / 1_000_000 * rates.output);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Summary
|
|
|
|
**Best patterns to steal:**
|
|
|
|
1. **Clawtrol's session chat** - Clean two-panel layout with activity dots
|
|
2. **Mission Control's planning flow** - Multi-choice Q&A with polling
|
|
3. **Clawtrol's JSONL parsing** - Efficient reverse-iteration for last N messages
|
|
4. **Mission Control's SSE events** - Simple broadcast pattern with client registry
|
|
5. **Activity color logic** - Hour = green, day = yellow, older = dim
|
|
|
|
**Don't copy:**
|
|
|
|
1. Telegram Bot API integration - Use OpenClaw `chat.send` instead
|
|
2. File-based session index - Mosaic Stack has PostgreSQL
|
|
3. PM2 daemon management - Use Docker/systemd
|
|
|
|
**Next steps:**
|
|
|
|
1. Create `/app/(dashboard)/sessions` page with session list
|
|
2. Add chat view at `/app/(dashboard)/sessions/[key]`
|
|
3. Wire `/api/sessions` route to OpenClaw CLI or sessions.json
|
|
4. Add `ActivityIndicator` component to session cards
|
|
5. Add "Start Planning" button to task cards in Kanban
|