@appoly/multiagent-chat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/renderer.js ADDED
@@ -0,0 +1,1626 @@
1
+ // State
2
+ let currentConfig = null;
3
+ let agentData = {};
4
+ let currentAgentTab = null;
5
+ let terminals = {};
6
+ let agentColors = {}; // Map of agent name -> color
7
+ let chatMessages = []; // Array of chat messages
8
+ let inputLocked = {}; // Map of agent name -> boolean (default true)
9
+ let planHasContent = false; // Track if PLAN_FINAL has content
10
+ let implementationStarted = false; // Track if implementation has started
11
+ let autoScrollEnabled = true;
12
+ let currentMainTab = 'chat'; // Track which main tab is active ('chat', 'plan', or 'diff')
13
+ let lastDiffData = null; // Cache last diff data
14
+ let parsedDiffFiles = []; // Parsed diff data per file
15
+ let selectedDiffFile = null; // Currently selected file in diff view (null = all)
16
+ let pollingIntervals = []; // Store interval IDs to clear on reset
17
+
18
+ // Workspace selection state
19
+ let selectedWorkspace = null; // Currently selected workspace path
20
+ let recentWorkspaces = []; // Array of recent workspaces
21
+ let cliWorkspace = null; // Workspace passed via CLI
22
+ let cwdInfo = null; // Current working directory info
23
+
24
+ const CHAT_SCROLL_THRESHOLD = 40;
25
+ const TAB_STORAGE_KEY = 'activeMainTab';
26
+
27
+ // DOM Elements
28
+ const challengeScreen = document.getElementById('challenge-screen');
29
+ const sessionScreen = document.getElementById('session-screen');
30
+ const challengeInput = document.getElementById('challenge-input');
31
+ const startButton = document.getElementById('start-button');
32
+ const configDetails = document.getElementById('config-details');
33
+ const newSessionButton = document.getElementById('new-session-button');
34
+ const workspacePath = document.getElementById('workspace-path');
35
+ const agentTabsContainer = document.getElementById('agent-tabs');
36
+ const agentOutputsContainer = document.getElementById('agent-outputs');
37
+ const chatViewer = document.getElementById('chat-viewer');
38
+ const chatNewMessages = document.getElementById('chat-new-messages');
39
+ const chatNewMessagesButton = document.getElementById('chat-new-messages-button');
40
+ const userMessageInput = document.getElementById('user-message-input');
41
+ const sendMessageButton = document.getElementById('send-message-button');
42
+ const planViewer = document.getElementById('plan-viewer');
43
+ const startImplementingButton = document.getElementById('start-implementing-button');
44
+
45
+ // Workspace picker elements
46
+ const recentWorkspacesEl = document.getElementById('recent-workspaces');
47
+ const browseWorkspaceButton = document.getElementById('browse-workspace-button');
48
+ const useCwdButton = document.getElementById('use-cwd-button');
49
+ const editConfigButton = document.getElementById('edit-config-button');
50
+ const selectedWorkspaceInfo = document.getElementById('selected-workspace-info');
51
+ const selectedWorkspacePathEl = document.getElementById('selected-workspace-path');
52
+ const cliBadge = document.getElementById('cli-badge');
53
+
54
+ // Main tabs elements
55
+ const mainTabChat = document.getElementById('main-tab-chat');
56
+ const mainTabPlan = document.getElementById('main-tab-plan');
57
+ const mainTabDiff = document.getElementById('main-tab-diff');
58
+ const chatTabContent = document.getElementById('chat-tab-content');
59
+ const planTabContent = document.getElementById('plan-tab-content');
60
+ const diffTabContent = document.getElementById('diff-tab-content');
61
+
62
+ // Diff elements
63
+ const diffBadge = document.getElementById('diff-badge');
64
+ const diffStats = document.getElementById('diff-stats');
65
+ const diffContent = document.getElementById('diff-content');
66
+ const diffUntracked = document.getElementById('diff-untracked');
67
+ const diffFileListItems = document.getElementById('diff-file-list-items');
68
+ const refreshDiffButton = document.getElementById('refresh-diff-button');
69
+ const refreshPlanButton = document.getElementById('refresh-plan-button');
70
+
71
+ // Modal elements
72
+ const implementationModal = document.getElementById('implementation-modal');
73
+ const agentSelectionContainer = document.getElementById('agent-selection');
74
+ const modalCancelButton = document.getElementById('modal-cancel');
75
+ const modalStartButton = document.getElementById('modal-start');
76
+
77
+ // New session modal elements
78
+ const newSessionModal = document.getElementById('new-session-modal');
79
+ const newSessionCancelButton = document.getElementById('new-session-cancel');
80
+ const newSessionConfirmButton = document.getElementById('new-session-confirm');
81
+
82
+ // Initialize
83
+ async function initialize() {
84
+ console.log('Initializing app...');
85
+
86
+ // Check if xterm is available
87
+ console.log('Terminal available?', typeof Terminal !== 'undefined');
88
+ console.log('FitAddon available?', typeof FitAddon !== 'undefined');
89
+ console.log('FitAddon object:', FitAddon);
90
+ console.log('marked available?', typeof marked !== 'undefined');
91
+
92
+ // Check if electronAPI is available
93
+ if (!window.electronAPI) {
94
+ console.error('electronAPI not available!');
95
+ configDetails.innerHTML = '<span style="color: #dc3545;">Error: Electron API not available</span>';
96
+ return;
97
+ }
98
+
99
+ console.log('electronAPI available:', Object.keys(window.electronAPI));
100
+
101
+ try {
102
+ console.log('Loading config...');
103
+ currentConfig = await window.electronAPI.loadConfig();
104
+ console.log('Config loaded:', currentConfig);
105
+ displayConfig();
106
+
107
+ // Load workspace info
108
+ await loadWorkspaceInfo();
109
+ } catch (error) {
110
+ console.error('Error loading config:', error);
111
+ configDetails.innerHTML = `<span style="color: #dc3545;">Error loading configuration: ${error.message}</span>`;
112
+ }
113
+ }
114
+
115
+ // Load workspace info: CLI workspace, recent workspaces, current directory
116
+ async function loadWorkspaceInfo() {
117
+ try {
118
+ // Check for CLI workspace
119
+ cliWorkspace = await window.electronAPI.getCliWorkspace();
120
+
121
+ // Load recent workspaces
122
+ recentWorkspaces = await window.electronAPI.getRecentWorkspaces();
123
+
124
+ // Get current directory info
125
+ cwdInfo = await window.electronAPI.getCurrentDirectory();
126
+
127
+ // If CLI workspace provided, auto-select it
128
+ if (cliWorkspace) {
129
+ selectWorkspace(cliWorkspace, true);
130
+ } else if (recentWorkspaces.length > 0 && recentWorkspaces[0].exists) {
131
+ // Auto-select most recent valid workspace
132
+ selectWorkspace(recentWorkspaces[0].path, false);
133
+ }
134
+
135
+ // Render workspace list
136
+ renderWorkspaceList();
137
+
138
+ // Show/hide "Use current directory" button
139
+ if (cwdInfo && cwdInfo.isUsable) {
140
+ useCwdButton.style.display = 'flex';
141
+ } else {
142
+ useCwdButton.style.display = 'none';
143
+ }
144
+ } catch (error) {
145
+ console.error('Error loading workspace info:', error);
146
+ recentWorkspacesEl.innerHTML = '<li class="workspace-empty">Error loading workspaces</li>';
147
+ }
148
+ }
149
+
150
+ // Render the workspace list
151
+ function renderWorkspaceList() {
152
+ if (recentWorkspaces.length === 0) {
153
+ recentWorkspacesEl.innerHTML = `
154
+ <li class="workspace-empty">
155
+ No recent workspaces.<br>
156
+ Browse for a folder to get started.
157
+ </li>
158
+ `;
159
+ return;
160
+ }
161
+
162
+ recentWorkspacesEl.innerHTML = recentWorkspaces.map(ws => {
163
+ const isSelected = selectedWorkspace === ws.path;
164
+ const isMissing = !ws.exists;
165
+ const classes = ['workspace-item'];
166
+ if (isSelected) classes.push('selected');
167
+ if (isMissing) classes.push('missing');
168
+
169
+ const timeAgo = formatRelativeTime(ws.lastUsed);
170
+
171
+ let actionsHtml = '';
172
+ if (isMissing) {
173
+ actionsHtml = `
174
+ <div class="workspace-missing-actions">
175
+ <button class="remove-btn" data-path="${escapeHtml(ws.path)}">Remove</button>
176
+ <button class="locate-btn" data-path="${escapeHtml(ws.path)}">Locate</button>
177
+ </div>
178
+ `;
179
+ }
180
+
181
+ return `
182
+ <li class="${classes.join(' ')}" data-path="${escapeHtml(ws.path)}" data-exists="${ws.exists}">
183
+ <div class="workspace-item-header">
184
+ <span class="workspace-name">${escapeHtml(ws.name)}</span>
185
+ ${isMissing ? '<span class="workspace-badge missing">Missing</span>' : ''}
186
+ </div>
187
+ <span class="workspace-path" title="${escapeHtml(ws.path)}">${escapeHtml(ws.path)}</span>
188
+ <span class="workspace-time">${timeAgo}</span>
189
+ ${actionsHtml}
190
+ </li>
191
+ `;
192
+ }).join('');
193
+
194
+ // Add click handlers
195
+ recentWorkspacesEl.querySelectorAll('.workspace-item').forEach(item => {
196
+ const path = item.dataset.path;
197
+ const exists = item.dataset.exists === 'true';
198
+
199
+ // Main item click (only if exists)
200
+ item.addEventListener('click', (e) => {
201
+ // Don't select if clicking on action buttons
202
+ if (e.target.classList.contains('remove-btn') || e.target.classList.contains('locate-btn')) {
203
+ return;
204
+ }
205
+ if (exists) {
206
+ selectWorkspace(path, false);
207
+ renderWorkspaceList();
208
+ }
209
+ });
210
+ });
211
+
212
+ // Add action button handlers
213
+ recentWorkspacesEl.querySelectorAll('.remove-btn').forEach(btn => {
214
+ btn.addEventListener('click', async (e) => {
215
+ e.stopPropagation();
216
+ const path = btn.dataset.path;
217
+ await window.electronAPI.removeRecentWorkspace(path);
218
+ recentWorkspaces = await window.electronAPI.getRecentWorkspaces();
219
+ renderWorkspaceList();
220
+ });
221
+ });
222
+
223
+ recentWorkspacesEl.querySelectorAll('.locate-btn').forEach(btn => {
224
+ btn.addEventListener('click', async (e) => {
225
+ e.stopPropagation();
226
+ const oldPath = btn.dataset.path;
227
+ const result = await window.electronAPI.browseForWorkspace();
228
+ if (!result.canceled) {
229
+ await window.electronAPI.updateRecentWorkspacePath(oldPath, result.path);
230
+ recentWorkspaces = await window.electronAPI.getRecentWorkspaces();
231
+ selectWorkspace(result.path, false);
232
+ renderWorkspaceList();
233
+ }
234
+ });
235
+ });
236
+ }
237
+
238
+ // Select a workspace
239
+ function selectWorkspace(path, fromCli = false) {
240
+ selectedWorkspace = path;
241
+
242
+ // Update UI
243
+ selectedWorkspaceInfo.style.display = 'flex';
244
+ selectedWorkspacePathEl.textContent = path;
245
+ selectedWorkspacePathEl.title = path;
246
+
247
+ if (fromCli) {
248
+ cliBadge.style.display = 'inline';
249
+ } else {
250
+ cliBadge.style.display = 'none';
251
+ }
252
+
253
+ // Enable start button
254
+ startButton.disabled = false;
255
+ startButton.textContent = 'Start Session';
256
+ }
257
+
258
+ // Format relative time (e.g., "2 hours ago", "Yesterday")
259
+ function formatRelativeTime(isoString) {
260
+ const date = new Date(isoString);
261
+ const now = new Date();
262
+ const diffMs = now - date;
263
+ const diffMins = Math.floor(diffMs / 60000);
264
+ const diffHours = Math.floor(diffMs / 3600000);
265
+ const diffDays = Math.floor(diffMs / 86400000);
266
+
267
+ if (diffMins < 1) return 'Just now';
268
+ if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
269
+ if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
270
+ if (diffDays === 1) return 'Yesterday';
271
+ if (diffDays < 7) return `${diffDays} days ago`;
272
+ return date.toLocaleDateString();
273
+ }
274
+
275
+ // Browse for workspace
276
+ async function browseForWorkspace() {
277
+ const result = await window.electronAPI.browseForWorkspace();
278
+ if (!result.canceled) {
279
+ selectWorkspace(result.path, false);
280
+ // Add to recents immediately
281
+ await window.electronAPI.addRecentWorkspace(result.path);
282
+ recentWorkspaces = await window.electronAPI.getRecentWorkspaces();
283
+ renderWorkspaceList();
284
+ }
285
+ }
286
+
287
+ // Use current working directory
288
+ async function useCwd() {
289
+ if (cwdInfo && cwdInfo.isUsable) {
290
+ selectWorkspace(cwdInfo.path, false);
291
+ // Add to recents like Browse does
292
+ await window.electronAPI.addRecentWorkspace(cwdInfo.path);
293
+ recentWorkspaces = await window.electronAPI.getRecentWorkspaces();
294
+ renderWorkspaceList();
295
+ }
296
+ }
297
+
298
+ // Open config folder
299
+ async function openConfigFolder() {
300
+ await window.electronAPI.openConfigFolder();
301
+ }
302
+
303
+ // Display configuration info
304
+ function displayConfig() {
305
+ if (!currentConfig) {
306
+ configDetails.innerHTML = '<span style="color: #dc3545;">No configuration loaded</span>';
307
+ return;
308
+ }
309
+
310
+ const agentList = currentConfig.agents.map(a => {
311
+ const color = a.color || '#667eea';
312
+ return `<span style="color: ${color}">• ${a.name}</span> (${a.command})`;
313
+ }).join('<br>');
314
+
315
+ configDetails.innerHTML = `
316
+ <strong>Agents:</strong><br>${agentList}
317
+ `;
318
+ }
319
+
320
+ // Start session
321
+ async function startSession() {
322
+ const challenge = challengeInput.value.trim();
323
+
324
+ if (!challenge) {
325
+ alert('Please enter a challenge for the agents to work on.');
326
+ return;
327
+ }
328
+
329
+ if (!selectedWorkspace) {
330
+ alert('Please select a workspace first.');
331
+ return;
332
+ }
333
+
334
+ startButton.disabled = true;
335
+ startButton.textContent = 'Starting...';
336
+
337
+ try {
338
+ const result = await window.electronAPI.startSession({
339
+ challenge,
340
+ workspace: selectedWorkspace
341
+ });
342
+
343
+ if (result.success) {
344
+ // Initialize agent data and colors
345
+ agentColors = result.colors || {};
346
+ chatMessages = []; // Reset chat messages
347
+
348
+ result.agents.forEach(agent => {
349
+ agentData[agent.name] = {
350
+ name: agent.name,
351
+ status: 'starting',
352
+ output: [],
353
+ use_pty: agent.use_pty
354
+ };
355
+ });
356
+
357
+ // Switch to session screen
358
+ challengeScreen.classList.remove('active');
359
+ sessionScreen.classList.add('active');
360
+
361
+ // Setup UI
362
+ workspacePath.textContent = result.workspace;
363
+ if (result.fromCli) {
364
+ workspacePath.innerHTML = result.workspace + ' <span class="cli-badge" style="font-size: 10px; padding: 2px 6px; background: rgba(20, 241, 149, 0.15); color: var(--accent-primary); border-radius: 4px; margin-left: 8px;">(via CLI)</span>';
365
+ }
366
+ createAgentTabs(result.agents);
367
+ renderChatMessages(); // Initial render (empty)
368
+ startChatPolling();
369
+ } else {
370
+ alert(`Failed to start session: ${result.error}`);
371
+ startButton.disabled = false;
372
+ startButton.textContent = 'Start Session';
373
+ }
374
+ } catch (error) {
375
+ console.error('Error starting session:', error);
376
+ alert('Error starting session. Check console for details.');
377
+ startButton.disabled = false;
378
+ startButton.textContent = 'Start Session';
379
+ }
380
+ }
381
+
382
+ // Create agent tabs
383
+ function createAgentTabs(agents) {
384
+ agentTabsContainer.innerHTML = '';
385
+ agentOutputsContainer.innerHTML = '';
386
+
387
+ agents.forEach((agent, index) => {
388
+ const agentInfo = agentData[agent.name];
389
+
390
+ // Create tab
391
+ const tab = document.createElement('button');
392
+ tab.className = 'tab';
393
+ if (index === 0) {
394
+ tab.classList.add('active');
395
+ currentAgentTab = agent.name;
396
+ }
397
+ tab.textContent = agent.name;
398
+ tab.onclick = () => switchAgentTab(agent.name);
399
+ agentTabsContainer.appendChild(tab);
400
+
401
+ // Create output container
402
+ const outputDiv = document.createElement('div');
403
+ outputDiv.className = 'agent-output';
404
+ outputDiv.id = `output-${agent.name}`;
405
+ if (index === 0) {
406
+ outputDiv.classList.add('active');
407
+ }
408
+
409
+ // Add status indicator
410
+ const statusDiv = document.createElement('div');
411
+ statusDiv.className = 'agent-status starting';
412
+ statusDiv.textContent = 'Starting...';
413
+ statusDiv.id = `status-${agent.name}`;
414
+ outputDiv.appendChild(statusDiv);
415
+
416
+ if (agentInfo && agentInfo.use_pty) {
417
+ // Create xterm terminal for PTY agents
418
+ const terminalDiv = document.createElement('div');
419
+ terminalDiv.id = `terminal-${agent.name}`;
420
+ terminalDiv.className = 'terminal-container';
421
+ outputDiv.appendChild(terminalDiv);
422
+
423
+ // Create and initialize terminal
424
+ const terminal = new Terminal({
425
+ cursorBlink: true,
426
+ fontSize: 13,
427
+ fontFamily: 'Courier New, monospace',
428
+ theme: {
429
+ background: '#1e1e1e',
430
+ foreground: '#e0e0e0'
431
+ },
432
+ rows: 40,
433
+ cols: 120
434
+ });
435
+
436
+ const fitAddon = new FitAddon.FitAddon();
437
+ terminal.loadAddon(fitAddon);
438
+ terminal.open(terminalDiv);
439
+ fitAddon.fit();
440
+
441
+ terminals[agent.name] = { terminal, fitAddon };
442
+
443
+ // Initialize input lock state (default: locked)
444
+ inputLocked[agent.name] = true;
445
+
446
+ // Disable stdin by default (prevents cursor/typing when locked)
447
+ terminal.options.disableStdin = true;
448
+
449
+ // Add lock toggle button (inside terminal container so it stays anchored)
450
+ const lockToggle = document.createElement('button');
451
+ lockToggle.className = 'input-lock-toggle';
452
+ lockToggle.innerHTML = '🔒 Input locked';
453
+ lockToggle.onclick = () => toggleInputLock(agent.name);
454
+ terminalDiv.appendChild(lockToggle);
455
+
456
+ // Wire terminal input to PTY (only when unlocked)
457
+ terminal.onData((data) => {
458
+ if (!inputLocked[agent.name]) {
459
+ window.electronAPI.sendPtyInput(agent.name, data);
460
+ }
461
+ });
462
+
463
+ // Fit terminal on window resize
464
+ window.addEventListener('resize', () => {
465
+ if (terminals[agent.name]) {
466
+ terminals[agent.name].fitAddon.fit();
467
+ }
468
+ });
469
+ } else {
470
+ // Create regular text output for non-PTY agents
471
+ const contentPre = document.createElement('pre');
472
+ contentPre.id = `content-${agent.name}`;
473
+ outputDiv.appendChild(contentPre);
474
+ }
475
+
476
+ agentOutputsContainer.appendChild(outputDiv);
477
+ });
478
+ }
479
+
480
+ // Switch agent tab
481
+ function switchAgentTab(agentName) {
482
+ currentAgentTab = agentName;
483
+
484
+ // Update tabs
485
+ document.querySelectorAll('.tab').forEach(tab => {
486
+ if (tab.textContent === agentName) {
487
+ tab.classList.add('active');
488
+ } else {
489
+ tab.classList.remove('active');
490
+ }
491
+ });
492
+
493
+ // Update outputs
494
+ document.querySelectorAll('.agent-output').forEach(output => {
495
+ if (output.id === `output-${agentName}`) {
496
+ output.classList.add('active');
497
+ } else {
498
+ output.classList.remove('active');
499
+ }
500
+ });
501
+
502
+ // Fit terminal if this agent uses PTY
503
+ if (terminals[agentName]) {
504
+ setTimeout(() => {
505
+ terminals[agentName].fitAddon.fit();
506
+ }, 100);
507
+ }
508
+ }
509
+
510
+ // Toggle input lock for a terminal
511
+ function toggleInputLock(agentName) {
512
+ inputLocked[agentName] = !inputLocked[agentName];
513
+ const toggle = document.querySelector(`#terminal-${agentName} .input-lock-toggle`);
514
+ const terminal = terminals[agentName]?.terminal;
515
+
516
+ if (inputLocked[agentName]) {
517
+ toggle.innerHTML = '🔒 Input locked';
518
+ toggle.classList.remove('unlocked');
519
+ // Disable stdin and blur terminal when locked
520
+ if (terminal) {
521
+ terminal.options.disableStdin = true;
522
+ if (terminal.textarea) {
523
+ terminal.textarea.blur();
524
+ }
525
+ }
526
+ } else {
527
+ toggle.innerHTML = '🔓 Input unlocked';
528
+ toggle.classList.add('unlocked');
529
+ // Enable stdin and focus terminal when unlocked
530
+ if (terminal) {
531
+ terminal.options.disableStdin = false;
532
+ terminal.focus();
533
+ }
534
+ }
535
+ }
536
+
537
+ // Update agent output
538
+ function updateAgentOutput(agentName, output, isPty) {
539
+ if (!agentData[agentName]) {
540
+ agentData[agentName] = { name: agentName, output: [] };
541
+ }
542
+
543
+ agentData[agentName].output.push(output);
544
+
545
+ // Check if this agent uses PTY/terminal
546
+ if (isPty && terminals[agentName]) {
547
+ // Write directly to xterm terminal
548
+ terminals[agentName].terminal.write(output);
549
+ } else {
550
+ // Use regular text output
551
+ const contentElement = document.getElementById(`content-${agentName}`);
552
+ if (contentElement) {
553
+ contentElement.textContent = agentData[agentName].output.join('');
554
+
555
+ // Auto-scroll if this is the active tab
556
+ if (currentAgentTab === agentName) {
557
+ const outputContainer = document.getElementById(`output-${agentName}`);
558
+ if (outputContainer) {
559
+ outputContainer.scrollTop = outputContainer.scrollHeight;
560
+ }
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ // Update agent status
567
+ function updateAgentStatus(agentName, status, exitCode = null, error = null) {
568
+ if (agentData[agentName]) {
569
+ agentData[agentName].status = status;
570
+ }
571
+
572
+ const statusElement = document.getElementById(`status-${agentName}`);
573
+ if (statusElement) {
574
+ statusElement.className = `agent-status ${status}`;
575
+
576
+ if (status === 'running') {
577
+ statusElement.textContent = 'Running';
578
+ } else if (status === 'stopped') {
579
+ statusElement.textContent = `Stopped (exit code: ${exitCode})`;
580
+ } else if (status === 'error') {
581
+ statusElement.textContent = `Error: ${error}`;
582
+ }
583
+ }
584
+ }
585
+
586
+ // Format timestamp for display
587
+ function formatTimestamp(isoString) {
588
+ const date = new Date(isoString);
589
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
590
+ }
591
+
592
+ // Render a single chat message as HTML
593
+ function renderChatMessage(message) {
594
+ const isUser = message.type === 'user';
595
+ const alignClass = isUser ? 'chat-message-right' : 'chat-message-left';
596
+ const color = message.color || agentColors[message.agent?.toLowerCase()] || '#667eea';
597
+
598
+ // Parse markdown content
599
+ const htmlContent = marked.parse(message.content || '');
600
+
601
+ return `
602
+ <div class="chat-message ${alignClass}" data-seq="${message.seq}">
603
+ <div class="chat-bubble" style="--agent-color: ${color}">
604
+ <div class="chat-header">
605
+ <span class="chat-agent" style="color: ${color}">${escapeHtml(message.agent)}</span>
606
+ <span class="chat-time">${formatTimestamp(message.timestamp)}</span>
607
+ </div>
608
+ <div class="chat-content markdown-content">${htmlContent}</div>
609
+ </div>
610
+ </div>
611
+ `;
612
+ }
613
+
614
+ // Render all chat messages
615
+ function renderChatMessages() {
616
+ if (chatMessages.length === 0) {
617
+ chatViewer.innerHTML = '<div class="chat-empty">No messages yet. Agents are starting...</div>';
618
+ setNewMessagesBanner(false);
619
+ return;
620
+ }
621
+
622
+ chatViewer.innerHTML = chatMessages.map(renderChatMessage).join('');
623
+ if (autoScrollEnabled) {
624
+ scrollChatToBottom();
625
+ }
626
+ }
627
+
628
+ // Add a new message to chat
629
+ function addChatMessage(message) {
630
+ // Check if message already exists (by sequence number)
631
+ const exists = chatMessages.some(m => m.seq === message.seq);
632
+ if (!exists) {
633
+ const shouldScroll = autoScrollEnabled;
634
+ chatMessages.push(message);
635
+ chatMessages.sort((a, b) => a.seq - b.seq); // Ensure order
636
+ renderChatMessages();
637
+ if (!shouldScroll) {
638
+ setNewMessagesBanner(true);
639
+ }
640
+ }
641
+ }
642
+
643
+ // Update chat from full message array (for refresh/sync)
644
+ function updateChatFromMessages(messages) {
645
+ if (Array.isArray(messages)) {
646
+ const prevLastSeq = chatMessages.length > 0 ? chatMessages[chatMessages.length - 1].seq : null;
647
+ const nextLastSeq = messages.length > 0 ? messages[messages.length - 1].seq : null;
648
+ const hasChanges = messages.length !== chatMessages.length || prevLastSeq !== nextLastSeq;
649
+ if (!hasChanges) {
650
+ return;
651
+ }
652
+
653
+ const shouldScroll = autoScrollEnabled;
654
+ chatMessages = messages;
655
+ renderChatMessages();
656
+ if (!shouldScroll) {
657
+ setNewMessagesBanner(true);
658
+ }
659
+ }
660
+ }
661
+
662
+ // Send user message
663
+ async function sendUserMessage() {
664
+ const message = userMessageInput.value.trim();
665
+
666
+ if (!message) {
667
+ return;
668
+ }
669
+
670
+ sendMessageButton.disabled = true;
671
+ sendMessageButton.textContent = 'Sending...';
672
+
673
+ try {
674
+ const result = await window.electronAPI.sendUserMessage(message);
675
+
676
+ if (result.success) {
677
+ userMessageInput.value = '';
678
+ // Chat will be updated via file watcher
679
+ } else {
680
+ alert(`Failed to send message: ${result.error}`);
681
+ }
682
+ } catch (error) {
683
+ console.error('Error sending message:', error);
684
+ alert('Error sending message. Check console for details.');
685
+ } finally {
686
+ sendMessageButton.disabled = false;
687
+ sendMessageButton.textContent = 'Send Message';
688
+ }
689
+ }
690
+
691
+ // Refresh plan and update button visibility
692
+ async function refreshPlan() {
693
+ try {
694
+ const content = await window.electronAPI.getPlanContent();
695
+
696
+ if (content.trim()) {
697
+ const htmlContent = marked.parse(content);
698
+ planViewer.innerHTML = `<div class="markdown-content">${htmlContent}</div>`;
699
+ planHasContent = true;
700
+ } else {
701
+ planViewer.innerHTML = '<em>No plan yet...</em>';
702
+ planHasContent = false;
703
+ }
704
+
705
+ // Update button visibility
706
+ updateImplementButtonState();
707
+ } catch (error) {
708
+ console.error('Error refreshing plan:', error);
709
+ }
710
+ }
711
+
712
+ // Update the Start Implementing button state
713
+ function updateImplementButtonState() {
714
+ if (implementationStarted) {
715
+ startImplementingButton.textContent = 'Implementation in progress';
716
+ startImplementingButton.disabled = true;
717
+ startImplementingButton.style.display = 'block';
718
+ } else if (planHasContent) {
719
+ startImplementingButton.textContent = 'Start Implementing';
720
+ startImplementingButton.disabled = false;
721
+ startImplementingButton.style.display = 'block';
722
+ } else {
723
+ startImplementingButton.style.display = 'none';
724
+ }
725
+ }
726
+
727
+ // Switch between main tabs (Chat, Plan, Diff)
728
+ function switchMainTab(tabName) {
729
+ currentMainTab = tabName;
730
+
731
+ // Update tab active states
732
+ mainTabChat.classList.toggle('active', tabName === 'chat');
733
+ mainTabPlan.classList.toggle('active', tabName === 'plan');
734
+ mainTabDiff.classList.toggle('active', tabName === 'diff');
735
+
736
+ // Update content visibility
737
+ chatTabContent.classList.toggle('active', tabName === 'chat');
738
+ planTabContent.classList.toggle('active', tabName === 'plan');
739
+ diffTabContent.classList.toggle('active', tabName === 'diff');
740
+
741
+ // Save tab state to localStorage
742
+ try {
743
+ localStorage.setItem(TAB_STORAGE_KEY, tabName);
744
+ } catch (e) {
745
+ // Ignore localStorage errors
746
+ }
747
+
748
+ // Refresh main panels layout on any tab switch
749
+ requestAnimationFrame(() => handleContainerResize());
750
+
751
+ // Fetch diff if switching to diff tab
752
+ if (tabName === 'diff') {
753
+ refreshGitDiff();
754
+ // Restore diff layout after tab is visible (deferred to avoid zero-width issue)
755
+ requestAnimationFrame(() => restoreDiffLayout());
756
+ }
757
+
758
+ // Refresh plan if switching to plan tab
759
+ if (tabName === 'plan') {
760
+ refreshPlan();
761
+ }
762
+ }
763
+
764
+ // Restore saved tab state
765
+ function restoreTabState() {
766
+ try {
767
+ const savedTab = localStorage.getItem(TAB_STORAGE_KEY);
768
+ if (savedTab && ['chat', 'plan', 'diff'].includes(savedTab)) {
769
+ switchMainTab(savedTab);
770
+ }
771
+ } catch (e) {
772
+ // Ignore localStorage errors
773
+ }
774
+ }
775
+
776
+ // Fetch and render git diff
777
+ async function refreshGitDiff() {
778
+ try {
779
+ const data = await window.electronAPI.getGitDiff();
780
+ lastDiffData = data;
781
+ renderGitDiff(data);
782
+ updateDiffBadge(data);
783
+ } catch (error) {
784
+ console.error('Error fetching git diff:', error);
785
+ diffContent.innerHTML = `<em class="diff-error">Error loading diff: ${error.message}</em>`;
786
+ }
787
+ }
788
+
789
+ // Parse diff into per-file blocks
790
+ function parseDiffIntoFiles(diffText) {
791
+ if (!diffText || !diffText.trim()) return [];
792
+
793
+ const files = [];
794
+ const lines = diffText.split('\n');
795
+ let currentFile = null;
796
+ let currentContent = [];
797
+
798
+ for (const line of lines) {
799
+ if (line.startsWith('diff --git')) {
800
+ // Save previous file if exists
801
+ if (currentFile) {
802
+ currentFile.content = currentContent.join('\n');
803
+ files.push(currentFile);
804
+ }
805
+
806
+ // Extract filename from diff header
807
+ const match = line.match(/diff --git a\/(.+?) b\/(.+)/);
808
+ const filename = match ? match[2] : 'unknown';
809
+
810
+ currentFile = {
811
+ filename,
812
+ status: 'modified',
813
+ content: ''
814
+ };
815
+ currentContent = [line];
816
+ } else if (currentFile) {
817
+ currentContent.push(line);
818
+
819
+ // Detect file status
820
+ if (line.startsWith('new file mode')) {
821
+ currentFile.status = 'added';
822
+ } else if (line.startsWith('deleted file mode')) {
823
+ currentFile.status = 'deleted';
824
+ }
825
+ }
826
+ }
827
+
828
+ // Don't forget the last file
829
+ if (currentFile) {
830
+ currentFile.content = currentContent.join('\n');
831
+ files.push(currentFile);
832
+ }
833
+
834
+ return files;
835
+ }
836
+
837
+ // Render file list in diff view
838
+ function renderDiffFileList(files, untracked) {
839
+ if (!diffFileListItems) return;
840
+
841
+ let html = '';
842
+
843
+ // Add "All Files" option
844
+ const allActive = selectedDiffFile === null ? 'active' : '';
845
+ html += `<li class="file-list-item all-files ${allActive}" data-file="__all__">All Files</li>`;
846
+
847
+ // Add changed files
848
+ for (const file of files) {
849
+ const isActive = selectedDiffFile === file.filename ? 'active' : '';
850
+ const statusClass = file.status;
851
+ const statusLabel = file.status === 'added' ? 'A' : file.status === 'deleted' ? 'D' : 'M';
852
+ html += `<li class="file-list-item ${isActive}" data-file="${escapeHtml(file.filename)}">
853
+ <span class="file-status ${statusClass}">${statusLabel}</span>
854
+ <span class="file-name">${escapeHtml(file.filename)}</span>
855
+ </li>`;
856
+ }
857
+
858
+ // Add untracked files
859
+ for (const file of (untracked || [])) {
860
+ const isActive = selectedDiffFile === file ? 'active' : '';
861
+ html += `<li class="file-list-item ${isActive}" data-file="${escapeHtml(file)}" data-untracked="true">
862
+ <span class="file-status added">?</span>
863
+ <span class="file-name">${escapeHtml(file)}</span>
864
+ </li>`;
865
+ }
866
+
867
+ if (files.length === 0 && (!untracked || untracked.length === 0)) {
868
+ html = '<li class="file-list-item no-changes" style="color: var(--text-dim); cursor: default;">No changes</li>';
869
+ }
870
+
871
+ diffFileListItems.innerHTML = html;
872
+
873
+ // Add click handlers (skip items without data-file attribute)
874
+ diffFileListItems.querySelectorAll('.file-list-item[data-file]').forEach(item => {
875
+ item.addEventListener('click', () => {
876
+ const file = item.dataset.file;
877
+ if (file === '__all__') {
878
+ selectedDiffFile = null;
879
+ } else {
880
+ selectedDiffFile = file;
881
+ }
882
+ renderDiffFileList(parsedDiffFiles, lastDiffData?.untracked);
883
+ renderDiffContent();
884
+ });
885
+ });
886
+ }
887
+
888
+ // Render diff content based on selected file
889
+ function renderDiffContent() {
890
+ if (!lastDiffData) {
891
+ diffContent.innerHTML = '<em class="diff-empty">No diff data available</em>';
892
+ return;
893
+ }
894
+
895
+ if (selectedDiffFile === null) {
896
+ // Show all files
897
+ if (lastDiffData.diff && lastDiffData.diff.trim()) {
898
+ diffContent.innerHTML = `<pre class="diff-output">${formatDiffOutput(lastDiffData.diff)}</pre>`;
899
+ } else {
900
+ diffContent.innerHTML = '<em class="diff-empty">No uncommitted changes</em>';
901
+ }
902
+ } else {
903
+ // Show specific file
904
+ const file = parsedDiffFiles.find(f => f.filename === selectedDiffFile);
905
+ if (file) {
906
+ diffContent.innerHTML = `<pre class="diff-output">${formatDiffOutput(file.content)}</pre>`;
907
+ } else {
908
+ // Might be an untracked file
909
+ diffContent.innerHTML = `<em class="diff-empty">Untracked file: ${escapeHtml(selectedDiffFile)}</em>`;
910
+ }
911
+ }
912
+ }
913
+
914
+ // Render git diff data
915
+ function renderGitDiff(data) {
916
+ if (!data.isGitRepo) {
917
+ diffStats.innerHTML = '';
918
+ diffContent.innerHTML = `<em class="diff-no-repo">${data.error || 'Not a git repository'}</em>`;
919
+ diffUntracked.innerHTML = '';
920
+ if (diffFileListItems) diffFileListItems.innerHTML = '';
921
+ return;
922
+ }
923
+
924
+ if (data.error) {
925
+ diffStats.innerHTML = '';
926
+ diffContent.innerHTML = `<em class="diff-error">Error: ${data.error}</em>`;
927
+ diffUntracked.innerHTML = '';
928
+ if (diffFileListItems) diffFileListItems.innerHTML = '';
929
+ return;
930
+ }
931
+
932
+ // Parse diff into files
933
+ parsedDiffFiles = parseDiffIntoFiles(data.diff);
934
+
935
+ // Render stats
936
+ const { filesChanged, insertions, deletions } = data.stats;
937
+ if (filesChanged > 0 || insertions > 0 || deletions > 0) {
938
+ diffStats.innerHTML = `
939
+ <span class="diff-stat-files">${filesChanged} file${filesChanged !== 1 ? 's' : ''}</span>
940
+ <span class="diff-stat-insertions">+${insertions}</span>
941
+ <span class="diff-stat-deletions">-${deletions}</span>
942
+ `;
943
+ } else {
944
+ diffStats.innerHTML = '<span class="diff-stat-none">No changes</span>';
945
+ }
946
+
947
+ // Render file list
948
+ renderDiffFileList(parsedDiffFiles, data.untracked);
949
+
950
+ // Render diff content
951
+ renderDiffContent();
952
+
953
+ // Render untracked files summary (below diff)
954
+ if (data.untracked && data.untracked.length > 0) {
955
+ diffUntracked.innerHTML = `
956
+ <div class="diff-untracked-header">Untracked files (${data.untracked.length})</div>
957
+ `;
958
+ } else {
959
+ diffUntracked.innerHTML = '';
960
+ }
961
+ }
962
+
963
+ // Format diff output with syntax highlighting
964
+ function formatDiffOutput(diff) {
965
+ return diff.split('\n').map(line => {
966
+ const escaped = escapeHtml(line);
967
+ if (line.startsWith('+++') || line.startsWith('---')) {
968
+ return `<span class="diff-file-header">${escaped}</span>`;
969
+ } else if (line.startsWith('@@')) {
970
+ return `<span class="diff-hunk-header">${escaped}</span>`;
971
+ } else if (line.startsWith('+')) {
972
+ return `<span class="diff-added">${escaped}</span>`;
973
+ } else if (line.startsWith('-')) {
974
+ return `<span class="diff-removed">${escaped}</span>`;
975
+ } else if (line.startsWith('diff --git')) {
976
+ return `<span class="diff-file-separator">${escaped}</span>`;
977
+ }
978
+ return escaped;
979
+ }).join('\n');
980
+ }
981
+
982
+ // Update diff badge to show when changes exist
983
+ function updateDiffBadge(data) {
984
+ if (!data || !data.isGitRepo) {
985
+ diffBadge.style.display = 'none';
986
+ return;
987
+ }
988
+
989
+ const totalChanges = (data.stats?.filesChanged || 0) + (data.untracked?.length || 0);
990
+ if (totalChanges > 0) {
991
+ diffBadge.textContent = totalChanges;
992
+ diffBadge.style.display = 'inline-block';
993
+ } else {
994
+ diffBadge.style.display = 'none';
995
+ }
996
+ }
997
+
998
+ // Show implementation modal
999
+ function showImplementationModal() {
1000
+ // Populate agent selection with enabled agents
1001
+ agentSelectionContainer.innerHTML = '';
1002
+
1003
+ const enabledAgents = currentConfig.agents || [];
1004
+
1005
+ enabledAgents.forEach((agent, index) => {
1006
+ const label = document.createElement('label');
1007
+ const radio = document.createElement('input');
1008
+ radio.type = 'radio';
1009
+ radio.name = 'implementer';
1010
+ radio.value = agent.name;
1011
+ if (index === 0 || enabledAgents.length === 1) {
1012
+ radio.checked = true; // Auto-select first (or only) agent
1013
+ }
1014
+
1015
+ const agentNameSpan = document.createElement('span');
1016
+ agentNameSpan.className = 'agent-name';
1017
+ agentNameSpan.textContent = agent.name;
1018
+ agentNameSpan.style.color = agent.color || '#e0e0e0';
1019
+
1020
+ label.appendChild(radio);
1021
+ label.appendChild(agentNameSpan);
1022
+ agentSelectionContainer.appendChild(label);
1023
+ });
1024
+
1025
+ implementationModal.style.display = 'flex';
1026
+ }
1027
+
1028
+ // Hide implementation modal
1029
+ function hideImplementationModal() {
1030
+ implementationModal.style.display = 'none';
1031
+ }
1032
+
1033
+ // Start implementation with selected agent
1034
+ async function startImplementation() {
1035
+ const selectedRadio = document.querySelector('input[name="implementer"]:checked');
1036
+ if (!selectedRadio) {
1037
+ alert('Please select an agent to implement the plan.');
1038
+ return;
1039
+ }
1040
+
1041
+ const selectedAgent = selectedRadio.value;
1042
+ const allAgents = currentConfig.agents.map(a => a.name);
1043
+ const otherAgents = allAgents.filter(name => name !== selectedAgent);
1044
+
1045
+ hideImplementationModal();
1046
+
1047
+ // Mark implementation as started
1048
+ implementationStarted = true;
1049
+ updateImplementButtonState();
1050
+
1051
+ try {
1052
+ const result = await window.electronAPI.startImplementation(selectedAgent, otherAgents);
1053
+ if (!result.success) {
1054
+ alert(`Failed to start implementation: ${result.error}`);
1055
+ implementationStarted = false;
1056
+ updateImplementButtonState();
1057
+ }
1058
+ } catch (error) {
1059
+ console.error('Error starting implementation:', error);
1060
+ alert('Error starting implementation. Check console for details.');
1061
+ implementationStarted = false;
1062
+ updateImplementButtonState();
1063
+ }
1064
+ }
1065
+
1066
+ // Stop all polling intervals
1067
+ function stopChatPolling() {
1068
+ pollingIntervals.forEach(id => clearInterval(id));
1069
+ pollingIntervals = [];
1070
+ }
1071
+
1072
+ // Start polling chat content (fallback if file watcher has issues)
1073
+ function startChatPolling() {
1074
+ // Clear any existing intervals first
1075
+ stopChatPolling();
1076
+
1077
+ pollingIntervals.push(setInterval(async () => {
1078
+ try {
1079
+ const messages = await window.electronAPI.getChatContent();
1080
+ if (messages && messages.length > 0) {
1081
+ updateChatFromMessages(messages);
1082
+ }
1083
+ } catch (error) {
1084
+ console.error('Error polling chat:', error);
1085
+ }
1086
+ }, 2000));
1087
+
1088
+ // Also poll plan
1089
+ pollingIntervals.push(setInterval(refreshPlan, 3000));
1090
+
1091
+ // Poll for diff updates (also updates badge even when not on diff tab)
1092
+ pollingIntervals.push(setInterval(async () => {
1093
+ try {
1094
+ const data = await window.electronAPI.getGitDiff();
1095
+ lastDiffData = data;
1096
+ updateDiffBadge(data);
1097
+ // Only re-render if diff tab is active
1098
+ if (currentMainTab === 'diff') {
1099
+ renderGitDiff(data);
1100
+ }
1101
+ } catch (error) {
1102
+ console.error('Error polling git diff:', error);
1103
+ }
1104
+ }, 5000));
1105
+ }
1106
+
1107
+ // New Session Modal Functions
1108
+ function showNewSessionModal() {
1109
+ newSessionModal.style.display = 'flex';
1110
+ }
1111
+
1112
+ function hideNewSessionModal() {
1113
+ newSessionModal.style.display = 'none';
1114
+ }
1115
+
1116
+ async function startNewSession() {
1117
+ hideNewSessionModal();
1118
+
1119
+ try {
1120
+ // Stop polling intervals to prevent memory leaks
1121
+ stopChatPolling();
1122
+
1123
+ await window.electronAPI.resetSession();
1124
+
1125
+ // Reset UI state
1126
+ chatMessages = [];
1127
+ planHasContent = false;
1128
+ implementationStarted = false;
1129
+ agentData = {};
1130
+ currentAgentTab = null;
1131
+ autoScrollEnabled = true;
1132
+ lastDiffData = null;
1133
+ parsedDiffFiles = [];
1134
+ selectedDiffFile = null;
1135
+
1136
+ // Clear terminals (they'll be recreated on new session)
1137
+ Object.values(terminals).forEach(({ terminal }) => {
1138
+ try {
1139
+ terminal.dispose();
1140
+ } catch (e) {
1141
+ // Ignore disposal errors
1142
+ }
1143
+ });
1144
+ terminals = {};
1145
+
1146
+ // Clear input
1147
+ challengeInput.value = '';
1148
+
1149
+ // Reset UI elements
1150
+ agentTabsContainer.innerHTML = '';
1151
+ agentOutputsContainer.innerHTML = '';
1152
+ chatViewer.innerHTML = '<div class="chat-empty">No messages yet. Agents are starting...</div>';
1153
+ planViewer.innerHTML = '<em>Awaiting agent synthesis...</em>';
1154
+ diffStats.innerHTML = '';
1155
+ diffContent.innerHTML = '<em>Select a file or view all changes...</em>';
1156
+ diffUntracked.innerHTML = '';
1157
+ diffBadge.style.display = 'none';
1158
+ startImplementingButton.style.display = 'none';
1159
+ if (diffFileListItems) diffFileListItems.innerHTML = '';
1160
+
1161
+ // Reset main tab to Chat
1162
+ currentMainTab = 'chat';
1163
+ mainTabChat.classList.add('active');
1164
+ mainTabPlan.classList.remove('active');
1165
+ mainTabDiff.classList.remove('active');
1166
+ chatTabContent.classList.add('active');
1167
+ planTabContent.classList.remove('active');
1168
+ diffTabContent.classList.remove('active');
1169
+
1170
+ // Switch screens
1171
+ sessionScreen.classList.remove('active');
1172
+ challengeScreen.classList.add('active');
1173
+
1174
+ // Reload workspace info (keeps selection, refreshes recents)
1175
+ await loadWorkspaceInfo();
1176
+
1177
+ // Update start button state based on workspace selection
1178
+ if (selectedWorkspace) {
1179
+ startButton.disabled = false;
1180
+ startButton.textContent = 'Start Session';
1181
+ } else {
1182
+ startButton.disabled = true;
1183
+ startButton.textContent = 'Select a Workspace';
1184
+ }
1185
+
1186
+ } catch (error) {
1187
+ console.error('Error resetting session:', error);
1188
+ alert('Error resetting session. Check console for details.');
1189
+ }
1190
+ }
1191
+
1192
+ // Utility: Escape HTML
1193
+ function escapeHtml(text) {
1194
+ const div = document.createElement('div');
1195
+ div.textContent = text;
1196
+ return div.innerHTML;
1197
+ }
1198
+
1199
+ function isChatNearBottom() {
1200
+ return chatViewer.scrollHeight - chatViewer.scrollTop - chatViewer.clientHeight <= CHAT_SCROLL_THRESHOLD;
1201
+ }
1202
+
1203
+ function scrollChatToBottom() {
1204
+ chatViewer.scrollTop = chatViewer.scrollHeight;
1205
+ }
1206
+
1207
+ function setNewMessagesBanner(visible) {
1208
+ if (!chatNewMessages) {
1209
+ return;
1210
+ }
1211
+ chatNewMessages.classList.toggle('visible', visible);
1212
+ chatNewMessages.setAttribute('aria-hidden', visible ? 'false' : 'true');
1213
+ }
1214
+
1215
+ // Resize Handles
1216
+ const LAYOUT_STORAGE_KEY = 'multiagent-layout';
1217
+
1218
+ function initResizers() {
1219
+ const mainHandle = document.querySelector('[data-resize="main"]');
1220
+ const diffHandle = document.querySelector('[data-resize="diff"]');
1221
+
1222
+ if (mainHandle) {
1223
+ setupResizer({
1224
+ handle: mainHandle,
1225
+ direction: 'horizontal',
1226
+ container: document.querySelector('.main-content'),
1227
+ panelA: document.querySelector('.left-panel'),
1228
+ panelB: document.querySelector('.right-panel'),
1229
+ minA: 200,
1230
+ minB: 200,
1231
+ layoutKey: 'mainSplit'
1232
+ });
1233
+ }
1234
+
1235
+ if (diffHandle) {
1236
+ setupResizer({
1237
+ handle: diffHandle,
1238
+ direction: 'horizontal',
1239
+ container: document.querySelector('.diff-layout'),
1240
+ panelA: document.querySelector('.diff-file-list'),
1241
+ panelB: document.querySelector('.diff-content-pane'),
1242
+ minA: 150,
1243
+ minB: 200,
1244
+ layoutKey: 'diffSplit'
1245
+ });
1246
+ }
1247
+
1248
+ // Restore saved layout
1249
+ restoreLayout();
1250
+ }
1251
+
1252
+ function setupResizer(config) {
1253
+ const { handle, direction, container, panelA, panelB, minA, minB, layoutKey } = config;
1254
+
1255
+ if (!handle || !container || !panelA || !panelB) {
1256
+ console.warn('Resizer setup failed: missing elements', config);
1257
+ return;
1258
+ }
1259
+
1260
+ let startPos = 0;
1261
+ let startSizeA = 0;
1262
+ let startSizeB = 0;
1263
+ let rafId = null;
1264
+
1265
+ function onPointerDown(e) {
1266
+ // Only respond to primary button
1267
+ if (e.button !== 0) return;
1268
+
1269
+ e.preventDefault();
1270
+ handle.setPointerCapture(e.pointerId);
1271
+ handle.classList.add('dragging');
1272
+ document.body.classList.add('resizing');
1273
+ document.body.classList.add(direction === 'horizontal' ? 'resizing-h' : 'resizing-v');
1274
+
1275
+ startPos = direction === 'horizontal' ? e.clientX : e.clientY;
1276
+ startSizeA = direction === 'horizontal'
1277
+ ? panelA.getBoundingClientRect().width
1278
+ : panelA.getBoundingClientRect().height;
1279
+ startSizeB = direction === 'horizontal'
1280
+ ? panelB.getBoundingClientRect().width
1281
+ : panelB.getBoundingClientRect().height;
1282
+ }
1283
+
1284
+ function onPointerMove(e) {
1285
+ if (!handle.hasPointerCapture(e.pointerId)) return;
1286
+
1287
+ if (rafId) cancelAnimationFrame(rafId);
1288
+
1289
+ rafId = requestAnimationFrame(() => {
1290
+ const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
1291
+ const delta = currentPos - startPos;
1292
+ const availableSize = startSizeA + startSizeB; // Only resizable panels
1293
+
1294
+ let newSizeA = startSizeA + delta;
1295
+ let newSizeB = startSizeB - delta;
1296
+
1297
+ // Clamp to minimums
1298
+ if (newSizeA < minA) {
1299
+ newSizeA = minA;
1300
+ newSizeB = availableSize - minA;
1301
+ }
1302
+ if (newSizeB < minB) {
1303
+ newSizeB = minB;
1304
+ newSizeA = availableSize - minB;
1305
+ }
1306
+
1307
+ // Use pixel values to avoid overflow issues
1308
+ panelA.style.flex = `0 0 ${newSizeA}px`;
1309
+ panelB.style.flex = `0 0 ${newSizeB}px`;
1310
+
1311
+ // Refit terminals if resizing affects them
1312
+ if (direction === 'horizontal') {
1313
+ refitTerminals();
1314
+ }
1315
+ });
1316
+ }
1317
+
1318
+ function onPointerUp(e) {
1319
+ if (rafId) cancelAnimationFrame(rafId);
1320
+ handle.releasePointerCapture(e.pointerId);
1321
+ handle.classList.remove('dragging');
1322
+ document.body.classList.remove('resizing', 'resizing-h', 'resizing-v');
1323
+
1324
+ // Save layout to localStorage as ratio
1325
+ const sizeA = direction === 'horizontal'
1326
+ ? panelA.getBoundingClientRect().width
1327
+ : panelA.getBoundingClientRect().height;
1328
+ const sizeB = direction === 'horizontal'
1329
+ ? panelB.getBoundingClientRect().width
1330
+ : panelB.getBoundingClientRect().height;
1331
+ const ratio = sizeA / (sizeA + sizeB);
1332
+ saveLayoutRatio(layoutKey, ratio);
1333
+
1334
+ // Final refit
1335
+ refitTerminals();
1336
+ }
1337
+
1338
+ handle.addEventListener('pointerdown', onPointerDown);
1339
+ handle.addEventListener('pointermove', onPointerMove);
1340
+ handle.addEventListener('pointerup', onPointerUp);
1341
+ handle.addEventListener('pointercancel', onPointerUp);
1342
+ }
1343
+
1344
+ function saveLayoutRatio(layoutKey, ratio) {
1345
+ try {
1346
+ const layout = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY) || '{}');
1347
+ layout[layoutKey] = ratio;
1348
+ localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout));
1349
+ } catch (err) {
1350
+ console.warn('Failed to save layout:', err);
1351
+ }
1352
+ }
1353
+
1354
+ function restoreLayout() {
1355
+ try {
1356
+ const layout = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY) || '{}');
1357
+
1358
+ if (layout.mainSplit !== undefined) {
1359
+ const leftPanel = document.querySelector('.left-panel');
1360
+ const rightPanel = document.querySelector('.right-panel');
1361
+ const mainContent = document.querySelector('.main-content');
1362
+ const mainHandle = document.querySelector('[data-resize="main"]');
1363
+
1364
+ if (leftPanel && rightPanel && mainContent) {
1365
+ const containerWidth = mainContent.getBoundingClientRect().width;
1366
+ const handleWidth = mainHandle ? mainHandle.offsetWidth : 8;
1367
+ const availableWidth = containerWidth - handleWidth;
1368
+ const leftWidth = availableWidth * layout.mainSplit;
1369
+ const rightWidth = availableWidth * (1 - layout.mainSplit);
1370
+
1371
+ leftPanel.style.flex = `0 0 ${leftWidth}px`;
1372
+ rightPanel.style.flex = `0 0 ${rightWidth}px`;
1373
+ }
1374
+ }
1375
+
1376
+ // Note: diffSplit is restored via restoreDiffLayout() when diff tab becomes visible
1377
+ } catch (err) {
1378
+ console.warn('Failed to restore layout:', err);
1379
+ }
1380
+ }
1381
+
1382
+ // Restore diff pane layout (called when diff tab becomes visible)
1383
+ function restoreDiffLayout() {
1384
+ try {
1385
+ const layout = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY) || '{}');
1386
+
1387
+ if (layout.diffSplit !== undefined) {
1388
+ const diffFileList = document.querySelector('.diff-file-list');
1389
+ const diffContentPane = document.querySelector('.diff-content-pane');
1390
+ const diffLayout = document.querySelector('.diff-layout');
1391
+ const diffHandle = document.querySelector('[data-resize="diff"]');
1392
+
1393
+ if (diffFileList && diffContentPane && diffLayout) {
1394
+ const containerWidth = diffLayout.getBoundingClientRect().width;
1395
+ // Guard against zero-width container (tab still hidden)
1396
+ if (containerWidth <= 0) return;
1397
+
1398
+ const handleWidth = diffHandle ? diffHandle.offsetWidth : 8;
1399
+ const availableWidth = containerWidth - handleWidth;
1400
+ const fileListWidth = availableWidth * layout.diffSplit;
1401
+ const contentWidth = availableWidth * (1 - layout.diffSplit);
1402
+
1403
+ diffFileList.style.flex = `0 0 ${fileListWidth}px`;
1404
+ diffContentPane.style.flex = `0 0 ${contentWidth}px`;
1405
+ }
1406
+ }
1407
+ } catch (err) {
1408
+ console.warn('Failed to restore diff layout:', err);
1409
+ }
1410
+ }
1411
+
1412
+ // Handle container resize to keep panels responsive
1413
+ function handleContainerResize() {
1414
+ try {
1415
+ const layout = JSON.parse(localStorage.getItem(LAYOUT_STORAGE_KEY) || '{}');
1416
+
1417
+ // Update main panels
1418
+ if (layout.mainSplit !== undefined) {
1419
+ const leftPanel = document.querySelector('.left-panel');
1420
+ const rightPanel = document.querySelector('.right-panel');
1421
+ const mainContent = document.querySelector('.main-content');
1422
+ const mainHandle = document.querySelector('[data-resize="main"]');
1423
+
1424
+ if (leftPanel && rightPanel && mainContent) {
1425
+ const containerWidth = mainContent.getBoundingClientRect().width;
1426
+ const handleWidth = mainHandle ? mainHandle.offsetWidth : 8;
1427
+ const availableWidth = containerWidth - handleWidth;
1428
+
1429
+ // Only update if we have valid dimensions
1430
+ if (containerWidth > 0 && availableWidth > 0) {
1431
+ const minLeft = 200;
1432
+ const minRight = 200;
1433
+ const minTotal = minLeft + minRight;
1434
+
1435
+ let leftWidth, rightWidth;
1436
+
1437
+ // If available space is less than minimum total, scale proportionally
1438
+ if (availableWidth < minTotal) {
1439
+ leftWidth = Math.max(0, availableWidth * layout.mainSplit);
1440
+ rightWidth = Math.max(0, availableWidth * (1 - layout.mainSplit));
1441
+ } else {
1442
+ // Normal case: apply ratio and clamp to minimums
1443
+ leftWidth = availableWidth * layout.mainSplit;
1444
+ rightWidth = availableWidth * (1 - layout.mainSplit);
1445
+
1446
+ if (leftWidth < minLeft) {
1447
+ leftWidth = minLeft;
1448
+ rightWidth = Math.max(minRight, availableWidth - minLeft);
1449
+ } else if (rightWidth < minRight) {
1450
+ rightWidth = minRight;
1451
+ leftWidth = Math.max(minLeft, availableWidth - minRight);
1452
+ }
1453
+ }
1454
+
1455
+ leftPanel.style.flex = `0 0 ${leftWidth}px`;
1456
+ rightPanel.style.flex = `0 0 ${rightWidth}px`;
1457
+
1458
+ refitTerminals();
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ // Update diff panels if diff tab is active
1464
+ const diffTabContent = document.getElementById('diff-tab-content');
1465
+ if (diffTabContent && diffTabContent.classList.contains('active') && layout.diffSplit !== undefined) {
1466
+ const diffFileList = document.querySelector('.diff-file-list');
1467
+ const diffContentPane = document.querySelector('.diff-content-pane');
1468
+ const diffLayout = document.querySelector('.diff-layout');
1469
+ const diffHandle = document.querySelector('[data-resize="diff"]');
1470
+
1471
+ if (diffFileList && diffContentPane && diffLayout) {
1472
+ const containerWidth = diffLayout.getBoundingClientRect().width;
1473
+ const handleWidth = diffHandle ? diffHandle.offsetWidth : 8;
1474
+ const availableWidth = containerWidth - handleWidth;
1475
+
1476
+ // Only update if we have valid dimensions
1477
+ if (containerWidth > 0 && availableWidth > 0) {
1478
+ const minFileList = 150;
1479
+ const minContent = 200;
1480
+ const minTotal = minFileList + minContent;
1481
+
1482
+ let fileListWidth, contentWidth;
1483
+
1484
+ // If available space is less than minimum total, scale proportionally
1485
+ if (availableWidth < minTotal) {
1486
+ fileListWidth = Math.max(0, availableWidth * layout.diffSplit);
1487
+ contentWidth = Math.max(0, availableWidth * (1 - layout.diffSplit));
1488
+ } else {
1489
+ // Normal case: apply ratio and clamp to minimums
1490
+ fileListWidth = availableWidth * layout.diffSplit;
1491
+ contentWidth = availableWidth * (1 - layout.diffSplit);
1492
+
1493
+ if (fileListWidth < minFileList) {
1494
+ fileListWidth = minFileList;
1495
+ contentWidth = Math.max(minContent, availableWidth - minFileList);
1496
+ } else if (contentWidth < minContent) {
1497
+ contentWidth = minContent;
1498
+ fileListWidth = Math.max(minFileList, availableWidth - minContent);
1499
+ }
1500
+ }
1501
+
1502
+ diffFileList.style.flex = `0 0 ${fileListWidth}px`;
1503
+ diffContentPane.style.flex = `0 0 ${contentWidth}px`;
1504
+ }
1505
+ }
1506
+ }
1507
+ } catch (err) {
1508
+ console.warn('Failed to handle container resize:', err);
1509
+ }
1510
+ }
1511
+
1512
+ // Debounced window resize handler
1513
+ let resizeTimeout = null;
1514
+ function onWindowResize() {
1515
+ if (resizeTimeout) clearTimeout(resizeTimeout);
1516
+ resizeTimeout = setTimeout(() => {
1517
+ handleContainerResize();
1518
+ }, 150);
1519
+ }
1520
+
1521
+ function refitTerminals() {
1522
+ // Debounce terminal refitting
1523
+ if (refitTerminals.timeout) clearTimeout(refitTerminals.timeout);
1524
+ refitTerminals.timeout = setTimeout(() => {
1525
+ Object.values(terminals).forEach(({ fitAddon }) => {
1526
+ try {
1527
+ fitAddon.fit();
1528
+ } catch (err) {
1529
+ // Ignore fit errors
1530
+ }
1531
+ });
1532
+ }, 50);
1533
+ }
1534
+
1535
+ // Event Listeners
1536
+ startButton.addEventListener('click', startSession);
1537
+ newSessionButton.addEventListener('click', showNewSessionModal);
1538
+ newSessionCancelButton.addEventListener('click', hideNewSessionModal);
1539
+ newSessionConfirmButton.addEventListener('click', startNewSession);
1540
+ sendMessageButton.addEventListener('click', sendUserMessage);
1541
+ startImplementingButton.addEventListener('click', showImplementationModal);
1542
+ modalCancelButton.addEventListener('click', hideImplementationModal);
1543
+ modalStartButton.addEventListener('click', startImplementation);
1544
+
1545
+ // Workspace picker event listeners
1546
+ browseWorkspaceButton.addEventListener('click', browseForWorkspace);
1547
+ useCwdButton.addEventListener('click', useCwd);
1548
+ editConfigButton.addEventListener('click', openConfigFolder);
1549
+
1550
+ // Main tab event listeners
1551
+ mainTabChat.addEventListener('click', () => switchMainTab('chat'));
1552
+ mainTabPlan.addEventListener('click', () => switchMainTab('plan'));
1553
+ mainTabDiff.addEventListener('click', () => switchMainTab('diff'));
1554
+ refreshDiffButton.addEventListener('click', refreshGitDiff);
1555
+ if (refreshPlanButton) {
1556
+ refreshPlanButton.addEventListener('click', refreshPlan);
1557
+ }
1558
+ chatNewMessagesButton.addEventListener('click', () => {
1559
+ autoScrollEnabled = true;
1560
+ scrollChatToBottom();
1561
+ setNewMessagesBanner(false);
1562
+ });
1563
+ chatViewer.addEventListener('scroll', () => {
1564
+ if (isChatNearBottom()) {
1565
+ autoScrollEnabled = true;
1566
+ setNewMessagesBanner(false);
1567
+ } else {
1568
+ autoScrollEnabled = false;
1569
+ }
1570
+ });
1571
+
1572
+ // Allow Enter+Shift to send message
1573
+ userMessageInput.addEventListener('keydown', (e) => {
1574
+ if (e.key === 'Enter' && e.shiftKey) {
1575
+ e.preventDefault();
1576
+ sendUserMessage();
1577
+ }
1578
+ });
1579
+
1580
+ // Allow Enter to submit challenge
1581
+ challengeInput.addEventListener('keydown', (e) => {
1582
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
1583
+ e.preventDefault();
1584
+ startSession();
1585
+ }
1586
+ });
1587
+
1588
+ // Keyboard shortcut for New Session (Cmd/Ctrl + Shift + N)
1589
+ document.addEventListener('keydown', (e) => {
1590
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'N') {
1591
+ e.preventDefault();
1592
+ if (sessionScreen.classList.contains('active')) {
1593
+ showNewSessionModal();
1594
+ }
1595
+ }
1596
+ });
1597
+
1598
+ // IPC Listeners
1599
+ window.electronAPI.onAgentOutput((data) => {
1600
+ updateAgentOutput(data.agentName, data.output, data.isPty);
1601
+ });
1602
+
1603
+ window.electronAPI.onAgentStatus((data) => {
1604
+ updateAgentStatus(data.agentName, data.status, data.exitCode, data.error);
1605
+ });
1606
+
1607
+ // Listen for full chat refresh (array of messages)
1608
+ window.electronAPI.onChatUpdated((messages) => {
1609
+ updateChatFromMessages(messages);
1610
+ });
1611
+
1612
+ // Listen for new individual messages
1613
+ window.electronAPI.onChatMessage((message) => {
1614
+ addChatMessage(message);
1615
+ });
1616
+
1617
+ // Initialize on load
1618
+ initialize();
1619
+ initResizers();
1620
+ restoreTabState();
1621
+
1622
+ // Apply responsive layout on initial load
1623
+ requestAnimationFrame(() => handleContainerResize());
1624
+
1625
+ // Add window resize listener to keep panels responsive
1626
+ window.addEventListener('resize', onWindowResize);