Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 48 additions & 16 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isAuthenticated, getOrgId, makeRequest, streamCompletion, stopResponse,
import { createStreamState, processSSEChunk, type StreamCallbacks } from './streaming/parser';
import type { SettingsSchema, AttachmentPayload, UploadFilePayload } from './types';

let mainWindow: BrowserWindow | null = null;
let mainWindows: BrowserWindow[] = [];
let spotlightWindow: BrowserWindow | null = null;
let settingsWindow: BrowserWindow | null = null;

Expand Down Expand Up @@ -94,8 +94,8 @@ function createSpotlightWindow() {
});
}

function createMainWindow() {
mainWindow = new BrowserWindow({
function createMainWindow(): BrowserWindow {
const newWindow = new BrowserWindow({
width: 900,
height: 700,
transparent: true,
Expand All @@ -111,7 +111,26 @@ function createMainWindow() {
trafficLightPosition: { x: 16, y: 16 },
});

mainWindow.loadFile(path.join(__dirname, '../static/index.html'));
newWindow.loadFile(path.join(__dirname, '../static/index.html'));

// Remove from array when closed
newWindow.on('closed', () => {
mainWindows = mainWindows.filter(w => w !== newWindow);
});

mainWindows.push(newWindow);
return newWindow;
}

// Get the focused main window or the first one
function getMainWindow(): BrowserWindow | null {
// Return the focused window if it's a main window
const focused = BrowserWindow.getFocusedWindow();
if (focused && mainWindows.includes(focused)) {
return focused;
}
// Return the first main window that's not destroyed
return mainWindows.find(w => !w.isDestroyed()) || null;
}

// Create settings window
Expand Down Expand Up @@ -438,7 +457,7 @@ ipcMain.handle('upload-attachments', async (_event, files: UploadFilePayload[])
});

// Send a message and stream response
ipcMain.handle('send-message', async (_event, conversationId: string, message: string, parentMessageUuid: string, attachments: AttachmentPayload[] = []) => {
ipcMain.handle('send-message', async (event, conversationId: string, message: string, parentMessageUuid: string, attachments: AttachmentPayload[] = []) => {
const orgId = await getOrgId();
if (!orgId) throw new Error('Not authenticated');

Expand All @@ -451,25 +470,26 @@ ipcMain.handle('send-message', async (_event, conversationId: string, message: s
}

const state = createStreamState();
const sender = event.sender;

const callbacks: StreamCallbacks = {
onTextDelta: (text, fullText, blockIndex) => {
mainWindow?.webContents.send('message-stream', { conversationId, blockIndex, text, fullText });
sender.send('message-stream', { conversationId, blockIndex, text, fullText });
},
onThinkingStart: (blockIndex) => {
mainWindow?.webContents.send('message-thinking', { conversationId, blockIndex, isThinking: true });
sender.send('message-thinking', { conversationId, blockIndex, isThinking: true });
},
onThinkingDelta: (thinking, blockIndex) => {
const block = state.contentBlocks.get(blockIndex);
mainWindow?.webContents.send('message-thinking-stream', {
sender.send('message-thinking-stream', {
conversationId,
blockIndex,
thinking,
summaries: block?.summaries
});
},
onThinkingStop: (thinkingText, summaries, blockIndex) => {
mainWindow?.webContents.send('message-thinking', {
sender.send('message-thinking', {
conversationId,
blockIndex,
isThinking: false,
Expand All @@ -478,7 +498,7 @@ ipcMain.handle('send-message', async (_event, conversationId: string, message: s
});
},
onToolStart: (toolName, toolMessage, blockIndex) => {
mainWindow?.webContents.send('message-tool-use', {
sender.send('message-tool-use', {
conversationId,
blockIndex,
toolName,
Expand All @@ -488,7 +508,7 @@ ipcMain.handle('send-message', async (_event, conversationId: string, message: s
},
onToolStop: (toolName, input, blockIndex) => {
const block = state.contentBlocks.get(blockIndex);
mainWindow?.webContents.send('message-tool-use', {
sender.send('message-tool-use', {
conversationId,
blockIndex,
toolName,
Expand All @@ -498,7 +518,7 @@ ipcMain.handle('send-message', async (_event, conversationId: string, message: s
});
},
onToolResult: (toolName, result, isError, blockIndex) => {
mainWindow?.webContents.send('message-tool-result', {
sender.send('message-tool-result', {
conversationId,
blockIndex,
toolName,
Expand All @@ -507,16 +527,16 @@ ipcMain.handle('send-message', async (_event, conversationId: string, message: s
});
},
onCitation: (citation, blockIndex) => {
mainWindow?.webContents.send('message-citation', { conversationId, blockIndex, citation });
sender.send('message-citation', { conversationId, blockIndex, citation });
},
onToolApproval: (toolName, approvalKey, input) => {
mainWindow?.webContents.send('message-tool-approval', { conversationId, toolName, approvalKey, input });
sender.send('message-tool-approval', { conversationId, toolName, approvalKey, input });
},
onCompaction: (status, compactionMessage) => {
mainWindow?.webContents.send('message-compaction', { conversationId, status, message: compactionMessage });
sender.send('message-compaction', { conversationId, status, message: compactionMessage });
},
onComplete: (fullText, steps, messageUuid) => {
mainWindow?.webContents.send('message-complete', { conversationId, fullText, steps, messageUuid });
sender.send('message-complete', { conversationId, fullText, steps, messageUuid });
}
};

Expand Down Expand Up @@ -568,12 +588,24 @@ ipcMain.handle('save-settings', async (_event, settings: Partial<SettingsSchema>
return getSettings();
});

// Window management
ipcMain.handle('new-window', async () => {
const newWindow = createMainWindow();
newWindow.focus();
return { success: true };
});

ipcMain.handle('get-window-count', async () => {
return mainWindows.filter(w => !w.isDestroyed()).length;
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filtering logic mainWindows.filter(w => !w.isDestroyed()).length is duplicated from the getMainWindow() helper. This could lead to inconsistencies if the filtering logic needs to change.

Consider using the helper consistently:

ipcMain.handle('get-window-count', async () => {
  return mainWindows.length;
});

Or if you need active windows only:

ipcMain.handle('get-window-count', async () => {
  return mainWindows.filter(w => !w.isDestroyed()).length;
});

Note: Since the closed event handler already removes windows from the array (line 118), the additional isDestroyed() check may be unnecessary.

Suggested change
return mainWindows.filter(w => !w.isDestroyed()).length;
return mainWindows.length;

Copilot uses AI. Check for mistakes.
});

// Handle deep link on Windows (single instance)
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
const mainWindow = getMainWindow();
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
Expand Down
4 changes: 4 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,8 @@ contextBridge.exposeInMainWorld('claude', {
getSettings: () => ipcRenderer.invoke('get-settings'),
saveSettings: (settings: { spotlightKeybind?: string; spotlightPersistHistory?: boolean }) =>
ipcRenderer.invoke('save-settings', settings),

// Window management
newWindow: () => ipcRenderer.invoke('new-window'),
getWindowCount: () => ipcRenderer.invoke('get-window-count'),
});
12 changes: 12 additions & 0 deletions src/renderer/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ declare global {
onMessageToolResult: (callback: (data: ToolResultData) => void) => void;
onMessageStream: (callback: (data: StreamData) => void) => void;
onMessageComplete: (callback: (data: CompleteData) => void) => void;
newWindow: () => Promise<{ success: boolean }>;
getWindowCount: () => Promise<number>;
};
}
}
Expand Down Expand Up @@ -1595,6 +1597,11 @@ function setupEventListeners() {
window.claude.openSettings();
});

// New window button
$('new-window-btn')?.addEventListener('click', () => {
window.claude.newWindow();
});

// Sidebar toggle
$('sidebar-tab')?.addEventListener('click', toggleSidebar);
$('sidebar-overlay')?.addEventListener('click', closeSidebar);
Expand Down Expand Up @@ -1648,6 +1655,11 @@ function setupEventListeners() {
e.preventDefault();
toggleSidebar();
}
// Cmd/Ctrl+Shift+N: New window
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'N') {
e.preventDefault();
window.claude.newWindow();
}
});

// Close dropdowns when clicking outside
Expand Down
7 changes: 7 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1913,6 +1913,13 @@ <h1>Open Claude</h1>
<div class="sidebar-header">
<span class="sidebar-title">Chats</span>
<div class="sidebar-spacer"></div>
<button class="settings-btn" id="new-window-btn" title="New Window (Ctrl/Cmd+Shift+N)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
<button class="settings-btn" id="settings-btn" title="Settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
Expand Down