@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/README.md +76 -0
- package/bin/multiagent-chat.js +14 -0
- package/config.yaml +158 -0
- package/index.html +211 -0
- package/main.js +1149 -0
- package/package.json +52 -0
- package/preload.js +71 -0
- package/renderer.js +1626 -0
- package/robot.png +0 -0
- package/styles.css +1626 -0
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);
|