@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
package/cli/mw.ts ADDED
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * forge — Forge CLI
4
+ *
5
+ * Local CLI that talks to the same backend as Telegram.
6
+ * Usage:
7
+ * mw task <project> "prompt" — submit a task
8
+ * mw run <flow-name> — run a YAML workflow
9
+ * mw tasks [status] — list tasks
10
+ * mw log <id> — show task execution log
11
+ * mw status <id> — task details
12
+ * mw cancel <id> — cancel a task
13
+ * mw retry <id> — retry a failed task
14
+ * mw flows — list available workflows
15
+ * mw projects — list projects
16
+ * mw watch <id> — live stream task output
17
+ */
18
+
19
+ const BASE = process.env.MW_URL || 'http://localhost:3000';
20
+
21
+ const [, , cmd, ...args] = process.argv;
22
+
23
+ async function api(path: string, opts?: RequestInit) {
24
+ const res = await fetch(`${BASE}${path}`, opts);
25
+ if (!res.ok) {
26
+ const text = await res.text();
27
+ console.error(`Error ${res.status}: ${text}`);
28
+ process.exit(1);
29
+ }
30
+ return res.json();
31
+ }
32
+
33
+ async function main() {
34
+ switch (cmd) {
35
+ case 'task':
36
+ case 't': {
37
+ // Parse --new flag to force a fresh session
38
+ const newSession = args.includes('--new');
39
+ const filtered = args.filter(a => a !== '--new');
40
+ const project = filtered[0];
41
+ const prompt = filtered.slice(1).join(' ');
42
+ if (!project || !prompt) {
43
+ console.log('Usage: mw task <project> <prompt> [--new]');
44
+ console.log(' --new Start a fresh session (ignore previous context)');
45
+ console.log('Example: mw task my-app "Fix the login bug"');
46
+ process.exit(1);
47
+ }
48
+ const task = await api('/api/tasks', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ projectName: project, prompt, newSession }),
52
+ });
53
+ const session = task.conversationId ? '(continuing session)' : '(new session)';
54
+ console.log(`✓ Task ${task.id} created ${session}`);
55
+ console.log(` Project: ${task.projectName}`);
56
+ console.log(` ${prompt}`);
57
+ console.log(`\n Watch: mw watch ${task.id}`);
58
+ console.log(` Status: mw status ${task.id}`);
59
+ break;
60
+ }
61
+
62
+ case 'run':
63
+ case 'r': {
64
+ const flowName = args[0];
65
+ if (!flowName) {
66
+ console.log('Usage: mw run <flow-name>');
67
+ console.log('List flows: mw flows');
68
+ process.exit(1);
69
+ }
70
+ const result = await api('/api/flows/run', {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ name: flowName }),
74
+ });
75
+ console.log(`✓ Flow "${flowName}" started`);
76
+ for (const t of result.tasks) {
77
+ console.log(` Task ${t.id}: ${t.projectName} — ${t.prompt.slice(0, 60)}`);
78
+ }
79
+ break;
80
+ }
81
+
82
+ case 'tasks':
83
+ case 'ls': {
84
+ const status = args[0] || '';
85
+ const query = status ? `?status=${status}` : '';
86
+ const tasks = await api(`/api/tasks${query}`);
87
+ if (tasks.length === 0) {
88
+ console.log('No tasks.');
89
+ break;
90
+ }
91
+ const icons: Record<string, string> = {
92
+ queued: '⏳', running: '🔄', done: '✅', failed: '❌', cancelled: '⚪',
93
+ };
94
+ for (const t of tasks) {
95
+ const icon = icons[t.status] || '?';
96
+ const cost = t.costUSD != null ? ` $${t.costUSD.toFixed(3)}` : '';
97
+ console.log(`${icon} ${t.id} ${t.status.padEnd(9)} ${t.projectName.padEnd(20)} ${t.prompt.slice(0, 50)}${cost}`);
98
+ }
99
+ break;
100
+ }
101
+
102
+ case 'status':
103
+ case 's': {
104
+ const id = args[0];
105
+ if (!id) { console.log('Usage: mw status <id>'); process.exit(1); }
106
+ const task = await api(`/api/tasks/${id}`);
107
+ console.log(`Task: ${task.id}`);
108
+ console.log(`Project: ${task.projectName} (${task.projectPath})`);
109
+ console.log(`Status: ${task.status}`);
110
+ console.log(`Prompt: ${task.prompt}`);
111
+ if (task.startedAt) console.log(`Started: ${task.startedAt}`);
112
+ if (task.completedAt) console.log(`Completed: ${task.completedAt}`);
113
+ if (task.costUSD != null) console.log(`Cost: $${task.costUSD.toFixed(4)}`);
114
+ if (task.error) console.log(`Error: ${task.error}`);
115
+ if (task.resultSummary) {
116
+ console.log(`\nResult:\n${task.resultSummary}`);
117
+ }
118
+ if (task.gitDiff) {
119
+ console.log(`\nGit Diff:\n${task.gitDiff.slice(0, 2000)}`);
120
+ }
121
+ break;
122
+ }
123
+
124
+ case 'log':
125
+ case 'l': {
126
+ const id = args[0];
127
+ if (!id) { console.log('Usage: mw log <id>'); process.exit(1); }
128
+ const task = await api(`/api/tasks/${id}`);
129
+ if (task.log.length === 0) {
130
+ console.log('No log entries.');
131
+ break;
132
+ }
133
+ for (const entry of task.log) {
134
+ const prefix = entry.subtype === 'tool_use' ? `🔧 [${entry.tool}]`
135
+ : entry.subtype === 'error' ? '❗'
136
+ : entry.type === 'result' ? '✅'
137
+ : entry.subtype === 'tool_result' ? ' ↳'
138
+ : ' ';
139
+ console.log(`${prefix} ${entry.content.slice(0, 300)}`);
140
+ }
141
+ break;
142
+ }
143
+
144
+ case 'watch':
145
+ case 'w': {
146
+ const id = args[0];
147
+ if (!id) { console.log('Usage: mw watch <id>'); process.exit(1); }
148
+ console.log(`Watching task ${id}... (Ctrl+C to stop)\n`);
149
+
150
+ const res = await fetch(`${BASE}/api/tasks/${id}/stream`);
151
+ if (!res.ok || !res.body) {
152
+ console.error('Failed to connect to stream');
153
+ process.exit(1);
154
+ }
155
+
156
+ const reader = res.body.getReader();
157
+ const decoder = new TextDecoder();
158
+ let buffer = '';
159
+
160
+ while (true) {
161
+ const { done, value } = await reader.read();
162
+ if (done) break;
163
+
164
+ buffer += decoder.decode(value, { stream: true });
165
+ const lines = buffer.split('\n');
166
+ buffer = lines.pop() || '';
167
+
168
+ for (const line of lines) {
169
+ if (!line.startsWith('data: ')) continue;
170
+ try {
171
+ const data = JSON.parse(line.slice(6));
172
+ if (data.type === 'log') {
173
+ const e = data.entry;
174
+ if (e.subtype === 'tool_use') {
175
+ console.log(`🔧 [${e.tool}] ${e.content.slice(0, 200)}`);
176
+ } else if (e.subtype === 'text') {
177
+ process.stdout.write(e.content);
178
+ } else if (e.type === 'result') {
179
+ console.log(`\n✅ ${e.content}`);
180
+ } else if (e.subtype === 'error') {
181
+ console.log(`❗ ${e.content}`);
182
+ }
183
+ } else if (data.type === 'status') {
184
+ if (data.status === 'done') {
185
+ console.log('\n✅ Task completed');
186
+ } else if (data.status === 'failed') {
187
+ console.log('\n❌ Task failed');
188
+ } else if (data.status === 'running') {
189
+ console.log('🚀 Started...\n');
190
+ }
191
+ } else if (data.type === 'complete') {
192
+ if (data.task?.costUSD != null) {
193
+ console.log(`Cost: $${data.task.costUSD.toFixed(4)}`);
194
+ }
195
+ process.exit(0);
196
+ }
197
+ } catch {}
198
+ }
199
+ }
200
+ break;
201
+ }
202
+
203
+ case 'cancel': {
204
+ const id = args[0];
205
+ if (!id) { console.log('Usage: mw cancel <id>'); process.exit(1); }
206
+ await api(`/api/tasks/${id}`, {
207
+ method: 'POST',
208
+ headers: { 'Content-Type': 'application/json' },
209
+ body: JSON.stringify({ action: 'cancel' }),
210
+ });
211
+ console.log(`✓ Task ${id} cancelled`);
212
+ break;
213
+ }
214
+
215
+ case 'retry': {
216
+ const id = args[0];
217
+ if (!id) { console.log('Usage: mw retry <id>'); process.exit(1); }
218
+ const task = await api(`/api/tasks/${id}`, {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({ action: 'retry' }),
222
+ });
223
+ console.log(`✓ Retrying as task ${task.id}`);
224
+ break;
225
+ }
226
+
227
+ case 'flows':
228
+ case 'f': {
229
+ const flows = await api('/api/flows');
230
+ if (flows.length === 0) {
231
+ console.log('No flows defined.');
232
+ console.log(`Create flows in ~/.my-workflow/flows/*.yaml`);
233
+ break;
234
+ }
235
+ for (const f of flows) {
236
+ const schedule = f.schedule ? ` (${f.schedule})` : '';
237
+ console.log(` ${f.name}${schedule} — ${f.steps.length} steps`);
238
+ }
239
+ break;
240
+ }
241
+
242
+ case 'session': {
243
+ const subCmd = args[0];
244
+
245
+ // mw session link <project> <session-id> — register a local CLI session
246
+ if (subCmd === 'link') {
247
+ const project = args[1];
248
+ const sessionId = args[2];
249
+ if (!project || !sessionId) {
250
+ console.log('Usage: mw session link <project> <session-id>');
251
+ console.log('\nFind your session ID:');
252
+ console.log(' In Claude Code CLI, look for the session ID in the output');
253
+ console.log(' Or check: ls ~/.claude/projects/');
254
+ process.exit(1);
255
+ }
256
+ const result = await api('/api/tasks/link', {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({ projectName: project, conversationId: sessionId }),
260
+ });
261
+ console.log(`✓ Linked session ${sessionId} to project ${result.projectName}`);
262
+ console.log(` Future "mw task ${result.projectName} ..." will continue this session`);
263
+ break;
264
+ }
265
+
266
+ // mw session (no args) — list all project sessions
267
+ if (!subCmd) {
268
+ const tasks = await api('/api/tasks?status=done');
269
+ const sessions = new Map<string, { id: string; project: string; path: string; lastUsed: string }>();
270
+ for (const t of tasks) {
271
+ if (t.conversationId && !sessions.has(t.projectName)) {
272
+ sessions.set(t.projectName, {
273
+ id: t.conversationId,
274
+ project: t.projectName,
275
+ path: t.projectPath,
276
+ lastUsed: t.completedAt,
277
+ });
278
+ }
279
+ }
280
+ if (sessions.size === 0) {
281
+ console.log('No active sessions. Submit a task first, or link a local session:');
282
+ console.log(' mw session link <project> <session-id>');
283
+ break;
284
+ }
285
+ console.log('Project sessions:\n');
286
+ for (const [name, s] of sessions) {
287
+ console.log(` ${name.padEnd(25)} ${s.id}`);
288
+ console.log(` ${''.padEnd(25)} cd ${s.path} && claude --resume ${s.id}`);
289
+ console.log();
290
+ }
291
+ break;
292
+ }
293
+
294
+ // mw session <project> — get session for specific project
295
+ const project = subCmd;
296
+ const tasks = await api('/api/tasks?status=done');
297
+ const match = tasks.find((t: any) => t.projectName === project && t.conversationId);
298
+ if (!match) {
299
+ console.log(`No session found for project: ${project}`);
300
+ console.log(`\nLink a local session: mw session link ${project} <session-id>`);
301
+ break;
302
+ }
303
+ console.log(`Project: ${match.projectName}`);
304
+ console.log(`Session: ${match.conversationId}`);
305
+ console.log(`Path: ${match.projectPath}`);
306
+ console.log(`\nResume in CLI:`);
307
+ console.log(` cd ${match.projectPath} && claude --resume ${match.conversationId}`);
308
+ break;
309
+ }
310
+
311
+ case 'password':
312
+ case 'pw': {
313
+ const { readFileSync } = await import('node:fs');
314
+ const { homedir } = await import('node:os');
315
+ const { join } = await import('node:path');
316
+ const pwFile = join(homedir(), '.my-workflow', 'password.json');
317
+ try {
318
+ const data = JSON.parse(readFileSync(pwFile, 'utf-8'));
319
+ const today = new Date().toISOString().slice(0, 10);
320
+ if (data.date === today) {
321
+ console.log(`Login password: ${data.password}`);
322
+ console.log(`Valid for: ${data.date}`);
323
+ } else {
324
+ console.log(`Password expired (was for ${data.date}). Restart server to generate new one.`);
325
+ }
326
+ } catch {
327
+ console.log('No password file found. Password is set via MW_PASSWORD env var.');
328
+ }
329
+ break;
330
+ }
331
+
332
+ case 'projects':
333
+ case 'p': {
334
+ const projects = await api('/api/projects');
335
+ for (const p of projects) {
336
+ const lang = p.language ? `[${p.language}]` : '';
337
+ console.log(` ${p.name.padEnd(25)} ${lang.padEnd(6)} ${p.path}`);
338
+ }
339
+ console.log(`\n${projects.length} projects`);
340
+ break;
341
+ }
342
+
343
+ default:
344
+ console.log(`forge — Forge CLI (@aion0/forge)
345
+
346
+ Usage:
347
+ forge task <project> <prompt> Submit a task (auto-continues project session)
348
+ forge task <project> <prompt> --new Force a fresh session
349
+ forge run <flow-name> Run a YAML workflow
350
+ forge tasks [status] List tasks (running|queued|done|failed)
351
+ forge watch <id> Live stream task output
352
+ forge log <id> Show execution log
353
+ forge status <id> Task details + result
354
+ forge session [project] Show session IDs → local claude --resume
355
+ forge session link <project> <id> Link a local CLI session to the web system
356
+ forge cancel <id> Cancel a task
357
+ forge retry <id> Retry a failed task
358
+ forge flows List workflows
359
+ forge projects List projects
360
+ forge password Show login password
361
+
362
+ Shortcuts: t=task, r=run, ls=tasks, w=watch, l=log, s=status, f=flows, p=projects, pw=password
363
+
364
+ Examples:
365
+ forge task accord "Fix the authentication bug in login.ts"
366
+ forge watch abc123
367
+ forge run daily-review
368
+ forge tasks running
369
+ forge session accord Show session ID, then:
370
+ cd ~/IdeaProjects/accord && claude --resume <session-id>`);
371
+ }
372
+ }
373
+
374
+ main().catch(err => {
375
+ console.error(err.message);
376
+ process.exit(1);
377
+ });
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import type { Session, Message } from '@/src/types';
5
+
6
+ const providerLabels: Record<string, string> = {
7
+ anthropic: 'Claude',
8
+ google: 'Gemini',
9
+ openai: 'OpenAI',
10
+ grok: 'Grok',
11
+ };
12
+
13
+ export default function ChatPanel({
14
+ session,
15
+ onUpdate,
16
+ }: {
17
+ session: Session;
18
+ onUpdate: () => void;
19
+ }) {
20
+ const [messages, setMessages] = useState<Message[]>([]);
21
+ const [input, setInput] = useState('');
22
+ const [streaming, setStreaming] = useState(false);
23
+ const [streamContent, setStreamContent] = useState('');
24
+ const messagesEndRef = useRef<HTMLDivElement>(null);
25
+ const inputRef = useRef<HTMLTextAreaElement>(null);
26
+
27
+ // Load messages when session changes
28
+ useEffect(() => {
29
+ fetch(`/api/sessions/${session.id}/messages`)
30
+ .then(r => r.json())
31
+ .then(setMessages);
32
+ }, [session.id]);
33
+
34
+ // Auto-scroll
35
+ useEffect(() => {
36
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
37
+ }, [messages, streamContent]);
38
+
39
+ const sendMessage = async () => {
40
+ const text = input.trim();
41
+ if (!text || streaming) return;
42
+
43
+ setInput('');
44
+ setStreaming(true);
45
+ setStreamContent('');
46
+
47
+ // Optimistic: add user message
48
+ const userMsg: Message = {
49
+ id: Date.now(),
50
+ sessionId: session.id,
51
+ role: 'user',
52
+ content: text,
53
+ provider: session.provider,
54
+ model: session.model,
55
+ createdAt: new Date().toISOString(),
56
+ };
57
+ setMessages(prev => [...prev, userMsg]);
58
+
59
+ try {
60
+ const res = await fetch(`/api/sessions/${session.id}/chat`, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ message: text }),
64
+ });
65
+
66
+ const reader = res.body?.getReader();
67
+ const decoder = new TextDecoder();
68
+ let fullContent = '';
69
+
70
+ if (reader) {
71
+ while (true) {
72
+ const { done, value } = await reader.read();
73
+ if (done) break;
74
+
75
+ const chunk = decoder.decode(value);
76
+ const lines = chunk.split('\n');
77
+
78
+ for (const line of lines) {
79
+ if (!line.startsWith('data: ')) continue;
80
+ try {
81
+ const data = JSON.parse(line.slice(6));
82
+ if (data.token) {
83
+ fullContent += data.token;
84
+ setStreamContent(fullContent);
85
+ }
86
+ if (data.done) {
87
+ // Add final assistant message
88
+ const assistantMsg: Message = {
89
+ id: Date.now() + 1,
90
+ sessionId: session.id,
91
+ role: 'assistant',
92
+ content: fullContent,
93
+ provider: session.provider,
94
+ model: session.model,
95
+ createdAt: new Date().toISOString(),
96
+ };
97
+ setMessages(prev => [...prev, assistantMsg]);
98
+ setStreamContent('');
99
+ }
100
+ if (data.error) {
101
+ setStreamContent(`Error: ${data.error}`);
102
+ }
103
+ } catch {}
104
+ }
105
+ }
106
+ }
107
+ } catch (err: any) {
108
+ setStreamContent(`Error: ${err.message}`);
109
+ }
110
+
111
+ setStreaming(false);
112
+ onUpdate();
113
+ inputRef.current?.focus();
114
+ };
115
+
116
+ const handleKeyDown = (e: React.KeyboardEvent) => {
117
+ if (e.key === 'Enter' && !e.shiftKey) {
118
+ e.preventDefault();
119
+ sendMessage();
120
+ }
121
+ };
122
+
123
+ return (
124
+ <div className="flex-1 flex flex-col min-h-0">
125
+ {/* Session header */}
126
+ <div className="h-10 border-b border-[var(--border)] flex items-center px-4 gap-3 shrink-0">
127
+ <span className="text-sm font-semibold">{session.name}</span>
128
+ <span className="text-[10px] px-1.5 py-0.5 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
129
+ {providerLabels[session.provider] || session.provider}
130
+ </span>
131
+ <span className="text-[10px] px-1.5 py-0.5 bg-[var(--bg-tertiary)] rounded text-[var(--text-secondary)]">
132
+ {session.memory.strategy}
133
+ </span>
134
+ <span className="text-[10px] text-[var(--text-secondary)]">
135
+ {session.messageCount} messages
136
+ </span>
137
+ </div>
138
+
139
+ {/* Messages */}
140
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
141
+ {messages.map(msg => (
142
+ <div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
143
+ <div
144
+ className={`max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap ${
145
+ msg.role === 'user'
146
+ ? 'bg-[var(--accent)] text-white'
147
+ : 'bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
148
+ }`}
149
+ >
150
+ {msg.content}
151
+ </div>
152
+ </div>
153
+ ))}
154
+
155
+ {streamContent && (
156
+ <div className="flex justify-start">
157
+ <div className="max-w-[80%] px-3 py-2 rounded-lg text-sm bg-[var(--bg-tertiary)] text-[var(--text-primary)] whitespace-pre-wrap">
158
+ {streamContent}
159
+ <span className="animate-pulse">▌</span>
160
+ </div>
161
+ </div>
162
+ )}
163
+
164
+ <div ref={messagesEndRef} />
165
+ </div>
166
+
167
+ {/* Input */}
168
+ <div className="border-t border-[var(--border)] p-3 shrink-0">
169
+ <div className="flex gap-2">
170
+ <textarea
171
+ ref={inputRef}
172
+ value={input}
173
+ onChange={e => setInput(e.target.value)}
174
+ onKeyDown={handleKeyDown}
175
+ placeholder={streaming ? 'Waiting for response...' : 'Type a message... (Enter to send, Shift+Enter for newline)'}
176
+ disabled={streaming}
177
+ rows={1}
178
+ className="flex-1 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
179
+ />
180
+ <button
181
+ onClick={sendMessage}
182
+ disabled={streaming || !input.trim()}
183
+ className="px-4 py-2 bg-[var(--accent)] text-white text-sm rounded hover:opacity-90 disabled:opacity-50"
184
+ >
185
+ Send
186
+ </button>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ );
191
+ }