Skip to content

Commit 4e1ed74

Browse files
committed
Assistant tabs, and windows of its own
The panel holds several conversations as tabs, each with its own transcript, scope, draft and attachments, and each kept mounted while another is in front so an answer in progress keeps arriving. One row of chrome: the tabs, a plus, and one menu holding the chat history, the moves between windows and close. A tab's face is the host/session selector, so what it is about is what tells it from the next one; the plus-in-a-square affordance shows only while nothing is picked. Right-click gives the same moves and closes; tabs open and collapse on the title bar's own tweens, and the strip scrolls with the wheel. Tabs can be lifted into windows of the assistant's own, one for a set or one each, running the same bundle off a URL hash. Main keeps what every window shows, relays the main window's sessions, hosts and active session to the others, hands a closing window's tabs back to the panel, and takes a conversation off any other window the moment one opens it, so a chat is shown in one place. A tab that moves keeps the servers it was pinned to, read back from the selection main already stores per conversation. The window controls are one component shared with the title bar, and the context menu is sized to its content before it is placed, so it no longer wraps against the window's edge.
1 parent 01e3a7f commit 4e1ed74

23 files changed

Lines changed: 2619 additions & 1143 deletions

‎src/main/ai/windows.js‎

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
const { BrowserWindow, app, screen, shell } = require('electron');
2+
const path = require('path');
3+
4+
/**
5+
* The assistant's own windows.
6+
*
7+
* A conversation can be lifted out of the panel beside the terminal and put in
8+
* a window of its own: one window for a set of tabs, or one per tab. The
9+
* conversations themselves stay where they were, in `ai/index.js`; a window is
10+
* only a place they are drawn, and the same events reach every window that
11+
* happens to be drawing them.
12+
*
13+
* Each window runs the same renderer bundle as the main window, told by its
14+
* URL hash to show the assistant alone, the way the screenshot viewer is told
15+
* to show one capture. What the renderer in it cannot know is which terminals
16+
* are open and which one the user is looking at, since those live in the main
17+
* window's tab tree, so the main window publishes that here and it is relayed.
18+
*
19+
* What a window holds is remembered here, not in the window: a reload asks
20+
* for it back, and a window that closes hands its tabs to the main window so
21+
* a conversation is never stranded with nothing showing it.
22+
*/
23+
24+
/** windowId -> { id, window, conversationIds } */
25+
const windows = new Map();
26+
let counter = 0;
27+
28+
/**
29+
* What the main window's panel is showing, reported the same way a detached
30+
* window reports its own. Kept so that a conversation opened somewhere else
31+
* can be taken off the panel: a chat is shown in one place, never two.
32+
*/
33+
let mainTabs = [];
34+
35+
/** How the main window is told things: adopting tabs, going to a page. */
36+
let notifyMain = () => {};
37+
38+
/**
39+
* What the main window knows and a detached one cannot: the open sessions,
40+
* the saved hosts, and the session in front. Kept so a window opening late
41+
* gets the current answer without waiting for the next change.
42+
*/
43+
let context = { sessions: [], hosts: [], activeSessionId: '' };
44+
45+
function setMainNotifier(fn) {
46+
notifyMain = fn;
47+
}
48+
49+
const cleanIds = (list) => [...new Set((Array.isArray(list) ? list : [])
50+
.map(id => String(id || '').trim())
51+
.filter(Boolean))];
52+
53+
/** The same bundle as the parent, whichever way the parent was loaded. */
54+
function windowUrl(parent, id) {
55+
const current = parent && !parent.isDestroyed() ? parent.webContents.getURL() : '';
56+
const base = current.split('#')[0];
57+
if (base) return `${base}#assistant=${id}`;
58+
59+
const fallback = path.join(__dirname, '..', '..', 'dist', 'renderer', 'index.html');
60+
return `file://${fallback.replace(/\\/g, '/')}#assistant=${id}`;
61+
}
62+
63+
function entryFor(webContents) {
64+
for (const entry of windows.values()) {
65+
if (entry.window.webContents === webContents) return entry;
66+
}
67+
return null;
68+
}
69+
70+
/**
71+
* Open a window holding these conversations.
72+
*
73+
* Sized to a comfortable panel rather than to the parent, and placed beside
74+
* the parent's right edge when there is room, since that is where the panel
75+
* it came out of was.
76+
*/
77+
function open(parent, conversationIds = []) {
78+
const ids = cleanIds(conversationIds);
79+
80+
counter += 1;
81+
const id = `aiw-${Date.now().toString(36)}-${counter}`;
82+
83+
const width = 480;
84+
const height = 720;
85+
let position = {};
86+
if (parent && !parent.isDestroyed()) {
87+
const bounds = parent.getBounds();
88+
const work = screen.getDisplayMatching(bounds).workArea;
89+
// Beside the parent when that fits on the same screen, else over it.
90+
const x = bounds.x + bounds.width + 8 + width <= work.x + work.width
91+
? bounds.x + bounds.width + 8
92+
: Math.max(work.x, bounds.x + bounds.width - width - 24);
93+
const y = Math.max(work.y, Math.min(bounds.y + 24, work.y + work.height - height));
94+
position = { x, y };
95+
}
96+
97+
const window = new BrowserWindow({
98+
width,
99+
height,
100+
minWidth: 360,
101+
minHeight: 420,
102+
...position,
103+
...(process.platform === 'darwin'
104+
? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 19, y: 24 } }
105+
: { frame: false }),
106+
backgroundColor: '#16161e',
107+
show: false,
108+
title: 'CloudTerm - AI Agent',
109+
webPreferences: {
110+
preload: path.join(__dirname, '..', 'preload.js'),
111+
contextIsolation: true,
112+
nodeIntegration: false,
113+
sandbox: true,
114+
webviewTag: false,
115+
},
116+
});
117+
118+
// The same hardening as the main window: this renderer draws model output
119+
// and terminal text and must neither navigate nor spawn windows.
120+
window.webContents.setWindowOpenHandler(({ url }) => {
121+
if (url.startsWith('https://')) shell.openExternal(url);
122+
return { action: 'deny' };
123+
});
124+
window.webContents.on('will-navigate', (event, url) => {
125+
const isDevServer = !app.isPackaged && url.startsWith('http://localhost:5173');
126+
if (!isDevServer) event.preventDefault();
127+
});
128+
129+
const entry = { id, window, conversationIds: ids };
130+
windows.set(id, entry);
131+
132+
window.once('ready-to-show', () => window.show());
133+
window.on('closed', () => {
134+
windows.delete(id);
135+
// Whatever it still held goes back to the panel, so closing the window
136+
// means "put these back", never "lose these".
137+
if (entry.conversationIds.length > 0) {
138+
notifyMain('ai-tabs-adopt', { conversationIds: entry.conversationIds });
139+
}
140+
});
141+
142+
window.loadURL(windowUrl(parent, id));
143+
return { success: true, windowId: id };
144+
}
145+
146+
/** What the window a call came from is holding, for its first render. */
147+
function tabs(webContents) {
148+
const entry = entryFor(webContents);
149+
return { conversationIds: entry ? [...entry.conversationIds] : [], detached: Boolean(entry) };
150+
}
151+
152+
/**
153+
* A window telling us what it holds now, so a reload and a close agree.
154+
*
155+
* Also where "one place" is enforced. Anything this window has just taken on
156+
* that another window was showing is taken off that other window: it is told
157+
* to let go, and the record here is corrected at once so a close in between
158+
* cannot hand the same conversation back a second time. Only what is new to
159+
* this window counts, so two windows reporting the same standing list cannot
160+
* strip each other bare.
161+
*/
162+
function setTabs(webContents, conversationIds) {
163+
const ids = cleanIds(conversationIds);
164+
const entry = entryFor(webContents);
165+
166+
const previous = entry ? entry.conversationIds : mainTabs;
167+
const added = ids.filter(id => !previous.includes(id));
168+
if (entry) entry.conversationIds = ids;
169+
else mainTabs = ids;
170+
171+
if (added.length > 0) {
172+
for (const other of windows.values()) {
173+
if (other === entry || other.window.isDestroyed()) continue;
174+
const taken = other.conversationIds.filter(id => added.includes(id));
175+
if (taken.length === 0) continue;
176+
other.conversationIds = other.conversationIds.filter(id => !taken.includes(id));
177+
other.window.webContents.send('ai-tabs-release', { conversationIds: taken });
178+
}
179+
if (entry) {
180+
const taken = mainTabs.filter(id => added.includes(id));
181+
if (taken.length > 0) {
182+
mainTabs = mainTabs.filter(id => !taken.includes(id));
183+
notifyMain('ai-tabs-release', { conversationIds: taken });
184+
}
185+
}
186+
}
187+
188+
return { success: true };
189+
}
190+
191+
/**
192+
* Hand some or all of a window's tabs back to the main window. With nothing
193+
* left the window closes; its `closed` handler then has nothing to send.
194+
*/
195+
function reattach(webContents, conversationIds) {
196+
const entry = entryFor(webContents);
197+
if (!entry) return { success: false };
198+
199+
const asked = cleanIds(conversationIds);
200+
const going = asked.length > 0
201+
? entry.conversationIds.filter(id => asked.includes(id))
202+
: [...entry.conversationIds];
203+
204+
entry.conversationIds = entry.conversationIds.filter(id => !going.includes(id));
205+
if (going.length > 0) notifyMain('ai-tabs-adopt', { conversationIds: going });
206+
if (entry.conversationIds.length === 0) entry.window.close();
207+
return { success: true };
208+
}
209+
210+
/** Close the window a call came from, without handing anything back. */
211+
function closeWindow(webContents) {
212+
const entry = entryFor(webContents);
213+
if (!entry) return { success: false };
214+
entry.conversationIds = [];
215+
entry.window.close();
216+
return { success: true };
217+
}
218+
219+
/** Every conversation shown in a detached window. */
220+
function held() {
221+
const ids = [];
222+
for (const entry of windows.values()) ids.push(...entry.conversationIds);
223+
return ids;
224+
}
225+
226+
function setContext(next) {
227+
context = {
228+
sessions: Array.isArray(next?.sessions) ? next.sessions : [],
229+
hosts: Array.isArray(next?.hosts) ? next.hosts : [],
230+
activeSessionId: String(next?.activeSessionId || ''),
231+
};
232+
for (const entry of windows.values()) {
233+
if (!entry.window.isDestroyed()) entry.window.webContents.send('ai-context', context);
234+
}
235+
return { success: true };
236+
}
237+
238+
function getContext() {
239+
return context;
240+
}
241+
242+
/** On the main window going: nothing to hand tabs back to, so just close. */
243+
function closeAll() {
244+
for (const entry of windows.values()) {
245+
entry.conversationIds = [];
246+
if (!entry.window.isDestroyed()) entry.window.close();
247+
}
248+
windows.clear();
249+
}
250+
251+
module.exports = {
252+
setMainNotifier,
253+
open,
254+
tabs,
255+
setTabs,
256+
reattach,
257+
closeWindow,
258+
held,
259+
setContext,
260+
getContext,
261+
closeAll,
262+
};

