@aion0/forge 0.6.1 → 0.8.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 (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Simplified web chat for Forge — no extension required.
3
+ *
4
+ * Talks to the chat-standalone server through /api/chat-proxy. Full
5
+ * featureset (per-tab connector hints, browser-side scripts, unread
6
+ * badges, fork UI) stays in the browser extension; this page is a
7
+ * fallback so users can chat from any device with a browser.
8
+ *
9
+ * Routes used:
10
+ * GET /api/chat-proxy/sessions
11
+ * GET /api/chat-proxy/sessions/main
12
+ * POST /api/chat-proxy/sessions (new temp session)
13
+ * GET /api/chat-proxy/sessions/:id (messages for a session)
14
+ * DELETE /api/chat-proxy/sessions/:id/messages (clear)
15
+ * POST /api/chat-proxy/sessions/:id/messages (send user turn)
16
+ * GET /api/chat-proxy/sessions/:id/events (SSE)
17
+ * DELETE /api/chat-proxy/sessions/:id (delete — refused for main)
18
+ */
19
+
20
+ 'use client';
21
+
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import MarkdownContent from '@/components/MarkdownContent';
24
+ import type { ContentBlock, Message, Session } from '@/lib/chat/types';
25
+
26
+ const PROXY = '/api/chat-proxy';
27
+
28
+ // The global body font is monospace (see globals.css). The chat page
29
+ // is a reading surface — override to a UI-friendly system stack and
30
+ // let MarkdownContent's <code> elements switch back to mono inline.
31
+ const SANS_FONT =
32
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';
33
+
34
+ interface MemoryStatus {
35
+ backend?: 'temper' | 'local';
36
+ pinnedCount?: number;
37
+ blocksCount?: number;
38
+ hitsCount?: number;
39
+ }
40
+
41
+ interface ChatSession extends Session {
42
+ meta?: { kind?: 'main' | 'temp'; [k: string]: unknown };
43
+ }
44
+
45
+ export default function ChatPage() {
46
+ const [sessions, setSessions] = useState<ChatSession[]>([]);
47
+ const [activeId, setActiveId] = useState<string>('');
48
+ const [messages, setMessages] = useState<Message[]>([]);
49
+ const [input, setInput] = useState('');
50
+ const [streaming, setStreaming] = useState(false);
51
+ const [partial, setPartial] = useState('');
52
+ const [memory, setMemory] = useState<MemoryStatus | null>(null);
53
+ const [error, setError] = useState('');
54
+
55
+ const eventSrcRef = useRef<EventSource | null>(null);
56
+ const scrollRef = useRef<HTMLDivElement>(null);
57
+ const composerRef = useRef<HTMLTextAreaElement>(null);
58
+
59
+ // ─── Load sessions ────────────────────────────────────────
60
+ const refreshSessions = useCallback(async () => {
61
+ try {
62
+ const [listResp, mainResp] = await Promise.all([
63
+ fetch(`${PROXY}/sessions?limit=200`),
64
+ fetch(`${PROXY}/sessions/main`),
65
+ ]);
66
+ const listJson = (await listResp.json()) as { sessions: ChatSession[] };
67
+ const list = listJson.sessions || [];
68
+ const mainJson = (await mainResp.json()) as { session: ChatSession };
69
+ const mainId = mainJson?.session?.id;
70
+ const ordered = list.slice().sort((a, b) => {
71
+ if (a.id === mainId) return -1;
72
+ if (b.id === mainId) return 1;
73
+ return (b.updated_at || 0) - (a.updated_at || 0);
74
+ });
75
+ setSessions(ordered);
76
+ if (!activeId && mainId) setActiveId(mainId);
77
+ } catch (e) {
78
+ setError(e instanceof Error ? e.message : String(e));
79
+ }
80
+ }, [activeId]);
81
+
82
+ useEffect(() => {
83
+ refreshSessions();
84
+ }, [refreshSessions]);
85
+
86
+ // ─── Load messages on session change ──────────────────────
87
+ const loadMessages = useCallback(async (id: string) => {
88
+ if (!id) return;
89
+ try {
90
+ const r = await fetch(`${PROXY}/sessions/${id}?limit=1000`);
91
+ const j = (await r.json()) as { messages?: Message[] };
92
+ setMessages(j.messages || []);
93
+ } catch (e) {
94
+ setError(e instanceof Error ? e.message : String(e));
95
+ }
96
+ }, []);
97
+
98
+ useEffect(() => {
99
+ if (activeId) loadMessages(activeId);
100
+ }, [activeId, loadMessages]);
101
+
102
+ // ─── SSE subscription ─────────────────────────────────────
103
+ useEffect(() => {
104
+ if (!activeId) return;
105
+ eventSrcRef.current?.close();
106
+ const src = new EventSource(`${PROXY}/sessions/${activeId}/events`);
107
+ eventSrcRef.current = src;
108
+
109
+ src.onmessage = (ev) => {
110
+ let payload: { type?: string; data?: any; message_id?: string };
111
+ try { payload = JSON.parse(ev.data); } catch { return; }
112
+ const type = payload.type;
113
+ const data = payload.data || {};
114
+ if (type === 'text_delta') {
115
+ setPartial((p) => p + (data.delta || ''));
116
+ } else if (type === 'message_saved') {
117
+ loadMessages(activeId);
118
+ setPartial('');
119
+ } else if (type === 'memory_status') {
120
+ setMemory({
121
+ backend: data.backend,
122
+ pinnedCount: data.pinnedCount,
123
+ blocksCount: data.blocksCount,
124
+ hitsCount: data.hitsCount,
125
+ });
126
+ } else if (type === 'turn_done') {
127
+ setStreaming(false);
128
+ setPartial('');
129
+ loadMessages(activeId);
130
+ refreshSessions();
131
+ } else if (type === 'error') {
132
+ setStreaming(false);
133
+ setError(String(data.error || 'unknown error'));
134
+ }
135
+ };
136
+ return () => {
137
+ src.close();
138
+ };
139
+ }, [activeId, loadMessages, refreshSessions]);
140
+
141
+ // ─── Auto-scroll on new content ───────────────────────────
142
+ useEffect(() => {
143
+ const el = scrollRef.current;
144
+ if (el) el.scrollTop = el.scrollHeight;
145
+ }, [messages, partial]);
146
+
147
+ // ─── Auto-resize composer ─────────────────────────────────
148
+ useEffect(() => {
149
+ const el = composerRef.current;
150
+ if (!el) return;
151
+ el.style.height = 'auto';
152
+ el.style.height = Math.min(el.scrollHeight, 200) + 'px';
153
+ }, [input]);
154
+
155
+ // ─── Actions ──────────────────────────────────────────────
156
+ async function send() {
157
+ const text = input.trim();
158
+ if (!text || !activeId || streaming) return;
159
+ setInput('');
160
+ setStreaming(true);
161
+ setError('');
162
+ setPartial('');
163
+ try {
164
+ const r = await fetch(`${PROXY}/sessions/${activeId}/messages`, {
165
+ method: 'POST',
166
+ headers: { 'content-type': 'application/json' },
167
+ body: JSON.stringify({ text }),
168
+ });
169
+ if (!r.ok) {
170
+ const j = await r.json().catch(() => ({}));
171
+ throw new Error(j.error || `HTTP ${r.status}`);
172
+ }
173
+ } catch (e) {
174
+ setStreaming(false);
175
+ setError(e instanceof Error ? e.message : String(e));
176
+ }
177
+ }
178
+
179
+ async function newSession() {
180
+ try {
181
+ const r = await fetch(`${PROXY}/sessions`, {
182
+ method: 'POST',
183
+ headers: { 'content-type': 'application/json' },
184
+ body: JSON.stringify({ meta: { kind: 'temp' } }),
185
+ });
186
+ const j = (await r.json()) as { session?: ChatSession };
187
+ if (j?.session?.id) {
188
+ await refreshSessions();
189
+ setActiveId(j.session.id);
190
+ }
191
+ } catch (e) {
192
+ setError(e instanceof Error ? e.message : String(e));
193
+ }
194
+ }
195
+
196
+ async function clearMessages() {
197
+ if (!activeId) return;
198
+ if (!confirm('Clear all messages in this session?')) return;
199
+ await fetch(`${PROXY}/sessions/${activeId}/messages`, { method: 'DELETE' });
200
+ setMessages([]);
201
+ setPartial('');
202
+ }
203
+
204
+ async function deleteSession(id: string) {
205
+ if (!confirm('Delete this session permanently?')) return;
206
+ const r = await fetch(`${PROXY}/sessions/${id}`, { method: 'DELETE' });
207
+ if (!r.ok) {
208
+ const j = await r.json().catch(() => ({}));
209
+ alert(j.error || 'Delete failed (main session cannot be deleted)');
210
+ return;
211
+ }
212
+ if (id === activeId) setActiveId('');
213
+ await refreshSessions();
214
+ }
215
+
216
+ // ─── Render ───────────────────────────────────────────────
217
+ const activeSession = useMemo(
218
+ () => sessions.find((s) => s.id === activeId),
219
+ [sessions, activeId],
220
+ );
221
+
222
+ return (
223
+ <div
224
+ className="flex h-screen text-[var(--text-primary)] bg-[var(--bg-primary)]"
225
+ style={{ fontFamily: SANS_FONT }}
226
+ >
227
+ {/* ─── Sidebar ─────────────────────────────────────── */}
228
+ <aside className="w-64 shrink-0 border-r border-[var(--border)] flex flex-col bg-[var(--bg-secondary)]/40">
229
+ <div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
230
+ <a
231
+ href="/"
232
+ className="text-sm font-semibold text-[var(--text-primary)] hover:text-[var(--accent)] transition-colors"
233
+ title="Back to Dashboard"
234
+ >
235
+ ← Forge
236
+ </a>
237
+ <button
238
+ onClick={newSession}
239
+ className="text-xs px-2.5 py-1 bg-[var(--accent)] text-white rounded-md hover:opacity-90 transition-opacity"
240
+ >
241
+ + New
242
+ </button>
243
+ </div>
244
+
245
+ <div className="flex-1 overflow-y-auto py-2 px-2">
246
+ {sessions.length === 0 && (
247
+ <div className="text-xs text-[var(--text-secondary)] italic px-2 py-3">No sessions yet.</div>
248
+ )}
249
+ {sessions.map((s) => {
250
+ const isMain = s.meta?.kind === 'main';
251
+ const isActive = s.id === activeId;
252
+ return (
253
+ <div
254
+ key={s.id}
255
+ className={`group flex items-center gap-2 px-3 py-2 rounded-md text-sm cursor-pointer mb-1 transition-colors ${
256
+ isActive
257
+ ? 'bg-[var(--accent)]/15 text-[var(--text-primary)]'
258
+ : 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
259
+ }`}
260
+ onClick={() => setActiveId(s.id)}
261
+ >
262
+ <span
263
+ className={`inline-block w-2 h-2 rounded-full shrink-0 ${
264
+ isMain ? 'bg-[var(--accent)]' : 'bg-[var(--text-secondary)]/40'
265
+ }`}
266
+ />
267
+ <div className="truncate flex-1">
268
+ {s.title || (isMain ? 'Main conversation' : `Temp · ${s.id.slice(0, 6)}`)}
269
+ </div>
270
+ {!isMain && (
271
+ <button
272
+ onClick={(e) => { e.stopPropagation(); deleteSession(s.id); }}
273
+ className="opacity-0 group-hover:opacity-100 text-[var(--text-secondary)] hover:text-red-400 text-base leading-none px-1"
274
+ title="Delete session"
275
+ >
276
+ ×
277
+ </button>
278
+ )}
279
+ </div>
280
+ );
281
+ })}
282
+ </div>
283
+
284
+ <div className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--text-secondary)] space-y-1">
285
+ <div className="flex items-center gap-2">
286
+ <span>Memory</span>
287
+ <span
288
+ className={`px-1.5 py-[1px] rounded text-[10px] uppercase tracking-wide border ${
289
+ memory?.backend === 'temper'
290
+ ? 'border-green-500/60 text-green-400'
291
+ : memory?.backend === 'local'
292
+ ? 'border-[var(--accent)] text-[var(--accent)]'
293
+ : 'border-[var(--border)]'
294
+ }`}
295
+ >
296
+ {memory?.backend ?? '…'}
297
+ </span>
298
+ </div>
299
+ {memory && (
300
+ <div className="text-[11px]">
301
+ {memory.pinnedCount ?? 0} pinned · {memory.blocksCount ?? 0} blocks
302
+ </div>
303
+ )}
304
+ <div className="text-[10px] italic pt-1 leading-snug">
305
+ Simplified web chat — full UX lives in the browser extension.
306
+ </div>
307
+ </div>
308
+ </aside>
309
+
310
+ {/* ─── Main pane ───────────────────────────────────── */}
311
+ <main className="flex-1 flex flex-col min-w-0">
312
+ <header className="border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
313
+ <div className="min-w-0">
314
+ <div className="text-sm font-medium truncate">
315
+ {activeSession?.title ||
316
+ (activeSession?.meta?.kind === 'main' ? 'Main conversation' : activeSession?.id) ||
317
+ 'No session'}
318
+ </div>
319
+ {activeSession && (
320
+ <div className="text-[11px] text-[var(--text-secondary)]">
321
+ {activeSession.provider || 'auto'} · {activeSession.model || 'default'}
322
+ </div>
323
+ )}
324
+ </div>
325
+ <button
326
+ onClick={clearMessages}
327
+ disabled={!activeId}
328
+ className="text-xs px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded-md hover:border-red-500/60 hover:text-red-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
329
+ >
330
+ Clear
331
+ </button>
332
+ </header>
333
+
334
+ <div ref={scrollRef} className="flex-1 overflow-y-auto">
335
+ <div className="max-w-3xl mx-auto px-6 py-6 space-y-6">
336
+ {messages.length === 0 && !partial && !streaming && (
337
+ <div className="text-center text-sm text-[var(--text-secondary)] mt-12">
338
+ <div className="text-base mb-1">Start a conversation</div>
339
+ <div className="text-xs">Type a message below. Markdown supported.</div>
340
+ </div>
341
+ )}
342
+ {messages.map((m) => (
343
+ <MessageView key={m.id} m={m} />
344
+ ))}
345
+ {partial && (
346
+ <RoleBlock role="assistant">
347
+ <MarkdownContent content={partial} />
348
+ <span className="inline-block w-2 h-3 ml-0.5 align-text-bottom bg-[var(--accent)] animate-pulse" />
349
+ </RoleBlock>
350
+ )}
351
+ {streaming && !partial && (
352
+ <RoleBlock role="assistant">
353
+ <div className="text-sm text-[var(--text-secondary)] italic">thinking…</div>
354
+ </RoleBlock>
355
+ )}
356
+ {error && (
357
+ <div className="text-sm text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-3">
358
+ {error}
359
+ </div>
360
+ )}
361
+ </div>
362
+ </div>
363
+
364
+ <form
365
+ className="border-t border-[var(--border)] px-6 py-4"
366
+ onSubmit={(e) => { e.preventDefault(); send(); }}
367
+ >
368
+ <div className="max-w-3xl mx-auto flex items-end gap-3">
369
+ <textarea
370
+ ref={composerRef}
371
+ value={input}
372
+ onChange={(e) => setInput(e.target.value)}
373
+ onKeyDown={(e) => {
374
+ // Skip Enter while an IME composition is active — otherwise
375
+ // pinyin/kana commit-with-Enter sends the message by mistake.
376
+ // isComposing covers modern browsers; keyCode===229 is the
377
+ // legacy fallback some IMEs still emit.
378
+ if (e.nativeEvent.isComposing || e.keyCode === 229) return;
379
+ if (e.key === 'Enter' && !e.shiftKey) {
380
+ e.preventDefault();
381
+ send();
382
+ }
383
+ }}
384
+ disabled={!activeId || streaming}
385
+ placeholder={activeId ? 'Message… (Enter to send · Shift+Enter for newline)' : 'Pick or create a session'}
386
+ rows={1}
387
+ style={{ fontFamily: SANS_FONT }}
388
+ className="flex-1 resize-none bg-[var(--bg-tertiary)] border border-[var(--border)] rounded-lg px-4 py-3 text-sm text-[var(--text-primary)] leading-relaxed focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)]/40 disabled:opacity-50"
389
+ />
390
+ <button
391
+ type="submit"
392
+ disabled={!input.trim() || !activeId || streaming}
393
+ className="px-4 py-2.5 text-sm font-medium bg-[var(--accent)] text-white rounded-lg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
394
+ >
395
+ {streaming ? '…' : 'Send'}
396
+ </button>
397
+ </div>
398
+ </form>
399
+ </main>
400
+ </div>
401
+ );
402
+ }
403
+
404
+ // ─── Message renderers ────────────────────────────────────
405
+
406
+ function RoleBlock({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
407
+ const isUser = role === 'user';
408
+ return (
409
+ <div className="flex gap-4">
410
+ <div
411
+ className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
412
+ isUser
413
+ ? 'bg-[var(--accent)] text-white'
414
+ : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] border border-[var(--border)]'
415
+ }`}
416
+ aria-hidden
417
+ >
418
+ {isUser ? 'U' : 'AI'}
419
+ </div>
420
+ <div className="flex-1 min-w-0">
421
+ <div className="text-[11px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">
422
+ {role}
423
+ </div>
424
+ <div className="space-y-2">{children}</div>
425
+ </div>
426
+ </div>
427
+ );
428
+ }
429
+
430
+ function MessageView({ m }: { m: Message }) {
431
+ return (
432
+ <RoleBlock role={m.role}>
433
+ {m.blocks.map((b, i) => (
434
+ <BlockView key={i} b={b} />
435
+ ))}
436
+ {m.error && (
437
+ <div className="text-xs text-red-400 border border-red-500/30 bg-red-500/5 rounded-md p-2 mt-1">
438
+ {m.error}
439
+ </div>
440
+ )}
441
+ </RoleBlock>
442
+ );
443
+ }
444
+
445
+ function BlockView({ b }: { b: ContentBlock }) {
446
+ if (b.type === 'text') {
447
+ return <MarkdownContent content={b.text} />;
448
+ }
449
+ if (b.type === 'tool_use') {
450
+ return <ToolUseBlockView name={b.name} input={b.input} />;
451
+ }
452
+ if (b.type === 'tool_result') {
453
+ const txt = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
454
+ return <ToolResultBlockView content={txt} isError={!!b.is_error} />;
455
+ }
456
+ return null;
457
+ }
458
+
459
+ function ToolUseBlockView({ name, input }: { name: string; input: unknown }) {
460
+ const [open, setOpen] = useState(false);
461
+ const preview = JSON.stringify(input);
462
+ return (
463
+ <div className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]/50 text-xs">
464
+ <button
465
+ type="button"
466
+ onClick={() => setOpen((v) => !v)}
467
+ className="w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors"
468
+ >
469
+ <span className="text-[10px]">{open ? '▾' : '▸'}</span>
470
+ <span className="text-[var(--accent)] font-mono">→ {name}</span>
471
+ {!open && (
472
+ <span className="text-[var(--text-secondary)] font-mono truncate flex-1">{preview}</span>
473
+ )}
474
+ </button>
475
+ {open && (
476
+ <pre className="px-3 pb-2 pt-1 text-[11px] font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-words border-t border-[var(--border)] bg-[var(--bg-tertiary)]/30">
477
+ {tryPrettyJson(preview)}
478
+ </pre>
479
+ )}
480
+ </div>
481
+ );
482
+ }
483
+
484
+ function ToolResultBlockView({ content, isError }: { content: string; isError: boolean }) {
485
+ const [open, setOpen] = useState(false);
486
+ const truncated = content.length > 400 ? content.slice(0, 400) + '…' : content;
487
+ return (
488
+ <div
489
+ className={`rounded-md border text-xs ${
490
+ isError
491
+ ? 'border-red-500/40 bg-red-500/5'
492
+ : 'border-[var(--border)] bg-[var(--bg-tertiary)]/30'
493
+ }`}
494
+ >
495
+ <button
496
+ type="button"
497
+ onClick={() => setOpen((v) => !v)}
498
+ className="w-full px-3 py-1.5 flex items-center gap-2 text-left hover:bg-[var(--bg-tertiary)] transition-colors"
499
+ >
500
+ <span className="text-[10px]">{open ? '▾' : '▸'}</span>
501
+ <span className={isError ? 'text-red-400 font-mono' : 'text-[var(--text-secondary)] font-mono'}>
502
+ {isError ? 'error result' : 'tool result'}
503
+ </span>
504
+ {!open && (
505
+ <span className="text-[var(--text-secondary)] font-mono truncate flex-1">
506
+ {truncated.replace(/\s+/g, ' ').slice(0, 100)}
507
+ </span>
508
+ )}
509
+ </button>
510
+ {open && (
511
+ <pre
512
+ className={`px-3 pb-2 pt-1 text-[11px] font-mono whitespace-pre-wrap break-words border-t ${
513
+ isError
514
+ ? 'border-red-500/30 text-red-400'
515
+ : 'border-[var(--border)] text-[var(--text-secondary)]'
516
+ }`}
517
+ >
518
+ {content.length > 4000 ? content.slice(0, 4000) + '\n…(truncated)' : content}
519
+ </pre>
520
+ )}
521
+ </div>
522
+ );
523
+ }
524
+
525
+ function tryPrettyJson(s: string): string {
526
+ try {
527
+ return JSON.stringify(JSON.parse(s), null, 2);
528
+ } catch {
529
+ return s;
530
+ }
531
+ }
@@ -296,7 +296,7 @@ function cleanupOrphans() {
296
296
  }
297
297
  // Kill standalone processes: our instance's + orphans without any tag
298
298
  try {
299
- const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone|workspace-standalone' | grep -v grep`, {
299
+ const out = execSync(`ps aux | grep -E 'telegram-standalone|terminal-standalone|workspace-standalone|browser-bridge-standalone|chat-standalone' | grep -v grep`, {
300
300
  encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
301
301
  }).trim();
302
302
  for (const line of out.split('\n').filter(Boolean)) {
@@ -348,6 +348,8 @@ function startServices(daemonize = false) {
348
348
  spawnService('Terminal server', join(ROOT, 'lib', 'terminal-standalone.ts'));
349
349
  spawnService('Telegram bot', join(ROOT, 'lib', 'telegram-standalone.ts'));
350
350
  spawnService('Workspace daemon', join(ROOT, 'lib', 'workspace-standalone.ts'));
351
+ spawnService('Browser bridge', join(ROOT, 'lib', 'browser-bridge-standalone.ts'));
352
+ spawnService('Chat', join(ROOT, 'lib', 'chat-standalone.ts'));
351
353
 
352
354
  const childPids = services.map(c => c.pid).filter(Boolean);
353
355
  savePids(childPids);