Skip to content

Commit 8cda90d

Browse files
committed
Release v0.0.43
## What's New ### Features - **Multi-Window Support** — Open chats in new windows via context menu - **VS Code Theme Import** — Import themes from VS Code, Cursor, and Windsurf - **Human-Readable Worktree Names** — Worktree folders now have readable names ### Improvements & Fixes - **Window-Scoped Sidebars** — Sub-chat and details sidebars are now per-window - **Image Drops** — Handle image drops as attachments, not file mentions - **Plan Refetch** — Fixed plan refetch trigger with subChatId
1 parent 48df758 commit 8cda90d

29 files changed

Lines changed: 1495 additions & 206 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "21st-desktop",
3-
"version": "0.0.42",
3+
"version": "0.0.43",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {

src/main/index.ts

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ import {
2828
} from "./lib/cli"
2929
import { cleanupGitWatchers } from "./lib/git/watcher"
3030
import { cancelAllPendingOAuth, handleMcpOAuthCallback } from "./lib/mcp-auth"
31-
import { createMainWindow, getWindow } from "./windows/main"
31+
import {
32+
createMainWindow,
33+
createWindow,
34+
getWindow,
35+
getAllWindows,
36+
} from "./windows/main"
37+
import { windowManager } from "./windows/window-manager"
3238

3339
import { IS_DEV, AUTH_SERVER_PORT } from "./constants"
3440

@@ -128,20 +134,46 @@ export async function handleAuthCode(code: string): Promise<void> {
128134
console.warn("[Auth] Cookie set failed (non-critical):", cookieError)
129135
}
130136

131-
// Notify renderer
132-
const win = getWindow()
133-
win?.webContents.send("auth:success", authData.user)
134-
135-
// Reload window to show app
136-
if (process.env.ELECTRON_RENDERER_URL) {
137-
win?.loadURL(process.env.ELECTRON_RENDERER_URL)
138-
} else {
139-
win?.loadFile(join(__dirname, "../renderer/index.html"))
137+
// Notify all windows and reload them to show app
138+
const windows = getAllWindows()
139+
for (const win of windows) {
140+
try {
141+
if (win.isDestroyed()) continue
142+
win.webContents.send("auth:success", authData.user)
143+
144+
// Use stable window ID (main, window-2, etc.) instead of Electron's numeric ID
145+
const stableId = windowManager.getStableId(win)
146+
147+
if (process.env.ELECTRON_RENDERER_URL) {
148+
// Pass window ID via query param for dev mode
149+
const url = new URL(process.env.ELECTRON_RENDERER_URL)
150+
url.searchParams.set("windowId", stableId)
151+
win.loadURL(url.toString())
152+
} else {
153+
// Pass window ID via hash for production
154+
win.loadFile(join(__dirname, "../renderer/index.html"), {
155+
hash: `windowId=${stableId}`,
156+
})
157+
}
158+
} catch (error) {
159+
// Window may have been destroyed during iteration
160+
console.warn("[Auth] Failed to reload window:", error)
161+
}
140162
}
141-
win?.focus()
163+
// Focus the first window
164+
windows[0]?.focus()
142165
} catch (error) {
143166
console.error("[Auth] Exchange failed:", error)
144-
getWindow()?.webContents.send("auth:error", (error as Error).message)
167+
// Broadcast auth error to all windows (not just focused)
168+
for (const win of getAllWindows()) {
169+
try {
170+
if (!win.isDestroyed()) {
171+
win.webContents.send("auth:error", (error as Error).message)
172+
}
173+
} catch {
174+
// Window destroyed during iteration
175+
}
176+
}
145177
}
146178
}
147179

@@ -499,10 +531,15 @@ if (gotTheLock) {
499531
handleDeepLink(url)
500532
}
501533

502-
const window = getWindow()
503-
if (window) {
534+
// Focus on the first available window
535+
const windows = getAllWindows()
536+
if (windows.length > 0) {
537+
const window = windows[0]!
504538
if (window.isMinimized()) window.restore()
505539
window.focus()
540+
} else {
541+
// No windows open, create a new one
542+
createMainWindow()
506543
}
507544
})
508545