‎src/main/ipc.js‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { ipcMain, dialog, app, shell, clipboard, powerMonitor } = require('electron');
1+
const { ipcMain, dialog, app, shell, clipboard, powerMonitor, BrowserWindow } = require('electron');
22
const fs = require('fs');
33
const path = require('path');
44
const store = require('./store');
@@ -29,6 +29,7 @@ const cloudSnapshot = require('./cloud-snapshot');
2929
const activity = require('./activity');
3030
const sessionLog = require('./session-log');
3131
const assistant = require('./ai');
32+
const aiWindows = require('./ai/windows');
3233
const updates = require('./updates');
3334
const startup = require('./startup');
3435
const proxy = require('./proxy');
@@ -202,7 +203,18 @@ function register(getWindow) {
202203
updates.start(notify);
203204

204205
remoteEdit.setNotifier(notify);
205-
assistant.setNotifier(notify);
206+
207+
// The assistant is the one thing drawn in more than one window: a
208+
// conversation can be lifted into a window of its own, and the events
209+
// that draw it have to reach whichever window is showing it. Every window
210+
// gets them; each filters by the conversations it holds.
211+
const broadcast = (channel, payload) => {
212+
for (const window of BrowserWindow.getAllWindows()) {
213+
if (!window.isDestroyed()) window.webContents.send(channel, payload);
214+
}
215+
};
216+
assistant.setNotifier(broadcast);
217+
aiWindows.setMainNotifier(notify);
206218
tunnels.setNotifier(notify);
207219
vnc.setNotifier(notify);
208220
rdp.setNotifier(notify);
@@ -1647,6 +1659,32 @@ function register(getWindow) {
16471659
handle('ai-approval-response', (event, payload) => assistant.respondToApproval(payload || {}));
16481660
handle('ai-action-response', (event, payload) => assistant.respondToAction(payload || {}));
16491661

1662+
/* ---------------- Assistant: windows of its own ---------------- */
1663+
1664+
// Lifting tabs into a window, and what that window is told. See
1665+
// `ai/windows.js`. The parent is whichever window asked, so a window
1666+
// detached from a detached window opens beside that one.
1667+
handle('ai-window-open', (event, payload) =>
1668+
aiWindows.open(BrowserWindow.fromWebContents(event.sender) || getWindow(), payload?.conversationIds || []));
1669+
handle('ai-window-tabs', (event) => aiWindows.tabs(event.sender));
1670+
handle('ai-window-tabs-set', (event, conversationIds) => aiWindows.setTabs(event.sender, conversationIds));
1671+
handle('ai-window-reattach', (event, conversationIds) => aiWindows.reattach(event.sender, conversationIds));
1672+
handle('ai-window-close', (event) => aiWindows.closeWindow(event.sender));
1673+
1674+
// The main window saying which terminals are open and which is in front,
1675+
// relayed to every detached window; and a detached window asking to be
1676+
// taken to a page only the main window has.
1677+
handle('ai-context-set', (event, context) => aiWindows.setContext(context));
1678+
handle('ai-context', () => aiWindows.getContext());
1679+
handle('ai-navigate-main', (event, nav) => {
1680+
const window = getWindow();
1681+
if (!window || window.isDestroyed()) return { success: false };
1682+
if (window.isMinimized()) window.restore();
1683+
window.focus();
1684+
window.webContents.send('ai-navigate', { nav: String(nav || '') });
1685+
return { success: true };
1686+
});
1687+
16501688
/* ---------------- Startup ---------------- */
16511689

16521690
// Read from the system on every call rather than cached, because the user
@@ -1656,16 +1694,20 @@ function register(getWindow) {
16561694

16571695
/* ---------------- Window ---------------- */
16581696

1659-
ipcMain.on('window-minimize', () => getWindow()?.minimize());
1660-
ipcMain.on('window-maximize', () => {
1661-
const window = getWindow();
1697+
// The window the call came from, not the main window: the assistant's own
1698+
// windows are frameless too and draw the same three buttons.
1699+
const windowOf = (event) => BrowserWindow.fromWebContents(event.sender) || getWindow();
1700+
1701+
ipcMain.on('window-minimize', (event) => windowOf(event)?.minimize());
1702+
ipcMain.on('window-maximize', (event) => {
1703+
const window = windowOf(event);
16621704
if (!window) return;
16631705
if (window.isMaximized()) window.unmaximize();
16641706
else window.maximize();
16651707
});
1666-
ipcMain.on('window-close', () => getWindow()?.close());
1667-
ipcMain.on('open-devtools', () => getWindow()?.webContents.toggleDevTools());
1668-
ipcMain.on('reload-window', () => getWindow()?.reload());
1708+
ipcMain.on('window-close', (event) => windowOf(event)?.close());
1709+
ipcMain.on('open-devtools', (event) => windowOf(event)?.webContents.toggleDevTools());
1710+
ipcMain.on('reload-window', (event) => windowOf(event)?.reload());
16691711
ipcMain.on('force-quit', () => app.quit());
16701712
}
16711713

‎src/main/main.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const { app, BrowserWindow, screen, shell } = require('electron');
22
const fs = require('fs');
33
const path = require('path');
44
const ipc = require('./ipc');
5+
const aiWindows = require('./ai/windows');
56
const transport = require('./transport');
67
const cloudSnapshot = require('./cloud-snapshot');
78

@@ -144,6 +145,10 @@ function createWindow() {
144145

145146
mainWindow.on('closed', () => {
146147
ipc.cancelPendingPrompts();
148+
// The assistant's own windows draw conversations the main window
149+
// runs; with it gone they have nothing to show and nothing to hand
150+
// their tabs back to.
151+
aiWindows.closeAll();
147152
mainWindow = null;
148153
});
149154

0 commit comments

Comments
 (0)