@aion0/forge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +264 -0
  3. package/app/api/auth/[...nextauth]/route.ts +3 -0
  4. package/app/api/claude/[id]/route.ts +31 -0
  5. package/app/api/claude/[id]/stream/route.ts +63 -0
  6. package/app/api/claude/route.ts +28 -0
  7. package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  8. package/app/api/claude-sessions/[projectName]/route.ts +37 -0
  9. package/app/api/claude-sessions/sync/route.ts +17 -0
  10. package/app/api/flows/route.ts +6 -0
  11. package/app/api/flows/run/route.ts +19 -0
  12. package/app/api/notify/test/route.ts +33 -0
  13. package/app/api/projects/route.ts +7 -0
  14. package/app/api/sessions/[id]/chat/route.ts +64 -0
  15. package/app/api/sessions/[id]/messages/route.ts +9 -0
  16. package/app/api/sessions/[id]/route.ts +17 -0
  17. package/app/api/sessions/route.ts +20 -0
  18. package/app/api/settings/route.ts +15 -0
  19. package/app/api/status/route.ts +12 -0
  20. package/app/api/tasks/[id]/route.ts +36 -0
  21. package/app/api/tasks/[id]/stream/route.ts +77 -0
  22. package/app/api/tasks/link/route.ts +37 -0
  23. package/app/api/tasks/route.ts +43 -0
  24. package/app/api/tasks/session/route.ts +14 -0
  25. package/app/api/templates/route.ts +6 -0
  26. package/app/api/tunnel/route.ts +20 -0
  27. package/app/api/watchers/route.ts +33 -0
  28. package/app/globals.css +26 -0
  29. package/app/icon.svg +26 -0
  30. package/app/layout.tsx +17 -0
  31. package/app/login/page.tsx +61 -0
  32. package/app/page.tsx +9 -0
  33. package/cli/mw.ts +377 -0
  34. package/components/ChatPanel.tsx +191 -0
  35. package/components/ClaudeTerminal.tsx +267 -0
  36. package/components/Dashboard.tsx +270 -0
  37. package/components/MarkdownContent.tsx +57 -0
  38. package/components/NewSessionModal.tsx +93 -0
  39. package/components/NewTaskModal.tsx +456 -0
  40. package/components/ProjectList.tsx +108 -0
  41. package/components/SessionList.tsx +74 -0
  42. package/components/SessionView.tsx +655 -0
  43. package/components/SettingsModal.tsx +366 -0
  44. package/components/StatusBar.tsx +99 -0
  45. package/components/TaskBoard.tsx +110 -0
  46. package/components/TaskDetail.tsx +351 -0
  47. package/components/TunnelToggle.tsx +163 -0
  48. package/components/WebTerminal.tsx +1069 -0
  49. package/docs/LOCAL-DEPLOY.md +144 -0
  50. package/docs/roadmap-multi-agent-workflow.md +330 -0
  51. package/instrumentation.ts +14 -0
  52. package/lib/auth.ts +47 -0
  53. package/lib/claude-process.ts +352 -0
  54. package/lib/claude-sessions.ts +267 -0
  55. package/lib/cloudflared.ts +218 -0
  56. package/lib/flows.ts +86 -0
  57. package/lib/init.ts +82 -0
  58. package/lib/notify.ts +75 -0
  59. package/lib/password.ts +77 -0
  60. package/lib/projects.ts +86 -0
  61. package/lib/session-manager.ts +156 -0
  62. package/lib/session-watcher.ts +345 -0
  63. package/lib/settings.ts +44 -0
  64. package/lib/task-manager.ts +668 -0
  65. package/lib/telegram-bot.ts +912 -0
  66. package/lib/terminal-server.ts +70 -0
  67. package/lib/terminal-standalone.ts +363 -0
  68. package/middleware.ts +33 -0
  69. package/next-env.d.ts +6 -0
  70. package/next.config.ts +16 -0
  71. package/package.json +66 -0
  72. package/postcss.config.mjs +7 -0
  73. package/src/config/index.ts +119 -0
  74. package/src/core/db/database.ts +133 -0
  75. package/src/core/memory/strategy.ts +32 -0
  76. package/src/core/providers/chat.ts +65 -0
  77. package/src/core/providers/registry.ts +60 -0
  78. package/src/core/session/manager.ts +190 -0
  79. package/src/types/index.ts +128 -0
  80. package/tsconfig.json +41 -0
