lvwerra HF Staff Claude Opus 4.6 commited on
Commit
78f4d62
Β·
1 Parent(s): 4e3db9c

Split frontend script.js (5460 lines) into 8 focused modules

Browse files

- utils.js: global state, apiFetch, escapeHtml, formatDate, 3 shared helpers
- timeline.js: sidebar timeline data + rendering + multi-user
- sessions.js: session CRUD + panel UI
- tabs.js: tab creation/switching, sendMessage
- streaming.js: SSE streaming, code cells, action widgets, parseMarkdown
- workspace.js: serialize/restore workspace state
- settings.js: settings CRUD, themes, debug/files/sessions panels
- app.js: DOMContentLoaded, initializeEventListeners

Deduplication: setupInputListeners (3 copies), setupCollapseToggle (4 copies),
closeAllPanels (4 verbose blocks) extracted to shared helpers in utils.js.

Update README with DIRECT_TOOL_REGISTRY docs, key patterns & conventions
section for agent contributors, and updated file tree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

README.md CHANGED
@@ -34,7 +34,14 @@ backend/
34
 
35
  frontend/
36
  β”œβ”€β”€ index.html # Entry point
37
- β”œβ”€β”€ script.js # Application logic, agent registry, settings, themes
 
 
 
 
 
 
 
38
  β”œβ”€β”€ style.css # All styles (CSS custom properties for theming)
39
  └── research-ui.js # Research-specific UI components
40
  ```
@@ -50,7 +57,7 @@ frontend/
50
 
51
  ### Adding a New Agent
52
 
53
- Two files need matching entries: `backend/agents.py` and `frontend/script.js`.
54
 
55
  **1. Backend registry** β€” add to `AGENT_REGISTRY` in `backend/agents.py`:
56
 
@@ -125,9 +132,9 @@ elif request.agent_type == "my_agent":
125
 
126
  ### Adding a Direct Tool
127
 
128
- Direct tools execute synchronously in the command center (no sub-agent spawned).
129
 
130
- **1. Define the tool** in `backend/tools.py`:
131
 
132
  ```python
133
  my_tool = {
@@ -145,25 +152,25 @@ my_tool = {
145
  }
146
  }
147
 
148
- def execute_my_tool(input: str) -> dict:
149
  return {"content": "Result text for the LLM", "extra_data": "..."}
150
  ```
151
 
152
- **2. Register it** in `backend/command.py`:
153
 
154
  ```python
155
- from .tools import my_tool, execute_my_tool
156
-
157
- TOOLS = get_tools() + [show_html_tool, my_tool]
158
- DIRECT_TOOLS = {"show_html", "my_tool"}
 
 
 
 
 
159
  ```
160
 
161
- **3. Add execution handler** in the `stream_command_center` function (same file):
162
-
163
- ```python
164
- if function_name == "my_tool":
165
- result = execute_my_tool(args.get("input"))
166
- ```
167
 
168
  ### Modifying System Prompts
169
 
@@ -189,7 +196,7 @@ Settings are stored in `workspace/settings.json` and managed via the Settings pa
189
 
190
  ### Creating a Theme
191
 
192
- Themes are CSS custom property sets defined in `frontend/script.js`.
193
 
194
  **Add to `themeColors` object** (search for `const themeColors`):
195
 
@@ -252,15 +259,48 @@ All agents communicate via Server-Sent Events. Each event is a JSON object with
252
  | `code_start` | `{code}` β€” code execution started |
253
  | `code` | `{output, error, images}` β€” code execution result |
254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  ## Testing
256
 
257
  ```bash
 
258
  make test # Run all tests
259
  make test-backend # Backend API tests (pytest)
260
  make test-frontend # Frontend unit tests (vitest)
261
  make test-e2e # E2E browser tests (playwright)
262
  ```
263
 
 
 
264
  ## Deployment
265
 
266
  The app runs as a Docker container (designed for HuggingFace Spaces):
 
34
 
35
  frontend/
36
  β”œβ”€β”€ index.html # Entry point
37
+ β”œβ”€β”€ utils.js # Global state, shared helpers (setupInputListeners, closeAllPanels)
38
+ β”œβ”€β”€ timeline.js # Sidebar timeline data + rendering
39
+ β”œβ”€β”€ sessions.js # Session CRUD + panel
40
+ β”œβ”€β”€ tabs.js # Tab creation/switching, sendMessage
41
+ β”œβ”€β”€ streaming.js # SSE streaming, code cells, action widgets, markdown
42
+ β”œβ”€β”€ workspace.js # Workspace serialize/restore
43
+ β”œβ”€β”€ settings.js # Settings CRUD, themes, debug/files/sessions panels
44
+ β”œβ”€β”€ app.js # Initialization, event listeners, DOMContentLoaded
45
  β”œβ”€β”€ style.css # All styles (CSS custom properties for theming)
46
  └── research-ui.js # Research-specific UI components
47
  ```
 
57
 
58
  ### Adding a New Agent
59
 
60
+ Only the backend needs changes β€” the frontend fetches the registry from `GET /api/agents` at startup.
61
 
62
  **1. Backend registry** β€” add to `AGENT_REGISTRY` in `backend/agents.py`:
63
 
 
132
 
133
  ### Adding a Direct Tool
134
 
135
+ Direct tools execute synchronously in the command center (no sub-agent spawned). Only two files need changes.
136
 
137
+ **1. Define the tool schema + execute function** in `backend/tools.py`:
138
 
139
  ```python
140
  my_tool = {
 
152
  }
153
  }
154
 
155
+ def execute_my_tool(input: str, files_root: str = None) -> dict:
156
  return {"content": "Result text for the LLM", "extra_data": "..."}
157
  ```
158
 
159
+ **2. Register it** in `DIRECT_TOOL_REGISTRY` at the bottom of `backend/tools.py`:
160
 
161
  ```python
162
+ DIRECT_TOOL_REGISTRY = {
163
+ "show_html": { ... }, # existing
164
+ "my_tool": {
165
+ "schema": my_tool,
166
+ "execute": lambda args, ctx: execute_my_tool(
167
+ args.get("input", ""), files_root=ctx.get("files_root")
168
+ ),
169
+ },
170
+ }
171
  ```
172
 
173
+ That's it β€” `command.py` automatically picks up tools from the registry.
 
 
 
 
 
174
 
175
  ### Modifying System Prompts
176
 
 
196
 
197
  ### Creating a Theme
198
 
199
+ Themes are CSS custom property sets defined in `frontend/settings.js`.
200
 
201
  **Add to `themeColors` object** (search for `const themeColors`):
202
 
 
259
  | `code_start` | `{code}` β€” code execution started |
260
  | `code` | `{output, error, images}` β€” code execution result |
261
 
262
+ ## Key Patterns & Conventions
263
+
264
+ ### Backend
265
+
266
+ - **Single source of truth**: `AGENT_REGISTRY` in `backend/agents.py` defines all agent types. The frontend fetches it via `GET /api/agents` β€” never duplicate agent definitions.
267
+ - **LLM calls**: Always use `call_llm()` from `agents.py` β€” it handles retries, abort checking, and emits `debug_call_input`/`debug_call_output` events for the debug panel.
268
+ - **Streaming pattern**: Agent handlers are sync generators yielding event dicts. `_stream_sync_generator()` in `main.py` wraps them for async SSE delivery β€” never duplicate the async queue boilerplate.
269
+ - **Direct tools**: `DIRECT_TOOL_REGISTRY` in `tools.py` maps tool name β†’ `{schema, execute}`. `command.py` dispatches automatically.
270
+ - **Result nudging**: When an agent finishes without `<result>` tags, `nudge_for_result()` in `agents.py` asks the LLM for a final answer. It uses `call_llm` internally.
271
+
272
+ ### Frontend
273
+
274
+ - **No build system**: Plain `<script>` tags in `index.html`, no bundler. Files share `window` scope.
275
+ - **Load order matters**: `utils.js` loads first (declares all globals), then other files. Cross-file function calls are fine because they happen at runtime, not parse time.
276
+ - **Global state** lives in `utils.js`: `AGENT_REGISTRY`, `settings`, `activeTabId`, `tabCounter`, `timelineData`, `debugHistory`, `globalFigureRegistry`, etc.
277
+ - **Shared helpers** (also in `utils.js`):
278
+ - `setupInputListeners(container, tabId)` β€” wires textarea auto-resize, Enter-to-send, send button click
279
+ - `setupCollapseToggle(cell, labelSelector)` β€” wires click-to-collapse on tool/code cells
280
+ - `closeAllPanels()` β€” closes all right-side panels (settings, debug, files, sessions)
281
+ - **Markdown rendering**: `parseMarkdown()` in `streaming.js` is the single entry point (marked + KaTeX + Prism).
282
+ - **Panel toggle pattern**: Call `closeAllPanels()` first, then add `.active` to the panel being opened.
283
+ - **Workspace persistence**: Changes auto-save via `saveWorkspaceDebounced()`. Tab state is serialized to JSON and posted to `/api/workspace`.
284
+ - **Cache busting**: Bump `?v=N` query params in `index.html` when changing JS/CSS files.
285
+
286
+ ### Naming
287
+
288
+ - Backend: `stream_<agent>_execution()` for the sync generator, `_stream_<agent>_inner()` for the async wrapper in `main.py`
289
+ - Frontend: Agent types use short keys (`code`, `agent`, `research`, `image`, `command`)
290
+ - CSS: `--theme-*` for accent colors, `--bg-*` / `--text-*` / `--border-*` for surface colors
291
+
292
  ## Testing
293
 
294
  ```bash
295
+ make dev # Start server at http://localhost:8765
296
  make test # Run all tests
297
  make test-backend # Backend API tests (pytest)
298
  make test-frontend # Frontend unit tests (vitest)
299
  make test-e2e # E2E browser tests (playwright)
300
  ```
301
 
302
+ Verify backend imports: `python -c "from backend.command import stream_command_center"`
303
+
304
  ## Deployment
305
 
306
  The app runs as a Docker container (designed for HuggingFace Spaces):
backend/agents.py CHANGED
@@ -4,7 +4,7 @@ Centralized Agent Type Registry.
4
  Adding a new agent type:
5
  1. Add an entry to AGENT_REGISTRY below
6
  2. Implement its streaming handler (or use "builtin:chat" for simple LLM proxy)
7
- 3. Add the same entry to AGENT_REGISTRY in frontend/script.js
8
 
9
  SSE Event Protocol β€” every handler emits `data: {JSON}\\n\\n` lines with a `type` field.
10
 
 
4
  Adding a new agent type:
5
  1. Add an entry to AGENT_REGISTRY below
6
  2. Implement its streaming handler (or use "builtin:chat" for simple LLM proxy)
7
+ The frontend fetches the registry from GET /api/agents at startup β€” no JS changes needed.
8
 
9
  SSE Event Protocol β€” every handler emits `data: {JSON}\\n\\n` lines with a `type` field.
10
 
frontend/app.js ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Initialize
2
+ document.addEventListener('DOMContentLoaded', async () => {
3
+ // Check if multi-user mode is enabled (retry if server not ready yet)
4
+ for (let attempt = 0; attempt < 5; attempt++) {
5
+ try {
6
+ const configResp = await fetch('/api/config');
7
+ const config = await configResp.json();
8
+ IS_MULTI_USER = config.multiUser;
9
+ if (config.multiUser && !SESSION_ID) {
10
+ await showUsernameOverlay();
11
+ }
12
+ break;
13
+ } catch (e) {
14
+ if (attempt < 4) {
15
+ await new Promise(r => setTimeout(r, 1000));
16
+ }
17
+ // After all retries fail, continue in single-user mode
18
+ }
19
+ }
20
+
21
+ // Fetch agent registry from backend (single source of truth)
22
+ try {
23
+ const agentsResp = await apiFetch('/api/agents');
24
+ const agentsData = await agentsResp.json();
25
+ for (const agent of agentsData.agents) {
26
+ AGENT_REGISTRY[agent.key] = agent;
27
+ }
28
+ } catch (e) {
29
+ console.error('Failed to fetch agent registry, using fallback');
30
+ // Minimal fallback so the app still works if backend is slow
31
+ AGENT_REGISTRY = {
32
+ command: { label: 'MAIN', hasCounter: false, inMenu: false, inLauncher: false, placeholder: 'Enter message...' },
33
+ agent: { label: 'AGENT', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
34
+ code: { label: 'CODE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
35
+ research: { label: 'RESEARCH', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
36
+ image: { label: 'IMAGE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Describe an image or paste a URL...' },
37
+ };
38
+ }
39
+ // Initialize settings.agents with registry keys (before loadSettings merges saved values)
40
+ settings.agents = Object.fromEntries(Object.keys(AGENT_REGISTRY).map(k => [k, '']));
41
+
42
+ await loadSettings();
43
+ applyTheme(settings.themeColor || 'forest');
44
+ initializeEventListeners();
45
+ initializeSessionListeners();
46
+ updateUserIndicator();
47
+
48
+ // Always show session selector on load (don't auto-resume last session)
49
+ const sessionsData = await fetchSessions();
50
+ showSessionSelector(sessionsData.sessions);
51
+ });
52
+
53
+ // ============================================================
54
+ // Dynamic HTML generation from AGENT_REGISTRY
55
+ // ============================================================
56
+
57
+ function renderNewTabMenu() {
58
+ const menu = document.getElementById('newTabMenu');
59
+ if (!menu) return;
60
+ menu.innerHTML = '';
61
+ for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
62
+ if (!agent.inMenu) continue;
63
+ const item = document.createElement('div');
64
+ item.className = 'menu-item';
65
+ item.dataset.type = key;
66
+ item.textContent = agent.label;
67
+ menu.appendChild(item);
68
+ }
69
+ }
70
+
71
+ function renderLauncherButtons() {
72
+ const container = document.getElementById('launcherButtons');
73
+ if (!container) return;
74
+ // Insert before the debug button (keep it at the end)
75
+ const debugBtn = container.querySelector('#debugBtn');
76
+ for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
77
+ if (!agent.inLauncher) continue;
78
+ const btn = document.createElement('button');
79
+ btn.className = 'launcher-btn';
80
+ btn.dataset.type = key;
81
+ btn.textContent = agent.label;
82
+ container.insertBefore(btn, debugBtn);
83
+ }
84
+ }
85
+
86
+ function renderAgentModelSelectors() {
87
+ const grid = document.getElementById('agentModelsGrid');
88
+ if (!grid) return;
89
+ grid.innerHTML = '';
90
+ for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
91
+ const label = document.createElement('label');
92
+ label.textContent = `${agent.label}:`;
93
+ const select = document.createElement('select');
94
+ select.id = `setting-agent-${key}`;
95
+ select.className = 'settings-select';
96
+ grid.appendChild(label);
97
+ grid.appendChild(select);
98
+ }
99
+ }
100
+
101
+ function initializeEventListeners() {
102
+ // Generate dynamic UI elements from registry
103
+ renderLauncherButtons();
104
+ renderNewTabMenu();
105
+ renderAgentModelSelectors();
106
+
107
+ // Launcher buttons in command center
108
+ document.querySelectorAll('.launcher-btn').forEach(btn => {
109
+ btn.addEventListener('click', (e) => {
110
+ e.stopPropagation();
111
+ const type = btn.dataset.type;
112
+ createAgentTab(type);
113
+ });
114
+ });
115
+
116
+ // Command center chat functionality
117
+ const commandContent = document.querySelector('.tab-content[data-content-id="0"]');
118
+ if (commandContent) {
119
+ setupInputListeners(commandContent, 0);
120
+ }
121
+
122
+ // Sidebar checkboxes
123
+ const compactViewCheckbox = document.getElementById('compactViewCheckbox');
124
+ const collapseAgentsCheckbox = document.getElementById('collapseAgentsCheckbox');
125
+ const collapseToolsCheckbox = document.getElementById('collapseToolsCheckbox');
126
+
127
+ // Compact view: affects timeline only (collapse agents + hide turns)
128
+ if (compactViewCheckbox) {
129
+ compactViewCheckbox.addEventListener('change', () => {
130
+ const compact = compactViewCheckbox.checked;
131
+ if (compact) {
132
+ // Collapse all agent boxes in timeline
133
+ Object.entries(timelineData).forEach(([id, data]) => {
134
+ if (data.parentTabId !== null) {
135
+ collapsedAgents.add(String(id));
136
+ }
137
+ });
138
+ showAllTurns = false;
139
+ } else {
140
+ collapsedAgents.clear();
141
+ showAllTurns = true;
142
+ }
143
+ renderTimeline();
144
+ });
145
+ }
146
+
147
+ // Chat collapse: agents β€” affects action-widgets in chat only
148
+ if (collapseAgentsCheckbox) {
149
+ collapseAgentsCheckbox.addEventListener('change', () => {
150
+ const collapse = collapseAgentsCheckbox.checked;
151
+ document.querySelectorAll('.action-widget').forEach(w => {
152
+ const toggle = w.querySelector('.widget-collapse-toggle');
153
+ if (collapse) {
154
+ w.classList.add('collapsed');
155
+ if (toggle) toggle.classList.add('collapsed');
156
+ } else {
157
+ w.classList.remove('collapsed');
158
+ if (toggle) toggle.classList.remove('collapsed');
159
+ }
160
+ });
161
+ });
162
+ }
163
+
164
+ // Chat collapse: tools β€” affects tool-cells in chat only
165
+ if (collapseToolsCheckbox) {
166
+ collapseToolsCheckbox.addEventListener('change', () => {
167
+ const collapse = collapseToolsCheckbox.checked;
168
+ document.querySelectorAll('.tool-cell').forEach(w => {
169
+ const toggle = w.querySelector('.widget-collapse-toggle');
170
+ if (collapse) {
171
+ w.classList.add('collapsed');
172
+ if (toggle) toggle.classList.add('collapsed');
173
+ } else {
174
+ w.classList.remove('collapsed');
175
+ if (toggle) toggle.classList.remove('collapsed');
176
+ }
177
+ });
178
+ });
179
+ }
180
+
181
+ // New tab button - toggle menu
182
+ const newTabBtn = document.getElementById('newTabBtn');
183
+ const newTabMenu = document.getElementById('newTabMenu');
184
+
185
+ if (newTabBtn && newTabMenu) {
186
+ newTabBtn.addEventListener('click', (e) => {
187
+ e.stopPropagation();
188
+ e.preventDefault();
189
+
190
+ // Position the menu below the button
191
+ const rect = newTabBtn.getBoundingClientRect();
192
+ newTabMenu.style.top = `${rect.bottom}px`;
193
+ newTabMenu.style.left = `${rect.left}px`;
194
+
195
+ newTabMenu.classList.toggle('active');
196
+ });
197
+ }
198
+
199
+ // Close menu when clicking outside
200
+ document.addEventListener('click', (e) => {
201
+ if (!e.target.closest('.new-tab-wrapper')) {
202
+ newTabMenu.classList.remove('active');
203
+ }
204
+ });
205
+
206
+ // Menu items
207
+ document.querySelectorAll('.menu-item').forEach(item => {
208
+ item.addEventListener('click', () => {
209
+ const type = item.dataset.type;
210
+ createAgentTab(type);
211
+ newTabMenu.classList.remove('active');
212
+ });
213
+ });
214
+
215
+ // Tab switching
216
+ document.addEventListener('click', (e) => {
217
+ const tab = e.target.closest('.tab');
218
+ if (tab && !e.target.closest('.new-tab-wrapper')) {
219
+ if (e.target.classList.contains('tab-close')) {
220
+ closeTab(parseInt(tab.dataset.tabId));
221
+ } else {
222
+ switchToTab(parseInt(tab.dataset.tabId));
223
+ }
224
+ }
225
+ });
226
+
227
+ // Settings button - now handled in settings panel section below
228
+ // (removed old openSettings() call)
229
+
230
+ // Save settings button
231
+ const saveSettingsBtn = document.getElementById('saveSettingsBtn');
232
+ if (saveSettingsBtn) {
233
+ saveSettingsBtn.addEventListener('click', () => {
234
+ saveSettings();
235
+ });
236
+ }
237
+
238
+ // Cancel settings button
239
+ const cancelSettingsBtn = document.getElementById('cancelSettingsBtn');
240
+ if (cancelSettingsBtn) {
241
+ cancelSettingsBtn.addEventListener('click', () => closeAllPanels());
242
+ }
243
+
244
+ // Theme color picker
245
+ const themePicker = document.getElementById('theme-color-picker');
246
+ if (themePicker) {
247
+ themePicker.addEventListener('click', (e) => {
248
+ const themeOption = e.target.closest('.theme-option');
249
+ if (themeOption) {
250
+ // Remove selected from all
251
+ themePicker.querySelectorAll('.theme-option').forEach(opt => {
252
+ opt.classList.remove('selected');
253
+ });
254
+ // Add selected to clicked
255
+ themeOption.classList.add('selected');
256
+ // Update hidden input
257
+ const themeValue = themeOption.dataset.theme;
258
+ document.getElementById('setting-theme-color').value = themeValue;
259
+ }
260
+ });
261
+ }
262
+
263
+ // Double-click on collapse toggle: collapse/uncollapse all widgets in the same chat
264
+ document.addEventListener('dblclick', (e) => {
265
+ const toggle = e.target.closest('.widget-collapse-toggle');
266
+ if (!toggle) return;
267
+ e.stopPropagation();
268
+ const chatContainer = toggle.closest('.chat-container');
269
+ if (!chatContainer) return;
270
+ const widgets = chatContainer.querySelectorAll('.tool-cell, .code-cell, .action-widget');
271
+ if (widgets.length === 0) return;
272
+ // If clicked widget is now collapsed, collapse all; otherwise uncollapse all
273
+ const clickedWidget = toggle.closest('.tool-cell, .code-cell, .action-widget');
274
+ const shouldCollapse = !clickedWidget?.classList.contains('collapsed');
275
+ widgets.forEach(w => {
276
+ w.classList.toggle('collapsed', shouldCollapse);
277
+ const t = w.querySelector('.widget-collapse-toggle');
278
+ if (t) t.classList.toggle('collapsed', shouldCollapse);
279
+ });
280
+ });
281
+
282
+ // Resizable sidebar
283
+ const sidebar = document.getElementById('agentsSidebar');
284
+ const resizeHandle = document.getElementById('sidebarResizeHandle');
285
+ if (sidebar && resizeHandle) {
286
+ const savedWidth = localStorage.getItem('agentui_sidebar_width');
287
+ if (savedWidth) {
288
+ const w = parseInt(savedWidth);
289
+ if (w >= 140 && w <= 400) {
290
+ sidebar.style.setProperty('--sidebar-width', w + 'px');
291
+ sidebar.style.width = w + 'px';
292
+ }
293
+ }
294
+
295
+ let dragging = false;
296
+ resizeHandle.addEventListener('mousedown', (e) => {
297
+ e.preventDefault();
298
+ dragging = true;
299
+ resizeHandle.classList.add('dragging');
300
+ document.body.style.cursor = 'col-resize';
301
+ document.body.style.userSelect = 'none';
302
+ });
303
+ document.addEventListener('mousemove', (e) => {
304
+ if (!dragging) return;
305
+ const w = Math.min(400, Math.max(140, e.clientX));
306
+ sidebar.style.setProperty('--sidebar-width', w + 'px');
307
+ sidebar.style.width = w + 'px';
308
+ });
309
+ document.addEventListener('mouseup', () => {
310
+ if (!dragging) return;
311
+ dragging = false;
312
+ resizeHandle.classList.remove('dragging');
313
+ document.body.style.cursor = '';
314
+ document.body.style.userSelect = '';
315
+ const w = parseInt(sidebar.style.width);
316
+ if (w) localStorage.setItem('agentui_sidebar_width', w);
317
+ });
318
+ }
319
+ }
frontend/index.html CHANGED
@@ -504,7 +504,14 @@
504
  </div>
505
  </div>
506
 
 
507
  <script src="research-ui.js?v=23"></script>
508
- <script src="script.js?v=99"></script>
 
 
 
 
 
 
509
  </body>
510
  </html>
 
504
  </div>
505
  </div>
506
 
507
+ <script src="utils.js?v=1"></script>
508
  <script src="research-ui.js?v=23"></script>
509
+ <script src="timeline.js?v=1"></script>
510
+ <script src="sessions.js?v=1"></script>
511
+ <script src="streaming.js?v=1"></script>
512
+ <script src="tabs.js?v=1"></script>
513
+ <script src="workspace.js?v=1"></script>
514
+ <script src="settings.js?v=1"></script>
515
+ <script src="app.js?v=1"></script>
516
  </body>
517
  </html>
frontend/script.js DELETED
The diff for this file is too large to render. See raw diff
 
