Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ jobs:
dist/*.deb
dist/*.rpm
dist/*.pacman
dist/latest-linux.yml
- os: windows-latest
dist: dist:win
artifacts: dist/*.exe
artifacts: |
dist/*.exe
dist/*.exe.blockmap
dist/latest.yml
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 2026-09-30

- [sidebar] Collapse/expand animates the width (220 ms; content is revealed/clipped, not reflowed, and fades in; off under reduced motion) (files: src/renderer/components/Sidebar.tsx, src/renderer/styles/base.css)
- [sidebar] Right-click a folder (rename, collapse, new folder, delete) or a workspace (rename, change icon, two-step delete) via a new keyboard-accessible `ContextMenu`; icon popover split out as `IconPopover`; both portal to <body> (files: src/renderer/components/{ContextMenu,IconPicker,Sidebar,SidebarAccounts}.tsx, src/renderer/styles/base.css, tests/e2e/sidebar-menus.spec.ts)
- [updates] Settings "Update & restart" downloads the new release, installs it over this one (pacman/deb/rpm via pkexec, AppImage, silent NSIS) and relaunches; the new-release notification opens Settings instead of the browser. Works from v1.3.0 on (files: src/main/update-install.ts, src/main/{index,ipc,update-notify}.ts, src/shared/{ipc-channels,ipc-contract}.ts, src/preload/index.ts, src/renderer/app/store.ts, src/renderer/components/SettingsView.tsx, electron-builder.yml, package.json, .github/workflows/build.yml, tests/unit/update-install.test.ts)
- [tests] E2E check that Shift+Enter sends ESC CR and Enter a bare CR to claude panes (files: tests/e2e/keys.spec.ts, tests/fixtures/raw-keys.sh)
- [release] v1.2.0 (files: package.json)
- [docs] Refresh README screenshots and text for sharing, folders, icons, shortcuts and update alerts (files: README.md, docs/screenshots/)
- [ui] Shorter per-account sharing note; font options stay on one row (files: src/renderer/components/AccountsView.tsx, src/renderer/styles/base.css)
Expand Down
6 changes: 4 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
| `platform.ts` | Desktop/window-chrome detection, default shell, extra bin dirs, spawn command, login-shell PATH |
| `shared-config.ts` | Symlink/junction/hardlink ~/.claude's CLAUDE.md, settings.json, skills, plugins, agents, commands into accounts; backs up real files, replaces stale links |
| `update-check.ts` | `UpdateChecker`: GitHub Releases API, cached, report only |
| `update-notify.ts` | `notifyIfNew`: native Notification for a newer release, once per version; click opens the release page |
| `update-notify.ts` | `notifyIfNew`: native Notification for a newer release, once per version; click opens Settings → Updates |
| `update-install.ts` | `UpdateInstaller`: facade over electron-updater; check, download (progress events), install and relaunch |
| `paths.ts` | Config file path, accounts root, `~` resolution, `isStrictlyInside` guard |

## Renderer
Expand All @@ -40,10 +41,11 @@
## IPC surface
Full list in `src/shared/ipc-channels.ts`; schemas in `src/shared/ipc-contract.ts`. Groups: pty,
config, dialog, window, claude detect, account dir create/delete, openExternal (http/https only),
shared apply, app info, update check.
shared apply, app info, update check/install (+ `update:progress`, `update:show` events).

## External services
- GitHub Releases API for `cachewraith/wraithgrid` (optional update check; fixed URL).
- GitHub release assets `latest.yml` / `latest-linux.yml` + installers, read by electron-updater (repo fixed in `resources/app-update.yml`).
- Nothing else. No telemetry.

## Persistence
Expand Down
14 changes: 9 additions & 5 deletions docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Purpose
Electron desktop app that runs many official `claude` CLI sessions side by side, one per pane,
each with its own account (`CLAUDE_CONFIG_DIR`) and project folder. Windows + Linux. v1.1.0.
each with its own account (`CLAUDE_CONFIG_DIR`) and project folder. Windows + Linux. v1.2.0.

## Stack
Electron 44, electron-vite 5, React 19, TypeScript 6, zustand, zod 4, @xterm/xterm 6, node-pty,
Expand All @@ -28,7 +28,8 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e
- `src/main/claude-detect.ts`: finds the `claude` binary and version
- `src/main/platform.ts`: OS/desktop differences as pure functions (chrome, shell, PATH, spawn)
- `src/main/shared-config.ts`: link ~/.claude's CLAUDE.md, settings.json, skills/, plugins/, agents/, commands/ into every account ("overall" mode)
- `src/main/update-check.ts`: GitHub Releases check (fixed URL, report only)
- `src/main/update-check.ts`: GitHub Releases check (fixed URL) for the Settings/status-bar notice
- `src/main/update-install.ts`: in-app update facade over electron-updater (check → download → install + relaunch)
- `src/main/update-notify.ts`: OS notification for a new release, once per version (`update-notified.json` in userData)
- `src/main/paths.ts`: config/account paths, `isStrictlyInside` guard
- `src/preload/index.ts`: typed bridge; renderer never sees ipcRenderer
Expand All @@ -40,11 +41,11 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e
- `src/renderer/main.tsx`: composition root (builds Services)
- `src/renderer/app/store.ts`: zustand store, all app actions (largest file)
- `src/renderer/app/{App,services,shortcuts}`: root view, DI context, key matching
- `src/renderer/components/`: UI (PaneGrid, Pane, Terminal, dialogs, Settings/Accounts views; SidebarAccounts = folders + drag-drop; AccountIcon/Avatar = badges; IconPicker)
- `src/renderer/components/`: UI (PaneGrid, Pane, Terminal, dialogs, Settings/Accounts views; SidebarAccounts = folders + drag-drop; AccountIcon/Avatar = badges; IconPicker/IconPopover; ContextMenu = right-click menus)
- `src/renderer/layout/`: pure split-tree ops, presets, directional focus
- `src/renderer/lib/`: PtyBus, status detection, terminal themes, ANSI strip, scheduling
- `src/renderer/styles/`: design tokens + base CSS
- `tests/unit/`, `tests/e2e/`, `tests/fixtures/fake-claude.sh`: tests + fake CLI
- `tests/unit/`, `tests/e2e/`, `tests/fixtures/{fake-claude,raw-keys}.sh`: tests + fake CLIs (raw-keys prints input bytes as hex)
- `scripts/`: Docker Linux build, multi-distro package smoke test
- `build/`: icons; `.github/workflows/`: build.yml (CI checks; packages only when release.yml calls it), release.yml (tag → release)
- `docs/REQUIREMENTS.md`: original requirements (FR-*/NFR-* IDs); `docs/screenshots/`
Expand Down Expand Up @@ -76,6 +77,9 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e
- AppImage on Ubuntu 24.04+/Kali blocked by AppArmor; prefer .deb.
- claude only has a "new line" key via ESC CR; the terminal maps Shift+Enter to it for claude panes.
- Running claude reads CLAUDE.md/skills/plugins at start: a sharing change needs a pane restart.
- In-app update needs `latest*.yml` in the release (CI uploads them from v1.3.0); 1.2.0 and older must be updated by hand once. Local `dist` scripts pass `--publish never`.
- Popovers/menus must portal to <body>: sidebar sections animate opacity (own stacking context) and the sidebar clips.
- e2e/manual tests: `pgrep -f 'out/main/index.js'` also matches your own shell command; match the electron binary path instead.