@@ -0,0 +1,267 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+
5
+ interface ClaudeMessage {
6
+ type: 'system' | 'assistant' | 'result';
7
+ subtype?: string;
8
+ content: string;
9
+ tool?: string;
10
+ costUSD?: number;
11
+ sessionId?: string;
12
+ timestamp: string;
13
+ }
14
+
15
+ interface ClaudeProcess {
16
+ id: string;
17
+ projectName: string;
18
+ projectPath: string;
19
+ status: string;
20
+ conversationId?: string;
21
+ }
22
+
23
+ export default function ClaudeTerminal({
24
+ process: proc,
25
+ onKill,
26
+ }: {
27
+ process: ClaudeProcess;
28
+ onKill: (id: string) => void;
29
+ }) {
30
+ const [messages, setMessages] = useState<ClaudeMessage[]>([]);
31
+ const [input, setInput] = useState('');
32
+ const [isRunning, setIsRunning] = useState(false);
33
+ const [conversationId, setConversationId] = useState<string | undefined>(proc.conversationId);
34
+ const messagesEndRef = useRef<HTMLDivElement>(null);
35
+ const eventSourceRef = useRef<EventSource | null>(null);
36
+
37
+ // Connect SSE stream with auto-reconnect
38
+ useEffect(() => {
39
+ let cancelled = false;
40
+ let es: EventSource | null = null;
41
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
42
+
43
+ function connect() {
44
+ if (cancelled) return;
45
+ es = new EventSource(`/api/claude/${proc.id}/stream`);
46
+ eventSourceRef.current = es;
47
+
48
+ es.onmessage = (event) => {
49
+ try {
50
+ const msg: ClaudeMessage = JSON.parse(event.data);
51
+ setMessages(prev => {
52
+ // Deduplicate by timestamp+content on reconnect
53
+ if (prev.some(m => m.timestamp === msg.timestamp && m.content === msg.content)) {
54
+ return prev;
55
+ }
56
+ return [...prev, msg];
57
+ });
58
+
59
+ if (msg.sessionId) {
60
+ setConversationId(msg.sessionId);
61
+ }
62
+
63
+ if (msg.subtype === 'complete') {
64
+ setIsRunning(false);
65
+ }
66
+ } catch {}
67
+ };
68
+
69
+ es.onerror = () => {
70
+ es?.close();
71
+ eventSourceRef.current = null;
72
+ // Auto-reconnect after 2s
73
+ if (!cancelled) {
74
+ reconnectTimer = setTimeout(connect, 2000);
75
+ }
76
+ };
77
+ }
78
+
79
+ connect();
80
+
81
+ return () => {
82
+ cancelled = true;
83
+ if (reconnectTimer) clearTimeout(reconnectTimer);
84
+ es?.close();
85
+ eventSourceRef.current = null;
86
+ };
87
+ }, [proc.id]);
88
+
89
+ // Auto-scroll
90
+ useEffect(() => {
91
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
92
+ }, [messages]);
93
+
94
+ const sendMessage = async () => {
95
+ const text = input.trim();
96
+ if (!text || isRunning) return;
97
+
98
+ setInput('');
99
+ setIsRunning(true);
100
+
101
+ await fetch(`/api/claude/${proc.id}`, {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ type: 'message', content: text, conversationId }),
105
+ });
106
+ };
107
+
108
+ const handleKeyDown = (e: React.KeyboardEvent) => {
109
+ if (e.key === 'Enter' && !e.shiftKey) {
110
+ e.preventDefault();
111
+ sendMessage();
112
+ }
113
+ };
114
+
115
+ return (
116
+ <div className="flex flex-col h-full">
117
+ {/* Header */}
118
+ <div className="h-8 border-b border-[var(--border)] flex items-center justify-between px-3 shrink-0">
119
+ <div className="flex items-center gap-2">
120
+ <span className={`text-[10px] ${proc.status === 'running' || isRunning ? 'text-[var(--green)]' : 'text-[var(--text-secondary)]'}`}>●</span>
121
+ <span className="text-xs font-semibold">Claude Code</span>
122
+ <span className="text-[10px] text-[var(--text-secondary)]">{proc.projectName}</span>
123
+ {isRunning && <span className="text-[10px] text-[var(--accent)] animate-pulse">thinking...</span>}
124
+ </div>
125
+ <button
126
+ onClick={() => onKill(proc.id)}
127
+ className="text-[10px] px-2 py-0.5 text-[var(--red)] hover:bg-[var(--red)] hover:text-white rounded transition-colors"
128
+ >
129
+ Kill
130
+ </button>
131
+ </div>
132
+
133
+ {/* Messages */}
134
+ <div className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
135
+ {messages.length === 0 && (
136
+ <div className="text-center text-[var(--text-secondary)] py-8">
137
+ <p>Send a message to start working with Claude Code</p>
138
+ <p className="text-[10px] mt-1">Working in: {proc.projectPath}</p>
139
+ </div>
140
+ )}
141
+
142
+ {messages.map((msg, i) => (
143
+ <MessageBubble key={i} msg={msg} />
144
+ ))}
145
+ <div ref={messagesEndRef} />
146
+ </div>
147
+
148
+ {/* Input */}
149
+ <div className="border-t border-[var(--border)] p-3">
150
+ <div className="flex gap-2">
151
+ <textarea
152
+ value={input}
153
+ onChange={e => setInput(e.target.value)}
154
+ onKeyDown={handleKeyDown}
155
+ placeholder={isRunning ? 'Waiting for response...' : 'Send a message to Claude Code...'}
156
+ disabled={isRunning}
157
+ rows={2}
158
+ className="flex-1 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
159
+ />
160
+ <button
161
+ onClick={sendMessage}
162
+ disabled={isRunning || !input.trim()}
163
+ className="px-4 py-2 bg-[var(--accent)] text-white rounded text-xs hover:opacity-90 disabled:opacity-50 self-end"
164
+ >
165
+ Send
166
+ </button>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ function MessageBubble({ msg }: { msg: ClaudeMessage }) {
174
+ // User input
175
+ if (msg.type === 'system' && msg.subtype === 'user_input') {
176
+ return (
177
+ <div className="flex justify-end">
178
+ <div className="max-w-[80%] px-3 py-2 bg-[var(--accent)] text-white rounded-lg rounded-br-sm">
179
+ <pre className="whitespace-pre-wrap break-words">{msg.content}</pre>
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ // System init — show model info subtly
186
+ if (msg.type === 'system' && msg.subtype === 'init') {
187
+ return (
188
+ <div className="text-center text-[10px] text-[var(--text-secondary)] py-1">
189
+ {msg.content}
190
+ </div>
191
+ );
192
+ }
193
+
194
+ // Error
195
+ if (msg.subtype === 'error') {
196
+ return (
197
+ <div className="px-3 py-2 bg-red-900/20 border border-red-800/30 rounded text-[var(--red)]">
198
+ <pre className="whitespace-pre-wrap break-words">{msg.content}</pre>
199
+ </div>
200
+ );
201
+ }
202
+
203
+ // Completion notice
204
+ if (msg.subtype === 'complete') {
205
+ return (
206
+ <div className="text-center text-[10px] text-[var(--text-secondary)] py-1">
207
+ {msg.content}
208
+ {msg.costUSD != null && ` · $${msg.costUSD.toFixed(4)}`}
209
+ </div>
210
+ );
211
+ }
212
+
213
+ // Tool use
214
+ if (msg.subtype === 'tool_use') {
215
+ return (
216
+ <div className="px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded">
217
+ <div className="flex items-center gap-2 mb-1">
218
+ <span className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/20 text-[var(--accent)] rounded">
219
+ {msg.tool || 'tool'}
220
+ </span>
221
+ </div>
222
+ <pre className="whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-40 overflow-y-auto">
223
+ {formatToolContent(msg.content)}
224
+ </pre>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ // Tool result
230
+ if (msg.subtype === 'tool_result') {
231
+ return (
232
+ <div className="px-3 py-2 bg-[var(--bg-tertiary)] border-l-2 border-[var(--accent)] rounded-r">
233
+ <pre className="whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-60 overflow-y-auto">
234
+ {formatToolContent(msg.content)}
235
+ </pre>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ // Final result
241
+ if (msg.type === 'result') {
242
+ return (
243
+ <div className="px-3 py-2 bg-green-900/10 border border-green-800/20 rounded">
244
+ <pre className="whitespace-pre-wrap break-words">{msg.content}</pre>
245
+ {msg.costUSD != null && (
246
+ <div className="text-[10px] text-[var(--text-secondary)] mt-1">Cost: ${msg.costUSD.toFixed(4)}</div>
247
+ )}
248
+ </div>
249
+ );
250
+ }
251
+
252
+ // Regular assistant text
253
+ return (
254
+ <div className="px-3 py-2">
255
+ <pre className="whitespace-pre-wrap break-words text-[var(--text-primary)]">{msg.content}</pre>
256
+ </div>
257
+ );
258
+ }
259
+
260
+ function formatToolContent(content: string): string {
261
+ try {
262
+ const parsed = JSON.parse(content);
263
+ return JSON.stringify(parsed, null, 2);
264
+ } catch {
265
+ return content;
266
+ }
267
+ }
@@ -0,0 +1,270 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
4
+ import TaskBoard from './TaskBoard';
5
+ import TaskDetail from './TaskDetail';
6
+ import SessionView from './SessionView';
7
+ import NewTaskModal from './NewTaskModal';
8
+ import SettingsModal from './SettingsModal';
9
+ import TunnelToggle from './TunnelToggle';
10
+ import type { Task } from '@/src/types';
11
+ import type { WebTerminalHandle } from './WebTerminal';
12
+
13
+ const WebTerminal = lazy(() => import('./WebTerminal'));
14
+
15
+ interface UsageSummary {
16
+ provider: string;
17
+ totalInput: number;
18
+ totalOutput: number;
19
+ totalCost: number;
20
+ }
21
+
22
+ interface ProviderInfo {
23
+ name: string;
24
+ displayName: string;
25
+ hasKey: boolean;
26
+ enabled: boolean;
27
+ }
28
+
29
+ interface ProjectInfo {
30
+ name: string;
31
+ path: string;
32
+ language: string | null;
33
+ }
34
+
35
+ export default function Dashboard({ user }: { user: any }) {
36
+ const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal'>('tasks');
37
+ const [tasks, setTasks] = useState<Task[]>([]);
38
+ const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
39
+ const [showNewTask, setShowNewTask] = useState(false);
40
+ const [showSettings, setShowSettings] = useState(false);
41
+ const [usage, setUsage] = useState<UsageSummary[]>([]);
42
+ const [providers, setProviders] = useState<ProviderInfo[]>([]);
43
+ const [projects, setProjects] = useState<ProjectInfo[]>([]);
44
+ const terminalRef = useRef<WebTerminalHandle>(null);
45
+
46
+ const fetchData = useCallback(async () => {
47
+ const [tasksRes, statusRes, projectsRes] = await Promise.all([
48
+ fetch('/api/tasks'),
49
+ fetch('/api/status'),
50
+ fetch('/api/projects'),
51
+ ]);
52
+ const tasksData = await tasksRes.json();
53
+ const statusData = await statusRes.json();
54
+ const projectsData = await projectsRes.json();
55
+ setTasks(tasksData);
56
+ setProviders(statusData.providers);
57
+ setUsage(statusData.usage);
58
+ setProjects(projectsData);
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ fetchData();
63
+ const interval = setInterval(fetchData, 5000);
64
+ return () => clearInterval(interval);
65
+ }, [fetchData]);
66
+
67
+ const activeTask = tasks.find(t => t.id === activeTaskId);
68
+ const running = tasks.filter(t => t.status === 'running');
69
+ const queued = tasks.filter(t => t.status === 'queued');
70
+
71
+ return (
72
+ <div className="h-screen flex flex-col">
73
+ {/* Top bar */}
74
+ <header className="h-10 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0">
75
+ <div className="flex items-center gap-4">
76
+ <span className="text-sm font-bold text-[var(--accent)]">Forge</span>
77
+
78
+ {/* View mode toggle */}
79
+ <div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
80
+ <button
81
+ onClick={() => setViewMode('tasks')}
82
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
83
+ viewMode === 'tasks'
84
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
85
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
86
+ }`}
87
+ >
88
+ Tasks
89
+ </button>
90
+ <button
91
+ onClick={() => setViewMode('sessions')}
92
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
93
+ viewMode === 'sessions'
94
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
95
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
96
+ }`}
97
+ >
98
+ Sessions
99
+ </button>
100
+ <button
101
+ onClick={() => setViewMode('terminal')}
102
+ className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
103
+ viewMode === 'terminal'
104
+ ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
105
+ : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
106
+ }`}
107
+ >
108
+ Terminal
109
+ </button>
110
+ </div>
111
+
112
+ {viewMode === 'tasks' && (
113
+ <span className="text-[10px] text-[var(--text-secondary)]">
114
+ {running.length} running · {queued.length} queued · {tasks.filter(t => t.status === 'done').length} done
115
+ </span>
116
+ )}
117
+ </div>
118
+ <div className="flex items-center gap-3">
119
+ {viewMode === 'tasks' && (
120
+ <button
121
+ onClick={() => setShowNewTask(true)}
122
+ className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90"
123
+ >
124
+ + New Task
125
+ </button>
126
+ )}
127
+ <TunnelToggle />
128
+ <button
129
+ onClick={() => setShowSettings(true)}
130
+ className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
131
+ >
132
+ Settings
133
+ </button>
134
+ <span className="text-xs text-[var(--text-secondary)]">{user?.name || 'local'}</span>
135
+ </div>
136
+ </header>
137
+
138
+ {/* Main content */}
139
+ <div className="flex-1 flex min-h-0">
140
+ {viewMode === 'tasks' ? (
141
+ <>
142
+ {/* Left — Task list */}
143
+ <aside className="w-72 border-r border-[var(--border)] flex flex-col shrink-0">
144
+ <TaskBoard tasks={tasks} activeId={activeTaskId} onSelect={setActiveTaskId} onRefresh={fetchData} />
145
+ </aside>
146
+
147
+ {/* Center — Task detail / empty state */}
148
+ <main className="flex-1 flex flex-col min-w-0">
149
+ {activeTask ? (
150
+ <TaskDetail
151
+ task={activeTask}
152
+ onRefresh={fetchData}
153
+ onFollowUp={async (data) => {
154
+ const res = await fetch('/api/tasks', {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify(data),
158
+ });
159
+ const newTask = await res.json();
160
+ setActiveTaskId(newTask.id);
161
+ fetchData();
162
+ }}
163
+ />
164
+ ) : (
165
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
166
+ <div className="text-center space-y-2">
167
+ <p className="text-lg">Select a task or create a new one</p>
168
+ <p className="text-xs">Submit tasks for Claude Code to work on autonomously</p>
169
+ </div>
170
+ </div>
171
+ )}
172
+ </main>
173
+
174
+ {/* Right — Status panel */}
175
+ <aside className="w-56 border-l border-[var(--border)] flex flex-col shrink-0 p-3 space-y-4">
176
+ {/* Providers */}
177
+ <div>
178
+ <h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Providers</h3>
179
+ <div className="space-y-1">
180
+ {providers.map(p => (
181
+ <div key={p.name} className="flex items-center justify-between text-xs">
182
+ <span className={p.hasKey && p.enabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}>
183
+ {p.displayName}
184
+ </span>
185
+ <span className={`text-[10px] ${p.hasKey && p.enabled ? 'text-[var(--green)]' : 'text-[var(--red)]'}`}>
186
+ {p.hasKey && p.enabled ? '● active' : '○ off'}
187
+ </span>
188
+ </div>
189
+ ))}
190
+ </div>
191
+ </div>
192
+
193
+ {/* Usage */}
194
+ {usage.length > 0 && (
195
+ <div>
196
+ <h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Usage (30d)</h3>
197
+ <div className="space-y-1">
198
+ {usage.map((u, i) => (
199
+ <div key={i} className="text-xs">
200
+ <div className="flex justify-between">
201
+ <span className="text-[var(--text-secondary)]">{u.provider}</span>
202
+ <span className="text-[var(--text-primary)]">{((u.totalInput + u.totalOutput) / 1000).toFixed(0)}k tokens</span>
203
+ </div>
204
+ </div>
205
+ ))}
206
+ </div>
207
+ </div>
208
+ )}
209
+
210
+ {/* Running tasks */}
211
+ {running.length > 0 && (
212
+ <div>
213
+ <h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Running</h3>
214
+ <div className="space-y-1">
215
+ {running.map(t => (
216
+ <button
217
+ key={t.id}
218
+ onClick={() => { setViewMode('tasks'); setActiveTaskId(t.id); }}
219
+ className="w-full text-left px-2 py-1 rounded text-xs hover:bg-[var(--bg-tertiary)]"
220
+ >
221
+ <span className="text-[var(--green)] text-[10px]">● </span>
222
+ <span className="truncate">{t.projectName}</span>
223
+ </button>
224
+ ))}
225
+ </div>
226
+ </div>
227
+ )}
228
+ </aside>
229
+ </>
230
+ ) : viewMode === 'sessions' ? (
231
+ <SessionView
232
+ projects={projects}
233
+ onOpenInTerminal={(sessionId, projectPath) => {
234
+ setViewMode('terminal');
235
+ setTimeout(() => {
236
+ terminalRef.current?.openSessionInTerminal(sessionId, projectPath);
237
+ }, 100);
238
+ }}
239
+ />
240
+ ) : null}
241
+
242
+ {/* Terminal — always mounted, hidden when not active to keep sessions alive */}
243
+ <div className={`flex-1 min-h-0 ${viewMode === 'terminal' ? '' : 'hidden'}`}>
244
+ <Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading terminal...</div>}>
245
+ <WebTerminal ref={terminalRef} />
246
+ </Suspense>
247
+ </div>
248
+ </div>
249
+
250
+ {showNewTask && (
251
+ <NewTaskModal
252
+ onClose={() => setShowNewTask(false)}
253
+ onCreate={async (data) => {
254
+ await fetch('/api/tasks', {
255
+ method: 'POST',
256
+ headers: { 'Content-Type': 'application/json' },
257
+ body: JSON.stringify(data),
258
+ });
259
+ setShowNewTask(false);
260
+ fetchData();
261
+ }}
262
+ />
263
+ )}
264
+
265
+ {showSettings && (
266
+ <SettingsModal onClose={() => { setShowSettings(false); fetchData(); }} />
267
+ )}
268
+ </div>
269
+ );
270
+ }
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import Markdown from 'react-markdown';
4
+
5
+ export default function MarkdownContent({ content }: { content: string }) {
6
+ return (
7
+ <Markdown
8
+ components={{
9
+ h1: ({ children }) => <h1 className="text-base font-bold text-[var(--text-primary)] mt-3 mb-1">{children}</h1>,
10
+ h2: ({ children }) => <h2 className="text-sm font-bold text-[var(--text-primary)] mt-3 mb-1">{children}</h2>,
11
+ h3: ({ children }) => <h3 className="text-xs font-bold text-[var(--text-primary)] mt-2 mb-1">{children}</h3>,
12
+ p: ({ children }) => <p className="text-xs text-[var(--text-primary)] mb-1.5 leading-relaxed">{children}</p>,
13
+ ul: ({ children }) => <ul className="text-xs text-[var(--text-primary)] mb-1.5 ml-4 list-disc space-y-0.5">{children}</ul>,
14
+ ol: ({ children }) => <ol className="text-xs text-[var(--text-primary)] mb-1.5 ml-4 list-decimal space-y-0.5">{children}</ol>,
15
+ li: ({ children }) => <li className="leading-relaxed">{children}</li>,
16
+ strong: ({ children }) => <strong className="font-semibold text-[var(--text-primary)]">{children}</strong>,
17
+ em: ({ children }) => <em className="italic text-[var(--text-secondary)]">{children}</em>,
18
+ a: ({ href, children }) => <a href={href} className="text-[var(--accent)] hover:underline" target="_blank" rel="noopener">{children}</a>,
19
+ blockquote: ({ children }) => <blockquote className="border-l-2 border-[var(--accent)]/40 pl-3 my-1.5 text-[var(--text-secondary)] text-xs italic">{children}</blockquote>,
20
+ code: ({ className, children }) => {
21
+ const isBlock = className?.includes('language-');
22
+ if (isBlock) {
23
+ const lang = className?.replace('language-', '') || '';
24
+ return (
25
+ <div className="my-2 rounded border border-[var(--border)] overflow-hidden max-w-full">
26
+ {lang && (
27
+ <div className="px-3 py-1 bg-[var(--bg-tertiary)] border-b border-[var(--border)] text-[9px] text-[var(--text-secondary)] font-mono">
28
+ {lang}
29
+ </div>
30
+ )}
31
+ <pre className="p-3 bg-[var(--bg-tertiary)] overflow-x-auto max-w-full">
32
+ <code className="text-[11px] font-mono text-[var(--text-primary)] break-all">{children}</code>
33
+ </pre>
34
+ </div>
35
+ );
36
+ }
37
+ return (
38
+ <code className="text-[11px] font-mono bg-[var(--bg-tertiary)] text-[var(--accent)] px-1 py-0.5 rounded">
39
+ {children}
40
+ </code>
41
+ );
42
+ },
43
+ pre: ({ children }) => <>{children}</>,
44
+ hr: () => <hr className="my-3 border-[var(--border)]" />,
45
+ table: ({ children }) => (
46
+ <div className="my-2 overflow-x-auto">
47
+ <table className="text-xs border-collapse w-full">{children}</table>
48
+ </div>
49
+ ),
50
+ th: ({ children }) => <th className="border border-[var(--border)] px-2 py-1 bg-[var(--bg-tertiary)] text-left font-semibold text-[11px]">{children}</th>,
51
+ td: ({ children }) => <td className="border border-[var(--border)] px-2 py-1 text-[11px]">{children}</td>,
52
+ }}
53
+ >
54
+ {content}
55
+ </Markdown>
56
+ );
57
+ }
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import type { SessionTemplate } from '@/src/types';
5
+
6
+ export default function NewSessionModal({
7
+ onClose,
8
+ onCreate,
9
+ }: {
10
+ onClose: () => void;
11
+ onCreate: (data: { name: string; templateId: string }) => void;
12
+ }) {
13
+ const [templates, setTemplates] = useState<SessionTemplate[]>([]);
14
+ const [selectedTemplate, setSelectedTemplate] = useState('');
15
+ const [name, setName] = useState('');
16
+
17
+ useEffect(() => {
18
+ fetch('/api/templates')
19
+ .then(r => r.json())
20
+ .then((t: SessionTemplate[]) => {
21
+ setTemplates(t);
22
+ if (t.length > 0) setSelectedTemplate(t[0].id);
23
+ });
24
+ }, []);
25
+
26
+ const handleCreate = () => {
27
+ if (!selectedTemplate) return;
28
+ const finalName = name || `${selectedTemplate}-${Date.now().toString(36)}`;
29
+ onCreate({ name: finalName, templateId: selectedTemplate });
30
+ };
31
+
32
+ return (
33
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
34
+ <div
35
+ className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-96 p-5 space-y-4"
36
+ onClick={e => e.stopPropagation()}
37
+ >
38
+ <h2 className="text-sm font-bold">New Session</h2>
39
+
40
+ {/* Template selection */}
41
+ <div className="space-y-2">
42
+ <label className="text-xs text-[var(--text-secondary)]">Template</label>
43
+ <div className="grid grid-cols-2 gap-2">
44
+ {templates.map(t => (
45
+ <button
46
+ key={t.id}
47
+ onClick={() => setSelectedTemplate(t.id)}
48
+ className={`p-2 rounded border text-left text-xs transition-colors ${
49
+ selectedTemplate === t.id
50
+ ? 'border-[var(--accent)] bg-[var(--bg-tertiary)]'
51
+ : 'border-[var(--border)] hover:border-[var(--text-secondary)]'
52
+ }`}
53
+ >
54
+ <div className="font-medium">{t.ui?.icon} {t.name}</div>
55
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
56
+ {t.provider} · {t.memory.strategy}
57
+ </div>
58
+ </button>
59
+ ))}
60
+ </div>
61
+ </div>
62
+
63
+ {/* Name */}
64
+ <div className="space-y-1">
65
+ <label className="text-xs text-[var(--text-secondary)]">Session Name (optional)</label>
66
+ <input
67
+ value={name}
68
+ onChange={e => setName(e.target.value)}
69
+ placeholder={selectedTemplate || 'auto-generated'}
70
+ className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
71
+ />
72
+ </div>
73
+
74
+ {/* Actions */}
75
+ <div className="flex justify-end gap-2">
76
+ <button
77
+ onClick={onClose}
78
+ className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
79
+ >
80
+ Cancel
81
+ </button>
82
+ <button
83
+ onClick={handleCreate}
84
+ disabled={!selectedTemplate}
85
+ className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
86
+ >
87
+ Create
88
+ </button>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ );
93
+ }