Skip to content

Commit aac3ce2

Browse files
committed
Release v0.0.29
## What's New ### Features - **Windows Support (Experimental)** — 1Code now runs on Windows! Big thanks to [@jesus-mgtc](https://github.com/jesus-mgtc) and [@evgyur](https://github.com/evgyur) for their contributions making this possible. ### Improvements & Fixes - Fixed sidebar loading spinners synchronization - Fixed token metadata reading from flat structure
1 parent 1abae50 commit aac3ce2

File tree

18 files changed

+472
-40
lines changed

18 files changed

+472
-40
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Best UI for Claude Code with local and remote agent execution.
66

77
By [21st.dev](https://21st.dev) team
88

9-
> **Note:** Currently tested on macOS and Linux. Windows support is experimental and may have issues.
9+
> **Platforms:** macOS, Linux, and Windows. Windows support improved thanks to community contributions from [@jesus-mgtc](https://github.com/jesus-mgtc) and [@evgyur](https://github.com/evgyur).
1010
1111
## Features
1212

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.28",
3+
"version": "0.0.29",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {

src/main/lib/git/worktree.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execFile } from "node:child_process";
22
import { randomBytes } from "node:crypto";
33
import { mkdir, readFile, stat } from "node:fs/promises";
4-
import os from "node:os";
4+
import { homedir } from "node:os";
55
import { join } from "node:path";
66
import { promisify } from "node:util";
77
import simpleGit from "simple-git";
@@ -907,8 +907,7 @@ export async function createWorktreeForChat(
907907
const baseBranch = selectedBaseBranch || await getDefaultBranch(projectPath);
908908

909909
const branch = generateBranchName();
910-
// Use os.homedir() for cross-platform compatibility (Windows uses USERPROFILE, not HOME)
911-
const worktreesDir = join(os.homedir(), ".21st", "worktrees");
910+
const worktreesDir = join(homedir(), ".21st", "worktrees");
912911
const worktreePath = join(worktreesDir, projectId, chatId);
913912

914913
await createWorktree(projectPath, branch, worktreePath, `origin/${baseBranch}`);

src/main/lib/terminal/session.ts

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import fs from "node:fs"
12
import os from "node:os"
3+
import path from "node:path"
24
import * as pty from "node-pty"
35
import { buildTerminalEnv, FALLBACK_SHELL, getDefaultShell } from "./env"
46
import type { InternalCreateSessionParams, TerminalSession } from "./types"
@@ -16,6 +18,66 @@ function getShellArgs(shell: string): string[] {
1618
return []
1719
}
1820

21+
/**
22+
* Validate and resolve cwd path (Windows compatibility)
23+
* Falls back to home directory if path doesn't exist
24+
*/
25+
function validateAndResolveCwd(cwd: string): string {
26+
if (!fs.existsSync(cwd)) {
27+
const homeDir = os.homedir()
28+
console.warn(`[Terminal] CWD does not exist: ${cwd}, using home directory: ${homeDir}`)
29+
return homeDir
30+
}
31+
32+
try {
33+
const stat = fs.statSync(cwd)
34+
if (!stat.isDirectory()) {
35+
const homeDir = os.homedir()
36+
console.warn(`[Terminal] CWD is not a directory: ${cwd}, using home directory: ${homeDir}`)
37+
return homeDir
38+
}
39+
} catch {
40+
const homeDir = os.homedir()
41+
console.warn(`[Terminal] Error checking CWD: ${cwd}, using home directory: ${homeDir}`)
42+
return homeDir
43+
}
44+
45+
try {
46+
return path.resolve(cwd)
47+
} catch {
48+
const homeDir = os.homedir()
49+
console.warn(`[Terminal] Error resolving CWD: ${cwd}, using home directory: ${homeDir}`)
50+
return homeDir
51+
}
52+
}
53+
54+
/**
55+
* Resolve shell path for Windows
56+
* Tries to find shell in common Windows locations
57+
*/
58+
function resolveShellPath(shell: string): string {
59+
if (os.platform() !== "win32") return shell
60+
61+
// If shell already has a path, use it as-is
62+
if (shell.includes("\\") || shell.includes("/")) return shell
63+
64+
// Try common Windows shell locations
65+
const commonPaths = [
66+
process.env.COMSPEC || "",
67+
process.env.SystemRoot ? `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "",
68+
process.env.SystemRoot ? `${process.env.SystemRoot}\\System32\\cmd.exe` : "",
69+
].filter(Boolean)
70+
71+
for (const shellPath of commonPaths) {
72+
if (fs.existsSync(shellPath)) {
73+
return shellPath
74+
}
75+
}
76+
77+
// Return as-is, let node-pty handle PATH resolution
78+
return shell
79+
}
80+
1981
function spawnPty(params: {
2082
shell: string
2183
cols: number
@@ -25,14 +87,29 @@ function spawnPty(params: {
2587
}): pty.IPty {
2688
const { shell, cols, rows, cwd, env } = params
2789
const shellArgs = getShellArgs(shell)
90+
const resolvedCwd = validateAndResolveCwd(cwd)
91+
const resolvedShell = resolveShellPath(shell)
2892

29-
return pty.spawn(shell, shellArgs, {
30-
name: "xterm-256color",
31-
cols,
32-
rows,
33-
cwd,
34-
env,
35-
})
93+
try {
94+
return pty.spawn(resolvedShell, shellArgs, {
95+
name: "xterm-256color",
96+
cols,
97+
rows,
98+
cwd: resolvedCwd,
99+
env,
100+
})
101+
} catch (error) {
102+
console.error(`[Terminal] Failed to spawn PTY with ${resolvedShell}:`, error)
103+
// Try with fallback shell
104+
console.log(`[Terminal] Retrying with fallback shell: ${FALLBACK_SHELL}`)
105+
return pty.spawn(FALLBACK_SHELL, [], {
106+
name: "xterm-256color",
107+
cols,
108+
rows,
109+
cwd: resolvedCwd,
110+
env,
111+
})
112+
}
36113
}
37114

38115
export async function createSession(
@@ -53,7 +130,7 @@ export async function createSession(
53130
} = params
54131

55132
const shell = useFallbackShell ? FALLBACK_SHELL : getDefaultShell()
56-
const workingDir = cwd || os.homedir()
133+
const workingDir = validateAndResolveCwd(cwd || os.homedir())
57134
const terminalCols = cols || DEFAULT_COLS
58135
const terminalRows = rows || DEFAULT_ROWS
59136

src/main/windows/main.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
app,
77
clipboard,
88
session,
9+
nativeImage,
910
} from "electron"
1011
import { join } from "path"
12+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs"
1113
import { createIPCHandler } from "trpc-electron/main"
1214
import { createAppRouter } from "../lib/trpc/routers"
1315
import { getAuthManager, handleAuthCode, getBaseUrl } from "../index"
@@ -23,17 +25,92 @@ function registerIpcHandlers(getWindow: () => BrowserWindow | null): void {
2325
// App info
2426
ipcMain.handle("app:version", () => app.getVersion())
2527
ipcMain.handle("app:isPackaged", () => app.isPackaged)
28+
29+
// Windows: Frame preference persistence
30+
ipcMain.handle("window:set-frame-preference", (_event, useNativeFrame: boolean) => {
31+
try {
32+
const settingsPath = join(app.getPath("userData"), "window-settings.json")
33+
const settingsDir = app.getPath("userData")
34+
mkdirSync(settingsDir, { recursive: true })
35+
writeFileSync(settingsPath, JSON.stringify({ useNativeFrame }, null, 2))
36+
return true
37+
} catch (error) {
38+
console.error("[Main] Failed to save frame preference:", error)
39+
return false
40+
}
41+
})
42+
43+
// Windows: Get current window frame state
44+
ipcMain.handle("window:get-frame-state", () => {
45+
if (process.platform !== "win32") return false
46+
try {
47+
const settingsPath = join(app.getPath("userData"), "window-settings.json")
48+
if (existsSync(settingsPath)) {
49+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"))
50+
return settings.useNativeFrame === true
51+
}
52+
return false // Default: frameless
53+
} catch {
54+
return false
55+
}
56+
})
57+
2658
// Note: Update checking is now handled by auto-updater module (lib/auto-updater.ts)
2759
ipcMain.handle("app:set-badge", (_event, count: number | null) => {
60+
const win = getWindow()
2861
if (process.platform === "darwin") {
2962
app.dock.setBadge(count ? String(count) : "")
63+
} else if (process.platform === "win32" && win) {
64+
// Windows: Update title with count as fallback
65+
if (count !== null && count > 0) {
66+
win.setTitle(`1Code (${count})`)
67+
} else {
68+
win.setTitle("1Code")
69+
win.setOverlayIcon(null, "")
70+
}
71+
}
72+
})
73+
74+
// Windows: Badge overlay icon
75+
ipcMain.handle("app:set-badge-icon", (_event, imageData: string | null) => {
76+
const win = getWindow()
77+
if (process.platform === "win32" && win) {
78+
if (imageData) {
79+
const image = nativeImage.createFromDataURL(imageData)
80+
win.setOverlayIcon(image, "New messages")
81+
} else {
82+
win.setOverlayIcon(null, "")
83+
}
3084
}
3185
})
86+
3287
ipcMain.handle(
3388
"app:show-notification",
3489
(_event, options: { title: string; body: string }) => {
35-
const { Notification } = require("electron")
36-
new Notification(options).show()
90+
try {
91+
const { Notification } = require("electron")
92+
const iconPath = join(__dirname, "../../../build/icon.ico")
93+
const icon = existsSync(iconPath) ? nativeImage.createFromPath(iconPath) : undefined
94+
95+
const notification = new Notification({
96+
title: options.title,
97+
body: options.body,
98+
icon,
99+
...(process.platform === "win32" && { silent: false }),
100+
})
101+
102+
notification.show()
103+
104+
notification.on("click", () => {
105+
const win = getWindow()
106+
if (win) {
107+
if (win.isMinimized()) win.restore()
108+
win.focus()
109+
}
110+
})
111+
} catch (error) {
112+
console.error("[Main] Failed to show notification:", error)
113+
}
37114
},
38115
)
39116

@@ -229,13 +306,35 @@ export function getWindow(): BrowserWindow | null {
229306
return currentWindow
230307
}
231308

309+
/**
310+
* Read window frame preference from settings file (Windows only)
311+
* Returns true if native frame should be used, false for frameless
312+
*/
313+
function getUseNativeFramePreference(): boolean {
314+
if (process.platform !== "win32") return false
315+
316+
try {
317+
const settingsPath = join(app.getPath("userData"), "window-settings.json")
318+
if (existsSync(settingsPath)) {
319+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"))
320+
return settings.useNativeFrame === true
321+
}
322+
return false // Default: frameless (dark title bar)
323+
} catch {
324+
return false
325+
}
326+
}
327+
232328
/**
233329
* Create the main application window
234330
*/
235331
export function createMainWindow(): BrowserWindow {
236332
// Register IPC handlers before creating window
237333
registerIpcHandlers(getWindow)
238334

335+
// Read Windows frame preference
336+
const useNativeFrame = getUseNativeFramePreference()
337+
239338
const window = new BrowserWindow({
240339
width: 1400,
241340
height: 900,
@@ -250,6 +349,11 @@ export function createMainWindow(): BrowserWindow {
250349
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
251350
trafficLightPosition:
252351
process.platform === "darwin" ? { x: 15, y: 12 } : undefined,
352+
// Windows: Use native frame or frameless based on user preference
353+
...(process.platform === "win32" && {
354+
frame: useNativeFrame,
355+
autoHideMenuBar: true,
356+
}),
253357
webPreferences: {
254358
preload: join(__dirname, "../preload/index.js"),
255359
nodeIntegration: false,

src/preload/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ contextBridge.exposeInMainWorld("desktopApi", {
7676
setTrafficLightVisibility: (visible: boolean) =>
7777
ipcRenderer.invoke("window:set-traffic-light-visibility", visible),
7878

79+
// Windows-specific: Frame preference (native vs frameless)
80+
setWindowFramePreference: (useNativeFrame: boolean) =>
81+
ipcRenderer.invoke("window:set-frame-preference", useNativeFrame),
82+
getWindowFrameState: () => ipcRenderer.invoke("window:get-frame-state"),
83+
7984
// Window events
8085
onFullscreenChange: (callback: (isFullscreen: boolean) => void) => {
8186
const handler = (_event: unknown, isFullscreen: boolean) => callback(isFullscreen)
@@ -102,6 +107,7 @@ contextBridge.exposeInMainWorld("desktopApi", {
102107

103108
// Native features
104109
setBadge: (count: number | null) => ipcRenderer.invoke("app:set-badge", count),
110+
setBadgeIcon: (imageData: string | null) => ipcRenderer.invoke("app:set-badge-icon", imageData),
105111
showNotification: (options: { title: string; body: string }) =>
106112
ipcRenderer.invoke("app:show-notification", options),
107113
openExternal: (url: string) => ipcRenderer.invoke("shell:open-external", url),
@@ -196,6 +202,9 @@ export interface DesktopApi {
196202
windowToggleFullscreen: () => Promise<void>
197203
windowIsFullscreen: () => Promise<boolean>
198204
setTrafficLightVisibility: (visible: boolean) => Promise<void>
205+
// Windows-specific frame preference
206+
setWindowFramePreference: (useNativeFrame: boolean) => Promise<boolean>
207+
getWindowFrameState: () => Promise<boolean>
199208
onFullscreenChange: (callback: (isFullscreen: boolean) => void) => () => void
200209
onFocusChange: (callback: (isFocused: boolean) => void) => () => void
201210
zoomIn: () => Promise<void>
@@ -205,6 +214,7 @@ export interface DesktopApi {
205214
toggleDevTools: () => Promise<void>
206215
setAnalyticsOptOut: (optedOut: boolean) => Promise<void>
207216
setBadge: (count: number | null) => Promise<void>
217+
setBadgeIcon: (imageData: string | null) => Promise<void>
208218
showNotification: (options: { title: string; body: string }) => Promise<void>
209219
openExternal: (url: string) => Promise<void>
210220
getApiBaseUrl: () => Promise<string>

src/renderer/components/ui/icons.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,21 +1363,15 @@ export function LoadingDot({
13631363
}) {
13641364
return (
13651365
<div className={`relative ${className || ""}`}>
1366-
<style>{`
1367-
@keyframes spin {
1368-
from { transform: rotate(0deg); }
1369-
to { transform: rotate(360deg); }
1370-
}
1371-
`}</style>
1372-
{/* Spinner - visible when loading */}
1366+
{/* Spinner - visible when loading. Uses global synchronized-spin keyframes from globals.css */}
13731367
<svg
13741368
viewBox="0 0 24 24"
13751369
fill="none"
13761370
className={`absolute inset-0 w-full h-full transition-[opacity,transform] duration-200 ease-out ${
13771371
isLoading ? "opacity-100 scale-100" : "opacity-0 scale-50"
13781372
}`}
13791373
style={{
1380-
animation: isLoading ? 'spin 1s linear infinite' : undefined,
1374+
animation: isLoading ? 'synchronized-spin 1s linear infinite' : undefined,
13811375
}}
13821376
>
13831377
<circle

0 commit comments

Comments
 (0)