## Current focus
v1.1.0 released. Unreleased: overall sharing via ~/.claude, Shift+Enter newline, Ctrl+=/-/0 font size, account folders, account + workspace icons, UI clean-up, running animation, new-release OS notification. Open: Claude mascot animation report (needs repro).
v1.2.0 released (overall ~/.claude sharing, folders + icons, Shift+Enter, font-size keys, running animation, release notification). Open: Claude mascot animation report (needs repro).
4 changes: 4 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ Entries dated 2026-09-29 are reconstructed from the code and commit history duri
- 2026-09-30 | Sharing changes offer a "Restart claude panes" button instead of restarting automatically | A restart ends the running conversation | Auto-restart on change
- 2026-09-30 | Account folders are one level deep, stored as `accountFolders` + `Account.folderId` | Enough to group accounts; sanitize drops dangling refs; old configs default to [] / null | Nested folders (tree ops, more UI for little gain); folders owning account lists (two sources of truth)
- 2026-09-30 | Account icon is an emoji string rendered as text | No asset storage or upload path; React escapes it | Uploaded images (file handling, size limits); fixed SVG icon set only
- 2026-09-30 | In-app updates via electron-updater (reverses "update check reports only") | Users want one click, not picking a package from the release page; electron-updater already handles NSIS, AppImage, deb, rpm and pacman, and sha512-checks downloads against latest*.yml; source stays fixed (app-update.yml, this repo) | Own per-package installer (re-implements pkexec/NSIS handling); keep report-only (user rejected it)
- 2026-09-30 | Updates download only on the button, never auto-install on quit | On Linux an install needs a pkexec password prompt; one appearing on quit would be a surprise | autoDownload + autoInstallOnAppQuit (electron-updater defaults)
- 2026-09-30 | Sidebar animates `width`, with content fixed-width only while moving (`.moving`) | Content is revealed/clipped instead of reflowing mid-animation; at rest it must still fit beside a scrollbar; terminal refit is already debounced (50 ms) so panes resize once | Transform/overlay slide (main area would jump at the end); permanent min-width (clips under a scrollbar)
- 2026-09-30 | Workspace delete from the right-click menu is two-step inside the menu | Deleting closes the workspace's panes; no extra dialog | Confirm dialog (heavier); one-click delete (loses running panes)
- 2026-09-30 | New-release OS notification, once per version, state in `userData/update-notified.json` | config.json is owned and rewritten whole by the renderer, so main can't keep state there safely | Notify every launch (nags); store in config.json (lost on the next renderer save)
4 changes: 3 additions & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# TODO