@@ -653,6 +690,25 @@ if (gotTheLock) {
653690
}
654691
},
655692
},
693+
{
694+
label: "New Window",
695+
accelerator: "CmdOrCtrl+Shift+N",
696+
click: () => {
697+
console.log("[Menu] New Window clicked (Cmd+Shift+N)")
698+
createWindow()
699+
},
700+
},
701+
{ type: "separator" },
702+
{
703+
label: "Close Window",
704+
accelerator: "CmdOrCtrl+W",
705+
click: () => {
706+
const win = getWindow()
707+
if (win) {
708+
win.close()
709+
}
710+
},
711+
},
656712
],
657713
},
658714
{
@@ -708,6 +764,20 @@ if (gotTheLock) {
708764
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
709765
}
710766

767+
// macOS: Set dock menu (right-click on dock icon)
768+
if (process.platform === "darwin") {
769+
const dockMenu = Menu.buildFromTemplate([
770+
{
771+
label: "New Window",
772+
click: () => {
773+
console.log("[Dock] New Window clicked")
774+
createWindow()
775+
},
776+
},
777+
])
778+
app.dock.setMenu(dockMenu)
779+
}
780+
711781
// Set update state and rebuild menu
712782
const setUpdateAvailable = (available: boolean, version?: string) => {
713783
updateAvailable = available
@@ -786,9 +856,9 @@ if (gotTheLock) {
786856

787857
// Initialize auto-updater (production only)
788858
if (app.isPackaged) {
789-
await initAutoUpdater(getWindow)
859+
await initAutoUpdater(getAllWindows)
790860
// Setup update check on window focus (instead of periodic interval)
791-
setupFocusUpdateCheck(getWindow)
861+
setupFocusUpdateCheck(getAllWindows)
792862
// Check for updates 5 seconds after startup (force to bypass interval check)
793863
setTimeout(() => {
794864
checkForUpdates(true)

src/main/lib/auto-updater.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,30 @@ const CDN_BASE = "https://cdn.21st.dev/releases/desktop"
3030
const MIN_CHECK_INTERVAL = 60 * 1000 // 1 minute
3131
let lastCheckTime = 0
3232

33-
let mainWindow: (() => BrowserWindow | null) | null = null
33+
let getAllWindows: (() => BrowserWindow[]) | null = null
3434

3535
/**
36-
* Send update event to renderer process
36+
* Send update event to all renderer windows
37+
* Update events are app-wide and should be visible in all windows
3738
*/
38-
function sendToRenderer(channel: string, data?: unknown) {
39-
const win = mainWindow?.()
40-
if (win && !win.isDestroyed()) {
41-
win.webContents.send(channel, data)
39+
function sendToAllRenderers(channel: string, data?: unknown) {
40+
const windows = getAllWindows?.() ?? BrowserWindow.getAllWindows()
41+
for (const win of windows) {
42+
try {
43+
if (win && !win.isDestroyed()) {
44+
win.webContents.send(channel, data)
45+
}
46+
} catch {
47+
// Window may have been destroyed between check and send
48+
}
4249
}
4350
}
4451

4552
/**
4653
* Initialize the auto-updater with event handlers and IPC
4754
*/
48-
export async function initAutoUpdater(getWindow: () => BrowserWindow | null) {
49-
mainWindow = getWindow
55+
export async function initAutoUpdater(getWindows: () => BrowserWindow[]) {
56+
getAllWindows = getWindows
5057

5158
// Initialize config
5259
initAutoUpdaterConfig()
@@ -67,7 +74,7 @@ export async function initAutoUpdater(getWindow: () => BrowserWindow | null) {
6774
// Event: Checking for updates
6875
autoUpdater.on("checking-for-update", () => {
6976
log.info("[AutoUpdater] Checking for updates...")
70-
sendToRenderer("update:checking")
77+
sendToAllRenderers("update:checking")
7178
})
7279

7380
// Event: Update available
@@ -78,7 +85,7 @@ export async function initAutoUpdater(getWindow: () => BrowserWindow | null) {
7885
if (setUpdateAvailable) {
7986
setUpdateAvailable(true, info.version)
8087
}
81-
sendToRenderer("update:available", {
88+
sendToAllRenderers("update:available", {
8289
version: info.version,
8390
releaseDate: info.releaseDate,
8491
releaseNotes: info.releaseNotes,
@@ -88,7 +95,7 @@ export async function initAutoUpdater(getWindow: () => BrowserWindow | null) {
8895
// Event: No update available
8996
autoUpdater.on("update-not-available", (info: UpdateInfo) => {
9097
log.info(`[AutoUpdater] App is up to date (v${info.version})`)
91-
sendToRenderer("update:not-available", {
98+
sendToAllRenderers("update:not-available", {
9299
version: info.version,
93100
})
94101
})
@@ -99,7 +106,7 @@ export async function initAutoUpdater(getWindow: () => BrowserWindow | null) {
99106
`[AutoUpdater] Download progress: ${progress.percent.toFixed(1)}% ` +
100107
`(${formatBytes(progress.transferred)}/${formatBytes(progress.total)})`,
101108
)
102-
sendToRenderer("update:progress", {
109+
sendToAllRenderers("update:progress", {
103110
percent: progress.percent,
104111
bytesPerSecond: progress.bytesPerSecond,
105112
transferred: progress.transferred,
@@ -115,7 +122,7 @@ export async function initAutoUpdater(getWindow: () => BrowserWindow | null) {
115122
if (setUpdateAvailable) {
116123
setUpdateAvailable(false)
117124
}
118-
sendToRenderer("update:downloaded", {
125+
sendToAllRenderers("update:downloaded", {
119126
version: info.version,
120127
releaseDate: info.releaseDate,
121128
releaseNotes: info.releaseNotes,
@@ -125,7 +132,7 @@ export async function initAutoUpdater(getWindow: () => BrowserWindow | null) {
125132
// Event: Error
126133
autoUpdater.on("error", (error: Error) => {
127134
log.error("[AutoUpdater] Error:", error.message)
128-
sendToRenderer("update:error", error.message)
135+
sendToAllRenderers("update:error", error.message)
129136
})
130137

131138
// Register IPC handlers
@@ -243,7 +250,7 @@ export async function downloadUpdate() {
243250
* Check for updates when window gains focus
244251
* This is more natural than checking on an interval
245252
*/
246-
export function setupFocusUpdateCheck(getWindow: () => BrowserWindow | null) {
253+
export function setupFocusUpdateCheck(_getWindows: () => BrowserWindow[]) {
247254
// Listen for window focus events
248255
app.on("browser-window-focus", () => {
249256
log.info("[AutoUpdater] Window focused - checking for updates")

src/main/lib/git/watcher/ipc-bridge.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,42 @@ import { gitCache } from "../cache";
77
* Handles subscription/unsubscription from renderer and forwards file change events.
88
*/
99

10-
// Track active subscriptions per worktree
11-
const activeSubscriptions: Map<string, () => void> = new Map();
10+
// Track active subscriptions per worktree with subscribing window ID
11+
// This ensures events are sent to the window that subscribed, not the focused window
12+
const activeSubscriptions: Map<string, { windowId: number; unsubscribe: () => void }> = new Map();
1213

1314
/**
1415
* Register IPC handlers for git watcher.
1516
* Call this once during app initialization.
1617
*/
17-
export function registerGitWatcherIPC(
18-
getWindow: () => BrowserWindow | null,
19-
): void {
18+
export function registerGitWatcherIPC(): void {
2019
// Handle subscription requests from renderer
2120
ipcMain.handle(
2221
"git:subscribe-watcher",
23-
async (_event, worktreePath: string) => {
22+
async (event, worktreePath: string) => {
2423
if (!worktreePath) return;
2524

2625
// Already subscribed?
2726
if (activeSubscriptions.has(worktreePath)) {
2827
return;
2928
}
3029

30+
// Get the window that made the subscription request
31+
const subscribingWindow = BrowserWindow.fromWebContents(event.sender);
32+
if (!subscribingWindow || subscribingWindow.isDestroyed()) return;
33+
34+
const windowId = subscribingWindow.id;
35+
3136
// Subscribe to file changes (await to ensure watcher is ready)
3237
const unsubscribe = await gitWatcherRegistry.subscribe(
3338
worktreePath,
34-
(event: GitWatchEvent) => {
35-
const win = getWindow();
36-
if (!win || win.isDestroyed()) return;
39+
(watchEvent: GitWatchEvent) => {
40+
// Send to the subscribing window, not the focused window
41+
const subscription = activeSubscriptions.get(worktreePath);
42+
if (!subscription) return;
43+
44+
const targetWindow = BrowserWindow.fromId(subscription.windowId);
45+
if (!targetWindow || targetWindow.isDestroyed()) return;
3746

3847
// We're watching .git/index and .git/HEAD, so any event means a git operation occurred.
3948
// Invalidate status and parsedDiff caches - these are always affected by git operations.
@@ -42,16 +51,20 @@ export function registerGitWatcherIPC(
4251
gitCache.invalidateParsedDiff(worktreePath);
4352

4453
// Send event to renderer
45-
win.webContents.send("git:status-changed", {
46-
worktreePath: event.worktreePath,
47-
changes: event.changes,
48-
});
54+
try {
55+
targetWindow.webContents.send("git:status-changed", {
56+
worktreePath: watchEvent.worktreePath,
57+
changes: watchEvent.changes,
58+
});
59+
} catch {
60+
// Window may have been destroyed between check and send
61+
}
4962
},
5063
);
5164

52-
activeSubscriptions.set(worktreePath, unsubscribe);
65+
activeSubscriptions.set(worktreePath, { windowId, unsubscribe });
5366
console.log(
54-
`[GitWatcher] Subscribed to: ${worktreePath}`,
67+
`[GitWatcher] Window ${windowId} subscribed to: ${worktreePath}`,
5568
);
5669
},
5770
);
@@ -62,27 +75,41 @@ export function registerGitWatcherIPC(
6275
async (_event, worktreePath: string) => {
6376
if (!worktreePath) return;
6477

65-
const unsubscribe = activeSubscriptions.get(worktreePath);
66-
if (unsubscribe) {
67-
unsubscribe();
78+
const subscription = activeSubscriptions.get(worktreePath);
79+
if (subscription) {
80+
subscription.unsubscribe();
6881
activeSubscriptions.delete(worktreePath);
6982
console.log(
70-
`[GitWatcher] Unsubscribed from: ${worktreePath}`,
83+
`[GitWatcher] Window ${subscription.windowId} unsubscribed from: ${worktreePath}`,
7184
);
7285
}
7386
},
7487
);
7588
}
7689

90+
/**
91+
* Cleanup subscriptions for a specific window.
92+
* Call this when a window is closed to prevent memory leaks.
93+
*/
94+
export function cleanupWindowSubscriptions(windowId: number): void {
95+
for (const [path, subscription] of activeSubscriptions) {
96+
if (subscription.windowId === windowId) {
97+
subscription.unsubscribe();
98+
activeSubscriptions.delete(path);
99+
console.log(`[GitWatcher] Cleaned up subscription for closed window ${windowId}: ${path}`);
100+
}
101+
}
102+
}
103+
77104
/**
78105
* Cleanup all watchers.
79106
* Call this when the app is shutting down.
80107
*/
81108
export async function cleanupGitWatchers(): Promise<void> {
82109
// Unsubscribe all
83-
const unsubscribers = Array.from(activeSubscriptions.values());
84-
for (const unsubscribe of unsubscribers) {
85-
unsubscribe();
110+
const subscriptions = Array.from(activeSubscriptions.values());
111+
for (const subscription of subscriptions) {
112+
subscription.unsubscribe();
86113
}
87114
activeSubscriptions.clear();
88115

0 commit comments

Comments
 (0)