frontend/sessions.js ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ============================================
2
+ // Session Management
3
+ // ============================================
4
+
5
+ async function fetchSessions() {
6
+ try {
7
+ const response = await apiFetch('/api/sessions');
8
+ if (response.ok) {
9
+ return await response.json();
10
+ }
11
+ } catch (e) {
12
+ console.error('Failed to fetch sessions:', e);
13
+ }
14
+ return { sessions: [], current: null };
15
+ }
16
+
17
+ function showSessionSelector(sessions) {
18
+ const selector = document.getElementById('sessionSelector');
19
+ const welcome = document.getElementById('welcomeMessage');
20
+ const sessionIndicator = document.getElementById('sessionIndicator');
21
+ const inputArea = document.getElementById('commandInputArea');
22
+
23
+ // Show welcome message and session selector
24
+ if (welcome) welcome.style.display = 'block';
25
+ if (selector) selector.style.display = 'block';
26
+ if (sessionIndicator) sessionIndicator.style.display = 'none';
27
+ if (inputArea) inputArea.style.display = 'none';
28
+
29
+ // Populate existing sessions dropdown
30
+ const select = document.getElementById('existingSessionSelect');
31
+ if (select) {
32
+ select.innerHTML = '<option value="">-- Select session --</option>';
33
+ sessions.forEach(session => {
34
+ const option = document.createElement('option');
35
+ option.value = session.name;
36
+ option.textContent = `${session.name} (${formatDate(session.modified)})`;
37
+ select.appendChild(option);
38
+ });
39
+ }
40
+ }
41
+
42
+ function hideSessionSelector() {
43
+ const selector = document.getElementById('sessionSelector');
44
+ const welcome = document.getElementById('welcomeMessage');
45
+ const sessionIndicator = document.getElementById('sessionIndicator');
46
+ const inputArea = document.getElementById('commandInputArea');
47
+
48
+ // Hide session selector, keep welcome visible, show session indicator and input
49
+ if (selector) selector.style.display = 'none';
50
+ if (welcome) welcome.style.display = 'block';
51
+ if (sessionIndicator) sessionIndicator.style.display = 'block';
52
+ if (inputArea) inputArea.style.display = 'block';
53
+ }
54
+
55
+ async function onSessionSelected(sessionName) {
56
+ currentSession = sessionName;
57
+ hideSessionSelector();
58
+
59
+ // Update session name display
60
+ const nameEl = document.getElementById('currentSessionName');
61
+ if (nameEl) nameEl.textContent = sessionName;
62
+
63
+ const renameInput = document.getElementById('currentSessionRename');
64
+ if (renameInput) renameInput.value = sessionName;
65
+
66
+ // Update timeline title to show session name
67
+ if (timelineData[0]) {
68
+ timelineData[0].title = sessionName;
69
+ renderTimeline();
70
+ }
71
+
72
+ // Load workspace for this session
73
+ await loadWorkspace();
74
+
75
+ // Refresh sessions panel list
76
+ await refreshSessionsList();
77
+ }
78
+
79
+ async function createSession(name) {
80
+ try {
81
+ const response = await apiFetch('/api/sessions', {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({ name })
85
+ });
86
+ if (response.ok) {
87
+ const data = await response.json();
88
+ await onSessionSelected(data.name);
89
+ return true;
90
+ } else {
91
+ const error = await response.json();
92
+ alert(error.detail || 'Failed to create session');
93
+ }
94
+ } catch (e) {
95
+ console.error('Failed to create session:', e);
96
+ alert('Failed to create session');
97
+ }
98
+ return false;
99
+ }
100
+
101
+ async function selectSession(name) {
102
+ try {
103
+ const response = await apiFetch('/api/sessions/select', {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({ name })
107
+ });
108
+ if (response.ok) {
109
+ // Reset local state and load the selected session
110
+ resetLocalState();
111
+ await onSessionSelected(name);
112
+ return true;
113
+ } else {
114
+ const error = await response.json();
115
+ alert(error.detail || 'Failed to select session');
116
+ }
117
+ } catch (e) {
118
+ console.error('Failed to select session:', e);
119
+ alert('Failed to select session');
120
+ }
121
+ return false;
122
+ }
123
+
124
+ async function renameSession(oldName, newName) {
125
+ try {
126
+ const response = await apiFetch('/api/sessions/rename', {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json' },
129
+ body: JSON.stringify({ oldName, newName })
130
+ });
131
+ if (response.ok) {
132
+ const data = await response.json();
133
+ currentSession = data.name;
134
+
135
+ // Update displays
136
+ const nameEl = document.getElementById('currentSessionName');
137
+ if (nameEl) nameEl.textContent = data.name;
138
+
139
+ // Update timeline title
140
+ if (timelineData[0]) {
141
+ timelineData[0].title = data.name;
142
+ renderTimeline();
143
+ }
144
+
145
+ await refreshSessionsList();
146
+ return true;
147
+ } else {
148
+ const error = await response.json();
149
+ alert(error.detail || 'Failed to rename session');
150
+ }
151
+ } catch (e) {
152
+ console.error('Failed to rename session:', e);
153
+ alert('Failed to rename session');
154
+ }
155
+ return false;
156
+ }
157
+
158
+ function openSessionsPanel() {
159
+ const sessionsBtn = document.getElementById('sessionsBtn');
160
+ if (sessionsBtn) {
161
+ sessionsBtn.click();
162
+ }
163
+ }
164
+
165
+ async function refreshSessionsList() {
166
+ const sessionsData = await fetchSessions();
167
+ const listEl = document.getElementById('sessionsList');
168
+
169
+ // Update the current session rename input
170
+ const renameInput = document.getElementById('currentSessionRename');
171
+ if (renameInput && currentSession) {
172
+ renameInput.value = currentSession;
173
+ }
174
+
175
+ if (!listEl) return;
176
+
177
+ if (sessionsData.sessions.length === 0) {
178
+ listEl.innerHTML = '<div class="sessions-loading">No other sessions</div>';
179
+ return;
180
+ }
181
+
182
+ listEl.innerHTML = '';
183
+ sessionsData.sessions.forEach(session => {
184
+ const item = document.createElement('div');
185
+ item.className = 'sessions-list-item' + (session.name === currentSession ? ' current' : '');
186
+
187
+ const isCurrent = session.name === currentSession;
188
+ item.innerHTML = `
189
+ <span class="sessions-list-item-name">${escapeHtml(session.name)}</span>
190
+ <span class="sessions-list-item-date">${formatDate(session.modified)}</span>
191
+ ${!isCurrent ? `<button class="sessions-delete-btn" title="Delete session">Γ—</button>` : ''}
192
+ `;
193
+
194
+ if (!isCurrent) {
195
+ // Click on name/date to select
196
+ item.querySelector('.sessions-list-item-name').addEventListener('click', () => selectSession(session.name));
197
+ item.querySelector('.sessions-list-item-date').addEventListener('click', () => selectSession(session.name));
198
+
199
+ // Click on delete button to delete
200
+ const deleteBtn = item.querySelector('.sessions-delete-btn');
201
+ if (deleteBtn) {
202
+ deleteBtn.addEventListener('click', (e) => {
203
+ e.stopPropagation();
204
+ deleteSession(session.name);
205
+ });
206
+ }
207
+ }
208
+
209
+ listEl.appendChild(item);
210
+ });
211
+ }
212
+
213
+ async function deleteSession(sessionName) {
214
+ if (!confirm(`Delete session "${sessionName}"? This cannot be undone.`)) {
215
+ return;
216
+ }
217
+
218
+ try {
219
+ const response = await apiFetch(`/api/sessions/${encodeURIComponent(sessionName)}`, {
220
+ method: 'DELETE'
221
+ });
222
+
223
+ if (!response.ok) {
224
+ try {
225
+ const error = await response.json();
226
+ alert(error.detail || 'Failed to delete session');
227
+ } catch {
228
+ alert('Failed to delete session');
229
+ }
230
+ return;
231
+ }
232
+
233
+ // Refresh the sessions list in the panel
234
+ refreshSessionsList();
235
+
236
+ // Also refresh the welcome page dropdown
237
+ const sessionsData = await fetchSessions();
238
+ const select = document.getElementById('existingSessionSelect');
239
+ if (select) {
240
+ select.innerHTML = '<option value="">-- Select session --</option>';
241
+ sessionsData.sessions.forEach(session => {
242
+ const option = document.createElement('option');
243
+ option.value = session.name;
244
+ option.textContent = `${session.name} (${formatDate(session.modified)})`;
245
+ select.appendChild(option);
246
+ });
247
+ }
248
+ } catch (error) {
249
+ console.error('Error deleting session:', error);
250
+ alert('Failed to delete session');
251
+ }
252
+ }
253
+
254
+
255
+ function initializeSessionListeners() {
256
+ // Welcome page session selector
257
+ const createBtn = document.getElementById('createSessionBtn');
258
+ const newNameInput = document.getElementById('newSessionName');
259
+ const existingSelect = document.getElementById('existingSessionSelect');
260
+
261
+ // Pre-populate with a cool random name
262
+ if (newNameInput) {
263
+ generateSessionName().then(name => newNameInput.value = name);
264
+ }
265
+
266
+ // Regenerate button
267
+ const regenerateBtn = document.getElementById('regenerateNameBtn');
268
+ if (regenerateBtn && newNameInput) {
269
+ regenerateBtn.addEventListener('click', async () => {
270
+ newNameInput.value = await generateSessionName();
271
+ });
272
+ }
273
+
274
+ if (createBtn) {
275
+ createBtn.addEventListener('click', async () => {
276
+ const name = newNameInput?.value.trim();
277
+ if (name) {
278
+ createSession(name);
279
+ } else {
280
+ // Auto-generate name
281
+ createSession(await generateSessionName());
282
+ }
283
+ });
284
+ }
285
+
286
+ if (newNameInput) {
287
+ newNameInput.addEventListener('keydown', (e) => {
288
+ if (e.key === 'Enter') {
289
+ createBtn?.click();
290
+ }
291
+ });
292
+ }
293
+
294
+ if (existingSelect) {
295
+ existingSelect.addEventListener('change', () => {
296
+ const name = existingSelect.value;
297
+ if (name) {
298
+ selectSession(name);
299
+ }
300
+ });
301
+ }
302
+
303
+ // Sessions panel handlers are set up at the end of the file with other panels
304
+
305
+ // Panel rename button
306
+ const renameBtn = document.getElementById('renameSessionBtn');
307
+ const renameInput = document.getElementById('currentSessionRename');
308
+
309
+ if (renameBtn && renameInput) {
310
+ renameBtn.addEventListener('click', () => {
311
+ const newName = renameInput.value.trim();
312
+ if (newName && newName !== currentSession) {
313
+ renameSession(currentSession, newName);
314
+ }
315
+ });
316
+ }
317
+
318
+ // Panel create new session
319
+ const panelCreateBtn = document.getElementById('panelCreateSessionBtn');
320
+ const panelNewNameInput = document.getElementById('panelNewSessionName');
321
+ const panelRegenerateBtn = document.getElementById('panelRegenerateNameBtn');
322
+
323
+ // Pre-populate panel input with cool name too
324
+ if (panelNewNameInput) {
325
+ generateSessionName().then(name => panelNewNameInput.value = name);
326
+ }
327
+
328
+ // Panel regenerate button
329
+ if (panelRegenerateBtn && panelNewNameInput) {
330
+ panelRegenerateBtn.addEventListener('click', async () => {
331
+ panelNewNameInput.value = await generateSessionName();
332
+ });
333
+ }
334
+
335
+ if (panelCreateBtn) {
336
+ panelCreateBtn.addEventListener('click', async () => {
337
+ const name = panelNewNameInput?.value.trim();
338
+ if (name) {
339
+ resetLocalState();
340
+ await createSession(name);
341
+ // Pre-populate a new name for next time
342
+ if (panelNewNameInput) {
343
+ panelNewNameInput.value = await generateSessionName();
344
+ }
345
+ }
346
+ });
347
+ }
348
+ }
frontend/settings.js ADDED
@@ -0,0 +1,1439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ============================================
2
+ // Settings management
3
+ // ============================================
4
+
5
+ // Migrate old settings format (v1) to new format (v2)
6
+ function migrateSettings(oldSettings) {
7
+ // Already migrated or new format
8
+ if (oldSettings.settingsVersion >= 2) {
9
+ return oldSettings;
10
+ }
11
+
12
+ console.log('Migrating settings from v1 to v2...');
13
+
14
+ const newSettings = {
15
+ providers: {},
16
+ models: {},
17
+ agents: {
18
+ command: '',
19
+ agent: '',
20
+ code: '',
21
+ research: '',
22
+ chat: ''
23
+ },
24
+ e2bKey: oldSettings.e2bKey || '',
25
+ serperKey: oldSettings.serperKey || '',
26
+ hfToken: oldSettings.hfToken || '',
27
+ imageGenModel: oldSettings.imageGenModel || '',
28
+ imageEditModel: oldSettings.imageEditModel || '',
29
+ researchSubAgentModel: oldSettings.researchSubAgentModel || '',
30
+ researchParallelWorkers: oldSettings.researchParallelWorkers || null,
31
+ researchMaxWebsites: oldSettings.researchMaxWebsites || null,
32
+ themeColor: oldSettings.themeColor || 'forest',
33
+ settingsVersion: 2
34
+ };
35
+
36
+ // Create a default provider from old endpoint/token if they exist
37
+ if (oldSettings.endpoint) {
38
+ const providerId = 'provider_default';
39
+ newSettings.providers[providerId] = {
40
+ name: 'Default',
41
+ endpoint: oldSettings.endpoint,
42
+ token: oldSettings.token || ''
43
+ };
44
+
45
+ // Create a default model if old model exists
46
+ if (oldSettings.model) {
47
+ const modelId = 'model_default';
48
+ newSettings.models[modelId] = {
49
+ name: oldSettings.model,
50
+ providerId: providerId,
51
+ modelId: oldSettings.model
52
+ };
53
+
54
+ // Set as default for all agents
55
+ newSettings.agents.command = modelId;
56
+ newSettings.agents.agent = modelId;
57
+ newSettings.agents.code = modelId;
58
+ newSettings.agents.research = modelId;
59
+ newSettings.agents.chat = modelId;
60
+ }
61
+
62
+ // Migrate agent-specific models if they existed
63
+ const oldModels = oldSettings.models || {};
64
+ const agentTypes = Object.keys(AGENT_REGISTRY).filter(k => AGENT_REGISTRY[k].hasCounter);
65
+ agentTypes.forEach(type => {
66
+ if (oldModels[type]) {
67
+ const specificModelId = `model_${type}`;
68
+ newSettings.models[specificModelId] = {
69
+ name: `${type.charAt(0).toUpperCase() + type.slice(1)} - ${oldModels[type]}`,
70
+ providerId: providerId,
71
+ modelId: oldModels[type]
72
+ };
73
+ newSettings.agents[type] = specificModelId;
74
+ }
75
+ });
76
+ }
77
+
78
+ console.log('Settings migrated:', newSettings);
79
+ return newSettings;
80
+ }
81
+
82
+ async function loadSettings() {
83
+ let loadedSettings = null;
84
+
85
+ // Try to load from backend API (file-based) first
86
+ try {
87
+ const response = await apiFetch('/api/settings');
88
+ if (response.ok) {
89
+ loadedSettings = await response.json();
90
+ console.log('Settings loaded from file:', loadedSettings);
91
+ }
92
+ } catch (e) {
93
+ console.log('Could not load settings from backend, falling back to localStorage');
94
+ }
95
+
96
+ // Fallback to localStorage if backend is unavailable
97
+ if (!loadedSettings) {
98
+ const savedSettings = localStorage.getItem('agentui_settings') || localStorage.getItem('productive_settings');
99
+ console.log('Loading settings from localStorage:', savedSettings ? 'found' : 'not found');
100
+ if (savedSettings) {
101
+ try {
102
+ loadedSettings = JSON.parse(savedSettings);
103
+ console.log('Settings loaded from localStorage:', loadedSettings);
104
+ } catch (e) {
105
+ console.error('Failed to parse settings:', e);
106
+ }
107
+ }
108
+ }
109
+
110
+ if (loadedSettings) {
111
+ // Migrate old "notebooks" key to "agents"
112
+ if (loadedSettings.notebooks && !loadedSettings.agents) {
113
+ loadedSettings.agents = loadedSettings.notebooks;
114
+ delete loadedSettings.notebooks;
115
+ }
116
+ // Migrate if needed
117
+ if (!loadedSettings.settingsVersion || loadedSettings.settingsVersion < 2) {
118
+ loadedSettings = migrateSettings(loadedSettings);
119
+ // Save migrated settings
120
+ try {
121
+ await apiFetch('/api/settings', {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify(loadedSettings)
125
+ });
126
+ console.log('Migrated settings saved to file');
127
+ } catch (e) {
128
+ console.log('Could not save migrated settings to file');
129
+ }
130
+ }
131
+ settings = { ...settings, ...loadedSettings };
132
+ } else {
133
+ console.log('Using default settings:', settings);
134
+ }
135
+ }
136
+
137
+ // Generate unique ID for providers/models
138
+ function generateId(prefix) {
139
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
140
+ }
141
+
142
+ // Render providers list in settings
143
+ function renderProvidersList() {
144
+ const container = document.getElementById('providers-list');
145
+ if (!container) return;
146
+
147
+ const providers = settings.providers || {};
148
+ let html = '';
149
+
150
+ Object.entries(providers).forEach(([id, provider]) => {
151
+ html += `
152
+ <div class="provider-item" data-provider-id="${id}">
153
+ <div class="provider-info">
154
+ <span class="provider-name">${escapeHtml(provider.name)}</span>
155
+ <span class="provider-endpoint">${escapeHtml(provider.endpoint)}</span>
156
+ </div>
157
+ <div class="provider-actions">
158
+ <button class="provider-edit-btn" onclick="editProvider('${id}')" title="Edit">✎</button>
159
+ <button class="provider-delete-btn" onclick="deleteProvider('${id}')" title="Delete">Γ—</button>
160
+ </div>
161
+ </div>
162
+ `;
163
+ });
164
+
165
+ if (Object.keys(providers).length === 0) {
166
+ html = '<div class="empty-list">No providers configured. Add one to get started.</div>';
167
+ }
168
+
169
+ container.innerHTML = html;
170
+ }
171
+
172
+ // Render models list in settings
173
+ function renderModelsList() {
174
+ const container = document.getElementById('models-list');
175
+ if (!container) return;
176
+
177
+ const models = settings.models || {};
178
+ const providers = settings.providers || {};
179
+ let html = '';
180
+
181
+ Object.entries(models).forEach(([id, model]) => {
182
+ const provider = providers[model.providerId];
183
+ const providerName = provider ? provider.name : 'Unknown';
184
+ html += `
185
+ <div class="model-item" data-model-id="${id}">
186
+ <div class="model-info">
187
+ <span class="model-name">${escapeHtml(model.name)}</span>
188
+ <span class="model-details">${escapeHtml(model.modelId)} @ ${escapeHtml(providerName)}</span>
189
+ </div>
190
+ <div class="model-actions">
191
+ <button class="model-edit-btn" onclick="editModel('${id}')" title="Edit">✎</button>
192
+ <button class="model-delete-btn" onclick="deleteModel('${id}')" title="Delete">Γ—</button>
193
+ </div>
194
+ </div>
195
+ `;
196
+ });
197
+
198
+ if (Object.keys(models).length === 0) {
199
+ html = '<div class="empty-list">No models configured. Add a provider first, then add models.</div>';
200
+ }
201
+
202
+ container.innerHTML = html;
203
+ }
204
+
205
+ // Populate model dropdowns for agent selection
206
+ function populateModelDropdowns() {
207
+ const models = settings.models || {};
208
+ const agents = settings.agents || {};
209
+
210
+ // Build dropdown IDs from registry + special dropdowns
211
+ const dropdownIds = [
212
+ ...Object.keys(AGENT_REGISTRY).map(t => `setting-agent-${t}`),
213
+ 'setting-research-sub-agent-model',
214
+ 'setting-image-gen-model',
215
+ 'setting-image-edit-model'
216
+ ];
217
+
218
+ dropdownIds.forEach(dropdownId => {
219
+ const dropdown = document.getElementById(dropdownId);
220
+ if (!dropdown) return;
221
+
222
+ // Preserve current selection
223
+ const currentValue = dropdown.value;
224
+
225
+ // Clear and rebuild options
226
+ dropdown.innerHTML = '<option value="">-- Select Model --</option>';
227
+
228
+ Object.entries(models).forEach(([id, model]) => {
229
+ const option = document.createElement('option');
230
+ option.value = id;
231
+ option.textContent = `${model.name} (${model.modelId})`;
232
+ dropdown.appendChild(option);
233
+ });
234
+
235
+ // Restore selection
236
+ if (currentValue && models[currentValue]) {
237
+ dropdown.value = currentValue;
238
+ }
239
+ });
240
+
241
+ // Set values from settings (driven by registry)
242
+ for (const type of Object.keys(AGENT_REGISTRY)) {
243
+ const dropdown = document.getElementById(`setting-agent-${type}`);
244
+ if (dropdown) dropdown.value = agents[type] || '';
245
+ }
246
+ const subAgentDropdown = document.getElementById('setting-research-sub-agent-model');
247
+ if (subAgentDropdown) subAgentDropdown.value = settings.researchSubAgentModel || '';
248
+ const imageGenDropdown = document.getElementById('setting-image-gen-model');
249
+ if (imageGenDropdown) imageGenDropdown.value = settings.imageGenModel || '';
250
+ const imageEditDropdown = document.getElementById('setting-image-edit-model');
251
+ if (imageEditDropdown) imageEditDropdown.value = settings.imageEditModel || '';
252
+ }
253
+
254
+ // Show add/edit provider dialog
255
+ function showProviderDialog(providerId = null) {
256
+ const isEdit = !!providerId;
257
+ const provider = isEdit ? settings.providers[providerId] : { name: '', endpoint: '', token: '' };
258
+
259
+ const dialog = document.getElementById('provider-dialog');
260
+ const title = document.getElementById('provider-dialog-title');
261
+ const nameInput = document.getElementById('provider-name');
262
+ const endpointInput = document.getElementById('provider-endpoint');
263
+ const tokenInput = document.getElementById('provider-token');
264
+
265
+ title.textContent = isEdit ? 'Edit Provider' : 'Add Provider';
266
+ nameInput.value = provider.name;
267
+ endpointInput.value = provider.endpoint;
268
+ tokenInput.value = provider.token;
269
+
270
+ dialog.dataset.providerId = providerId || '';
271
+ dialog.classList.add('active');
272
+ }
273
+
274
+ // Hide provider dialog
275
+ function hideProviderDialog() {
276
+ const dialog = document.getElementById('provider-dialog');
277
+ dialog.classList.remove('active');
278
+ }
279
+
280
+ // Save provider from dialog
281
+ function saveProviderFromDialog() {
282
+ const dialog = document.getElementById('provider-dialog');
283
+ const providerId = dialog.dataset.providerId || generateId('provider');
284
+ const name = document.getElementById('provider-name').value.trim();
285
+ const endpoint = document.getElementById('provider-endpoint').value.trim();
286
+ const token = document.getElementById('provider-token').value.trim();
287
+
288
+ if (!name || !endpoint) {
289
+ alert('Provider name and endpoint are required');
290
+ return;
291
+ }
292
+
293
+ settings.providers[providerId] = { name, endpoint, token };
294
+ hideProviderDialog();
295
+ renderProvidersList();
296
+ populateModelDropdowns();
297
+ }
298
+
299
+ // Edit provider
300
+ function editProvider(providerId) {
301
+ showProviderDialog(providerId);
302
+ }
303
+
304
+ // Delete provider
305
+ function deleteProvider(providerId) {
306
+ // Check if any models use this provider
307
+ const modelsUsingProvider = Object.entries(settings.models || {})
308
+ .filter(([_, model]) => model.providerId === providerId);
309
+
310
+ if (modelsUsingProvider.length > 0) {
311
+ alert(`Cannot delete provider. ${modelsUsingProvider.length} model(s) are using it.`);
312
+ return;
313
+ }
314
+
315
+ if (confirm('Delete this provider?')) {
316
+ delete settings.providers[providerId];
317
+ renderProvidersList();
318
+ }
319
+ }
320
+
321
+ // Show add/edit model dialog
322
+ function showModelDialog(modelId = null) {
323
+ const isEdit = !!modelId;
324
+ const model = isEdit ? settings.models[modelId] : { name: '', providerId: '', modelId: '', extraParams: null, multimodal: false };
325
+
326
+ const dialog = document.getElementById('model-dialog');
327
+ const title = document.getElementById('model-dialog-title');
328
+ const nameInput = document.getElementById('model-name');
329
+ const providerSelect = document.getElementById('model-provider');
330
+ const modelIdInput = document.getElementById('model-model-id');
331
+ const extraParamsInput = document.getElementById('model-extra-params');
332
+ const multimodalCheckbox = document.getElementById('model-multimodal');
333
+
334
+ title.textContent = isEdit ? 'Edit Model' : 'Add Model';
335
+ nameInput.value = model.name;
336
+ modelIdInput.value = model.modelId;
337
+ extraParamsInput.value = model.extraParams ? JSON.stringify(model.extraParams, null, 2) : '';
338
+ multimodalCheckbox.checked = !!model.multimodal;
339
+
340
+ // Populate provider dropdown
341
+ providerSelect.innerHTML = '<option value="">-- Select Provider --</option>';
342
+ Object.entries(settings.providers || {}).forEach(([id, provider]) => {
343
+ const option = document.createElement('option');
344
+ option.value = id;
345
+ option.textContent = provider.name;
346
+ if (id === model.providerId) option.selected = true;
347
+ providerSelect.appendChild(option);
348
+ });
349
+
350
+ dialog.dataset.modelId = modelId || '';
351
+ dialog.classList.add('active');
352
+ }
353
+
354
+ // Hide model dialog
355
+ function hideModelDialog() {
356
+ const dialog = document.getElementById('model-dialog');
357
+ dialog.classList.remove('active');
358
+ }
359
+
360
+ // Save model from dialog
361
+ function saveModelFromDialog() {
362
+ const dialog = document.getElementById('model-dialog');
363
+ const modelId = dialog.dataset.modelId || generateId('model');
364
+ const name = document.getElementById('model-name').value.trim();
365
+ const providerId = document.getElementById('model-provider').value;
366
+ const apiModelId = document.getElementById('model-model-id').value.trim();
367
+ const extraParamsStr = document.getElementById('model-extra-params').value.trim();
368
+
369
+ if (!name || !providerId || !apiModelId) {
370
+ alert('Name, provider, and model ID are required');
371
+ return;
372
+ }
373
+
374
+ // Parse extra params if provided
375
+ let extraParams = null;
376
+ if (extraParamsStr) {
377
+ try {
378
+ extraParams = JSON.parse(extraParamsStr);
379
+ } catch (e) {
380
+ alert('Invalid JSON in extra parameters: ' + e.message);
381
+ return;
382
+ }
383
+ }
384
+
385
+ const multimodal = document.getElementById('model-multimodal').checked;
386
+ settings.models[modelId] = { name, providerId, modelId: apiModelId, extraParams, multimodal };
387
+ hideModelDialog();
388
+ renderModelsList();
389
+ populateModelDropdowns();
390
+ }
391
+
392
+ // Edit model
393
+ function editModel(modelId) {
394
+ showModelDialog(modelId);
395
+ }
396
+
397
+ // Delete model
398
+ function deleteModel(modelId) {
399
+ // Check if any agents use this model
400
+ const agentsUsingModel = Object.entries(settings.agents || {})
401
+ .filter(([_, mid]) => mid === modelId);
402
+
403
+ if (agentsUsingModel.length > 0) {
404
+ const warning = `This model is used by: ${agentsUsingModel.map(([t]) => t).join(', ')}. Delete anyway?`;
405
+ if (!confirm(warning)) return;
406
+
407
+ // Clear the agent assignments
408
+ agentsUsingModel.forEach(([type]) => {
409
+ settings.agents[type] = '';
410
+ });
411
+ } else if (!confirm('Delete this model?')) {
412
+ return;
413
+ }
414
+
415
+ delete settings.models[modelId];
416
+ renderModelsList();
417
+ populateModelDropdowns();
418
+ }
419
+
420
+ function openSettings() {
421
+ // Show settings file path
422
+ const pathEl = document.getElementById('settingsPath');
423
+ if (pathEl) pathEl.textContent = settings._settingsPath || '';
424
+
425
+ // Render providers and models lists
426
+ renderProvidersList();
427
+ renderModelsList();
428
+ populateModelDropdowns();
429
+
430
+ // Populate service keys
431
+ document.getElementById('setting-e2b-key').value = settings.e2bKey || '';
432
+ document.getElementById('setting-serper-key').value = settings.serperKey || '';
433
+ document.getElementById('setting-hf-token').value = settings.hfToken || '';
434
+
435
+ // Populate research settings
436
+ document.getElementById('setting-research-parallel-workers').value = settings.researchParallelWorkers || '';
437
+ document.getElementById('setting-research-max-websites').value = settings.researchMaxWebsites || '';
438
+
439
+ // Set theme color
440
+ const themeColor = settings.themeColor || 'forest';
441
+ document.getElementById('setting-theme-color').value = themeColor;
442
+
443
+ // Update selected theme in picker
444
+ const themePicker = document.getElementById('theme-color-picker');
445
+ if (themePicker) {
446
+ themePicker.querySelectorAll('.theme-option').forEach(opt => {
447
+ opt.classList.remove('selected');
448
+ if (opt.dataset.theme === themeColor) {
449
+ opt.classList.add('selected');
450
+ }
451
+ });
452
+ }
453
+
454
+ // Clear any status message
455
+ const status = document.getElementById('settingsStatus');
456
+ status.className = 'settings-status';
457
+ status.textContent = '';
458
+ }
459
+
460
+ async function saveSettings() {
461
+ // Get agent model selections from dropdowns (driven by registry)
462
+ const agentModels = {};
463
+ for (const type of Object.keys(AGENT_REGISTRY)) {
464
+ agentModels[type] = document.getElementById(`setting-agent-${type}`)?.value || '';
465
+ }
466
+ const researchSubAgentModel = document.getElementById('setting-research-sub-agent-model')?.value || '';
467
+
468
+ // Get other settings
469
+ const e2bKey = document.getElementById('setting-e2b-key').value.trim();
470
+ const serperKey = document.getElementById('setting-serper-key').value.trim();
471
+ const hfToken = document.getElementById('setting-hf-token').value.trim();
472
+ const imageGenModel = document.getElementById('setting-image-gen-model')?.value || '';
473
+ const imageEditModel = document.getElementById('setting-image-edit-model')?.value || '';
474
+ const researchParallelWorkers = document.getElementById('setting-research-parallel-workers').value.trim();
475
+ const researchMaxWebsites = document.getElementById('setting-research-max-websites').value.trim();
476
+ const themeColor = document.getElementById('setting-theme-color').value || 'forest';
477
+
478
+ // Validate: at least one provider and one model should exist
479
+ if (Object.keys(settings.providers || {}).length === 0) {
480
+ showSettingsStatus('Please add at least one provider', 'error');
481
+ return;
482
+ }
483
+
484
+ if (Object.keys(settings.models || {}).length === 0) {
485
+ showSettingsStatus('Please add at least one model', 'error');
486
+ return;
487
+ }
488
+
489
+ // Update settings
490
+ settings.agents = agentModels;
491
+ settings.e2bKey = e2bKey;
492
+ settings.serperKey = serperKey;
493
+ settings.hfToken = hfToken;
494
+ settings.imageGenModel = imageGenModel;
495
+ settings.imageEditModel = imageEditModel;
496
+ settings.researchSubAgentModel = researchSubAgentModel;
497
+ settings.researchParallelWorkers = researchParallelWorkers ? parseInt(researchParallelWorkers) : null;
498
+ settings.researchMaxWebsites = researchMaxWebsites ? parseInt(researchMaxWebsites) : null;
499
+ settings.themeColor = themeColor;
500
+ settings.settingsVersion = 2;
501
+
502
+ // Save to backend API (file-based) first
503
+ try {
504
+ const response = await apiFetch('/api/settings', {
505
+ method: 'POST',
506
+ headers: { 'Content-Type': 'application/json' },
507
+ body: JSON.stringify(settings)
508
+ });
509
+ if (response.ok) {
510
+ console.log('Settings saved to file:', settings);
511
+ } else {
512
+ console.error('Failed to save settings to file, falling back to localStorage');
513
+ localStorage.setItem('agentui_settings', JSON.stringify(settings));
514
+ }
515
+ } catch (e) {
516
+ console.error('Could not save settings to backend, falling back to localStorage:', e);
517
+ localStorage.setItem('agentui_settings', JSON.stringify(settings));
518
+ }
519
+
520
+ // Apply theme
521
+ applyTheme(themeColor);
522
+
523
+ // Show success message
524
+ showSettingsStatus('Settings saved successfully', 'success');
525
+
526
+ // Close settings panel and go back to command center after a short delay
527
+ setTimeout(() => {
528
+ const settingsPanel = document.getElementById('settingsPanel');
529
+ const settingsBtn = document.getElementById('settingsBtn');
530
+ const appContainer = document.querySelector('.app-container');
531
+ if (settingsPanel) settingsPanel.classList.remove('active');
532
+ if (settingsBtn) settingsBtn.classList.remove('active');
533
+ if (appContainer) appContainer.classList.remove('panel-open');
534
+ }, 1000);
535
+ }
536
+
537
+ function showSettingsStatus(message, type) {
538
+ const status = document.getElementById('settingsStatus');
539
+ status.textContent = message;
540
+ status.className = `settings-status ${type}`;
541
+ }
542
+
543
+ // Theme colors mapping
544
+ // Default light surface colors shared by all light themes
545
+ const lightSurface = {
546
+ bgPrimary: '#ffffff',
547
+ bgSecondary: '#f5f5f5',
548
+ bgTertiary: '#fafafa',
549
+ bgInput: '#ffffff',
550
+ bgHover: '#f0f0f0',
551
+ bgCard: '#ffffff',
552
+ textPrimary: '#1a1a1a',
553
+ textSecondary: '#666666',
554
+ textMuted: '#999999',
555
+ borderPrimary: '#e0e0e0',
556
+ borderSubtle: '#f0f0f0'
557
+ };
558
+
559
+ const themeColors = {
560
+ forest: {
561
+ border: '#1b5e20', bg: '#e8f5e9', hoverBg: '#c8e6c9',
562
+ accent: '#1b5e20', accentRgb: '27, 94, 32',
563
+ ...lightSurface
564
+ },
565
+ sapphire: {
566
+ border: '#0d47a1', bg: '#e3f2fd', hoverBg: '#bbdefb',
567
+ accent: '#0d47a1', accentRgb: '13, 71, 161',
568
+ ...lightSurface
569
+ },
570
+ ocean: {
571
+ border: '#00796b', bg: '#e0f2f1', hoverBg: '#b2dfdb',
572
+ accent: '#004d40', accentRgb: '0, 77, 64',
573
+ ...lightSurface
574
+ },
575
+ midnight: {
576
+ border: '#283593', bg: '#e8eaf6', hoverBg: '#c5cae9',
577
+ accent: '#1a237e', accentRgb: '26, 35, 126',
578
+ ...lightSurface
579
+ },
580
+ steel: {
581
+ border: '#455a64', bg: '#eceff1', hoverBg: '#cfd8dc',
582
+ accent: '#263238', accentRgb: '38, 50, 56',
583
+ ...lightSurface
584
+ },
585
+ depths: {
586
+ border: '#01579b', bg: '#e3f2fd', hoverBg: '#bbdefb',
587
+ accent: '#01579b', accentRgb: '1, 87, 155',
588
+ ...lightSurface
589
+ },
590
+ ember: {
591
+ border: '#b71c1c', bg: '#fbe9e7', hoverBg: '#ffccbc',
592
+ accent: '#b71c1c', accentRgb: '183, 28, 28',
593
+ ...lightSurface
594
+ },
595
+ noir: {
596
+ border: '#888888', bg: '#1a1a1a', hoverBg: '#2a2a2a',
597
+ accent: '#999999', accentRgb: '153, 153, 153',
598
+ bgPrimary: '#111111',
599
+ bgSecondary: '#1a1a1a',
600
+ bgTertiary: '#0d0d0d',
601
+ bgInput: '#0d0d0d',
602
+ bgHover: '#2a2a2a',
603
+ bgCard: '#1a1a1a',
604
+ textPrimary: '#e0e0e0',
605
+ textSecondary: '#999999',
606
+ textMuted: '#666666',
607
+ borderPrimary: '#333333',
608
+ borderSubtle: '#222222'
609
+ },
610
+ eclipse: {
611
+ border: '#5c9eff', bg: '#0d1520', hoverBg: '#162030',
612
+ accent: '#5c9eff', accentRgb: '92, 158, 255',
613
+ bgPrimary: '#0b1118',
614
+ bgSecondary: '#111a25',
615
+ bgTertiary: '#080e14',
616
+ bgInput: '#080e14',
617
+ bgHover: '#1a2840',
618
+ bgCard: '#111a25',
619
+ textPrimary: '#d0d8e8',
620
+ textSecondary: '#7088a8',
621
+ textMuted: '#4a6080',
622
+ borderPrimary: '#1e2e45',
623
+ borderSubtle: '#151f30'
624
+ },
625
+ terminal: {
626
+ border: '#00cc00', bg: '#0a1a0a', hoverBg: '#0d260d',
627
+ accent: '#00cc00', accentRgb: '0, 204, 0',
628
+ bgPrimary: '#0a0a0a',
629
+ bgSecondary: '#0d1a0d',
630
+ bgTertiary: '#050505',
631
+ bgInput: '#050505',
632
+ bgHover: '#1a3a1a',
633
+ bgCard: '#0d1a0d',
634
+ textPrimary: '#00cc00',
635
+ textSecondary: '#009900',
636
+ textMuted: '#007700',
637
+ borderPrimary: '#1a3a1a',
638
+ borderSubtle: '#0d1a0d'
639
+ }
640
+ };
641
+
642
+ function applyTheme(themeName) {
643
+ const theme = themeColors[themeName] || themeColors.forest;
644
+ const root = document.documentElement;
645
+
646
+ // Accent colors
647
+ root.style.setProperty('--theme-border', theme.border);
648
+ root.style.setProperty('--theme-bg', theme.bg);
649
+ root.style.setProperty('--theme-hover-bg', theme.hoverBg);
650
+ root.style.setProperty('--theme-accent', theme.accent);
651
+ root.style.setProperty('--theme-accent-rgb', theme.accentRgb);
652
+
653
+ // Surface colors
654
+ root.style.setProperty('--bg-primary', theme.bgPrimary);
655
+ root.style.setProperty('--bg-secondary', theme.bgSecondary);
656
+ root.style.setProperty('--bg-tertiary', theme.bgTertiary);
657
+ root.style.setProperty('--bg-input', theme.bgInput);
658
+ root.style.setProperty('--bg-hover', theme.bgHover);
659
+ root.style.setProperty('--bg-card', theme.bgCard);
660
+ root.style.setProperty('--text-primary', theme.textPrimary);
661
+ root.style.setProperty('--text-secondary', theme.textSecondary);
662
+ root.style.setProperty('--text-muted', theme.textMuted);
663
+ root.style.setProperty('--border-primary', theme.borderPrimary);
664
+ root.style.setProperty('--border-subtle', theme.borderSubtle);
665
+
666
+ // Data attribute for any remaining theme-specific overrides
667
+ document.body.setAttribute('data-theme', themeName);
668
+ }
669
+
670
+ // Export settings for use in API calls
671
+ function getSettings() {
672
+ return settings;
673
+ }
674
+
675
+ // Resolve model configuration for an agent type
676
+ // Returns { endpoint, token, model, extraParams } or null if not configured
677
+ function resolveModelConfig(agentType) {
678
+ const modelId = settings.agents?.[agentType];
679
+ if (!modelId) return null;
680
+
681
+ const model = settings.models?.[modelId];
682
+ if (!model) return null;
683
+
684
+ const provider = settings.providers?.[model.providerId];
685
+ if (!provider) return null;
686
+
687
+ return {
688
+ endpoint: provider.endpoint,
689
+ token: provider.token,
690
+ model: model.modelId,
691
+ extraParams: model.extraParams || null,
692
+ multimodal: !!model.multimodal
693
+ };
694
+ }
695
+
696
+ // Get first available model config as fallback
697
+ function getDefaultModelConfig() {
698
+ const modelIds = Object.keys(settings.models || {});
699
+ if (modelIds.length === 0) return null;
700
+
701
+ const modelId = modelIds[0];
702
+ const model = settings.models[modelId];
703
+ const provider = settings.providers?.[model.providerId];
704
+ if (!provider) return null;
705
+
706
+ return {
707
+ endpoint: provider.endpoint,
708
+ token: provider.token,
709
+ model: model.modelId,
710
+ extraParams: model.extraParams || null,
711
+ multimodal: !!model.multimodal
712
+ };
713
+ }
714
+
715
+ // Build frontend context for API requests
716
+ function getFrontendContext() {
717
+ const currentThemeName = settings.themeColor || 'forest';
718
+ const theme = themeColors[currentThemeName];
719
+
720
+ return {
721
+ theme: theme ? {
722
+ name: currentThemeName,
723
+ accent: theme.accent,
724
+ bg: theme.bg,
725
+ border: theme.border,
726
+ bgPrimary: theme.bgPrimary,
727
+ bgSecondary: theme.bgSecondary,
728
+ textPrimary: theme.textPrimary,
729
+ textSecondary: theme.textSecondary
730
+ } : null,
731
+ open_agents: getOpenAgentTypes()
732
+ };
733
+ }
734
+
735
+ // Get list of open agent types
736
+ function getOpenAgentTypes() {
737
+ const tabs = document.querySelectorAll('.tab[data-tab-id]');
738
+ const types = [];
739
+ tabs.forEach(tab => {
740
+ const tabId = tab.dataset.tabId;
741
+ if (tabId === '0') {
742
+ types.push('command');
743
+ } else {
744
+ const content = document.querySelector(`[data-content-id="${tabId}"]`);
745
+ if (content) {
746
+ const chatContainer = content.querySelector('.chat-container');
747
+ if (chatContainer && chatContainer.dataset.agentType) {
748
+ types.push(chatContainer.dataset.agentType);
749
+ }
750
+ }
751
+ }
752
+ });
753
+ return types;
754
+ }
755
+
756
+ // Sandbox management for code agents
757
+ async function startSandbox(tabId) {
758
+ const currentSettings = getSettings();
759
+ const backendEndpoint = '/api';
760
+
761
+ if (!currentSettings.e2bKey) {
762
+ console.log('No E2B key configured, skipping sandbox start');
763
+ return;
764
+ }
765
+
766
+ // Add a status message to the agent
767
+ const uniqueId = `code-${tabId}`;
768
+ const chatContainer = document.getElementById(`messages-${uniqueId}`);
769
+ if (chatContainer) {
770
+ const statusMsg = document.createElement('div');
771
+ statusMsg.className = 'system-message';
772
+ statusMsg.innerHTML = '<em>βš™οΈ Starting sandbox...</em>';
773
+ chatContainer.appendChild(statusMsg);
774
+ }
775
+
776
+ try {
777
+ const response = await apiFetch(`${backendEndpoint}/sandbox/start`, {
778
+ method: 'POST',
779
+ headers: { 'Content-Type': 'application/json' },
780
+ body: JSON.stringify({
781
+ session_id: tabId.toString(),
782
+ e2b_key: currentSettings.e2bKey
783
+ })
784
+ });
785
+
786
+ const result = await response.json();
787
+
788
+ // Update status message
789
+ if (chatContainer) {
790
+ const statusMsg = chatContainer.querySelector('.system-message');
791
+ if (statusMsg) {
792
+ if (result.success) {
793
+ // Sandbox is ready - hide the message
794
+ statusMsg.remove();
795
+ } else {
796
+ statusMsg.innerHTML = `<em>⚠ Sandbox error: ${result.error}</em>`;
797
+ statusMsg.style.color = '#c62828';
798
+ }
799
+ }
800
+ }
801
+ } catch (error) {
802
+ console.error('Failed to start sandbox:', error);
803
+ if (chatContainer) {
804
+ const statusMsg = chatContainer.querySelector('.system-message');
805
+ if (statusMsg) {
806
+ statusMsg.innerHTML = `<em>⚠ Failed to start sandbox: ${error.message}</em>`;
807
+ statusMsg.style.color = '#c62828';
808
+ }
809
+ }
810
+ }
811
+ }
812
+
813
+ async function stopSandbox(tabId) {
814
+ const backendEndpoint = '/api';
815
+
816
+ try {
817
+ await apiFetch(`${backendEndpoint}/sandbox/stop`, {
818
+ method: 'POST',
819
+ headers: { 'Content-Type': 'application/json' },
820
+ body: JSON.stringify({
821
+ session_id: tabId.toString()
822
+ })
823
+ });
824
+ } catch (error) {
825
+ console.error('Failed to stop sandbox:', error);
826
+ }
827
+ }
828
+
829
+ // Image modal for click-to-zoom
830
+ function openImageModal(src) {
831
+ // Create modal if it doesn't exist
832
+ let modal = document.getElementById('imageModal');
833
+ if (!modal) {
834
+ modal = document.createElement('div');
835
+ modal.id = 'imageModal';
836
+ modal.style.cssText = `
837
+ display: none;
838
+ position: fixed;
839
+ z-index: 10000;
840
+ left: 0;
841
+ top: 0;
842
+ width: 100%;
843
+ height: 100%;
844
+ background-color: rgba(0, 0, 0, 0.9);
845
+ cursor: pointer;
846
+ `;
847
+ modal.onclick = function() {
848
+ modal.style.display = 'none';
849
+ };
850
+
851
+ const img = document.createElement('img');
852
+ img.id = 'imageModalContent';
853
+ img.style.cssText = `
854
+ margin: auto;
855
+ display: block;
856
+ max-width: 95%;
857
+ max-height: 95%;
858
+ position: absolute;
859
+ top: 50%;
860
+ left: 50%;
861
+ transform: translate(-50%, -50%);
862
+ `;
863
+ modal.appendChild(img);
864
+ document.body.appendChild(modal);
865
+ }
866
+
867
+ // Show modal with image
868
+ const modalImg = document.getElementById('imageModalContent');
869
+ modalImg.src = src;
870
+ modal.style.display = 'block';
871
+ }
872
+
873
+ // ============= DEBUG PANEL =============
874
+
875
+ const debugPanel = document.getElementById('debugPanel');
876
+ const debugBtn = document.getElementById('debugBtn');
877
+ const debugClose = document.getElementById('debugClose');
878
+ const debugContent = document.getElementById('debugContent');
879
+
880
+ // Toggle debug panel
881
+ if (debugBtn) {
882
+ debugBtn.addEventListener('click', () => {
883
+ const isOpening = !debugPanel.classList.contains('active');
884
+
885
+ // Close all panels first, then toggle debug
886
+ closeAllPanels();
887
+
888
+ if (isOpening) {
889
+ debugPanel.classList.add('active');
890
+ debugBtn.classList.add('active');
891
+ appContainer.classList.add('panel-open');
892
+ loadDebugMessages();
893
+ }
894
+ });
895
+ }
896
+
897
+ // Close debug panel
898
+ if (debugClose) {
899
+ debugClose.addEventListener('click', () => {
900
+ debugPanel.classList.remove('active');
901
+ debugBtn.classList.remove('active');
902
+ appContainer.classList.remove('panel-open');
903
+ });
904
+ }
905
+
906
+
907
+ // Load debug messages from backend
908
+ function formatDebugJson(obj) {
909
+ /**
910
+ * Format an object as HTML-escaped JSON, replacing base64 image data
911
+ * with clickable placeholders that show a thumbnail on hover.
912
+ */
913
+ // Collect base64 images and replace with placeholders before escaping
914
+ const images = [];
915
+ const json = JSON.stringify(obj, null, 2);
916
+ const placeholder = json.replace(
917
+ /"(data:image\/[^;]+;base64,)([A-Za-z0-9+/=\n]{200,})"/g,
918
+ (match, prefix, b64) => {
919
+ const idx = images.length;
920
+ const sizeKB = (b64.length * 0.75 / 1024).toFixed(1);
921
+ images.push(prefix + b64);
922
+ return `"__DEBUG_IMG_${idx}_${sizeKB}KB__"`;
923
+ }
924
+ );
925
+ // Now HTML-escape the JSON (placeholders are safe ASCII)
926
+ let html = escapeHtml(placeholder);
927
+ // Replace placeholders with hoverable image thumbnails
928
+ html = html.replace(/__DEBUG_IMG_(\d+)_([\d.]+KB)__/g, (match, idx, size) => {
929
+ const src = images[parseInt(idx)];
930
+ return `<span class="debug-image-placeholder" onmouseenter="this.querySelector('.debug-image-tooltip').style.display='block'" onmouseleave="this.querySelector('.debug-image-tooltip').style.display='none'">[image ${size}]<span class="debug-image-tooltip"><img src="${src}"></span></span>`;
931
+ });
932
+ return html;
933
+ }
934
+
935
+ function loadDebugMessages() {
936
+ const calls = debugHistory[activeTabId] || [];
937
+
938
+ if (calls.length === 0) {
939
+ debugContent.innerHTML = '<div style="padding: 10px; color: var(--text-secondary);">No LLM calls recorded yet.<br><br>Send a message in this tab to see the call history here.</div>';
940
+ return;
941
+ }
942
+
943
+ debugContent.innerHTML = calls.map((call, i) => {
944
+ const isLast = i === calls.length - 1;
945
+ const arrow = isLast ? 'β–Ό' : 'β–Ά';
946
+ const display = isLast ? 'block' : 'none';
947
+ const msgCount = call.input ? call.input.length : 0;
948
+
949
+ const inputHtml = call.input ? formatDebugJson(call.input) : '<em>No input</em>';
950
+
951
+ let outputHtml;
952
+ if (call.error) {
953
+ outputHtml = `<span style="color: #d32f2f;">${escapeHtml(call.error)}</span>`;
954
+ } else if (call.output) {
955
+ outputHtml = formatDebugJson(call.output);
956
+ } else {
957
+ outputHtml = '<em>Pending...</em>';
958
+ }
959
+
960
+ return `<div class="debug-call-item${isLast ? ' expanded' : ''}" id="callitem-${i}"><div class="debug-call-header" onclick="toggleDebugCall(${i})"><span class="debug-call-arrow" id="arrow-${i}">${arrow}</span><span class="debug-call-title">Call #${i + 1}</span><span class="debug-call-time">${call.timestamp}</span></div><div class="debug-call-content" id="call-${i}" style="display: ${display};"><div class="debug-section-label">INPUT (${msgCount} messages)</div><pre>${inputHtml}</pre><div class="debug-section-label">OUTPUT</div><pre>${outputHtml}</pre></div></div>`;
961
+ }).join('');
962
+ }
963
+
964
+ // Toggle debug call expansion
965
+ window.toggleDebugCall = function(index) {
966
+ const content = document.getElementById(`call-${index}`);
967
+ const arrow = document.getElementById(`arrow-${index}`);
968
+ const item = document.getElementById(`callitem-${index}`);
969
+ if (content.style.display === 'none') {
970
+ content.style.display = 'block';
971
+ arrow.textContent = 'β–Ό';
972
+ item.classList.add('expanded');
973
+ } else {
974
+ content.style.display = 'none';
975
+ arrow.textContent = 'β–Ά';
976
+ item.classList.remove('expanded');
977
+ }
978
+ }
979
+
980
+ // ============= SETTINGS PANEL =============
981
+
982
+ const settingsPanel = document.getElementById('settingsPanel');
983
+ const settingsPanelBody = document.getElementById('settingsPanelBody');
984
+ const settingsPanelClose = document.getElementById('settingsPanelClose');
985
+ const settingsBtn = document.getElementById('settingsBtn');
986
+ const appContainer = document.querySelector('.app-container');
987
+
988
+
989
+ // Open settings panel when SETTINGS button is clicked
990
+ if (settingsBtn) {
991
+ settingsBtn.addEventListener('click', () => {
992
+ closeAllPanels();
993
+ openSettings();
994
+ settingsPanel.classList.add('active');
995
+ settingsBtn.classList.add('active');
996
+ appContainer.classList.add('panel-open');
997
+ });
998
+ }
999
+
1000
+ // Close settings panel
1001
+ if (settingsPanelClose) {
1002
+ settingsPanelClose.addEventListener('click', () => {
1003
+ settingsPanel.classList.remove('active');
1004
+ settingsBtn.classList.remove('active');
1005
+ appContainer.classList.remove('panel-open');
1006
+ });
1007
+ }
1008
+
1009
+
1010
+ // ============= FILES PANEL =============
1011
+
1012
+ const filesPanel = document.getElementById('filesPanel');
1013
+ const filesPanelClose = document.getElementById('filesPanelClose');
1014
+ const filesBtn = document.getElementById('filesBtn');
1015
+ const fileTree = document.getElementById('fileTree');
1016
+ const showHiddenFiles = document.getElementById('showHiddenFiles');
1017
+ const filesRefresh = document.getElementById('filesRefresh');
1018
+ const filesUpload = document.getElementById('filesUpload');
1019
+
1020
+ // Track expanded folder paths to preserve state on refresh
1021
+ let expandedPaths = new Set();
1022
+ let filesRoot = '';
1023
+
1024
+ // Load file tree from API
1025
+ async function loadFileTree() {
1026
+ const showHidden = showHiddenFiles?.checked || false;
1027
+ try {
1028
+ const response = await apiFetch(`/api/files?show_hidden=${showHidden}`);
1029
+ if (response.ok) {
1030
+ const data = await response.json();
1031
+ filesRoot = data.root;
1032
+ renderFileTree(data.tree, fileTree, data.root);
1033
+ } else {
1034
+ fileTree.innerHTML = '<div class="files-loading">Failed to load files</div>';
1035
+ }
1036
+ } catch (e) {
1037
+ console.error('Failed to load file tree:', e);
1038
+ fileTree.innerHTML = '<div class="files-loading">Failed to load files</div>';
1039
+ }
1040
+ }
1041
+
1042
+ // Render file tree recursively
1043
+ function renderFileTree(tree, container, rootPath) {
1044
+ container.innerHTML = '';
1045
+ const rootWrapper = document.createElement('div');
1046
+ rootWrapper.className = 'file-tree-root';
1047
+
1048
+ // Add header with folder name
1049
+ const header = document.createElement('div');
1050
+ header.className = 'file-tree-header';
1051
+ const folderName = rootPath.split('/').pop() || rootPath;
1052
+ header.textContent = './' + folderName;
1053
+ rootWrapper.appendChild(header);
1054
+
1055
+ // Container with vertical line
1056
+ const treeContainer = document.createElement('div');
1057
+ treeContainer.className = 'file-tree-container';
1058
+ renderTreeItems(tree, treeContainer);
1059
+ rootWrapper.appendChild(treeContainer);
1060
+
1061
+ container.appendChild(rootWrapper);
1062
+ }
1063
+
1064
+ function renderTreeItems(tree, container) {
1065
+ const len = tree.length;
1066
+ for (let i = 0; i < len; i++) {
1067
+ const item = tree[i];
1068
+ const isLast = (i === len - 1);
1069
+
1070
+ const itemEl = document.createElement('div');
1071
+ itemEl.className = `file-tree-item ${item.type}`;
1072
+ if (isLast) itemEl.classList.add('last');
1073
+ itemEl.dataset.path = item.path;
1074
+
1075
+ // Check if this folder was previously expanded
1076
+ const wasExpanded = expandedPaths.has(item.path);
1077
+
1078
+ // Create the clickable line element
1079
+ const lineEl = document.createElement('div');
1080
+ lineEl.className = 'file-tree-line';
1081
+ lineEl.draggable = true;
1082
+
1083
+ // Only folders get an icon (arrow), files get empty icon
1084
+ const icon = item.type === 'folder' ? (wasExpanded ? 'β–Ό' : 'β–Ά') : '';
1085
+ const actionBtn = item.type === 'file'
1086
+ ? '<button class="file-tree-action-btn file-download-btn" title="Download">↓</button>'
1087
+ : '<button class="file-tree-action-btn file-upload-btn" title="Upload file here">+</button>';
1088
+ lineEl.innerHTML = `
1089
+ <span class="file-tree-icon">${icon}</span>
1090
+ <span class="file-tree-name">${item.name}</span>
1091
+ <span class="file-tree-actions">${actionBtn}</span>
1092
+ `;
1093
+ itemEl.appendChild(lineEl);
1094
+
1095
+ // Download button (files)
1096
+ const downloadBtn = lineEl.querySelector('.file-download-btn');
1097
+ if (downloadBtn) {
1098
+ downloadBtn.addEventListener('click', (e) => {
1099
+ e.stopPropagation();
1100
+ window.open(`/api/files/download?path=${encodeURIComponent(item.path)}${SESSION_ID ? '&session_id=' + encodeURIComponent(SESSION_ID) : ''}`, '_blank');
1101
+ });
1102
+ }
1103
+
1104
+ // Upload button (folders)
1105
+ const uploadBtn = lineEl.querySelector('.file-upload-btn');
1106
+ if (uploadBtn) {
1107
+ uploadBtn.addEventListener('click', (e) => {
1108
+ e.stopPropagation();
1109
+ const input = document.createElement('input');
1110
+ input.type = 'file';
1111
+ input.addEventListener('change', async () => {
1112
+ if (!input.files.length) return;
1113
+ const formData = new FormData();
1114
+ formData.append('file', input.files[0]);
1115
+ try {
1116
+ await apiFetch(`/api/files/upload?folder=${encodeURIComponent(item.path)}`, {
1117
+ method: 'POST',
1118
+ body: formData
1119
+ });
1120
+ loadFileTree();
1121
+ } catch (err) {
1122
+ console.error('Upload failed:', err);
1123
+ }
1124
+ });
1125
+ input.click();
1126
+ });
1127
+ }
1128
+
1129
+ container.appendChild(itemEl);
1130
+
1131
+ // Handle folder expansion
1132
+ if (item.type === 'folder' && item.children && item.children.length > 0) {
1133
+ const childrenContainer = document.createElement('div');
1134
+ childrenContainer.className = 'file-tree-children';
1135
+ if (wasExpanded) {
1136
+ childrenContainer.classList.add('expanded');
1137
+ itemEl.classList.add('expanded');
1138
+ }
1139
+ renderTreeItems(item.children, childrenContainer);
1140
+ itemEl.appendChild(childrenContainer);
1141
+
1142
+ // Use click delay to distinguish single vs double click
1143
+ let clickTimer = null;
1144
+ lineEl.addEventListener('click', (e) => {
1145
+ e.stopPropagation();
1146
+ if (clickTimer) {
1147
+ // Double click detected - clear timer and expand/collapse
1148
+ clearTimeout(clickTimer);
1149
+ clickTimer = null;
1150
+ const isExpanded = itemEl.classList.toggle('expanded');
1151
+ childrenContainer.classList.toggle('expanded');
1152
+ const iconEl = lineEl.querySelector('.file-tree-icon');
1153
+ if (iconEl) iconEl.textContent = isExpanded ? 'β–Ό' : 'β–Ά';
1154
+ if (isExpanded) {
1155
+ expandedPaths.add(item.path);
1156
+ } else {
1157
+ expandedPaths.delete(item.path);
1158
+ }
1159
+ } else {
1160
+ // Single click - wait to see if it's a double click
1161
+ clickTimer = setTimeout(() => {
1162
+ clickTimer = null;
1163
+ insertPathIntoInput('./' + item.path);
1164
+ showClickFeedback(lineEl);
1165
+ }, 250);
1166
+ }
1167
+ });
1168
+ } else if (item.type === 'file') {
1169
+ // Single click on file inserts path
1170
+ lineEl.addEventListener('click', (e) => {
1171
+ e.stopPropagation();
1172
+ insertPathIntoInput('./' + item.path);
1173
+ showClickFeedback(lineEl);
1174
+ });
1175
+ }
1176
+
1177
+ // Drag start handler for future drag-and-drop
1178
+ lineEl.addEventListener('dragstart', (e) => {
1179
+ e.dataTransfer.setData('text/plain', './' + item.path);
1180
+ e.dataTransfer.setData('application/x-file-path', './' + item.path);
1181
+ e.dataTransfer.effectAllowed = 'copy';
1182
+ });
1183
+ }
1184
+ }
1185
+
1186
+ // Helper to insert path into active input
1187
+ function insertPathIntoInput(path) {
1188
+ const inputId = activeTabId === 0 ? 'input-command' : `input-${activeTabId}`;
1189
+ const inputEl = document.getElementById(inputId);
1190
+ if (inputEl) {
1191
+ const start = inputEl.selectionStart;
1192
+ const end = inputEl.selectionEnd;
1193
+ const text = inputEl.value;
1194
+ // Wrap path in backticks and add trailing space
1195
+ const formattedPath = '`' + path + '` ';
1196
+ inputEl.value = text.substring(0, start) + formattedPath + text.substring(end);
1197
+ inputEl.focus();
1198
+ inputEl.selectionStart = inputEl.selectionEnd = start + formattedPath.length;
1199
+ }
1200
+ }
1201
+
1202
+ // Linkify inline code elements that match existing file paths
1203
+ async function linkifyFilePaths(container) {
1204
+ // Find all inline <code> elements (not inside <pre>)
1205
+ const codeEls = [...container.querySelectorAll('code')].filter(c => !c.closest('pre'));
1206
+ if (codeEls.length === 0) return;
1207
+
1208
+ // Collect candidate paths (must look like a file path)
1209
+ const candidates = new Map(); // normalized path -> code element(s)
1210
+ for (const code of codeEls) {
1211
+ const text = code.textContent.trim();
1212
+ if (!text || text.includes(' ') || text.length > 200) continue;
1213
+ // Must contain a dot (extension) or slash (directory)
1214
+ if (!text.includes('.') && !text.includes('/')) continue;
1215
+ const normalized = text.replace(/^\.\//, '');
1216
+ if (!candidates.has(normalized)) candidates.set(normalized, []);
1217
+ candidates.get(normalized).push(code);
1218
+ }
1219
+ if (candidates.size === 0) return;
1220
+
1221
+ // Check which paths exist on the server
1222
+ try {
1223
+ const resp = await apiFetch('/api/files/check', {
1224
+ method: 'POST',
1225
+ headers: { 'Content-Type': 'application/json' },
1226
+ body: JSON.stringify({ paths: [...candidates.keys()] })
1227
+ });
1228
+ if (!resp.ok) return;
1229
+ const { existing } = await resp.json();
1230
+
1231
+ for (const path of existing) {
1232
+ for (const code of candidates.get(path) || []) {
1233
+ if (code.closest('.file-path-link')) continue; // already linked
1234
+ const link = document.createElement('a');
1235
+ link.className = 'file-path-link';
1236
+ link.href = '#';
1237
+ link.title = 'Open in file explorer';
1238
+ link.addEventListener('click', (e) => {
1239
+ e.preventDefault();
1240
+ navigateToFileInExplorer(path);
1241
+ });
1242
+ code.parentNode.insertBefore(link, code);
1243
+ link.appendChild(code);
1244
+ }
1245
+ }
1246
+ } catch (e) {
1247
+ // Silently fail β€” linkification is a nice-to-have
1248
+ }
1249
+ }
1250
+
1251
+ // Helper to show click feedback
1252
+ function showClickFeedback(el) {
1253
+ const originalColor = el.style.color;
1254
+ el.style.color = 'var(--theme-accent)';
1255
+ setTimeout(() => {
1256
+ el.style.color = originalColor;
1257
+ }, 300);
1258
+ }
1259
+
1260
+ // Navigate to a file in the file explorer and highlight it
1261
+ function navigateToFileInExplorer(path) {
1262
+ let relPath = path.replace(/^\.\//, '');
1263
+
1264
+ // Open files panel if not already open
1265
+ if (!filesPanel.classList.contains('active')) {
1266
+ filesBtn.click();
1267
+ }
1268
+
1269
+ // Wait for tree to render, then expand parents and highlight
1270
+ setTimeout(() => {
1271
+ const segments = relPath.split('/');
1272
+ let currentPath = '';
1273
+ for (let i = 0; i < segments.length - 1; i++) {
1274
+ currentPath += (i > 0 ? '/' : '') + segments[i];
1275
+ const folderItem = fileTree.querySelector(`.file-tree-item[data-path="${currentPath}"]`);
1276
+ if (folderItem && !folderItem.classList.contains('expanded')) {
1277
+ folderItem.classList.add('expanded');
1278
+ const children = folderItem.querySelector('.file-tree-children');
1279
+ if (children) children.classList.add('expanded');
1280
+ const icon = folderItem.querySelector('.file-tree-icon');
1281
+ if (icon) icon.textContent = 'β–Ό';
1282
+ expandedPaths.add(currentPath);
1283
+ }
1284
+ }
1285
+ const targetItem = fileTree.querySelector(`.file-tree-item[data-path="${relPath}"]`);
1286
+ if (targetItem) {
1287
+ targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
1288
+ const line = targetItem.querySelector('.file-tree-line');
1289
+ if (line) {
1290
+ line.classList.add('file-tree-highlight');
1291
+ setTimeout(() => line.classList.remove('file-tree-highlight'), 2000);
1292
+ }
1293
+ }
1294
+ }, 500);
1295
+ }
1296
+
1297
+ // Open files panel when FILES button is clicked
1298
+ if (filesBtn) {
1299
+ filesBtn.addEventListener('click', () => {
1300
+ const isOpening = !filesPanel.classList.contains('active');
1301
+ closeAllPanels();
1302
+
1303
+ if (isOpening) {
1304
+ filesPanel.classList.add('active');
1305
+ filesBtn.classList.add('active');
1306
+ appContainer.classList.add('files-panel-open');
1307
+ loadFileTree();
1308
+ }
1309
+ });
1310
+ }
1311
+
1312
+ // Close files panel
1313
+ if (filesPanelClose) {
1314
+ filesPanelClose.addEventListener('click', () => {
1315
+ filesPanel.classList.remove('active');
1316
+ filesBtn.classList.remove('active');
1317
+ appContainer.classList.remove('files-panel-open');
1318
+ });
1319
+ }
1320
+
1321
+ // Refresh button
1322
+ if (filesRefresh) {
1323
+ filesRefresh.addEventListener('click', () => {
1324
+ loadFileTree();
1325
+ });
1326
+ }
1327
+
1328
+ // Upload to root directory
1329
+ if (filesUpload) {
1330
+ filesUpload.addEventListener('click', () => {
1331
+ const input = document.createElement('input');
1332
+ input.type = 'file';
1333
+ input.addEventListener('change', async () => {
1334
+ if (!input.files.length) return;
1335
+ const formData = new FormData();
1336
+ formData.append('file', input.files[0]);
1337
+ try {
1338
+ await apiFetch('/api/files/upload?folder=', { method: 'POST', body: formData });
1339
+ loadFileTree();
1340
+ } catch (err) {
1341
+ console.error('Upload failed:', err);
1342
+ }
1343
+ });
1344
+ input.click();
1345
+ });
1346
+ }
1347
+
1348
+ // Show hidden files toggle
1349
+ if (showHiddenFiles) {
1350
+ showHiddenFiles.addEventListener('change', () => {
1351
+ loadFileTree();
1352
+ });
1353
+ }
1354
+
1355
+ // Drag & drop upload on files panel
1356
+ if (fileTree) {
1357
+ let dragOverFolder = null;
1358
+
1359
+ fileTree.addEventListener('dragover', (e) => {
1360
+ // Only handle external file drops (not internal path drags)
1361
+ if (!e.dataTransfer.types.includes('Files')) return;
1362
+ e.preventDefault();
1363
+ e.dataTransfer.dropEffect = 'copy';
1364
+
1365
+ // Find folder under cursor
1366
+ const folderItem = e.target.closest('.file-tree-item.folder');
1367
+ if (folderItem) {
1368
+ if (dragOverFolder !== folderItem) {
1369
+ if (dragOverFolder) dragOverFolder.classList.remove('drag-over');
1370
+ fileTree.classList.remove('drag-over-root');
1371
+ folderItem.classList.add('drag-over');
1372
+ dragOverFolder = folderItem;
1373
+ }
1374
+ } else {
1375
+ if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
1376
+ fileTree.classList.add('drag-over-root');
1377
+ }
1378
+ });
1379
+
1380
+ fileTree.addEventListener('dragleave', (e) => {
1381
+ // Only clear when leaving the fileTree entirely
1382
+ if (!fileTree.contains(e.relatedTarget)) {
1383
+ if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
1384
+ fileTree.classList.remove('drag-over-root');
1385
+ }
1386
+ });
1387
+
1388
+ fileTree.addEventListener('drop', async (e) => {
1389
+ if (!e.dataTransfer.files.length) return;
1390
+ e.preventDefault();
1391
+
1392
+ // Determine target folder
1393
+ const folderItem = e.target.closest('.file-tree-item.folder');
1394
+ const folder = folderItem ? folderItem.dataset.path : '';
1395
+
1396
+ // Clear highlights
1397
+ if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
1398
+ fileTree.classList.remove('drag-over-root');
1399
+
1400
+ // Upload all files
1401
+ for (const file of e.dataTransfer.files) {
1402
+ const formData = new FormData();
1403
+ formData.append('file', file);
1404
+ try {
1405
+ await apiFetch(`/api/files/upload?folder=${encodeURIComponent(folder)}`, { method: 'POST', body: formData });
1406
+ } catch (err) {
1407
+ console.error('Upload failed:', err);
1408
+ }
1409
+ }
1410
+ loadFileTree();
1411
+ });
1412
+ }
1413
+
1414
+ // Sessions panel (same pattern as Files/Settings/Debug panels)
1415
+ const sessionsPanel = document.getElementById('sessionsPanel');
1416
+ const sessionsPanelClose = document.getElementById('sessionsPanelClose');
1417
+ const sessionsBtn = document.getElementById('sessionsBtn');
1418
+
1419
+ if (sessionsBtn && sessionsPanel) {
1420
+ sessionsBtn.addEventListener('click', () => {
1421
+ const isOpening = !sessionsPanel.classList.contains('active');
1422
+ closeAllPanels();
1423
+
1424
+ if (isOpening) {
1425
+ sessionsPanel.classList.add('active');
1426
+ sessionsBtn.classList.add('active');
1427
+ appContainer.classList.add('sessions-panel-open');
1428
+ refreshSessionsList();
1429
+ }
1430
+ });
1431
+ }
1432
+
1433
+ if (sessionsPanelClose) {
1434
+ sessionsPanelClose.addEventListener('click', () => {
1435
+ sessionsPanel.classList.remove('active');
1436
+ sessionsBtn.classList.remove('active');
1437
+ appContainer.classList.remove('sessions-panel-open');
1438
+ });
1439
+ }
frontend/streaming.js ADDED
@@ -0,0 +1,1279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ async function streamChatResponse(messages, chatContainer, agentType, tabId) {
2
+ const currentSettings = getSettings();
3
+ const backendEndpoint = '/api';
4
+
5
+ // Resolve model configuration for this agent type
6
+ let modelConfig = resolveModelConfig(agentType);
7
+ if (!modelConfig) {
8
+ modelConfig = getDefaultModelConfig();
9
+ }
10
+
11
+ if (!modelConfig) {
12
+ // No models configured - show error
13
+ const errorDiv = document.createElement('div');
14
+ errorDiv.className = 'message assistant error';
15
+ errorDiv.innerHTML = `<div class="message-content">No models configured. Please open Settings and add a provider and model.</div>`;
16
+ chatContainer.appendChild(errorDiv);
17
+ return;
18
+ }
19
+
20
+ // Resolve research sub-agent model if configured
21
+ let researchSubAgentConfig = null;
22
+ if (currentSettings.researchSubAgentModel) {
23
+ const subAgentModel = currentSettings.models?.[currentSettings.researchSubAgentModel];
24
+ if (subAgentModel) {
25
+ const subAgentProvider = currentSettings.providers?.[subAgentModel.providerId];
26
+ if (subAgentProvider) {
27
+ researchSubAgentConfig = {
28
+ endpoint: subAgentProvider.endpoint,
29
+ token: subAgentProvider.token,
30
+ model: subAgentModel.modelId
31
+ };
32
+ }
33
+ }
34
+ }
35
+
36
+ // Resolve image model selections to HF model ID strings
37
+ const imageGenModelId = currentSettings.imageGenModel
38
+ ? currentSettings.models?.[currentSettings.imageGenModel]?.modelId || null
39
+ : null;
40
+ const imageEditModelId = currentSettings.imageEditModel
41
+ ? currentSettings.models?.[currentSettings.imageEditModel]?.modelId || null
42
+ : null;
43
+
44
+ // Set up AbortController for this request
45
+ const abortController = new AbortController();
46
+ if (tabId !== undefined) {
47
+ activeAbortControllers[tabId] = abortController;
48
+ }
49
+
50
+ try {
51
+ // Determine parent agent ID for abort propagation
52
+ const parentAgentId = timelineData[tabId]?.parentTabId != null
53
+ ? timelineData[tabId].parentTabId.toString()
54
+ : null;
55
+
56
+ const response = await apiFetch(`${backendEndpoint}/chat/stream`, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ signal: abortController.signal,
60
+ body: JSON.stringify({
61
+ messages: messages,
62
+ agent_type: agentType,
63
+ stream: true,
64
+ endpoint: modelConfig.endpoint,
65
+ token: modelConfig.token || null,
66
+ model: modelConfig.model,
67
+ extra_params: modelConfig.extraParams || null,
68
+ multimodal: modelConfig.multimodal || false,
69
+ e2b_key: currentSettings.e2bKey || null,
70
+ serper_key: currentSettings.serperKey || null,
71
+ hf_token: currentSettings.hfToken || null,
72
+ image_gen_model: imageGenModelId,
73
+ image_edit_model: imageEditModelId,
74
+ research_sub_agent_model: researchSubAgentConfig?.model || null,
75
+ research_sub_agent_endpoint: researchSubAgentConfig?.endpoint || null,
76
+ research_sub_agent_token: researchSubAgentConfig?.token || null,
77
+ research_sub_agent_extra_params: researchSubAgentConfig?.extraParams || null,
78
+ research_parallel_workers: currentSettings.researchParallelWorkers || null,
79
+ research_max_websites: currentSettings.researchMaxWebsites || null,
80
+ agent_id: tabId.toString(), // Send unique tab ID for sandbox sessions
81
+ parent_agent_id: parentAgentId, // Parent agent ID for abort propagation
82
+ frontend_context: getFrontendContext() // Dynamic context for system prompts
83
+ })
84
+ });
85
+
86
+ if (!response.ok) {
87
+ throw new Error(`HTTP error! status: ${response.status}`);
88
+ }
89
+
90
+ const reader = response.body.getReader();
91
+ const decoder = new TextDecoder();
92
+ let buffer = '';
93
+ let fullResponse = '';
94
+ let currentMessageEl = null;
95
+ let progressHidden = false;
96
+
97
+ // Flush any accumulated assistant text to the timeline
98
+ // Call before adding tool/code/report timeline events to preserve ordering
99
+ function flushResponseToTimeline() {
100
+ if (fullResponse && tabId !== undefined) {
101
+ const evIdx = addTimelineEvent(tabId, 'assistant', fullResponse);
102
+ if (currentMessageEl) currentMessageEl.dataset.timelineIndex = evIdx;
103
+ fullResponse = '';
104
+ }
105
+ }
106
+
107
+ while (true) {
108
+ const { done, value } = await reader.read();
109
+ if (done) break;
110
+
111
+ buffer += decoder.decode(value, { stream: true });
112
+ const lines = buffer.split('\n');
113
+ buffer = lines.pop() || '';
114
+
115
+ for (const line of lines) {
116
+ if (line.startsWith('data: ')) {
117
+ const data = JSON.parse(line.slice(6));
118
+
119
+ // Hide progress widget on first meaningful response
120
+ if (!progressHidden && data.type !== 'generating' && data.type !== 'retry' && !data.type.startsWith('debug_')) {
121
+ hideProgressWidget(chatContainer);
122
+ progressHidden = true;
123
+ }
124
+
125
+ // Handle different message types from backend
126
+ if (data.type === 'thinking') {
127
+ // Assistant thinking - create message if not exists
128
+ if (!currentMessageEl) {
129
+ currentMessageEl = createAssistantMessage(chatContainer);
130
+ }
131
+ fullResponse += data.content;
132
+ appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse)));
133
+ scrollChatToBottom(chatContainer);
134
+
135
+ } else if (data.type === 'code') {
136
+ // Code execution result - update the last code cell
137
+ updateLastCodeCell(chatContainer, data.output, data.error, data.images);
138
+ currentMessageEl = null; // Reset for next thinking
139
+ scrollChatToBottom(chatContainer);
140
+
141
+ } else if (data.type === 'code_start') {
142
+ // Code cell starting execution - show with spinner
143
+ flushResponseToTimeline();
144
+ createCodeCell(chatContainer, data.code, null, false, true);
145
+ currentMessageEl = null;
146
+ scrollChatToBottom(chatContainer);
147
+ // Add to timeline - show code snippet preview
148
+ const codePreview = data.code ? data.code.split('\n')[0] : 'code execution';
149
+ const codeEvIdx = addTimelineEvent(tabId, 'assistant', codePreview, null, { tag: 'CODE' });
150
+ chatContainer.lastElementChild.dataset.timelineIndex = codeEvIdx;
151
+
152
+ } else if (data.type === 'upload') {
153
+ // File upload notification
154
+ flushResponseToTimeline();
155
+ createUploadMessage(chatContainer, data.paths, data.output);
156
+ currentMessageEl = null;
157
+ scrollChatToBottom(chatContainer);
158
+ // Add to timeline
159
+ const upEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'UPLOAD' });
160
+ chatContainer.lastElementChild.dataset.timelineIndex = upEvIdx;
161
+
162
+ } else if (data.type === 'download') {
163
+ // File download notification
164
+ flushResponseToTimeline();
165
+ createDownloadMessage(chatContainer, data.paths, data.output);
166
+ currentMessageEl = null;
167
+ scrollChatToBottom(chatContainer);
168
+ // Add to timeline
169
+ const dlEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'DOWNLOAD' });
170
+ chatContainer.lastElementChild.dataset.timelineIndex = dlEvIdx;
171
+
172
+ } else if (data.type === 'generating') {
173
+ // Still generating - no action needed
174
+
175
+ } else if (data.type === 'result') {
176
+ // Populate global figure/image registry only for items referenced in result content
177
+ const resultText = data.content || '';
178
+ if (data.figures) {
179
+ for (const [name, figData] of Object.entries(data.figures)) {
180
+ if (new RegExp(`</?${name}>`, 'i').test(resultText)) {
181
+ globalFigureRegistry[name] = figData;
182
+ }
183
+ }
184
+ }
185
+ if (data.images) {
186
+ for (const [name, imgBase64] of Object.entries(data.images)) {
187
+ if (new RegExp(`</?${name}>`, 'i').test(resultText)) {
188
+ globalFigureRegistry[name] = { type: 'png', data: imgBase64 };
189
+ }
190
+ }
191
+ }
192
+ // Agent result - update command center widget
193
+ updateActionWidgetWithResult(tabId, data.content, data.figures, data.images);
194
+
195
+ } else if (data.type === 'result_preview') {
196
+ // Show result preview
197
+ flushResponseToTimeline();
198
+ // Replace <figure_x> tags with placeholders BEFORE markdown processing
199
+ let previewContent = data.content;
200
+ const figurePlaceholders = {};
201
+
202
+ if (data.figures) {
203
+ for (const [figureName, figureData] of Object.entries(data.figures)) {
204
+ // Use %%% delimiters to avoid markdown interpretation
205
+ const placeholderId = `%%%FIGURE_${figureName}%%%`;
206
+ figurePlaceholders[placeholderId] = figureData;
207
+
208
+ // Handle both <figure_x> self-closing and <figure_x></figure_x> pairs
209
+ // First replace paired tags, preserving them as block elements
210
+ const pairedTag = new RegExp(`<${figureName}></${figureName}>`, 'gi');
211
+ previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
212
+
213
+ // Then replace remaining self-closing tags or orphaned closing tags
214
+ const singleTag = new RegExp(`</?${figureName}>`, 'gi');
215
+ previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
216
+ }
217
+ }
218
+
219
+ // Handle <image_N> references from image agent
220
+ if (data.images) {
221
+ for (const [imageName, imageBase64] of Object.entries(data.images)) {
222
+ const placeholderId = `%%%IMAGE_${imageName}%%%`;
223
+ figurePlaceholders[placeholderId] = { type: 'png', data: imageBase64, isGenerated: true };
224
+
225
+ const pairedTag = new RegExp(`<${imageName}></${imageName}>`, 'gi');
226
+ previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
227
+ const singleTag = new RegExp(`</?${imageName}>`, 'gi');
228
+ previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
229
+ }
230
+ }
231
+
232
+ // Process markdown
233
+ let html = parseMarkdown(previewContent);
234
+
235
+ // Replace placeholders with actual images AFTER markdown processing
236
+ for (const [placeholderId, figureData] of Object.entries(figurePlaceholders)) {
237
+ let imageHtml = '';
238
+ if (figureData.type === 'png' || figureData.type === 'jpeg') {
239
+ imageHtml = `<img src="data:image/${figureData.type};base64,${figureData.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
240
+ } else if (figureData.type === 'svg') {
241
+ imageHtml = `<div style="margin: 12px 0;">${atob(figureData.data)}</div>`;
242
+ }
243
+ // Replace both the placeholder and any paragraph-wrapped version
244
+ html = html.replace(new RegExp(`<p>${placeholderId}</p>`, 'g'), imageHtml);
245
+ html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
246
+ }
247
+
248
+ // Create result block
249
+ const resultDiv = document.createElement('div');
250
+ resultDiv.className = 'agent-result';
251
+ resultDiv.innerHTML = `
252
+ <div class="result-header">Result</div>
253
+ <div class="result-content">${html}</div>
254
+ `;
255
+ chatContainer.appendChild(resultDiv);
256
+ scrollChatToBottom(chatContainer);
257
+ // Add result dot to parent research timeline
258
+ const figCount = data.figures ? Object.keys(data.figures).length : 0;
259
+ const reportDesc = figCount > 0 ? `${figCount} figures` : (data.content?.replace(/<[^>]+>/g, '').trim().substring(0, 60) || 'done');
260
+ const resEvIdx = addTimelineEvent(tabId, 'assistant', reportDesc, null, { tag: 'RESULT' });
261
+ resultDiv.dataset.timelineIndex = resEvIdx;
262
+
263
+ } else if (data.type === 'status') {
264
+ // Research status update
265
+ createStatusMessage(chatContainer, data.message, data.iteration, data.total_iterations);
266
+ scrollChatToBottom(chatContainer);
267
+
268
+ } else if (data.type === 'queries') {
269
+ // Research queries generated
270
+ createQueriesMessage(chatContainer, data.queries, data.iteration);
271
+ scrollChatToBottom(chatContainer);
272
+ // Register each query as a virtual sub-agent in the timeline
273
+ const startIdx = Object.keys(researchQueryTabIds).length;
274
+ for (let qi = 0; qi < data.queries.length; qi++) {
275
+ const globalIdx = startIdx + qi;
276
+ const virtualId = `research-${tabId}-q${globalIdx}`;
277
+ researchQueryTabIds[globalIdx] = virtualId;
278
+ registerAgentInTimeline(virtualId, 'search', data.queries[qi], tabId);
279
+ setTimelineGenerating(virtualId, true);
280
+ }
281
+
282
+ } else if (data.type === 'progress') {
283
+ // Research progress
284
+ updateProgress(chatContainer, data.message, data.websites_visited, data.max_websites);
285
+ scrollChatToBottom(chatContainer);
286
+
287
+ } else if (data.type === 'source') {
288
+ // Research source found - now includes query grouping
289
+ createSourceMessage(chatContainer, data);
290
+ scrollChatToBottom(chatContainer);
291
+ // Add source as dot in virtual sub-agent timeline
292
+ const sourceVirtualId = researchQueryTabIds[data.query_index];
293
+ if (sourceVirtualId) {
294
+ addTimelineEvent(sourceVirtualId, 'assistant', data.title || data.url || 'source');
295
+ } else if (data.query_index === -1) {
296
+ // Browse result β€” create a virtual browse entry if needed
297
+ const browseId = `research-${tabId}-browse-${Date.now()}`;
298
+ registerAgentInTimeline(browseId, 'browse', data.url || 'webpage', tabId);
299
+ addTimelineEvent(browseId, 'assistant', data.title || data.url || 'page');
300
+ setTimelineGenerating(browseId, false);
301
+ }
302
+
303
+ } else if (data.type === 'query_stats') {
304
+ // Update query statistics
305
+ updateQueryStats(data.query_index, {
306
+ relevant: data.relevant_count,
307
+ irrelevant: data.irrelevant_count,
308
+ error: data.error_count
309
+ });
310
+ // Mark search sub-agent as done
311
+ const statsVirtualId = researchQueryTabIds[data.query_index];
312
+ if (statsVirtualId) {
313
+ setTimelineGenerating(statsVirtualId, false);
314
+ }
315
+
316
+ } else if (data.type === 'assessment') {
317
+ // Research completeness assessment
318
+ createAssessmentMessage(chatContainer, data.sufficient, data.missing_aspects, data.findings_count, data.reasoning);
319
+ scrollChatToBottom(chatContainer);
320
+
321
+ } else if (data.type === 'report') {
322
+ // Final research report
323
+ flushResponseToTimeline();
324
+ createReportMessage(chatContainer, data.content, data.sources_count, data.websites_visited);
325
+ scrollChatToBottom(chatContainer);
326
+ // Add to timeline
327
+ const rptEvIdx = addTimelineEvent(tabId, 'assistant', `${data.sources_count || 0} sources, ${data.websites_visited || 0} sites`, null, { tag: 'RESULT' });
328
+ chatContainer.lastElementChild.dataset.timelineIndex = rptEvIdx;
329
+
330
+ } else if (data.type === 'tool_start') {
331
+ // Agent tool execution starting β€” create a tool-cell box (like code cells)
332
+ flushResponseToTimeline();
333
+ currentMessageEl = null;
334
+
335
+ const toolLabels = {
336
+ 'web_search': 'SEARCH',
337
+ 'read_url': 'READ',
338
+ 'screenshot_url': 'SCREENSHOT',
339
+ 'generate_image': 'GENERATE',
340
+ 'edit_image': 'EDIT',
341
+ 'read_image_url': 'LOAD IMAGE',
342
+ 'read_image': 'LOAD IMAGE',
343
+ 'show_html': 'HTML'
344
+ };
345
+ const toolDescriptions = {
346
+ 'web_search': data.args?.query || '',
347
+ 'read_url': data.args?.url || '',
348
+ 'screenshot_url': data.args?.url || '',
349
+ 'generate_image': data.args?.prompt || '',
350
+ 'edit_image': `${data.args?.prompt || ''} (from ${data.args?.source || ''})`,
351
+ 'read_image_url': data.args?.url || '',
352
+ 'read_image': data.args?.source || '',
353
+ 'show_html': data.args?.source?.substring(0, 80) || ''
354
+ };
355
+ const label = toolLabels[data.tool] || data.tool.toUpperCase();
356
+ const description = toolDescriptions[data.tool] || '';
357
+
358
+ // Store tool call in DOM for history reconstruction
359
+ // Reuse currentMessageEl (from thinking) if it exists, like launch events do
360
+ let toolCallMsg = currentMessageEl;
361
+ if (!toolCallMsg) {
362
+ toolCallMsg = document.createElement('div');
363
+ toolCallMsg.className = 'message assistant';
364
+ toolCallMsg.style.display = 'none';
365
+ chatContainer.appendChild(toolCallMsg);
366
+ }
367
+ toolCallMsg.setAttribute('data-tool-call', JSON.stringify({
368
+ tool_call_id: data.tool_call_id,
369
+ function_name: data.tool,
370
+ arguments: data.arguments,
371
+ thinking: data.thinking || ''
372
+ }));
373
+
374
+ // Create tool-cell box (similar to code-cell)
375
+ const toolCell = document.createElement('div');
376
+ toolCell.className = 'tool-cell';
377
+ if (document.getElementById('collapseToolsCheckbox')?.checked) {
378
+ toolCell.classList.add('collapsed');
379
+ }
380
+ toolCell.setAttribute('data-tool-name', data.tool);
381
+ const descHtml = description ? `<span class="tool-cell-desc">${escapeHtml(description)}</span>` : '';
382
+ toolCell.innerHTML = `
383
+ <div class="tool-cell-label"><div class="widget-collapse-toggle${document.getElementById('collapseToolsCheckbox')?.checked ? ' collapsed' : ''}"></div><span>${label}</span>${descHtml}${createSpinnerHtml()}</div>
384
+ <div class="tool-cell-input">${escapeHtml(description)}</div>
385
+ `;
386
+ setupCollapseToggle(toolCell, '.tool-cell-label');
387
+ chatContainer.appendChild(toolCell);
388
+ scrollChatToBottom(chatContainer);
389
+ const toolEvIdx = addTimelineEvent(tabId, 'assistant', description, null, { tag: label });
390
+ toolCell.dataset.timelineIndex = toolEvIdx;
391
+
392
+ } else if (data.type === 'tool_result') {
393
+ // Agent tool result β€” populate the last tool-cell with output
394
+ const lastToolCell = chatContainer.querySelector('.tool-cell:last-of-type');
395
+
396
+ // Remove spinner
397
+ if (lastToolCell) {
398
+ const spinner = lastToolCell.querySelector('.tool-spinner');
399
+ if (spinner) spinner.remove();
400
+ }
401
+
402
+ // Store tool response in DOM for history reconstruction
403
+ const toolResponseMsg = document.createElement('div');
404
+ toolResponseMsg.className = 'message tool';
405
+ toolResponseMsg.style.display = 'none';
406
+ toolResponseMsg.setAttribute('data-tool-response', JSON.stringify({
407
+ tool_call_id: data.tool_call_id,
408
+ content: data.response || ''
409
+ }));
410
+ chatContainer.appendChild(toolResponseMsg);
411
+
412
+ // Build output HTML based on tool type
413
+ let outputHtml = '';
414
+
415
+ if (data.tool === 'web_search' && data.result?.results) {
416
+ try {
417
+ const results = typeof data.result.results === 'string' ? JSON.parse(data.result.results) : data.result.results;
418
+ if (Array.isArray(results)) {
419
+ outputHtml = '<div class="search-results-content">' +
420
+ results.map(r =>
421
+ `<div class="search-result-item"><a href="${escapeHtml(r.url)}" target="_blank">${escapeHtml(r.title)}</a><span class="search-snippet">${escapeHtml(r.snippet)}</span></div>`
422
+ ).join('') + '</div>';
423
+ }
424
+ } catch(e) { /* ignore parse errors */ }
425
+ } else if (data.tool === 'read_url') {
426
+ const len = data.result?.length || 0;
427
+ const markdown = data.result?.markdown || '';
428
+ const summaryText = len > 0 ? `Extracted ${(len / 1000).toFixed(1)}k chars` : 'No content extracted';
429
+ if (markdown) {
430
+ const toggleId = `read-content-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
431
+ outputHtml = `<div class="tool-cell-read-summary">${summaryText} <button class="read-content-toggle" onclick="const el=document.getElementById('${toggleId}');el.classList.toggle('expanded');this.textContent=el.classList.contains('expanded')?'Hide':'Show content'">Show content</button></div><div id="${toggleId}" class="read-content-body">${parseMarkdown(markdown)}</div>`;
432
+ } else {
433
+ outputHtml = `<div class="tool-cell-read-summary">${summaryText}</div>`;
434
+ }
435
+ } else if (data.tool === 'screenshot_url' && data.image) {
436
+ outputHtml = `<img src="data:image/png;base64,${data.image}" alt="Screenshot" class="screenshot-img" />`;
437
+ } else if ((data.tool === 'generate_image' || data.tool === 'edit_image' || data.tool === 'read_image_url' || data.tool === 'read_image') && data.image) {
438
+ const imgName = data.image_name || 'image';
439
+ outputHtml = `<img src="data:image/png;base64,${data.image}" alt="${escapeHtml(imgName)}" class="generated-img" />`;
440
+ } else if ((data.tool === 'generate_image' || data.tool === 'edit_image' || data.tool === 'read_image_url' || data.tool === 'read_image') && !data.image) {
441
+ const errMsg = data.response || 'Failed to process image';
442
+ outputHtml = `<div class="tool-cell-read-summary">${escapeHtml(errMsg)}</div>`;
443
+ } else if (data.tool === 'show_html' && data.result?.html) {
444
+ // Create iframe programmatically to avoid escaping issues with srcdoc
445
+ if (lastToolCell) {
446
+ const outputEl = document.createElement('div');
447
+ outputEl.className = 'tool-cell-output';
448
+ const iframe = document.createElement('iframe');
449
+ iframe.className = 'show-html-iframe';
450
+ iframe.sandbox = 'allow-scripts allow-same-origin';
451
+ iframe.srcdoc = data.result.html;
452
+ outputEl.appendChild(iframe);
453
+ lastToolCell.appendChild(outputEl);
454
+ }
455
+ } else if (data.tool === 'show_html' && !data.result?.html) {
456
+ const errMsg = data.response || 'Failed to load HTML';
457
+ outputHtml = `<div class="tool-cell-read-summary">${escapeHtml(errMsg)}</div>`;
458
+ }
459
+
460
+ if (outputHtml && lastToolCell) {
461
+ const outputEl = document.createElement('div');
462
+ outputEl.className = 'tool-cell-output';
463
+ outputEl.innerHTML = outputHtml;
464
+ lastToolCell.appendChild(outputEl);
465
+ }
466
+
467
+ scrollChatToBottom(chatContainer);
468
+
469
+ } else if (data.type === 'content') {
470
+ // Regular streaming content (non-code agents)
471
+ if (!currentMessageEl) {
472
+ currentMessageEl = createAssistantMessage(chatContainer);
473
+ }
474
+ fullResponse += data.content;
475
+ appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse)));
476
+ scrollChatToBottom(chatContainer);
477
+
478
+ } else if (data.type === 'launch') {
479
+ // Tool-based agent launch from command center
480
+ pendingAgentLaunches++;
481
+ const agentType = data.agent_type;
482
+ const initialMessage = data.initial_message;
483
+ const taskId = data.task_id;
484
+ const toolCallId = data.tool_call_id;
485
+
486
+ // Add tool call data to existing message (from thinking) or create new one
487
+ // This keeps content + tool_calls together for proper history reconstruction
488
+ let toolCallMsg = currentMessageEl;
489
+ if (!toolCallMsg) {
490
+ toolCallMsg = document.createElement('div');
491
+ toolCallMsg.className = 'message assistant';
492
+ toolCallMsg.innerHTML = '<div class="message-content"></div>';
493
+ chatContainer.appendChild(toolCallMsg);
494
+ }
495
+ toolCallMsg.setAttribute('data-tool-call', JSON.stringify({
496
+ agent_type: agentType,
497
+ message: initialMessage,
498
+ tool_call_id: toolCallId
499
+ }));
500
+
501
+ // Add tool response so LLM knows the tool was executed
502
+ const toolResponseMsg = document.createElement('div');
503
+ toolResponseMsg.className = 'message tool';
504
+ toolResponseMsg.style.display = 'none';
505
+ toolResponseMsg.setAttribute('data-tool-response', JSON.stringify({
506
+ tool_call_id: toolCallId,
507
+ content: `Launched ${agentType} agent with task: ${initialMessage}`
508
+ }));
509
+ chatContainer.appendChild(toolResponseMsg);
510
+
511
+ // The action widget will show the launch visually
512
+ handleActionToken(agentType, initialMessage, (targetTabId) => {
513
+ showActionWidget(chatContainer, agentType, initialMessage, targetTabId, taskId);
514
+ // Store tool call ID for this agent tab so we can send result back
515
+ toolCallIds[targetTabId] = toolCallId;
516
+ }, taskId, tabId);
517
+
518
+ // Reset current message element so any subsequent thinking starts fresh
519
+ currentMessageEl = null;
520
+
521
+ } else if (data.type === 'debug_call_input') {
522
+ // Debug: LLM call input (before API call)
523
+ if (!debugHistory[tabId]) debugHistory[tabId] = [];
524
+ debugHistory[tabId].push({
525
+ call_number: data.call_number,
526
+ timestamp: new Date().toLocaleTimeString(),
527
+ input: data.messages,
528
+ output: null,
529
+ error: null
530
+ });
531
+ if (document.getElementById('debugPanel')?.classList.contains('active')) loadDebugMessages();
532
+
533
+ } else if (data.type === 'debug_call_output') {
534
+ // Debug: LLM call output (after API call)
535
+ // Match the last pending call (call_numbers reset per streaming request)
536
+ const calls = debugHistory[tabId] || [];
537
+ const call = calls.findLast(c => c.output === null && c.error === null);
538
+ if (call) {
539
+ call.output = data.response || null;
540
+ call.error = data.error || null;
541
+ }
542
+ if (document.getElementById('debugPanel')?.classList.contains('active')) loadDebugMessages();
543
+
544
+ } else if (data.type === 'aborted') {
545
+ // Agent was aborted by user
546
+ hideProgressWidget(chatContainer);
547
+ removeRetryIndicator(chatContainer);
548
+
549
+ // Mark the action widget as aborted (show Γ— instead of βœ“)
550
+ const widget = actionWidgets[tabId];
551
+ if (widget) {
552
+ const orbitIndicator = widget.querySelector('.orbit-indicator');
553
+ if (orbitIndicator) {
554
+ const abortedIndicator = document.createElement('div');
555
+ abortedIndicator.className = 'done-indicator aborted';
556
+ orbitIndicator.replaceWith(abortedIndicator);
557
+ }
558
+ }
559
+
560
+ // For timeline agent boxes, mark as aborted
561
+ if (timelineData[tabId]) {
562
+ timelineData[tabId].aborted = true;
563
+ }
564
+
565
+ break; // Stop reading stream
566
+
567
+ } else if (data.type === 'done') {
568
+ // Remove retry indicator on success
569
+ removeRetryIndicator(chatContainer);
570
+
571
+ // Reset research state when research agent completes
572
+ if (agentType === 'research' && typeof resetResearchState === 'function') {
573
+ // Mark all research virtual sub-agents as done
574
+ for (const virtualId of Object.values(researchQueryTabIds)) {
575
+ setTimelineGenerating(virtualId, false);
576
+ }
577
+ researchQueryTabIds = {};
578
+ resetResearchState();
579
+ }
580
+
581
+ // Check for action tokens in regular agents (legacy support)
582
+ if (fullResponse) {
583
+ const actionMatch = fullResponse.match(/<action:(agent|code|research|chat)>([\s\S]*?)<\/action>/i);
584
+ if (actionMatch) {
585
+ const action = actionMatch[1].toLowerCase();
586
+ const actionMessage = actionMatch[2].trim();
587
+
588
+ // Remove action token from fullResponse
589
+ const cleanedResponse = fullResponse.replace(/<action:(agent|code|research|chat)>[\s\S]*?<\/action>/i, '').trim();
590
+
591
+ // Update the display to remove action tags
592
+ if (cleanedResponse.length === 0 && currentMessageEl) {
593
+ currentMessageEl.remove();
594
+ } else if (currentMessageEl) {
595
+ // Clear and replace the entire message content
596
+ const messageContent = currentMessageEl.querySelector('.message-content');
597
+ if (messageContent) {
598
+ messageContent.innerHTML = parseMarkdown(cleanedResponse);
599
+ linkifyFilePaths(messageContent);
600
+ }
601
+ }
602
+
603
+ handleActionToken(action, actionMessage, (targetTabId) => {
604
+ showActionWidget(chatContainer, action, actionMessage, targetTabId);
605
+ }, null, tabId);
606
+ }
607
+ }
608
+
609
+ } else if (data.type === 'info') {
610
+ // Info message (e.g., sandbox restart)
611
+ const infoDiv = document.createElement('div');
612
+ infoDiv.className = 'system-message';
613
+ infoDiv.innerHTML = `<em>${escapeHtml(data.content)}</em>`;
614
+ infoDiv.style.color = 'var(--theme-accent)';
615
+ chatContainer.appendChild(infoDiv);
616
+ scrollChatToBottom(chatContainer);
617
+
618
+ } else if (data.type === 'retry') {
619
+ // Show retry indicator
620
+ showRetryIndicator(chatContainer, data);
621
+
622
+ } else if (data.type === 'error') {
623
+ // Remove any retry indicator before showing error
624
+ removeRetryIndicator(chatContainer);
625
+ const errorDiv = document.createElement('div');
626
+ errorDiv.className = 'message assistant';
627
+ errorDiv.innerHTML = `<div class="message-content" style="color: #c62828;">Error: ${escapeHtml(data.content)}</div>`;
628
+ chatContainer.appendChild(errorDiv);
629
+ scrollChatToBottom(chatContainer);
630
+
631
+ // Propagate error to parent action widget
632
+ updateActionWidgetWithResult(tabId, `Error: ${data.content}`, {}, {});
633
+ const errorWidget = actionWidgets[tabId];
634
+ if (errorWidget) {
635
+ const doneIndicator = errorWidget.querySelector('.done-indicator');
636
+ if (doneIndicator) {
637
+ doneIndicator.classList.add('errored');
638
+ }
639
+ }
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ // Add assistant response to timeline
646
+ if (fullResponse && tabId !== undefined) {
647
+ const finalEvIdx = addTimelineEvent(tabId, 'assistant', fullResponse);
648
+ if (currentMessageEl) currentMessageEl.dataset.timelineIndex = finalEvIdx;
649
+ }
650
+ } catch (error) {
651
+ hideProgressWidget(chatContainer);
652
+ if (error.name === 'AbortError') {
653
+ // User-initiated abort β€” show as a result block
654
+ const abortResultText = 'Generation aborted by user.';
655
+ const resultDiv = document.createElement('div');
656
+ resultDiv.className = 'agent-result';
657
+ resultDiv.innerHTML = `
658
+ <div class="result-header">Result</div>
659
+ <div class="result-content"><p>${abortResultText}</p></div>
660
+ `;
661
+ chatContainer.appendChild(resultDiv);
662
+
663
+ // Send abort result to parent action widget (so command center knows it was aborted)
664
+ updateActionWidgetWithResult(tabId, abortResultText, {}, {});
665
+
666
+ // Override the done indicator to show Γ— instead of βœ“
667
+ const widget = actionWidgets[tabId];
668
+ if (widget) {
669
+ const doneIndicator = widget.querySelector('.done-indicator');
670
+ if (doneIndicator) {
671
+ doneIndicator.classList.add('aborted');
672
+ }
673
+ }
674
+
675
+ // Mark timeline data as aborted
676
+ if (timelineData[tabId]) {
677
+ timelineData[tabId].aborted = true;
678
+ }
679
+ } else {
680
+ const errorDiv = document.createElement('div');
681
+ errorDiv.className = 'message assistant';
682
+ errorDiv.innerHTML = `<div class="message-content" style="color: #c62828;">Connection error: ${escapeHtml(error.message)}</div>`;
683
+ chatContainer.appendChild(errorDiv);
684
+ }
685
+ if (tabId) {
686
+ setTabGenerating(tabId, false);
687
+ }
688
+ } finally {
689
+ // Clean up abort controller
690
+ delete activeAbortControllers[tabId];
691
+ }
692
+ }
693
+
694
+ function createAssistantMessage(chatContainer) {
695
+ const msg = document.createElement('div');
696
+ msg.className = 'message assistant';
697
+ msg.innerHTML = '<div class="message-content"></div>';
698
+ chatContainer.appendChild(msg);
699
+ return msg;
700
+ }
701
+
702
+ function appendToMessage(messageEl, content) {
703
+ const contentEl = messageEl.querySelector('.message-content');
704
+ if (contentEl) {
705
+ contentEl.innerHTML = content;
706
+ linkifyFilePaths(contentEl);
707
+ }
708
+ }
709
+
710
+ function createSpinnerHtml() {
711
+ return `<div class="tool-spinner"><span></span><span></span><span></span></div>`;
712
+ }
713
+
714
+ function createCodeCell(chatContainer, code, output, isError, isExecuting = false) {
715
+ const codeCell = document.createElement('div');
716
+ codeCell.className = 'code-cell';
717
+
718
+ let outputHtml = '';
719
+ const cleanedOutput = cleanCodeOutput(output);
720
+ if (cleanedOutput && !isExecuting) {
721
+ outputHtml = `<div class="code-cell-output${isError ? ' error' : ''}">${escapeHtml(cleanedOutput)}</div>`;
722
+ }
723
+
724
+ const spinnerHtml = isExecuting ? createSpinnerHtml() : '';
725
+
726
+ codeCell.innerHTML = `
727
+ <div class="code-cell-label"><div class="widget-collapse-toggle"></div><span>CODE</span>${spinnerHtml}</div>
728
+ <div class="code-cell-code"><pre><code class="language-python">${escapeHtml(code)}</code></pre></div>
729
+ ${outputHtml}
730
+ `;
731
+ setupCollapseToggle(codeCell, '.code-cell-label');
732
+ chatContainer.appendChild(codeCell);
733
+
734
+ // Apply syntax highlighting
735
+ if (typeof Prism !== 'undefined') {
736
+ const codeBlock = codeCell.querySelector('code.language-python');
737
+ if (codeBlock) {
738
+ Prism.highlightElement(codeBlock);
739
+ }
740
+ }
741
+ }
742
+
743
+ function createFileTransferCell(chatContainer, type, paths, output, isExecuting = false) {
744
+ const cell = document.createElement('div');
745
+ cell.className = 'action-widget';
746
+
747
+ const label = type === 'upload' ? 'UPLOAD' : 'DOWNLOAD';
748
+ const hasError = output && output.includes('Error:');
749
+
750
+ // Indicator: spinner while executing, checkmark when done
751
+ const indicatorHtml = isExecuting
752
+ ? `<div class="orbit-indicator"><span></span><span></span><span></span></div>`
753
+ : `<div class="done-indicator"></div>`;
754
+
755
+ // Format paths as list items (make local paths clickable for downloads)
756
+ const pathsList = paths.map(p => {
757
+ if (type === 'download') {
758
+ const arrowIdx = p.indexOf(' -> ');
759
+ if (arrowIdx !== -1) {
760
+ const localPath = p.substring(arrowIdx + 4);
761
+ return `<li>${escapeHtml(p.substring(0, arrowIdx + 4))}<a class="transfer-path-link" href="#" data-path="${escapeHtml(localPath)}">${escapeHtml(localPath)}</a></li>`;
762
+ }
763
+ }
764
+ return `<li>${escapeHtml(p)}</li>`;
765
+ }).join('');
766
+
767
+ let outputHtml = '';
768
+ if (output && !isExecuting) {
769
+ const outputClass = hasError ? 'transfer-output error' : 'transfer-output';
770
+ outputHtml = `<div class="${outputClass}">${escapeHtml(output)}</div>`;
771
+ }
772
+
773
+ cell.innerHTML = `
774
+ <div class="action-widget-header">
775
+ <div class="widget-collapse-toggle"></div>
776
+ <span class="action-widget-type">${label}</span>
777
+ <div class="action-widget-bar-right">${indicatorHtml}</div>
778
+ </div>
779
+ <div class="action-widget-body">
780
+ <ul class="transfer-paths">${pathsList}</ul>
781
+ ${outputHtml}
782
+ </div>
783
+ `;
784
+ cell.querySelector('.action-widget-header').style.cursor = 'pointer';
785
+ cell.querySelector('.action-widget-header').addEventListener('click', () => {
786
+ cell.classList.toggle('collapsed');
787
+ cell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed');
788
+ });
789
+ // Make download path links clickable to navigate in file explorer
790
+ cell.querySelectorAll('.transfer-path-link').forEach(link => {
791
+ link.addEventListener('click', (e) => {
792
+ e.preventDefault();
793
+ navigateToFileInExplorer(link.dataset.path);
794
+ });
795
+ });
796
+ chatContainer.appendChild(cell);
797
+ return cell;
798
+ }
799
+
800
+ function createUploadMessage(chatContainer, paths, output) {
801
+ createFileTransferCell(chatContainer, 'upload', paths, output, false);
802
+ }
803
+
804
+ function createDownloadMessage(chatContainer, paths, output) {
805
+ createFileTransferCell(chatContainer, 'download', paths, output, false);
806
+ }
807
+
808
+ function updateLastCodeCell(chatContainer, output, isError, images) {
809
+ const codeCells = chatContainer.querySelectorAll('.code-cell');
810
+ if (codeCells.length === 0) return;
811
+
812
+ const lastCell = codeCells[codeCells.length - 1];
813
+
814
+ // Remove spinner if present
815
+ const spinner = lastCell.querySelector('.tool-spinner');
816
+ if (spinner) {
817
+ spinner.remove();
818
+ }
819
+
820
+ // Remove existing output if any
821
+ const existingOutput = lastCell.querySelector('.code-cell-output');
822
+ if (existingOutput) {
823
+ existingOutput.remove();
824
+ }
825
+
826
+ // Add images if any
827
+ if (images && images.length > 0) {
828
+ for (const img of images) {
829
+ const imgDiv = document.createElement('div');
830
+ imgDiv.className = 'code-cell-image';
831
+
832
+ // Add figure label if available
833
+ if (img.name) {
834
+ const label = document.createElement('div');
835
+ label.className = 'figure-label';
836
+ label.textContent = img.name;
837
+ imgDiv.appendChild(label);
838
+ }
839
+
840
+ if (img.type === 'png' || img.type === 'jpeg') {
841
+ const imgEl = document.createElement('img');
842
+ imgEl.src = `data:image/${img.type};base64,${img.data}`;
843
+ imgEl.onclick = function() { openImageModal(this.src); };
844
+ imgDiv.appendChild(imgEl);
845
+ } else if (img.type === 'svg') {
846
+ const svgContainer = document.createElement('div');
847
+ svgContainer.innerHTML = atob(img.data);
848
+ imgDiv.appendChild(svgContainer);
849
+ }
850
+
851
+ lastCell.appendChild(imgDiv);
852
+ }
853
+ }
854
+
855
+ // Add text output
856
+ const cleanedOut = cleanCodeOutput(output);
857
+ if (cleanedOut) {
858
+ const outputDiv = document.createElement('div');
859
+ outputDiv.className = `code-cell-output${isError ? ' error' : ''}`;
860
+ outputDiv.textContent = cleanedOut;
861
+ lastCell.appendChild(outputDiv);
862
+ }
863
+ }
864
+
865
+ function showActionWidget(chatContainer, action, message, targetTabId, taskId = null) {
866
+ const widget = document.createElement('div');
867
+ widget.className = 'action-widget';
868
+ if (document.getElementById('collapseAgentsCheckbox')?.checked) {
869
+ widget.classList.add('collapsed');
870
+ }
871
+ widget.dataset.targetTabId = targetTabId;
872
+
873
+ // Display task_id as title if provided
874
+ const titleDisplay = taskId ? taskId : action.toUpperCase();
875
+ const agentCollapsed = document.getElementById('collapseAgentsCheckbox')?.checked;
876
+
877
+ widget.innerHTML = `
878
+ <div class="action-widget-clickable">
879
+ <div class="action-widget-header">
880
+ <div class="widget-collapse-toggle${agentCollapsed ? ' collapsed' : ''}"></div>
881
+ <span class="action-widget-type">${action.toUpperCase()}: ${escapeHtml(titleDisplay)}</span>
882
+ <div class="action-widget-bar-right">
883
+ <div class="orbit-indicator">
884
+ <span></span><span></span><span></span>
885
+ </div>
886
+ </div>
887
+ </div>
888
+ </div>
889
+ <div class="action-widget-body">
890
+ <div class="section-label">QUERY</div>
891
+ <div class="section-content">${parseMarkdown(message)}</div>
892
+ </div>
893
+ `;
894
+
895
+ // Collapse toggle β€” stop propagation so it doesn't navigate to the agent tab
896
+ const collapseToggle = widget.querySelector('.widget-collapse-toggle');
897
+ collapseToggle.addEventListener('click', (e) => {
898
+ e.stopPropagation();
899
+ widget.classList.toggle('collapsed');
900
+ collapseToggle.classList.toggle('collapsed');
901
+ });
902
+
903
+ // Make header clickable to jump to the agent
904
+ const clickableArea = widget.querySelector('.action-widget-clickable');
905
+
906
+ const clickHandler = () => {
907
+ const tabId = parseInt(targetTabId);
908
+ // Check if the tab still exists (use .tab to avoid matching timeline elements)
909
+ const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
910
+ if (tab) {
911
+ // Tab exists, just switch to it
912
+ switchToTab(tabId);
913
+ } else {
914
+ // Tab was closed - restore from timeline data
915
+ const notebook = timelineData[tabId];
916
+ if (notebook) {
917
+ reopenClosedTab(tabId, notebook);
918
+ }
919
+ }
920
+ };
921
+
922
+ clickableArea.addEventListener('click', clickHandler);
923
+
924
+ chatContainer.appendChild(widget);
925
+ scrollChatToBottom(chatContainer);
926
+
927
+ // Store widget for later updates
928
+ actionWidgets[targetTabId] = widget;
929
+ }
930
+
931
+ async function updateActionWidgetWithResult(tabId, resultContent, figures, images) {
932
+ const widget = actionWidgets[tabId];
933
+ if (!widget) return;
934
+
935
+ // Replace orbiting dots with checkmark
936
+ const orbitIndicator = widget.querySelector('.orbit-indicator');
937
+ if (orbitIndicator) {
938
+ const doneIndicator = document.createElement('div');
939
+ doneIndicator.className = 'done-indicator';
940
+ orbitIndicator.replaceWith(doneIndicator);
941
+ }
942
+
943
+ // Process and display result content FIRST (before async operations)
944
+ // Replace <figure_x> tags with placeholders BEFORE markdown processing
945
+ let processedContent = resultContent;
946
+ const figurePlaceholders = {};
947
+
948
+ if (figures) {
949
+ for (const [figureName, figureData] of Object.entries(figures)) {
950
+ // Use %%% delimiters to avoid markdown interpretation
951
+ const placeholderId = `%%%FIGURE_${figureName}%%%`;
952
+ figurePlaceholders[placeholderId] = figureData;
953
+
954
+ // Handle both <figure_x> self-closing and <figure_x></figure_x> pairs
955
+ // First replace paired tags, preserving them as block elements
956
+ const pairedTag = new RegExp(`<${figureName}></${figureName}>`, 'gi');
957
+ processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
958
+
959
+ // Then replace remaining self-closing tags or orphaned closing tags
960
+ const singleTag = new RegExp(`</?${figureName}>`, 'gi');
961
+ processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
962
+ }
963
+ }
964
+
965
+ // Handle <image_N> references from image agent
966
+ if (images) {
967
+ for (const [imageName, imageBase64] of Object.entries(images)) {
968
+ const placeholderId = `%%%IMAGE_${imageName}%%%`;
969
+ figurePlaceholders[placeholderId] = { type: 'png', data: imageBase64 };
970
+
971
+ const pairedTag = new RegExp(`<${imageName}></${imageName}>`, 'gi');
972
+ processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
973
+ const singleTag = new RegExp(`</?${imageName}>`, 'gi');
974
+ processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
975
+ }
976
+ }
977
+
978
+ // Process markdown
979
+ let html = parseMarkdown(processedContent);
980
+
981
+ // Replace placeholders with actual images AFTER markdown processing
982
+ for (const [placeholderId, figureData] of Object.entries(figurePlaceholders)) {
983
+ let imageHtml = '';
984
+ if (figureData.type === 'png' || figureData.type === 'jpeg') {
985
+ imageHtml = `<img src="data:image/${figureData.type};base64,${figureData.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
986
+ } else if (figureData.type === 'svg') {
987
+ imageHtml = `<div style="margin: 12px 0;">${atob(figureData.data)}</div>`;
988
+ }
989
+ // Replace both the placeholder and any paragraph-wrapped version
990
+ html = html.replace(new RegExp(`<p>${placeholderId}</p>`, 'g'), imageHtml);
991
+ html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
992
+ }
993
+
994
+ // Add result section inside the body
995
+ const body = widget.querySelector('.action-widget-body');
996
+ if (body) {
997
+ const resultSection = document.createElement('div');
998
+ resultSection.className = 'action-widget-result-section';
999
+ resultSection.innerHTML = `
1000
+ <div class="section-label" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-primary);">RESULT</div>
1001
+ <div class="section-content">${html}</div>
1002
+ `;
1003
+ body.appendChild(resultSection);
1004
+ }
1005
+
1006
+ // Update the tool response DOM element so getConversationHistory picks up actual results
1007
+ const toolCallId = toolCallIds[tabId];
1008
+ if (toolCallId) {
1009
+ // Find the hidden tool response element with this tool_call_id in the command center
1010
+ const commandContainer = document.getElementById('messages-command');
1011
+ if (commandContainer) {
1012
+ const toolMsgs = commandContainer.querySelectorAll('.message.tool[data-tool-response]');
1013
+ for (const toolMsg of toolMsgs) {
1014
+ try {
1015
+ const data = JSON.parse(toolMsg.getAttribute('data-tool-response'));
1016
+ if (data.tool_call_id === toolCallId) {
1017
+ data.content = resultContent;
1018
+ toolMsg.setAttribute('data-tool-response', JSON.stringify(data));
1019
+ break;
1020
+ }
1021
+ } catch (e) { /* ignore parse errors */ }
1022
+ }
1023
+ }
1024
+
1025
+ // Also send to backend (non-blocking)
1026
+ apiFetch('/api/conversation/add-tool-response', {
1027
+ method: 'POST',
1028
+ headers: { 'Content-Type': 'application/json' },
1029
+ body: JSON.stringify({
1030
+ tab_id: '0',
1031
+ tool_call_id: toolCallId,
1032
+ content: resultContent
1033
+ })
1034
+ }).catch(error => {
1035
+ console.error('Failed to update conversation history with result:', error);
1036
+ });
1037
+ }
1038
+ }
1039
+
1040
+ function sendMessageToTab(tabId, message) {
1041
+ // Programmatically send a message to an existing agent tab
1042
+ const content = document.querySelector(`[data-content-id="${tabId}"]`);
1043
+ if (!content) return;
1044
+
1045
+ const input = content.querySelector('textarea') || content.querySelector('input[type="text"]');
1046
+ if (!input) return;
1047
+
1048
+ // Set the message and trigger send
1049
+ input.value = message;
1050
+ sendMessage(tabId);
1051
+ }
1052
+
1053
+ function handleActionToken(action, message, callback, taskId = null, parentTabId = null) {
1054
+ // Check if an agent with this task_id already exists
1055
+ if (taskId && taskIdToTabId[taskId]) {
1056
+ const existingTabId = taskIdToTabId[taskId];
1057
+ const existingContent = document.querySelector(`[data-content-id="${existingTabId}"]`);
1058
+
1059
+ if (existingContent) {
1060
+ // Send the message to the existing agent
1061
+ sendMessageToTab(existingTabId, message);
1062
+ if (callback) {
1063
+ callback(existingTabId);
1064
+ }
1065
+ return;
1066
+ } else {
1067
+ // Tab no longer exists, clean up the mapping
1068
+ delete taskIdToTabId[taskId];
1069
+ }
1070
+ }
1071
+
1072
+ // Open the agent with the extracted message as initial prompt
1073
+ // Don't auto-switch to the new tab (autoSwitch = false)
1074
+ setTimeout(() => {
1075
+ const newTabId = createAgentTab(action, message, false, taskId, parentTabId);
1076
+ if (callback) {
1077
+ callback(newTabId);
1078
+ }
1079
+ }, 500);
1080
+ }
1081
+
1082
+ function setTabGenerating(tabId, isGenerating) {
1083
+ const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
1084
+ if (!tab) return;
1085
+
1086
+ const statusIndicator = tab.querySelector('.tab-status');
1087
+ if (!statusIndicator) return;
1088
+
1089
+ if (isGenerating) {
1090
+ statusIndicator.style.display = 'block';
1091
+ statusIndicator.classList.add('generating');
1092
+ } else {
1093
+ statusIndicator.classList.remove('generating');
1094
+ // Keep visible but stop animation
1095
+ setTimeout(() => {
1096
+ statusIndicator.style.display = 'none';
1097
+ }, 300);
1098
+ }
1099
+
1100
+ // Toggle SEND/STOP button
1101
+ const content = document.querySelector(`[data-content-id="${tabId}"]`);
1102
+ if (content) {
1103
+ const sendBtn = content.querySelector('.input-container button');
1104
+ if (sendBtn) {
1105
+ if (isGenerating) {
1106
+ sendBtn.textContent = 'STOP';
1107
+ sendBtn.classList.add('stop-btn');
1108
+ sendBtn.disabled = false; // Keep enabled so user can click STOP
1109
+ } else {
1110
+ sendBtn.textContent = 'SEND';
1111
+ sendBtn.classList.remove('stop-btn');
1112
+ }
1113
+ }
1114
+ }
1115
+
1116
+ // Update timeline to reflect generating state
1117
+ setTimelineGenerating(tabId, isGenerating);
1118
+
1119
+ // Track when a pending agent launch actually starts generating
1120
+ if (isGenerating && pendingAgentLaunches > 0 && tabId !== 0 && timelineData[tabId]?.parentTabId === 0) {
1121
+ pendingAgentLaunches--;
1122
+ }
1123
+
1124
+ // When a child agent finishes and command center is blocked, check if all agents are done
1125
+ if (!isGenerating && commandInputBlocked && tabId !== 0) {
1126
+ const anyStillGenerating = Object.values(timelineData).some(
1127
+ td => td.parentTabId === 0 && td.isGenerating
1128
+ );
1129
+ if (!anyStillGenerating && pendingAgentLaunches === 0) {
1130
+ commandInputBlocked = false;
1131
+ setCommandCenterStopState(false);
1132
+ // Auto-continue: call command center again with agent results now in history
1133
+ continueCommandCenter();
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+
1139
+ function cleanCodeOutput(text) {
1140
+ if (!text) return text;
1141
+ return text.split('\n')
1142
+ .filter(line => !line.match(/^\[Plot\/Image generated\]$/) && !line.match(/^\[Generated figures:.*\]$/))
1143
+ .join('\n')
1144
+ .trim();
1145
+ }
1146
+
1147
+ function showRetryIndicator(chatContainer, data) {
1148
+ // Remove existing retry indicator if present
1149
+ removeRetryIndicator(chatContainer);
1150
+
1151
+ const retryDiv = document.createElement('div');
1152
+ retryDiv.className = 'retry-indicator';
1153
+ retryDiv.innerHTML = `
1154
+ <div class="retry-content">
1155
+ <div class="retry-spinner"></div>
1156
+ <div class="retry-text">
1157
+ <div class="retry-message">${escapeHtml(data.message)}</div>
1158
+ <div class="retry-status">Retrying (${data.attempt}/${data.max_attempts}) in ${data.delay}s...</div>
1159
+ </div>
1160
+ </div>
1161
+ `;
1162
+ chatContainer.appendChild(retryDiv);
1163
+ scrollChatToBottom(chatContainer);
1164
+
1165
+ // Start countdown
1166
+ let remaining = data.delay;
1167
+ const statusEl = retryDiv.querySelector('.retry-status');
1168
+ const countdownInterval = setInterval(() => {
1169
+ remaining--;
1170
+ if (remaining > 0 && statusEl) {
1171
+ statusEl.textContent = `Retrying (${data.attempt}/${data.max_attempts}) in ${remaining}s...`;
1172
+ } else {
1173
+ clearInterval(countdownInterval);
1174
+ if (statusEl) {
1175
+ statusEl.textContent = `Retrying (${data.attempt}/${data.max_attempts})...`;
1176
+ }
1177
+ }
1178
+ }, 1000);
1179
+
1180
+ // Store interval ID for cleanup
1181
+ retryDiv.dataset.countdownInterval = countdownInterval;
1182
+ }
1183
+
1184
+ function removeRetryIndicator(chatContainer) {
1185
+ const existing = chatContainer.querySelector('.retry-indicator');
1186
+ if (existing) {
1187
+ // Clear countdown interval if exists
1188
+ if (existing.dataset.countdownInterval) {
1189
+ clearInterval(parseInt(existing.dataset.countdownInterval));
1190
+ }
1191
+ existing.remove();
1192
+ }
1193
+ }
1194
+
1195
+ // Configure marked options once
1196
+ if (typeof marked !== 'undefined') {
1197
+ // Custom renderer to add target="_blank" to links and syntax highlighting
1198
+ const renderer = new marked.Renderer();
1199
+
1200
+ // Handle both old API (separate args) and new API (token object)
1201
+ renderer.link = function(hrefOrToken, title, text) {
1202
+ const href = typeof hrefOrToken === 'object' ? hrefOrToken.href : hrefOrToken;
1203
+ const titleVal = typeof hrefOrToken === 'object' ? hrefOrToken.title : title;
1204
+ const textVal = typeof hrefOrToken === 'object' ? hrefOrToken.text : text;
1205
+ const titleAttr = titleVal ? ` title="${titleVal}"` : '';
1206
+ return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${textVal}</a>`;
1207
+ };
1208
+
1209
+ renderer.code = function(codeOrToken, language) {
1210
+ // Handle both old API (separate args) and new API (token object)
1211
+ const code = typeof codeOrToken === 'object' ? codeOrToken.text : codeOrToken;
1212
+ const lang = typeof codeOrToken === 'object' ? codeOrToken.lang : language;
1213
+
1214
+ // Use Prism for syntax highlighting if available
1215
+ if (typeof Prism !== 'undefined' && lang && Prism.languages[lang]) {
1216
+ const highlighted = Prism.highlight(code, Prism.languages[lang], lang);
1217
+ return `<pre><code class="language-${lang}">${highlighted}</code></pre>`;
1218
+ }
1219
+ return `<pre><code>${escapeHtml(code)}</code></pre>`;
1220
+ };
1221
+
1222
+ marked.setOptions({
1223
+ gfm: true, // GitHub Flavored Markdown
1224
+ breaks: false, // Don't convert \n to <br>
1225
+ pedantic: false,
1226
+ renderer: renderer
1227
+ });
1228
+ }
1229
+
1230
+ // Resolve <figure_N> and <image_N> references using the global registry
1231
+ function resolveGlobalFigureRefs(html) {
1232
+ return html.replace(/<\/?(figure_\d+|image_\d+)>/gi, (match) => {
1233
+ // Extract the name (strip < > and /)
1234
+ const name = match.replace(/[<>/]/g, '');
1235
+ const data = globalFigureRegistry[name];
1236
+ if (!data) return match; // Leave unresolved refs as-is
1237
+ if (data.type === 'png' || data.type === 'jpeg') {
1238
+ return `<img src="data:image/${data.type};base64,${data.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
1239
+ } else if (data.type === 'svg') {
1240
+ return `<div style="margin: 12px 0;">${atob(data.data)}</div>`;
1241
+ }
1242
+ return match;
1243
+ });
1244
+ }
1245
+
1246
+ function parseMarkdown(text) {
1247
+ // Use marked library for proper markdown parsing
1248
+ let html;
1249
+ if (typeof marked !== 'undefined') {
1250
+ html = marked.parse(text);
1251
+ } else {
1252
+ // Fallback: just escape HTML and convert newlines to paragraphs
1253
+ html = `<p>${escapeHtml(text).replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>`;
1254
+ }
1255
+
1256
+ // Render LaTeX with KaTeX if available
1257
+ if (typeof katex !== 'undefined') {
1258
+ // Block math: $$ ... $$ (must handle newlines)
1259
+ html = html.replace(/\$\$([\s\S]*?)\$\$/g, (match, latex) => {
1260
+ try {
1261
+ return katex.renderToString(latex.trim(), { displayMode: true, throwOnError: false });
1262
+ } catch (e) {
1263
+ return `<span class="katex-error">${escapeHtml(match)}</span>`;
1264
+ }
1265
+ });
1266
+
1267
+ // Inline math: $ ... $ (but not $$ or escaped \$)
1268
+ html = html.replace(/(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/g, (match, latex) => {
1269
+ try {
1270
+ return katex.renderToString(latex.trim(), { displayMode: false, throwOnError: false });
1271
+ } catch (e) {
1272
+ return `<span class="katex-error">${escapeHtml(match)}</span>`;
1273
+ }
1274
+ });
1275
+ }
1276
+
1277
+ return html;
1278
+ }
1279
+
frontend/tabs.js ADDED
@@ -0,0 +1,573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function createAgentTab(type, initialMessage = null, autoSwitch = true, taskId = null, parentTabId = null) {
2
+ const tabId = tabCounter++;
3
+
4
+ // Use task_id if provided, otherwise generate default title
5
+ let title;
6
+ if (taskId) {
7
+ // Convert dashes to spaces and title case for display
8
+ title = taskId;
9
+ // Register this agent for task_id reuse
10
+ taskIdToTabId[taskId] = tabId;
11
+ } else if (type !== 'command-center') {
12
+ agentCounters[type]++;
13
+ title = `New ${type} task`;
14
+ } else {
15
+ title = getTypeLabel(type);
16
+ }
17
+
18
+ // Register in timeline
19
+ registerAgentInTimeline(tabId, type, title, parentTabId);
20
+
21
+ // Create tab
22
+ const tab = document.createElement('div');
23
+ tab.className = 'tab';
24
+ tab.dataset.tabId = tabId;
25
+ tab.innerHTML = `
26
+ <span class="tab-title">${title}</span>
27
+ <span class="tab-status" style="display: none;"><span></span><span></span><span></span></span>
28
+ <span class="tab-close">Γ—</span>
29
+ `;
30
+
31
+ // Insert into the dynamic tabs container
32
+ const dynamicTabs = document.getElementById('dynamicTabs');
33
+ dynamicTabs.appendChild(tab);
34
+
35
+ // Hide tab for subagents until the user clicks on them
36
+ if (!autoSwitch && parentTabId !== null) {
37
+ tab.style.display = 'none';
38
+ }
39
+
40
+ // Create content
41
+ const content = document.createElement('div');
42
+ content.className = 'tab-content';
43
+ content.dataset.contentId = tabId;
44
+ content.innerHTML = createAgentContent(type, tabId, title);
45
+
46
+ document.querySelector('.main-content').appendChild(content);
47
+
48
+ // Only switch to new tab if autoSwitch is true
49
+ if (autoSwitch) {
50
+ switchToTab(tabId);
51
+ }
52
+
53
+ // Add event listeners for the new content
54
+ if (type !== 'command-center') {
55
+ setupInputListeners(content, tabId);
56
+ const input = content.querySelector('textarea');
57
+
58
+ // If this is a code agent, start the sandbox proactively
59
+ if (type === 'code') {
60
+ startSandbox(tabId);
61
+ }
62
+
63
+ // If there's an initial message, automatically send it
64
+ if (initialMessage && input) {
65
+ input.value = initialMessage;
66
+ // Small delay to ensure everything is set up
67
+ setTimeout(() => {
68
+ sendMessage(tabId);
69
+ }, 100);
70
+ }
71
+ }
72
+
73
+ // Save workspace state after creating new tab
74
+ saveWorkspaceDebounced();
75
+
76
+ return tabId; // Return the tabId so we can reference it
77
+ }
78
+
79
+ function createAgentContent(type, tabId, title = null) {
80
+ if (type === 'command-center') {
81
+ return document.querySelector('[data-content-id="0"]').innerHTML;
82
+ }
83
+
84
+ // Use unique ID combining type and tabId to ensure unique container IDs
85
+ const uniqueId = `${type}-${tabId}`;
86
+
87
+ // Display title or default
88
+ const displayTitle = title || `New ${type} task`;
89
+
90
+ return `
91
+ <div class="agent-interface">
92
+ <div class="agent-header">
93
+ <div>
94
+ <div class="agent-type">${getTypeLabel(type)}</div>
95
+ <h2>${escapeHtml(displayTitle)}</h2>
96
+ </div>
97
+ </div>
98
+ <div class="agent-body">
99
+ <div class="chat-container" id="messages-${uniqueId}" data-agent-type="${type}">
100
+ </div>
101
+ </div>
102
+ <div class="input-area">
103
+ <div class="input-container">
104
+ <textarea placeholder="${getPlaceholder(type)}" id="input-${uniqueId}" rows="1"></textarea>
105
+ <button>SEND</button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ `;
110
+ }
111
+
112
+ function switchToTab(tabId) {
113
+ // Deactivate all tabs
114
+ document.querySelectorAll('.tab').forEach(tab => {
115
+ tab.classList.remove('active');
116
+ });
117
+ document.querySelectorAll('.tab-content').forEach(content => {
118
+ content.classList.remove('active');
119
+ });
120
+
121
+ // Activate selected tab (use .tab selector to avoid matching timeline elements)
122
+ const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
123
+ const content = document.querySelector(`.tab-content[data-content-id="${tabId}"]`);
124
+
125
+ if (content) {
126
+ // For settings, there's no tab, just show the content
127
+ if (tabId === 'settings') {
128
+ content.classList.add('active');
129
+ activeTabId = tabId;
130
+ } else if (tab) {
131
+ tab.style.display = '';
132
+ tab.classList.add('active');
133
+ content.classList.add('active');
134
+ activeTabId = tabId;
135
+
136
+ // Save workspace state after switching tabs
137
+ saveWorkspaceDebounced();
138
+ }
139
+ }
140
+
141
+ // Update timeline to reflect active tab
142
+ renderTimeline();
143
+ }
144
+
145
+ function closeTab(tabId) {
146
+ if (tabId === 0) return; // Can't close command center
147
+
148
+ const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
149
+ const content = document.querySelector(`[data-content-id="${tabId}"]`);
150
+
151
+ if (tab && content) {
152
+ // Check if this is a code agent and stop its sandbox
153
+ const chatContainer = content.querySelector('.chat-container');
154
+ const agentType = chatContainer?.dataset.agentType || 'chat';
155
+
156
+ if (agentType === 'code') {
157
+ stopSandbox(tabId);
158
+ }
159
+
160
+ // Save the tab's content BEFORE removing from DOM so we can restore it later
161
+ if (timelineData[tabId]) {
162
+ timelineData[tabId].savedContent = content.innerHTML;
163
+ timelineData[tabId].savedTitle = tab.querySelector('.tab-title')?.textContent;
164
+ }
165
+
166
+ // Clean up task_id mapping
167
+ for (const [taskId, mappedTabId] of Object.entries(taskIdToTabId)) {
168
+ if (mappedTabId === tabId) {
169
+ delete taskIdToTabId[taskId];
170
+ break;
171
+ }
172
+ }
173
+
174
+ tab.remove();
175
+ content.remove();
176
+
177
+ // Mark as closed in timeline (don't remove - allow reopening)
178
+ if (timelineData[tabId]) {
179
+ timelineData[tabId].isClosed = true;
180
+ renderTimeline();
181
+ }
182
+
183
+ // Switch to command center if closing active tab
184
+ if (activeTabId === tabId) {
185
+ switchToTab(0);
186
+ }
187
+
188
+ // Save workspace state after closing tab
189
+ saveWorkspaceDebounced();
190
+ }
191
+ }
192
+
193
+ function showProgressWidget(chatContainer) {
194
+ // Remove any existing progress widget
195
+ hideProgressWidget(chatContainer);
196
+
197
+ const widget = document.createElement('div');
198
+ widget.className = 'progress-widget';
199
+ widget.innerHTML = `
200
+ <div class="progress-spinner">
201
+ <span></span>
202
+ <span></span>
203
+ <span></span>
204
+ </div>
205
+ <span class="progress-text">Generating...</span>
206
+ `;
207
+ chatContainer.appendChild(widget);
208
+ scrollChatToBottom(chatContainer, true);
209
+ return widget;
210
+ }
211
+
212
+ function hideProgressWidget(chatContainer) {
213
+ const widget = chatContainer.querySelector('.progress-widget');
214
+ if (widget) {
215
+ widget.remove();
216
+ }
217
+ }
218
+
219
+ function scrollChatToBottom(chatContainer, force = false) {
220
+ // The actual scrolling container is .agent-body
221
+ const agentBody = chatContainer.closest('.agent-body');
222
+ if (!agentBody) return;
223
+
224
+ // If not forced, only scroll if user is already near the bottom
225
+ if (!force) {
226
+ const distanceFromBottom = agentBody.scrollHeight - agentBody.scrollTop - agentBody.clientHeight;
227
+ if (distanceFromBottom > 150) return;
228
+ }
229
+
230
+ agentBody.scrollTop = agentBody.scrollHeight;
231
+ }
232
+
233
+ function scrollToTimelineEvent(tabId, eventIndex) {
234
+ if (eventIndex === undefined || eventIndex === null) return;
235
+
236
+ // Find the tab's content area and chat container
237
+ const content = document.querySelector(`[data-content-id="${tabId}"]`);
238
+ if (!content) return;
239
+ const chatContainer = content.querySelector('.chat-container');
240
+ if (!chatContainer) return;
241
+
242
+ // Find the chat element tagged with this timeline index
243
+ const target = chatContainer.querySelector(`[data-timeline-index="${eventIndex}"]`);
244
+ if (!target) return;
245
+
246
+ // Scroll the agent-body so the target is near the top
247
+ const agentBody = chatContainer.closest('.agent-body');
248
+ if (!agentBody) return;
249
+
250
+ // Use a small delay to ensure tab switch has rendered
251
+ requestAnimationFrame(() => {
252
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
253
+ });
254
+ }
255
+
256
+ async function abortAgent(tabId) {
257
+ // Abort the frontend fetch
258
+ const controller = activeAbortControllers[tabId];
259
+ if (controller) {
260
+ controller.abort();
261
+ }
262
+
263
+ // For command center (tab 0): also abort all generating child tabs
264
+ if (tabId === 0) {
265
+ for (const [childId, td] of Object.entries(timelineData)) {
266
+ if (td.parentTabId === 0 && td.isGenerating) {
267
+ // Abort frontend fetch
268
+ const childController = activeAbortControllers[childId];
269
+ if (childController) {
270
+ childController.abort();
271
+ }
272
+ // Abort backend agent
273
+ try {
274
+ await apiFetch('/api/abort', {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ agent_id: childId.toString() })
278
+ });
279
+ } catch (e) { /* ignore */ }
280
+ }
281
+ }
282
+ // Unblock command center input and restore SEND button
283
+ commandInputBlocked = false;
284
+ pendingAgentLaunches = 0;
285
+ setCommandCenterStopState(false);
286
+ const commandInput = document.getElementById('input-command');
287
+ if (commandInput) {
288
+ commandInput.disabled = false;
289
+ }
290
+ }
291
+
292
+ // Tell the backend to set the abort flag for this agent
293
+ try {
294
+ await apiFetch('/api/abort', {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({ agent_id: tabId.toString() })
298
+ });
299
+ } catch (e) {
300
+ // Ignore abort endpoint errors
301
+ }
302
+ }
303
+
304
+ async function sendMessage(tabId) {
305
+ // If tab is currently generating, abort instead of sending
306
+ // For command center: also abort if input is blocked (sub-agents still running)
307
+ if (timelineData[tabId]?.isGenerating || (tabId === 0 && commandInputBlocked)) {
308
+ abortAgent(tabId);
309
+ return;
310
+ }
311
+
312
+ const content = document.querySelector(`[data-content-id="${tabId}"]`);
313
+ if (!content) return;
314
+
315
+ // Support both textarea and input
316
+ const input = content.querySelector('textarea') || content.querySelector('input[type="text"]');
317
+ const chatContainer = content.querySelector('.chat-container');
318
+
319
+ if (!input || !chatContainer) return;
320
+
321
+ const message = input.value.trim();
322
+ if (!message) return;
323
+
324
+ // Remove welcome message if it exists (only on first user message)
325
+ const welcomeMsg = chatContainer.querySelector('.welcome-message');
326
+ const isFirstMessage = welcomeMsg !== null;
327
+ if (welcomeMsg) {
328
+ welcomeMsg.remove();
329
+ }
330
+
331
+ // Add user message
332
+ const userMsg = document.createElement('div');
333
+ userMsg.className = 'message user';
334
+ userMsg.innerHTML = `<div class="message-content">${parseMarkdown(message.trim())}</div>`;
335
+ linkifyFilePaths(userMsg);
336
+ chatContainer.appendChild(userMsg);
337
+
338
+ // Add to timeline and tag DOM element for scroll-to-turn
339
+ const evIdx = addTimelineEvent(tabId, 'user', message);
340
+ userMsg.dataset.timelineIndex = evIdx;
341
+
342
+ // Scroll the agent body (the actual scrolling container) to bottom
343
+ const agentBody = chatContainer.closest('.agent-body');
344
+ if (agentBody) {
345
+ agentBody.scrollTop = agentBody.scrollHeight;
346
+ }
347
+
348
+ // Show progress widget while waiting for response
349
+ showProgressWidget(chatContainer);
350
+
351
+ // Generate a title for the agent if this is the first message and not command center
352
+ if (isFirstMessage && tabId !== 0) {
353
+ generateAgentTitle(tabId, message);
354
+ }
355
+
356
+ // Clear input and disable it during processing
357
+ input.value = '';
358
+ input.style.height = 'auto'; // Reset textarea height
359
+ input.disabled = true;
360
+
361
+ // Set tab to generating state
362
+ setTabGenerating(tabId, true);
363
+
364
+ // Determine agent type from chat container ID
365
+ const agentType = getAgentTypeFromContainer(chatContainer);
366
+
367
+ // Send full conversation history for all agent types (stateless backend)
368
+ const messages = getConversationHistory(chatContainer);
369
+
370
+ // Stream response from backend
371
+ await streamChatResponse(messages, chatContainer, agentType, tabId);
372
+
373
+ // Re-enable input and mark generation as complete
374
+ setTabGenerating(tabId, false);
375
+
376
+ if (tabId === 0) {
377
+ // Command center: keep input blocked if launched agents are still running or pending
378
+ const anyChildGenerating = Object.values(timelineData).some(
379
+ td => td.parentTabId === 0 && td.isGenerating
380
+ );
381
+ if (anyChildGenerating || pendingAgentLaunches > 0) {
382
+ commandInputBlocked = true;
383
+ // Keep STOP button visible and input disabled while children run
384
+ input.disabled = true;
385
+ setCommandCenterStopState(true);
386
+ } else {
387
+ input.disabled = false;
388
+ input.focus();
389
+ }
390
+ } else {
391
+ input.disabled = false;
392
+ input.focus();
393
+ }
394
+
395
+ // Save workspace state after message exchange completes
396
+ saveWorkspaceDebounced();
397
+ }
398
+
399
+ function setCommandCenterStopState(showStop) {
400
+ const content = document.querySelector('[data-content-id="0"]');
401
+ if (!content) return;
402
+ const sendBtn = content.querySelector('.input-container button');
403
+ if (!sendBtn) return;
404
+ if (showStop) {
405
+ sendBtn.textContent = 'STOP';
406
+ sendBtn.classList.add('stop-btn');
407
+ sendBtn.disabled = false;
408
+ } else {
409
+ sendBtn.textContent = 'SEND';
410
+ sendBtn.classList.remove('stop-btn');
411
+ }
412
+ }
413
+
414
+ async function continueCommandCenter() {
415
+ // Called when all launched agents finish β€” re-run command center with actual results in history
416
+ const chatContainer = document.getElementById('messages-command');
417
+ const commandInput = document.getElementById('input-command');
418
+ if (!chatContainer) return;
419
+
420
+ setTabGenerating(0, true);
421
+
422
+ const messages = getConversationHistory(chatContainer);
423
+ await streamChatResponse(messages, chatContainer, 'command', 0);
424
+
425
+ setTabGenerating(0, false);
426
+
427
+ // Check if new agents were launched (recursive blocking)
428
+ const anyChildGenerating = Object.values(timelineData).some(
429
+ td => td.parentTabId === 0 && td.isGenerating
430
+ );
431
+ if (anyChildGenerating || pendingAgentLaunches > 0) {
432
+ commandInputBlocked = true;
433
+ if (commandInput) commandInput.disabled = true;
434
+ setCommandCenterStopState(true);
435
+ } else if (commandInput) {
436
+ commandInput.disabled = false;
437
+ commandInput.focus();
438
+ }
439
+
440
+ saveWorkspaceDebounced();
441
+ }
442
+
443
+ async function generateAgentTitle(tabId, query) {
444
+ const currentSettings = getSettings();
445
+ const backendEndpoint = '/api';
446
+ const llmEndpoint = currentSettings.endpoint || 'https://api.openai.com/v1';
447
+ const modelToUse = currentSettings.model || 'gpt-4';
448
+
449
+ try {
450
+ const response = await apiFetch(`${backendEndpoint}/generate-title`, {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': 'application/json' },
453
+ body: JSON.stringify({
454
+ query: query,
455
+ endpoint: llmEndpoint,
456
+ token: currentSettings.token || null,
457
+ model: modelToUse
458
+ })
459
+ });
460
+
461
+ if (response.ok) {
462
+ const result = await response.json();
463
+ const title = result.title;
464
+
465
+ // Update the tab title
466
+ const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
467
+ if (tab) {
468
+ const titleEl = tab.querySelector('.tab-title');
469
+ if (titleEl) {
470
+ titleEl.textContent = title.toUpperCase();
471
+ // Save workspace after title update
472
+ saveWorkspaceDebounced();
473
+ // Update timeline to reflect new title
474
+ updateTimelineTitle(tabId, title.toUpperCase());
475
+ }
476
+ }
477
+ }
478
+ } catch (error) {
479
+ console.error('Failed to generate title:', error);
480
+ // Don't show error to user, just keep the default title
481
+ }
482
+ }
483
+
484
+ function getAgentTypeFromContainer(chatContainer) {
485
+ // Try to get type from data attribute first (for dynamically created agents)
486
+ const typeFromData = chatContainer.dataset.agentType;
487
+ if (typeFromData) {
488
+ return typeFromData;
489
+ }
490
+
491
+ // Fallback: Extract agent type from the container ID (e.g., "messages-command" -> "command")
492
+ const containerId = chatContainer.id;
493
+ if (containerId && containerId.startsWith('messages-')) {
494
+ const type = containerId.replace('messages-', '');
495
+ // Map to agent type
496
+ if (type === 'command') return 'command';
497
+ if (type.startsWith('agent')) return 'agent';
498
+ if (type.startsWith('code')) return 'code';
499
+ if (type.startsWith('research')) return 'research';
500
+ if (type.startsWith('chat')) return 'chat';
501
+ }
502
+ return 'command'; // Default fallback
503
+ }
504
+
505
+ function getConversationHistory(chatContainer) {
506
+ const messages = [];
507
+ const messageElements = chatContainer.querySelectorAll('.message');
508
+
509
+ messageElements.forEach(msg => {
510
+ if (msg.classList.contains('user')) {
511
+ // User messages use .message-content
512
+ const contentEl = msg.querySelector('.message-content');
513
+ const content = contentEl?.textContent.trim() || msg.textContent.trim();
514
+ if (content) {
515
+ messages.push({ role: 'user', content: content });
516
+ }
517
+ } else if (msg.classList.contains('assistant')) {
518
+ // Assistant messages use .message-content
519
+ const contentEl = msg.querySelector('.message-content');
520
+ const content = contentEl?.textContent.trim();
521
+
522
+ // Check if this message has a tool call
523
+ const toolCallData = msg.getAttribute('data-tool-call');
524
+ if (toolCallData) {
525
+ const toolCall = JSON.parse(toolCallData);
526
+ let funcName, funcArgs;
527
+
528
+ if (toolCall.function_name) {
529
+ // Agent-style tool call (web_search, read_url, etc.)
530
+ funcName = toolCall.function_name;
531
+ funcArgs = toolCall.arguments;
532
+ } else {
533
+ // Command center-style tool call (launch_*_agent)
534
+ funcName = `launch_${toolCall.agent_type}_agent`;
535
+ funcArgs = JSON.stringify({
536
+ task: toolCall.message,
537
+ topic: toolCall.message,
538
+ message: toolCall.message
539
+ });
540
+ }
541
+
542
+ messages.push({
543
+ role: 'assistant',
544
+ content: toolCall.thinking || content || '',
545
+ tool_calls: [{
546
+ id: toolCall.tool_call_id || 'tool_' + Date.now(),
547
+ type: 'function',
548
+ function: {
549
+ name: funcName,
550
+ arguments: funcArgs
551
+ }
552
+ }]
553
+ });
554
+ } else if (content && !content.includes('msg-') && !content.includes('β†’ Launched')) {
555
+ // Regular assistant message (exclude launch notifications)
556
+ messages.push({ role: 'assistant', content: content });
557
+ }
558
+ } else if (msg.classList.contains('tool')) {
559
+ // Tool response message
560
+ const toolResponseData = msg.getAttribute('data-tool-response');
561
+ if (toolResponseData) {
562
+ const toolResponse = JSON.parse(toolResponseData);
563
+ messages.push({
564
+ role: 'tool',
565
+ tool_call_id: toolResponse.tool_call_id,
566
+ content: toolResponse.content
567
+ });
568
+ }
569
+ }
570
+ });
571
+
572
+ return messages;
573
+ }
frontend/timeline.js ADDED
@@ -0,0 +1,519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Add event to timeline
2
+ function addTimelineEvent(tabId, eventType, content, childTabId = null, meta = null) {
3
+ if (!timelineData[tabId]) {
4
+ timelineData[tabId] = { type: 'unknown', title: 'Unknown', events: [], parentTabId: null, isGenerating: false };
5
+ }
6
+
7
+ // Truncate content for preview (first 80 chars)
8
+ const preview = content.length > 80 ? content.substring(0, 80) + '...' : content;
9
+
10
+ const eventIndex = timelineData[tabId].events.length;
11
+ timelineData[tabId].events.push({
12
+ type: eventType, // 'user', 'assistant', or 'agent'
13
+ content: preview,
14
+ childTabId: childTabId,
15
+ meta: meta, // optional: { tag: 'SEARCH' } for tool-like entries
16
+ timestamp: Date.now(),
17
+ index: eventIndex,
18
+ });
19
+
20
+ renderTimeline();
21
+ return eventIndex;
22
+ }
23
+
24
+ // Register a new agent in timeline
25
+ function registerAgentInTimeline(tabId, type, title, parentTabId = null) {
26
+ timelineData[tabId] = {
27
+ type: type,
28
+ title: title,
29
+ events: [],
30
+ parentTabId: parentTabId,
31
+ isGenerating: false
32
+ };
33
+
34
+ // If this agent was launched from another, add an agent event to parent
35
+ if (parentTabId !== null && timelineData[parentTabId]) {
36
+ addTimelineEvent(parentTabId, 'agent', title, tabId);
37
+ }
38
+
39
+ renderTimeline();
40
+ }
41
+
42
+ // Update generating state
43
+ function setTimelineGenerating(tabId, isGenerating) {
44
+ if (timelineData[tabId]) {
45
+ timelineData[tabId].isGenerating = isGenerating;
46
+ renderTimeline();
47
+ }
48
+ }
49
+
50
+ // Update agent title in timeline
51
+ function updateTimelineTitle(tabId, title) {
52
+ if (timelineData[tabId]) {
53
+ timelineData[tabId].title = title;
54
+ renderTimeline();
55
+ }
56
+ }
57
+
58
+ // Remove agent from timeline
59
+ function removeFromTimeline(tabId) {
60
+ // Remove from parent's events if it was a child
61
+ const notebook = timelineData[tabId];
62
+ if (notebook && notebook.parentTabId !== null) {
63
+ const parent = timelineData[notebook.parentTabId];
64
+ if (parent) {
65
+ parent.events = parent.events.filter(e => e.childTabId !== tabId);
66
+ }
67
+ }
68
+ delete timelineData[tabId];
69
+ renderTimeline();
70
+ }
71
+
72
+ // Open a closed tab or switch to an existing one
73
+ function openOrSwitchToTab(tabId) {
74
+ // Check for actual tab element (not timeline elements which also have data-tab-id)
75
+ const existingTab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
76
+ if (existingTab) {
77
+ // Tab exists, just switch to it
78
+ switchToTab(tabId);
79
+ } else {
80
+ // Tab was closed, need to recreate it with the SAME tabId
81
+ const notebook = timelineData[tabId];
82
+ if (notebook) {
83
+ reopenClosedTab(tabId, notebook);
84
+ }
85
+ }
86
+ }
87
+
88
+ // Recreate a closed tab using its existing tabId (doesn't create new timeline entry)
89
+ function reopenClosedTab(tabId, notebook) {
90
+ const type = notebook.type;
91
+ const title = notebook.savedTitle || notebook.title;
92
+
93
+ // Create tab element
94
+ const tab = document.createElement('div');
95
+ tab.className = 'tab';
96
+ tab.dataset.tabId = tabId;
97
+ tab.innerHTML = `
98
+ <span class="tab-title">${title}</span>
99
+ <span class="tab-status" style="display: none;"><span></span><span></span><span></span></span>
100
+ <span class="tab-close">Γ—</span>
101
+ `;
102
+
103
+ // Insert into the dynamic tabs container
104
+ const dynamicTabs = document.getElementById('dynamicTabs');
105
+ dynamicTabs.appendChild(tab);
106
+
107
+ // Create content element - restore saved content if available
108
+ const content = document.createElement('div');
109
+ content.className = 'tab-content';
110
+ content.dataset.contentId = tabId;
111
+
112
+ if (notebook.savedContent) {
113
+ // Restore the saved content (includes all messages)
114
+ content.innerHTML = notebook.savedContent;
115
+ } else {
116
+ // Fallback: create fresh agent content
117
+ content.innerHTML = createAgentContent(type, tabId, title);
118
+ }
119
+
120
+ document.querySelector('.main-content').appendChild(content);
121
+
122
+ // Mark as no longer closed and clear saved content
123
+ notebook.isClosed = false;
124
+ delete notebook.savedContent;
125
+ delete notebook.savedTitle;
126
+
127
+ // Switch to the reopened tab
128
+ switchToTab(tabId);
129
+
130
+ // Re-attach event listeners for the restored content
131
+ if (type !== 'command-center') {
132
+ setupInputListeners(content, tabId);
133
+
134
+ // If this is a code agent, start the sandbox proactively
135
+ if (type === 'code') {
136
+ startSandbox(tabId);
137
+ }
138
+ }
139
+
140
+ // Update timeline to remove "closed" indicator
141
+ renderTimeline();
142
+
143
+ // Save workspace state
144
+ saveWorkspaceDebounced();
145
+ }
146
+
147
+ // Render the full timeline widget
148
+ function renderTimeline() {
149
+ const sidebarContent = document.getElementById('sidebarAgents');
150
+ if (!sidebarContent) return;
151
+
152
+ // Get root agents (those without parents) - always include command center for workspace name
153
+ const rootAgents = Object.entries(timelineData)
154
+ .filter(([id, data]) => data.parentTabId === null);
155
+
156
+ let html = '';
157
+
158
+ for (const [tabId, notebook] of rootAgents) {
159
+ html += renderAgentTimeline(parseInt(tabId), notebook);
160
+ }
161
+
162
+ sidebarContent.innerHTML = html;
163
+
164
+ // Update timeline line heights
165
+ updateTimelineLines();
166
+
167
+ // Add click handlers
168
+ sidebarContent.querySelectorAll('.tl-row[data-tab-id]').forEach(row => {
169
+ row.style.cursor = 'pointer';
170
+ row.addEventListener('click', (e) => {
171
+ if (!e.target.closest('.collapse-toggle')) {
172
+ const clickTabId = parseInt(row.dataset.tabId);
173
+ openOrSwitchToTab(clickTabId);
174
+ scrollToTimelineEvent(clickTabId, row.dataset.eventIndex);
175
+ }
176
+ });
177
+ });
178
+
179
+ sidebarContent.querySelectorAll('.agent-box[data-tab-id]').forEach(box => {
180
+ box.style.cursor = 'pointer';
181
+ box.addEventListener('click', (e) => {
182
+ e.stopPropagation(); // Prevent double-firing from parent tl-row
183
+ const clickTabId = parseInt(box.dataset.tabId);
184
+ openOrSwitchToTab(clickTabId);
185
+ });
186
+ });
187
+
188
+ // Add click handler to workspace block header
189
+ sidebarContent.querySelectorAll('.tl-widget[data-tab-id]').forEach(widget => {
190
+ const workspaceBlock = widget.querySelector('.workspace-block');
191
+ if (workspaceBlock) {
192
+ workspaceBlock.style.cursor = 'pointer';
193
+ workspaceBlock.addEventListener('click', (e) => {
194
+ e.stopPropagation();
195
+ const clickTabId = parseInt(widget.dataset.tabId);
196
+ openOrSwitchToTab(clickTabId);
197
+ });
198
+ }
199
+ });
200
+
201
+ sidebarContent.querySelectorAll('.collapse-toggle').forEach(toggle => {
202
+ toggle.addEventListener('click', (e) => {
203
+ e.stopPropagation();
204
+ // Toggle is now in .tl-row.has-agent, find the sibling .tl-nested
205
+ const row = toggle.closest('.tl-row.has-agent');
206
+ if (row) {
207
+ const nested = row.nextElementSibling;
208
+ if (nested && nested.classList.contains('tl-nested')) {
209
+ const childTabId = nested.dataset.childTabId;
210
+ nested.classList.toggle('collapsed');
211
+ toggle.classList.toggle('collapsed');
212
+ // Track collapsed state
213
+ if (childTabId) {
214
+ if (nested.classList.contains('collapsed')) {
215
+ collapsedAgents.add(childTabId);
216
+ } else {
217
+ collapsedAgents.delete(childTabId);
218
+ }
219
+ }
220
+ updateTimelineLines();
221
+ }
222
+ }
223
+ });
224
+ });
225
+ }
226
+
227
+ // Render a single agent's timeline (recursive for nested)
228
+ function renderAgentTimeline(tabId, notebook, isNested = false) {
229
+ const isActive = activeTabId === tabId;
230
+ const isClosed = notebook.isClosed || false;
231
+ const typeLabel = getTypeLabel(notebook.type);
232
+
233
+ let html = `<div class="tl-widget${isNested ? '' : ' compact'}${isActive ? ' active' : ''}${isClosed ? ' closed' : ''}" data-tab-id="${tabId}">`;
234
+
235
+ if (!isNested) {
236
+ // Workspace header - left edge aligned with vertical line
237
+ html += `<div class="workspace-block">
238
+ <div class="workspace-label">PROJECT</div>
239
+ <div class="workspace-name">${escapeHtml(notebook.title)}</div>
240
+ </div>`;
241
+ }
242
+
243
+ html += `<div class="tl">`;
244
+
245
+ // Group events: consecutive assistant events form a group
246
+ const groups = [];
247
+ for (const event of notebook.events) {
248
+ if (event.type === 'assistant') {
249
+ const lastGroup = groups[groups.length - 1];
250
+ if (lastGroup && lastGroup.type === 'assistant') {
251
+ lastGroup.events.push(event);
252
+ } else {
253
+ groups.push({ type: 'assistant', events: [event] });
254
+ }
255
+ } else {
256
+ groups.push({ type: event.type, events: [event] });
257
+ }
258
+ }
259
+
260
+ // Render groups
261
+ for (const group of groups) {
262
+ if (group.type === 'assistant') {
263
+ if (!showAllTurns && group.events.length > 1) {
264
+ // Collapsed: single summary dot with turn count label β€” use first event's index
265
+ const firstEvent = group.events[0];
266
+ html += `
267
+ <div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${firstEvent.index}">
268
+ <div class="tl-dot"></div>
269
+ <span class="tl-turn-count">${group.events.length} turns</span>
270
+ </div>`;
271
+ } else {
272
+ // Expanded or single: render each dot individually
273
+ for (const event of group.events) {
274
+ if (event.meta?.tag) {
275
+ html += `
276
+ <div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${event.index}">
277
+ <div class="tl-dot"></div>
278
+ <div class="tl-tool"><span class="tl-tool-tag">${event.meta.tag}</span>${event.content ? `<span class="tl-tool-text">${escapeHtml(event.content)}</span>` : ''}</div>
279
+ </div>`;
280
+ } else {
281
+ html += `
282
+ <div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${event.index}">
283
+ <div class="tl-dot"></div>
284
+ <span class="tl-label">${escapeHtml(event.content)}</span>
285
+ </div>`;
286
+ }
287
+ }
288
+ }
289
+ } else if (group.type === 'user') {
290
+ const event = group.events[0];
291
+ html += `
292
+ <div class="tl-row turn user" data-tab-id="${tabId}" data-event-index="${event.index}">
293
+ <div class="tl-dot"></div>
294
+ <span class="tl-label">${escapeHtml(event.content)}</span>
295
+ </div>`;
296
+ } else if (group.type === 'agent') {
297
+ const event = group.events[0];
298
+ if (event.childTabId !== null) {
299
+ const childNotebook = timelineData[event.childTabId];
300
+ if (childNotebook) {
301
+ const childTypeLabel = getTypeLabel(childNotebook.type);
302
+ const childIsGenerating = childNotebook.isGenerating;
303
+ const turnCount = childNotebook.events.length;
304
+
305
+ const hasEvents = childNotebook.events.length > 0;
306
+ const isCollapsed = collapsedAgents.has(String(event.childTabId));
307
+ const isChildActive = activeTabId === event.childTabId;
308
+ html += `
309
+ <div class="tl-row has-agent${hasEvents ? ' has-nested' : ''}" data-tab-id="${event.childTabId}" data-event-index="${event.index}">
310
+ <div class="tl-dot"></div>
311
+ <div class="agent-box${isChildActive ? ' active' : ''}" data-tab-id="${event.childTabId}">
312
+ <div class="agent-header">
313
+ ${hasEvents ? `<div class="collapse-toggle${isCollapsed ? ' collapsed' : ''}"></div>` : ''}
314
+ <span>${childTypeLabel}</span>
315
+ </div>
316
+ <div class="agent-body">
317
+ <span class="agent-body-text">${escapeHtml(childNotebook.title)}</span>
318
+ <div class="agent-status">
319
+ <span>${turnCount} turns</span>
320
+ ${childIsGenerating ? `
321
+ <div class="agent-progress"><span></span><span></span><span></span></div>
322
+ ` : `
323
+ <div class="agent-done${childNotebook.aborted ? ' aborted' : ''}"></div>
324
+ `}
325
+ </div>
326
+ </div>
327
+ </div>
328
+ </div>`;
329
+
330
+ // Render nested timeline if child has events
331
+ if (hasEvents) {
332
+ const isComplete = !childIsGenerating;
333
+ html += `
334
+ <div class="tl-nested${isComplete ? ' complete' : ''}${isCollapsed ? ' collapsed' : ''}" data-child-tab-id="${event.childTabId}">
335
+ ${renderAgentTimeline(event.childTabId, childNotebook, true)}
336
+ </div>`;
337
+ // Return row with dot on parent line - only when subagent is complete
338
+ if (isComplete) {
339
+ html += `
340
+ <div class="tl-row tl-return">
341
+ <div class="tl-dot"></div>
342
+ <div class="tl-return-connector"></div>
343
+ </div>`;
344
+ }
345
+ }
346
+ }
347
+ }
348
+ }
349
+ }
350
+
351
+ // Show generating indicator if currently generating and no events yet
352
+ if (notebook.isGenerating && notebook.events.length === 0) {
353
+ html += `
354
+ <div class="tl-row turn">
355
+ <div class="tl-dot generating"></div>
356
+ </div>`;
357
+ }
358
+
359
+ html += `</div></div>`;
360
+
361
+ return html;
362
+ }
363
+
364
+ // Update timeline line heights and return line positions
365
+ function updateTimelineLines() {
366
+ document.querySelectorAll('.tl').forEach(tl => {
367
+ const rows = Array.from(tl.children).filter(el => el.classList.contains('tl-row'));
368
+ if (rows.length < 1) return;
369
+
370
+ const firstRow = rows[0];
371
+ const firstDot = firstRow.querySelector('.tl-dot');
372
+ if (!firstDot) return;
373
+
374
+ const lastRow = rows[rows.length - 1];
375
+ const lastDot = lastRow.querySelector('.tl-dot');
376
+ if (!lastDot) return;
377
+
378
+ const tlRect = tl.getBoundingClientRect();
379
+ const firstDotRect = firstDot.getBoundingClientRect();
380
+ const lastDotRect = lastDot.getBoundingClientRect();
381
+
382
+ const dotOffset = 2;
383
+ const isNested = tl.closest('.tl-nested') !== null;
384
+ const lineTop = isNested ? -6 : (firstDotRect.top - tlRect.top + dotOffset);
385
+ const lineBottom = lastDotRect.top - tlRect.top + dotOffset;
386
+
387
+ tl.style.setProperty('--line-top', lineTop + 'px');
388
+ tl.style.setProperty('--line-height', (lineBottom - lineTop) + 'px');
389
+ });
390
+
391
+ // Position return rows to align with last nested dot or agent-box
392
+ document.querySelectorAll('.tl-nested').forEach(nested => {
393
+ const returnRow = nested.nextElementSibling;
394
+ if (!returnRow || !returnRow.classList.contains('tl-return')) return;
395
+
396
+ const isCollapsed = nested.classList.contains('collapsed');
397
+ const connector = returnRow.querySelector('.tl-return-connector');
398
+ const returnDot = returnRow.querySelector('.tl-dot');
399
+ if (!returnDot) return;
400
+
401
+ // Reset position first to get accurate baseline measurement
402
+ returnRow.style.top = '0';
403
+
404
+ // Find the agent-box in the previous sibling row
405
+ const agentRow = nested.previousElementSibling;
406
+ const agentBox = agentRow?.querySelector('.agent-box');
407
+
408
+ if (isCollapsed && agentBox) {
409
+ // When collapsed: align return dot with bottom of agent-box
410
+ const agentBoxRect = agentBox.getBoundingClientRect();
411
+ const returnDotRect = returnDot.getBoundingClientRect();
412
+
413
+ // Align return dot's top with agent-box bottom
414
+ const yOffset = agentBoxRect.bottom - returnDotRect.top - 3;
415
+ returnRow.style.top = yOffset + 'px';
416
+
417
+ if (connector) {
418
+ // Connector goes from return dot to agent-box
419
+ const connectorWidth = agentBoxRect.left - returnDotRect.right;
420
+ connector.style.width = Math.max(0, connectorWidth) + 'px';
421
+ connector.style.background = 'var(--theme-accent)';
422
+ }
423
+ } else {
424
+ // When expanded: align return dot with last nested dot
425
+ const nestedTl = nested.querySelector('.tl');
426
+ if (!nestedTl) return;
427
+
428
+ const nestedRows = Array.from(nestedTl.children).filter(el => el.classList.contains('tl-row'));
429
+ if (nestedRows.length === 0) return;
430
+
431
+ const lastNestedRow = nestedRows[nestedRows.length - 1];
432
+ const lastNestedDot = lastNestedRow.querySelector('.tl-dot');
433
+
434
+ if (lastNestedDot && returnDot) {
435
+ const lastNestedRect = lastNestedDot.getBoundingClientRect();
436
+ const returnDotRect = returnDot.getBoundingClientRect();
437
+
438
+ // Calculate offset to align Y positions
439
+ const yOffset = lastNestedRect.top - returnDotRect.top;
440
+ returnRow.style.top = yOffset + 'px';
441
+
442
+ // Connector width: from return dot to nested timeline's vertical line
443
+ if (connector) {
444
+ const nestedTlRect = nestedTl.getBoundingClientRect();
445
+ // Nested vertical line is at left: 2px from nested .tl
446
+ const nestedLineX = nestedTlRect.left + 2;
447
+ const connectorWidth = nestedLineX - returnDotRect.right;
448
+
449
+ connector.style.width = Math.max(0, connectorWidth) + 'px';
450
+ connector.style.background = 'var(--border-primary)';
451
+ }
452
+ }
453
+ }
454
+ });
455
+ }
456
+
457
+ let IS_MULTI_USER = false;
458
+
459
+ function showUsernameOverlay() {
460
+ return new Promise(resolve => {
461
+ const overlay = document.getElementById('usernameOverlay');
462
+ const input = document.getElementById('usernameInput');
463
+ const submit = document.getElementById('usernameSubmit');
464
+ const warning = document.getElementById('usernameWarning');
465
+ if (!overlay) { resolve(); return; }
466
+ overlay.style.display = 'flex';
467
+ input.value = '';
468
+ warning.style.display = 'none';
469
+ input.focus();
470
+
471
+ // Check if username exists on input change (debounced)
472
+ let checkTimeout;
473
+ const checkExists = async () => {
474
+ const name = sanitizeUsername(input.value);
475
+ if (!name) { warning.style.display = 'none'; return; }
476
+ try {
477
+ const resp = await fetch(`/api/user/exists/${encodeURIComponent(name)}`);
478
+ const data = await resp.json();
479
+ if (data.exists) {
480
+ warning.textContent = `"${name}" already has a workspace β€” you'll share it`;
481
+ warning.style.display = 'block';
482
+ } else {
483
+ warning.style.display = 'none';
484
+ }
485
+ } catch { warning.style.display = 'none'; }
486
+ };
487
+ input.oninput = () => { clearTimeout(checkTimeout); checkTimeout = setTimeout(checkExists, 300); };
488
+
489
+ const doSubmit = () => {
490
+ const name = sanitizeUsername(input.value);
491
+ if (!name) return;
492
+ SESSION_ID = name;
493
+ localStorage.setItem('agentui_username', name);
494
+ overlay.style.display = 'none';
495
+ updateUserIndicator();
496
+ input.oninput = null;
497
+ resolve();
498
+ };
499
+ submit.onclick = doSubmit;
500
+ input.onkeydown = (e) => { if (e.key === 'Enter') doSubmit(); };
501
+ });
502
+ }
503
+
504
+ function updateUserIndicator() {
505
+ const indicator = document.getElementById('userIndicator');
506
+ const nameEl = document.getElementById('userIndicatorName');
507
+ if (!indicator || !nameEl) return;
508
+ if (IS_MULTI_USER && SESSION_ID) {
509
+ nameEl.textContent = SESSION_ID;
510
+ indicator.title = 'Click to switch user';
511
+ indicator.style.display = 'flex';
512
+ indicator.onclick = async () => {
513
+ await showUsernameOverlay();
514
+ window.location.reload();
515
+ };
516
+ } else {
517
+ indicator.style.display = 'none';
518
+ }
519
+ }
frontend/utils.js ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ============================================================
2
+ // Multi-user session management
3
+ // ============================================================
4
+ let SESSION_ID = localStorage.getItem('agentui_username') || '';
5
+
6
+ function apiFetch(url, options = {}) {
7
+ if (SESSION_ID) {
8
+ options.headers = { ...options.headers, 'X-Session-ID': SESSION_ID };
9
+ }
10
+ return fetch(url, options);
11
+ }
12
+
13
+ function sanitizeUsername(name) {
14
+ return name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '').substring(0, 30);
15
+ }
16
+
17
+ // ============================================================
18
+ // Agent Type Registry β€” populated from backend /api/agents at startup
19
+ // To add a new agent type, add an entry in backend/agents.py (single source of truth)
20
+ // ============================================================
21
+ let AGENT_REGISTRY = {};
22
+ // Virtual types used only in timeline rendering (not real agents)
23
+ const VIRTUAL_TYPE_LABELS = { search: 'SEARCH', browse: 'BROWSE' };
24
+
25
+ // Derived helpers from registry
26
+ function getTypeLabel(type) {
27
+ if (AGENT_REGISTRY[type]) return AGENT_REGISTRY[type].label;
28
+ if (VIRTUAL_TYPE_LABELS[type]) return VIRTUAL_TYPE_LABELS[type];
29
+ return type.toUpperCase();
30
+ }
31
+ function getPlaceholder(type) {
32
+ return AGENT_REGISTRY[type]?.placeholder || 'Enter message...';
33
+ }
34
+ function getDefaultCounters() {
35
+ const counters = {};
36
+ for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
37
+ if (agent.hasCounter) counters[key] = 0;
38
+ }
39
+ return counters;
40
+ }
41
+
42
+ // State management
43
+ let tabCounter = 1;
44
+ let activeTabId = 0;
45
+ let currentSession = null; // Name of the current session
46
+ const collapsedAgents = new Set(); // Track collapsed agent tab IDs
47
+ let researchQueryTabIds = {}; // queryIndex -> virtual tabId for research timeline
48
+ let showAllTurns = true; // Toggle to show/hide individual assistant dots
49
+
50
+ // Fetch random isotope name from backend
51
+ async function generateSessionName() {
52
+ try {
53
+ const response = await apiFetch('/api/sessions/random-name');
54
+ const data = await response.json();
55
+ return data.name;
56
+ } catch (error) {
57
+ // Fallback to timestamp if API fails
58
+ return `session-${Date.now()}`;
59
+ }
60
+ }
61
+
62
+ let settings = {
63
+ // New provider/model structure
64
+ providers: {}, // providerId -> {name, endpoint, token}
65
+ models: {}, // modelId -> {name, providerId, modelId (API model string)}
66
+ agents: {}, // Populated after AGENT_REGISTRY is fetched
67
+ // Service API keys
68
+ e2bKey: '',
69
+ serperKey: '',
70
+ hfToken: '',
71
+ // Image model selections (model IDs from the models list)
72
+ imageGenModel: '',
73
+ imageEditModel: '',
74
+ // Research settings
75
+ researchSubAgentModel: '',
76
+ researchParallelWorkers: null,
77
+ researchMaxWebsites: null,
78
+ // UI settings
79
+ themeColor: 'forest',
80
+ // Schema version for migrations
81
+ settingsVersion: 2
82
+ };
83
+
84
+ // Track action widgets for result updates (maps tabId -> widget element)
85
+ const actionWidgets = {};
86
+
87
+ // Track tool call IDs for result updates (maps tabId -> tool_call_id)
88
+ const toolCallIds = {};
89
+
90
+ // Global figure/image registry populated by sub-agents for cross-agent reference resolution
91
+ // Maps "figure_1" -> {type, data} and "image_1" -> {type: "png", data: base64}
92
+ const globalFigureRegistry = {};
93
+
94
+ // Debug: per-tab LLM call history (populated by SSE debug_call_input/output events)
95
+ // Maps tabId -> [{call_number, timestamp, input, output, error}]
96
+ const debugHistory = {};
97
+
98
+ // Track agents by task_id for reuse (maps task_id -> tabId)
99
+ const taskIdToTabId = {};
100
+
101
+ // Whether command center input is blocked waiting for agents to finish
102
+ let commandInputBlocked = false;
103
+
104
+ // Count of agent launches that haven't started generating yet (handles race condition)
105
+ let pendingAgentLaunches = 0;
106
+
107
+ // Track agent counters for each type (derived from registry)
108
+ let agentCounters = getDefaultCounters();
109
+
110
+ // Debounce timer for workspace saving
111
+ let saveWorkspaceTimer = null;
112
+
113
+ // Abort controllers for in-flight fetch requests (tabId -> AbortController)
114
+ const activeAbortControllers = {};
115
+
116
+ // Timeline data structure for sidebar
117
+ // Maps tabId -> { type, title, events: [{type: 'user'|'assistant'|'agent', content, childTabId?}], parentTabId?, isGenerating }
118
+ const timelineData = {
119
+ 0: { type: 'command', title: 'Task Center', events: [], parentTabId: null, isGenerating: false }
120
+ };
121
+
122
+ // Reset all local state for session switching (without page reload)
123
+ function resetLocalState() {
124
+ // Reset counters
125
+ tabCounter = 1;
126
+ activeTabId = 0;
127
+ currentSession = null;
128
+ collapsedAgents.clear();
129
+
130
+ // Clear object maps
131
+ Object.keys(actionWidgets).forEach(k => delete actionWidgets[k]);
132
+ Object.keys(toolCallIds).forEach(k => delete toolCallIds[k]);
133
+ Object.keys(globalFigureRegistry).forEach(k => delete globalFigureRegistry[k]);
134
+ Object.keys(debugHistory).forEach(k => delete debugHistory[k]);
135
+ Object.keys(taskIdToTabId).forEach(k => delete taskIdToTabId[k]);
136
+ researchQueryTabIds = {};
137
+ showAllTurns = true;
138
+ agentCounters = getDefaultCounters();
139
+
140
+ // Reset sidebar checkboxes
141
+ const compactCb = document.getElementById('compactViewCheckbox');
142
+ if (compactCb) compactCb.checked = false;
143
+ const collapseAgentsCb = document.getElementById('collapseAgentsCheckbox');
144
+ if (collapseAgentsCb) collapseAgentsCb.checked = false;
145
+ const collapseToolsCb = document.getElementById('collapseToolsCheckbox');
146
+ if (collapseToolsCb) collapseToolsCb.checked = false;
147
+
148
+ // Reset timeline data
149
+ Object.keys(timelineData).forEach(k => delete timelineData[k]);
150
+ timelineData[0] = { type: 'command', title: 'Task Center', events: [], parentTabId: null, isGenerating: false };
151
+
152
+ // Clear dynamic tabs from DOM
153
+ const dynamicTabs = document.getElementById('dynamicTabs');
154
+ if (dynamicTabs) dynamicTabs.innerHTML = '';
155
+
156
+ // Remove all dynamic tab content elements (keep tab-content[data-content-id="0"])
157
+ document.querySelectorAll('.tab-content').forEach(el => {
158
+ if (el.dataset.contentId !== '0') el.remove();
159
+ });
160
+
161
+ // Clear command center messages
162
+ const commandMessages = document.getElementById('messages-command');
163
+ if (commandMessages) commandMessages.innerHTML = '';
164
+
165
+ // Close any open panels
166
+ closeAllPanels();
167
+ }
168
+
169
+ function escapeHtml(text) {
170
+ const div = document.createElement('div');
171
+ div.textContent = text;
172
+ return div.innerHTML;
173
+ }
174
+
175
+ function formatDate(timestamp) {
176
+ const date = new Date(timestamp * 1000);
177
+ const now = new Date();
178
+ const diff = now - date;
179
+
180
+ if (diff < 60000) return 'just now';
181
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
182
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
183
+ if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago';
184
+
185
+ return date.toLocaleDateString();
186
+ }
187
+
188
+ // ============================================================
189
+ // Shared UI helpers (deduplication)
190
+ // ============================================================
191
+
192
+ // Wire send-button, textarea auto-resize, and Enter-to-send for any agent tab
193
+ function setupInputListeners(container, tabId) {
194
+ const input = container.querySelector('textarea');
195
+ const sendBtn = container.querySelector('.input-container button');
196
+ if (!input || !sendBtn) return;
197
+
198
+ sendBtn.addEventListener('click', () => sendMessage(tabId));
199
+
200
+ input.addEventListener('input', () => {
201
+ input.style.height = 'auto';
202
+ input.style.height = Math.min(input.scrollHeight, 200) + 'px';
203
+ input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden';
204
+ });
205
+
206
+ input.addEventListener('keydown', (e) => {
207
+ if (e.key === 'Enter' && !e.shiftKey) {
208
+ e.preventDefault();
209
+ sendMessage(tabId);
210
+ }
211
+ });
212
+ }
213
+
214
+ // Wire click-to-collapse on tool cells, code cells, and action widgets
215
+ function setupCollapseToggle(cell, labelSelector) {
216
+ const label = cell.querySelector(labelSelector || '.tool-cell-label, .code-cell-label');
217
+ if (!label) return;
218
+ label.addEventListener('click', () => {
219
+ cell.classList.toggle('collapsed');
220
+ const toggle = cell.querySelector('.widget-collapse-toggle');
221
+ if (toggle) toggle.classList.toggle('collapsed');
222
+ });
223
+ }
224
+
225
+ // Close all right-side panels (settings, debug, files, sessions)
226
+ function closeAllPanels() {
227
+ const app = document.querySelector('.app-container');
228
+ for (const [panelId, btnId, cls] of [
229
+ ['settingsPanel', 'settingsBtn', 'panel-open'],
230
+ ['debugPanel', 'debugBtn', 'panel-open'],
231
+ ['filesPanel', 'filesBtn', 'files-panel-open'],
232
+ ['sessionsPanel', 'sessionsBtn', 'sessions-panel-open'],
233
+ ]) {
234
+ document.getElementById(panelId)?.classList.remove('active');
235
+ document.getElementById(btnId)?.classList.remove('active');
236
+ if (cls && app) app.classList.remove(cls);
237
+ }
238
+ }
frontend/workspace.js ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ============================================
2
+ // Workspace State Persistence
3
+ // ============================================
4
+
5
+ async function loadWorkspace() {
6
+ try {
7
+ const response = await apiFetch('/api/workspace');
8
+ if (response.ok) {
9
+ const workspace = await response.json();
10
+ console.log('Workspace loaded:', workspace);
11
+ restoreWorkspace(workspace);
12
+ }
13
+ } catch (e) {
14
+ console.log('Could not load workspace from backend:', e);
15
+ }
16
+ }
17
+
18
+ function restoreWorkspace(workspace) {
19
+ // Restore counters
20
+ tabCounter = workspace.tabCounter || 1;
21
+ agentCounters = workspace.agentCounters || workspace.notebookCounters || getDefaultCounters();
22
+
23
+ // Restore timeline data before tabs so renderTimeline works
24
+ if (workspace.timelineData) {
25
+ Object.keys(timelineData).forEach(k => delete timelineData[k]);
26
+ for (const [tabId, data] of Object.entries(workspace.timelineData)) {
27
+ timelineData[tabId] = data;
28
+ }
29
+ }
30
+
31
+ // Restore debug history
32
+ if (workspace.debugHistory) {
33
+ Object.keys(debugHistory).forEach(k => delete debugHistory[k]);
34
+ for (const [tabId, calls] of Object.entries(workspace.debugHistory)) {
35
+ debugHistory[tabId] = calls;
36
+ }
37
+ }
38
+
39
+ // Restore tabs (skip command center as it already exists in HTML)
40
+ const tabs = workspace.tabs || [];
41
+ for (const tabData of tabs) {
42
+ if (tabData.id === 0) {
43
+ // Restore command center messages
44
+ restoreTabMessages(tabData);
45
+ } else {
46
+ // Create and restore other tabs
47
+ restoreTab(tabData);
48
+ }
49
+ }
50
+
51
+ // Switch to the active tab
52
+ if (workspace.activeTabId !== undefined) {
53
+ switchToTab(workspace.activeTabId);
54
+ }
55
+
56
+ // Render timeline after everything is restored
57
+ renderTimeline();
58
+ }
59
+
60
+ function restoreTab(tabData) {
61
+ // Create tab element
62
+ const tab = document.createElement('div');
63
+ tab.className = 'tab';
64
+ tab.dataset.tabId = tabData.id;
65
+ tab.innerHTML = `
66
+ <span class="tab-title">${tabData.title || getTypeLabel(tabData.type) || 'TAB'}</span>
67
+ <span class="tab-status" style="display: none;"><span></span><span></span><span></span></span>
68
+ <span class="tab-close">Γ—</span>
69
+ `;
70
+
71
+ // Insert into dynamic tabs container
72
+ const dynamicTabs = document.getElementById('dynamicTabs');
73
+ dynamicTabs.appendChild(tab);
74
+
75
+ // Create content element
76
+ const content = document.createElement('div');
77
+ content.className = 'tab-content';
78
+ content.dataset.contentId = tabData.id;
79
+ content.innerHTML = createAgentContent(tabData.type, tabData.id);
80
+ document.querySelector('.main-content').appendChild(content);
81
+
82
+ // Add event listeners for the new content
83
+ setupInputListeners(content, tabData.id);
84
+
85
+ // Restore messages
86
+ restoreTabMessages(tabData);
87
+
88
+ // If this is a code agent, start the sandbox proactively
89
+ if (tabData.type === 'code') {
90
+ startSandbox(tabData.id);
91
+ }
92
+ }
93
+
94
+ function restoreTabMessages(tabData) {
95
+ const content = document.querySelector(`[data-content-id="${tabData.id}"]`);
96
+ if (!content) return;
97
+
98
+ const chatContainer = content.querySelector('.chat-container');
99
+ if (!chatContainer) return;
100
+
101
+ // Remove welcome message if restoring messages
102
+ if (tabData.messages && tabData.messages.length > 0) {
103
+ const welcomeMsg = chatContainer.querySelector('.welcome-message');
104
+ if (welcomeMsg) {
105
+ welcomeMsg.remove();
106
+ }
107
+ }
108
+
109
+ // Restore each message - messages are now flat, each with its own type
110
+ for (const msg of (tabData.messages || [])) {
111
+ if (msg.role === 'user') {
112
+ const userMsg = document.createElement('div');
113
+ userMsg.className = 'message user';
114
+ userMsg.innerHTML = `<div class="message-content">${parseMarkdown(msg.content || '')}</div>`;
115
+ linkifyFilePaths(userMsg);
116
+ chatContainer.appendChild(userMsg);
117
+ } else if (msg.role === 'assistant') {
118
+ // New flat structure: each message has a type field
119
+ switch (msg.type) {
120
+ case 'text':
121
+ const assistantMsg = document.createElement('div');
122
+ assistantMsg.className = 'message assistant';
123
+ const textDiv = document.createElement('div');
124
+ textDiv.className = 'message-content';
125
+ textDiv.innerHTML = msg.html || parseMarkdown(msg.content || '');
126
+ assistantMsg.appendChild(textDiv);
127
+ chatContainer.appendChild(assistantMsg);
128
+ break;
129
+
130
+ case 'action-widget':
131
+ const widget = renderActionWidget(msg);
132
+ chatContainer.appendChild(widget);
133
+ break;
134
+
135
+ case 'code-cell':
136
+ const codeCell = renderCodeCell(msg);
137
+ chatContainer.appendChild(codeCell);
138
+ break;
139
+
140
+ case 'tool-call':
141
+ // Restore hidden tool-call element for getConversationHistory()
142
+ const tcMsg = document.createElement('div');
143
+ tcMsg.className = 'message assistant';
144
+ tcMsg.style.display = 'none';
145
+ tcMsg.setAttribute('data-tool-call', JSON.stringify(msg.data));
146
+ chatContainer.appendChild(tcMsg);
147
+ break;
148
+
149
+ case 'tool-cell':
150
+ const toolCell = document.createElement('div');
151
+ toolCell.className = 'tool-cell';
152
+ toolCell.setAttribute('data-tool-name', msg.toolName || '');
153
+ const toolDesc = msg.input ? `<span class="tool-cell-desc">${escapeHtml(msg.input)}</span>` : '';
154
+ let toolCellHtml = `<div class="tool-cell-label"><div class="widget-collapse-toggle"></div><span>${escapeHtml(msg.label || '')}</span>${toolDesc}</div>`;
155
+ toolCellHtml += `<div class="tool-cell-input">${escapeHtml(msg.input || '')}</div>`;
156
+ if (msg.outputHtml) {
157
+ toolCellHtml += `<div class="tool-cell-output">${msg.outputHtml}</div>`;
158
+ }
159
+ toolCell.innerHTML = toolCellHtml;
160
+ setupCollapseToggle(toolCell, '.tool-cell-label');
161
+ chatContainer.appendChild(toolCell);
162
+ break;
163
+
164
+ case 'result-preview':
165
+ const preview = renderResultPreview(msg);
166
+ chatContainer.appendChild(preview);
167
+ break;
168
+
169
+ case 'agent-result':
170
+ const report = renderResearchReport(msg);
171
+ chatContainer.appendChild(report);
172
+ break;
173
+
174
+ case 'research-container':
175
+ const researchWidget = renderResearchContainer(msg);
176
+ chatContainer.appendChild(researchWidget);
177
+ break;
178
+
179
+ default:
180
+ // Legacy fallback: messages without type or with parts array
181
+ if (msg.parts && msg.parts.length > 0) {
182
+ // Old format with parts array
183
+ for (const part of msg.parts) {
184
+ switch (part.type) {
185
+ case 'text':
186
+ const oldTextDiv = document.createElement('div');
187
+ oldTextDiv.className = 'message assistant';
188
+ oldTextDiv.innerHTML = `<div class="message-content">${part.html || parseMarkdown(part.content || '')}</div>`;
189
+ chatContainer.appendChild(oldTextDiv);
190
+ break;
191
+ case 'action-widget':
192
+ chatContainer.appendChild(renderActionWidget(part));
193
+ break;
194
+ case 'code-cell':
195
+ chatContainer.appendChild(renderCodeCell(part));
196
+ break;
197
+ case 'result-preview':
198
+ chatContainer.appendChild(renderResultPreview(part));
199
+ break;
200
+ case 'agent-result':
201
+ chatContainer.appendChild(renderResearchReport(part));
202
+ break;
203
+ }
204
+ }
205
+ } else if (msg.contentHtml) {
206
+ const legacyMsg = document.createElement('div');
207
+ legacyMsg.className = 'message assistant';
208
+ legacyMsg.innerHTML = msg.contentHtml;
209
+ chatContainer.appendChild(legacyMsg);
210
+ } else if (msg.content) {
211
+ const contentMsg = document.createElement('div');
212
+ contentMsg.className = 'message assistant';
213
+ contentMsg.innerHTML = `<div class="message-content">${parseMarkdown(msg.content)}</div>`;
214
+ chatContainer.appendChild(contentMsg);
215
+ }
216
+ break;
217
+ }
218
+ } else if (msg.role === 'tool' && msg.type === 'tool-response') {
219
+ // Restore hidden tool-response element for getConversationHistory()
220
+ const trMsg = document.createElement('div');
221
+ trMsg.className = 'message tool';
222
+ trMsg.style.display = 'none';
223
+ trMsg.setAttribute('data-tool-response', JSON.stringify(msg.data));
224
+ chatContainer.appendChild(trMsg);
225
+ }
226
+ }
227
+ }
228
+
229
+ // Helper functions to render saved message parts
230
+
231
+ function renderActionWidget(data) {
232
+ const widget = document.createElement('div');
233
+ widget.className = 'action-widget';
234
+ if (data.targetTabId) {
235
+ widget.dataset.targetTabId = data.targetTabId;
236
+ }
237
+
238
+ const indicatorHtml = data.isDone
239
+ ? '<div class="done-indicator"></div>'
240
+ : '<div class="orbit-indicator"><span></span><span></span><span></span></div>';
241
+
242
+ let bodyHtml = `
243
+ <div class="section-label">QUERY</div>
244
+ <div class="section-content">${parseMarkdown(data.query || '')}</div>
245
+ `;
246
+
247
+ // Add result section if present
248
+ if (data.resultHtml || data.result) {
249
+ bodyHtml += `
250
+ <div class="action-widget-result-section">
251
+ <div class="section-label" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-primary);">RESULT</div>
252
+ <div class="section-content">${data.resultHtml || escapeHtml(data.result || '')}</div>
253
+ </div>
254
+ `;
255
+ }
256
+
257
+ widget.innerHTML = `
258
+ <div class="action-widget-clickable">
259
+ <div class="action-widget-header">
260
+ <div class="widget-collapse-toggle"></div>
261
+ <span class="action-widget-type">TASK: ${(data.actionType || '').toUpperCase()}</span>
262
+ <div class="action-widget-bar-right">
263
+ ${indicatorHtml}
264
+ </div>
265
+ </div>
266
+ </div>
267
+ <div class="action-widget-body">
268
+ ${bodyHtml}
269
+ </div>
270
+ `;
271
+
272
+ // Collapse toggle
273
+ const collapseToggle = widget.querySelector('.widget-collapse-toggle');
274
+ collapseToggle.addEventListener('click', (e) => {
275
+ e.stopPropagation();
276
+ widget.classList.toggle('collapsed');
277
+ collapseToggle.classList.toggle('collapsed');
278
+ });
279
+
280
+ // Make clickable if we have a target tab
281
+ if (data.targetTabId) {
282
+ const clickableArea = widget.querySelector('.action-widget-clickable');
283
+ clickableArea.addEventListener('click', () => {
284
+ const tabId = parseInt(data.targetTabId);
285
+ if (!isNaN(tabId)) {
286
+ // Check if the tab still exists (use .tab to avoid matching timeline elements)
287
+ const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
288
+ if (tab) {
289
+ // Tab exists, just switch to it
290
+ switchToTab(tabId);
291
+ } else {
292
+ // Tab was closed - restore from timeline data
293
+ const notebook = timelineData[tabId];
294
+ if (notebook) {
295
+ reopenClosedTab(tabId, notebook);
296
+ }
297
+ }
298
+ }
299
+ });
300
+ }
301
+
302
+ return widget;
303
+ }
304
+
305
+ function renderCodeCell(data) {
306
+ const cell = document.createElement('div');
307
+ cell.className = 'code-cell';
308
+
309
+ let outputHtml = '';
310
+ const cleanedData = cleanCodeOutput(data.output);
311
+ if (data.outputHtml || cleanedData) {
312
+ const errorClass = data.isError ? ' error' : '';
313
+ outputHtml = `<div class="code-cell-output${errorClass}">${data.outputHtml || escapeHtml(cleanedData)}</div>`;
314
+ }
315
+
316
+ // Build images HTML
317
+ let imagesHtml = '';
318
+ if (data.images && data.images.length > 0) {
319
+ for (const img of data.images) {
320
+ let labelHtml = '';
321
+ if (img.name) {
322
+ labelHtml = `<div class="figure-label">${escapeHtml(img.name)}</div>`;
323
+ }
324
+ imagesHtml += `<div class="code-cell-image">${labelHtml}<img src="${img.src}" onclick="openImageModal(this.src)"></div>`;
325
+ }
326
+ }
327
+
328
+ cell.innerHTML = `
329
+ <div class="code-cell-label"><div class="widget-collapse-toggle"></div><span>Code</span></div>
330
+ <div class="code-cell-code">
331
+ <pre><code class="language-python">${escapeHtml(data.code || '')}</code></pre>
332
+ </div>
333
+ ${outputHtml}
334
+ ${imagesHtml}
335
+ `;
336
+ setupCollapseToggle(cell, '.code-cell-label');
337
+
338
+ return cell;
339
+ }
340
+
341
+ function renderResultPreview(data) {
342
+ const preview = document.createElement('div');
343
+ preview.className = 'result-preview';
344
+
345
+ preview.innerHTML = `
346
+ <div class="result-preview-label">Result</div>
347
+ <div class="result-preview-content">${data.html || escapeHtml(data.content || '')}</div>
348
+ `;
349
+ linkifyFilePaths(preview);
350
+
351
+ return preview;
352
+ }
353
+
354
+ function renderResearchReport(data) {
355
+ const report = document.createElement('div');
356
+ report.className = 'agent-result';
357
+
358
+ report.innerHTML = `
359
+ <div class="result-header">Result</div>
360
+ <div class="result-content">${data.html || parseMarkdown(data.content || '')}</div>
361
+ `;
362
+ linkifyFilePaths(report);
363
+
364
+ return report;
365
+ }
366
+
367
+ function renderResearchContainer(data) {
368
+ const container = document.createElement('div');
369
+ container.className = 'research-container message assistant';
370
+
371
+ container.innerHTML = `
372
+ <div class="research-header">
373
+ <span class="research-header-title">RESEARCH</span>
374
+ </div>
375
+ <div class="research-body">${data.html || ''}</div>
376
+ `;
377
+
378
+ return container;
379
+ }
380
+
381
+ function serializeWorkspace() {
382
+ const workspace = {
383
+ version: 1,
384
+ tabCounter: tabCounter,
385
+ activeTabId: activeTabId,
386
+ agentCounters: agentCounters,
387
+ tabs: [],
388
+ timelineData: serializeTimelineData(),
389
+ debugHistory: debugHistory
390
+ };
391
+
392
+ // Serialize command center (tab 0)
393
+ workspace.tabs.push(serializeTab(0, 'command-center'));
394
+
395
+ // Serialize all other tabs
396
+ const tabElements = document.querySelectorAll('#dynamicTabs .tab');
397
+ for (const tabEl of tabElements) {
398
+ const tabId = parseInt(tabEl.dataset.tabId);
399
+ const content = document.querySelector(`[data-content-id="${tabId}"]`);
400
+ if (content) {
401
+ const chatContainer = content.querySelector('.chat-container');
402
+ const agentType = chatContainer?.dataset.agentType || 'chat';
403
+ workspace.tabs.push(serializeTab(tabId, agentType));
404
+ }
405
+ }
406
+
407
+ return workspace;
408
+ }
409
+
410
+ function serializeTimelineData() {
411
+ const serialized = {};
412
+ for (const [tabId, data] of Object.entries(timelineData)) {
413
+ // Clone but strip savedContent (large HTML, not needed for timeline)
414
+ serialized[tabId] = {
415
+ type: data.type,
416
+ title: data.title,
417
+ events: data.events,
418
+ parentTabId: data.parentTabId,
419
+ isGenerating: false,
420
+ isClosed: data.isClosed || false
421
+ };
422
+ }
423
+ return serialized;
424
+ }
425
+
426
+ function serializeTab(tabId, type) {
427
+ const tabEl = document.querySelector(`[data-tab-id="${tabId}"]`);
428
+ const content = document.querySelector(`[data-content-id="${tabId}"]`);
429
+
430
+ const tabData = {
431
+ id: tabId,
432
+ type: type,
433
+ title: tabEl?.querySelector('.tab-title')?.textContent || getTypeLabel(type) || 'TAB',
434
+ messages: []
435
+ };
436
+
437
+ if (!content) return tabData;
438
+
439
+ const chatContainer = content.querySelector('.chat-container');
440
+ if (!chatContainer) return tabData;
441
+
442
+ // Iterate over ALL direct children of chatContainer in order
443
+ // Elements can be: .message.user, .message.assistant, .action-widget, .code-cell, .agent-result, .system-message, .welcome-message
444
+ for (const child of chatContainer.children) {
445
+ // Skip welcome message and system messages
446
+ if (child.classList.contains('welcome-message') || child.classList.contains('system-message')) {
447
+ continue;
448
+ }
449
+
450
+ // User message
451
+ if (child.classList.contains('message') && child.classList.contains('user')) {
452
+ tabData.messages.push({
453
+ role: 'user',
454
+ content: child.textContent.trim()
455
+ });
456
+ continue;
457
+ }
458
+
459
+ // Research container (the search tree widget) - check BEFORE generic assistant message
460
+ // because research-container also has .message.assistant classes
461
+ if (child.classList.contains('research-container')) {
462
+ const bodyEl = child.querySelector('.research-body');
463
+ tabData.messages.push({
464
+ role: 'assistant',
465
+ type: 'research-container',
466
+ html: bodyEl?.innerHTML || ''
467
+ });
468
+ continue;
469
+ }
470
+
471
+ // Tool-call message (hidden, used for LLM conversation history)
472
+ if (child.classList.contains('message') && child.hasAttribute('data-tool-call')) {
473
+ try {
474
+ tabData.messages.push({
475
+ role: 'assistant',
476
+ type: 'tool-call',
477
+ data: JSON.parse(child.getAttribute('data-tool-call'))
478
+ });
479
+ } catch (e) { /* skip unparseable */ }
480
+ continue;
481
+ }
482
+
483
+ // Tool-response message (hidden, used for LLM conversation history)
484
+ if (child.classList.contains('message') && (child.hasAttribute('data-tool-response') || child.classList.contains('tool'))) {
485
+ try {
486
+ tabData.messages.push({
487
+ role: 'tool',
488
+ type: 'tool-response',
489
+ data: JSON.parse(child.getAttribute('data-tool-response'))
490
+ });
491
+ } catch (e) { /* skip unparseable */ }
492
+ continue;
493
+ }
494
+
495
+ // Assistant message (with .message-content inside)
496
+ if (child.classList.contains('message') && child.classList.contains('assistant')) {
497
+ const messageContent = child.querySelector('.message-content');
498
+ if (messageContent) {
499
+ tabData.messages.push({
500
+ role: 'assistant',
501
+ type: 'text',
502
+ content: messageContent.textContent.trim(),
503
+ html: messageContent.innerHTML
504
+ });
505
+ } else {
506
+ // Fallback to raw HTML
507
+ tabData.messages.push({
508
+ role: 'assistant',
509
+ type: 'text',
510
+ html: child.innerHTML
511
+ });
512
+ }
513
+ continue;
514
+ }
515
+
516
+ // Action widget (appended directly to chatContainer)
517
+ if (child.classList.contains('action-widget')) {
518
+ const widgetData = {
519
+ role: 'assistant',
520
+ type: 'action-widget',
521
+ targetTabId: child.dataset.targetTabId,
522
+ actionType: child.querySelector('.action-widget-type')?.textContent?.replace('TASK: ', '') || '',
523
+ query: child.querySelector('.action-widget-body .section-content')?.textContent?.trim() || '',
524
+ isDone: !!child.querySelector('.done-indicator'),
525
+ result: null,
526
+ resultHtml: null
527
+ };
528
+
529
+ // Extract result if present
530
+ const resultSection = child.querySelector('.action-widget-result-section');
531
+ if (resultSection) {
532
+ const resultContent = resultSection.querySelector('.section-content');
533
+ if (resultContent) {
534
+ widgetData.result = resultContent.textContent.trim();
535
+ widgetData.resultHtml = resultContent.innerHTML;
536
+ }
537
+ }
538
+
539
+ tabData.messages.push(widgetData);
540
+ continue;
541
+ }
542
+
543
+ // Code cell (appended directly to chatContainer)
544
+ if (child.classList.contains('code-cell')) {
545
+ const codeEl = child.querySelector('pre code');
546
+ const outputEl = child.querySelector('.code-cell-output');
547
+
548
+ // Collect images from the code cell
549
+ const images = [];
550
+ const imageEls = child.querySelectorAll('.code-cell-image');
551
+ for (const imgDiv of imageEls) {
552
+ const imgEl = imgDiv.querySelector('img');
553
+ const labelEl = imgDiv.querySelector('.figure-label');
554
+ if (imgEl) {
555
+ images.push({
556
+ src: imgEl.src, // data URL with base64
557
+ name: labelEl?.textContent || ''
558
+ });
559
+ }
560
+ }
561
+
562
+ tabData.messages.push({
563
+ role: 'assistant',
564
+ type: 'code-cell',
565
+ code: codeEl?.textContent || '',
566
+ output: outputEl?.textContent || '',
567
+ outputHtml: outputEl?.innerHTML || '',
568
+ isError: outputEl?.classList.contains('error') || false,
569
+ images: images
570
+ });
571
+ continue;
572
+ }
573
+
574
+ // Tool cell (web agent / image agent tool calls)
575
+ if (child.classList.contains('tool-cell')) {
576
+ const labelEl = child.querySelector('.tool-cell-label span');
577
+ const inputEl = child.querySelector('.tool-cell-input');
578
+ const outputEl = child.querySelector('.tool-cell-output');
579
+
580
+ tabData.messages.push({
581
+ role: 'assistant',
582
+ type: 'tool-cell',
583
+ toolName: child.getAttribute('data-tool-name') || '',
584
+ label: labelEl?.textContent || '',
585
+ input: inputEl?.textContent || '',
586
+ outputHtml: outputEl?.innerHTML || '',
587
+ });
588
+ continue;
589
+ }
590
+
591
+ // Research report (appended directly to chatContainer)
592
+ if (child.classList.contains('agent-result')) {
593
+ const contentEl = child.querySelector('.result-content');
594
+ tabData.messages.push({
595
+ role: 'assistant',
596
+ type: 'agent-result',
597
+ content: contentEl?.textContent || '',
598
+ html: contentEl?.innerHTML || ''
599
+ });
600
+ continue;
601
+ }
602
+
603
+ // Result preview
604
+ if (child.classList.contains('result-preview')) {
605
+ const contentEl = child.querySelector('.result-preview-content');
606
+ tabData.messages.push({
607
+ role: 'assistant',
608
+ type: 'result-preview',
609
+ content: contentEl?.textContent || '',
610
+ html: contentEl?.innerHTML || ''
611
+ });
612
+ continue;
613
+ }
614
+ }
615
+
616
+ return tabData;
617
+ }
618
+
619
+ function saveWorkspaceDebounced() {
620
+ // Clear any pending save
621
+ if (saveWorkspaceTimer) {
622
+ clearTimeout(saveWorkspaceTimer);
623
+ }
624
+
625
+ // Schedule save in 500ms
626
+ saveWorkspaceTimer = setTimeout(async () => {
627
+ try {
628
+ const workspace = serializeWorkspace();
629
+ const response = await apiFetch('/api/workspace', {
630
+ method: 'POST',
631
+ headers: { 'Content-Type': 'application/json' },
632
+ body: JSON.stringify(workspace)
633
+ });
634
+ if (response.ok) {
635
+ console.log('Workspace saved');
636
+ }
637
+ } catch (e) {
638
+ console.error('Failed to save workspace:', e);
639
+ }
640
+ }, 500);
641
+ }