## Now
- Account/workspace icons: picker (click the letter badge) is not discoverable; user wants a default icon. Awaiting which default + where to pick
- Shift+Enter "no new line" report: bytes (ESC CR) verified end to end on Hyprland and claude 2.1.285 accepts them; needs a repro (what happens: submits or nothing?)
- Claude mascot ("pet") not animating in panes: needs a repro from the user (which animation, when)

## Next
- (none recorded)
- Release v1.3.0 (first release with in-app updates); install it by hand once, then test 1.3.0 → 1.3.1 in-app

## Later
- macOS support (REQUIREMENTS §4 lists it; only Windows + Linux ship today)
Expand Down
8 changes: 7 additions & 1 deletion electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,10 @@ nsis:
artifactName: ${productName}-Setup-${version}-${arch}.${ext}
appImage:
artifactName: ${productName}-${version}-${arch}.${ext}
publish: null
# In-app updates (electron-updater) read latest.yml / latest-linux.yml from this repo's
# GitHub releases. The build never uploads (dist scripts pass --publish never); release.yml does.
publish:
provider: github
owner: cachewraith
repo: wraithgrid
releaseType: release
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
"format": "prettier --write .",
"test": "vitest run",
"test:e2e": "electron-vite build && playwright test",
"dist": "electron-vite build && electron-builder",
"dist:linux": "electron-vite build && electron-builder --linux",
"dist": "electron-vite build && electron-builder --publish never",
"dist:linux": "electron-vite build && electron-builder --linux --publish never",
"dist:linux:portable": "bash scripts/dist-linux-docker.sh",
"dist:win": "electron-vite build && electron-builder --win",
"dist:win": "electron-vite build && electron-builder --win --publish never",
"postinstall": "electron-builder install-app-deps"
},
"devDependencies": {
Expand Down Expand Up @@ -61,6 +61,7 @@
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0",
"electron-updater": "6.8.9",
"node-pty": "1.1.0",
"react": "19.3.0",
"react-dom": "19.3.0",
Expand Down
35 changes: 35 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 53 additions & 7 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import os from 'node:os'
import path from 'node:path'
import { app, BrowserWindow, Menu, nativeTheme, net, Notification, session, shell } from 'electron'
import { spawn } from 'node-pty'
import { autoUpdater, type ProgressInfo } from 'electron-updater'
import { IPC } from '@shared/ipc-channels'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc-contract'
import type { PtyDataEvent, PtyExitEvent, UpdateProgress } from '@shared/ipc-contract'
import { resolveTheme, type ThemeName } from '@shared/types'
import { ConfigStore } from './config-store'
import { registerIpc } from './ipc'
Expand All @@ -18,6 +19,7 @@ import {
} from './platform'
import { PtyManager } from './pty-manager'
import { UpdateChecker } from './update-check'
import { UpdateInstaller } from './update-install'
import { notifyIfNew, type UpdateNotice } from './update-notify'

// Requirements §9 names ~/.config/wraithgrid; Electron would default to the productName.
Expand Down Expand Up @@ -57,7 +59,7 @@ const envReady: Promise<void> = (async () => {
)
})()

function send(channel: string, payload: PtyDataEvent | PtyExitEvent): void {
function send(channel: string, payload?: PtyDataEvent | PtyExitEvent | UpdateProgress): void {
const wc = mainWindow?.webContents
if (wc && !wc.isDestroyed()) wc.send(channel, payload)
}
Expand Down Expand Up @@ -182,7 +184,7 @@ app.on('second-instance', () => {
mainWindow.focus()
})

/** A native notification; clicking it opens the release page and brings the window back. */
/** A native notification; clicking it brings the window back at Settings → Updates. */
function showUpdateNotice(notice: UpdateNotice): void {
if (!Notification.isSupported()) return
const n = new Notification({
Expand All @@ -191,15 +193,58 @@ function showUpdateNotice(notice: UpdateNotice): void {
icon: path.join(__dirname, '../../build/icon.png')
})
n.on('click', () => {
void shell.openExternal(notice.url)
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
if (!mainWindow || mainWindow.isDestroyed()) {
void shell.openExternal(notice.url)
return
}
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
send(IPC.updateShow)
})
n.show()
}

/** In-app updates through electron-updater, which picks the installer for this package type. */
function createInstaller(): UpdateInstaller {
// Downloads start only from the Settings button, and nothing installs on a plain quit:
// on Linux that would raise a password prompt out of nowhere.
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
autoUpdater.logger = null
// An 'error' event with no listener throws; the promises below already carry each error.
autoUpdater.on('error', () => {})
return new UpdateInstaller({
currentVersion: app.getVersion(),
check: async () => (await autoUpdater.checkForUpdates())?.updateInfo.version ?? null,
download: async (onPercent) => {
const listener = (p: ProgressInfo): void => onPercent(p.percent)
autoUpdater.on('download-progress', listener)
try {
await autoUpdater.downloadUpdate()
} finally {
autoUpdater.off('download-progress', listener)
}
},
// Linux installs synchronously here (pkexec pacman/apt/dnf) and reports failure as an
// 'error' event, e.g. a cancelled password prompt; on success the app quits and relaunches.
install: () => {
let failed: string | null = null
const onError = (e: Error): void => {
failed = e.message
}
autoUpdater.on('error', onError)
try {
autoUpdater.quitAndInstall(true, true)
} finally {
autoUpdater.off('error', onError)
}
return Promise.resolve(failed)
},
report: (p) => send(IPC.updateProgress, p),
log: (msg) => console.warn(`[wraithgrid] ${msg}`)
})
}

void app.whenReady().then(() => {
store.load()
const updates = new UpdateChecker({
Expand Down Expand Up @@ -228,6 +273,7 @@ void app.whenReady().then(() => {
desktop,
envReady,
updates,
installer: createInstaller(),
onLaunchUpdateCheck: notify,
isPackaged: app.isPackaged,
onConfigChanged: (prev, next) => {
Expand Down
Loading