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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## 2026-09-30

- [ui] Clean-up pass: letter/emoji badges (`Avatar`) for every account and workspace, one header per sidebar section, workspace shortcut digits on hover, only "login needed" shown as account status, balanced Accounts table, wrapped claude version, untruncated font names, shorter sharing copy; icon picker returns focus after a pick (files: src/renderer/components/{AccountIcon,IconPicker,Sidebar,SidebarAccounts,AccountsView,SettingsView,NewPaneDialog,PaneHeader}.tsx, src/renderer/styles/base.css)
- [workspaces] Custom emoji icon per workspace, set from the workspace switcher (files: src/shared/{types,schema}.ts, src/renderer/app/store.ts, src/renderer/components/WorkspaceSwitcher.tsx, tests/e2e/account-folders.spec.ts)
- [updates] A new release raises an OS notification once per version (launch check + every 6 h while open, packaged builds only); click opens the release page (files: src/main/update-notify.ts, src/main/index.ts, src/main/ipc.ts, tests/unit/update-notify.test.ts)
- [sidebar] Account folders (create, rename, collapse, delete) with drag-and-drop of accounts into/out of them (files: src/renderer/components/{SidebarAccounts,Sidebar}.tsx, src/shared/{types,schema}.ts, src/renderer/app/store.ts, tests/e2e/account-folders.spec.ts, tests/unit/config.test.ts)
- [accounts] Custom emoji icon per account, picked on the Accounts page, shown in the sidebar and pane header (files: src/renderer/components/{AccountIcon,AccountsView,PaneHeader,Pane}.tsx)
- [panes] Running panes animate: spinner status dot and a sheen on the account strip; off under reduced motion (files: src/renderer/components/Pane.tsx, src/renderer/styles/base.css)
- [shared] Sharing is now two modes, "Overall (~/.claude)" (default) and "Each account its own". Overall links CLAUDE.md, settings.json, skills/, plugins/, agents/ and commands/ from ~/.claude into every account, including ones added later. Adds a "Restart claude panes" button (files: src/main/shared-config.ts, src/main/ipc.ts, src/shared/{types,schema,ipc-contract}.ts, src/preload/index.ts, src/renderer/app/store.ts, src/renderer/components/{SettingsView,AccountsView}.tsx, tests/unit/shared-config.test.ts, tests/e2e/shared.spec.ts)
- [terminal] Shift+Enter inserts a new line in claude panes (sends ESC CR) (files: src/renderer/components/Terminal.tsx)
- [shortcuts] Ctrl+= / Ctrl++ / Ctrl+- / Ctrl+0 change the terminal font size (files: src/renderer/app/{shortcuts.ts,App.tsx}, src/renderer/components/ShortcutsSheet.tsx, tests/unit/shortcuts.test.ts)

- [ci] Build installers only on release tags; push/PR runs checks only, docs-only changes skip CI (files: .github/workflows/build.yml, .github/workflows/release.yml)
- [docs] Add the AI Working Agreement and context files (files: CLAUDE.md, CHANGELOG.md, docs/CONTEXT.md, docs/ARCHITECTURE.md, docs/DECISIONS.md, docs/TODO.md)

Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
| `config-store.ts` | `ConfigStore`: parse/migrate/sanitize, atomic write, quarantine to `config.bad-<ts>.json` |
| `claude-detect.ts` | Locate `claude` on PATH or via override; read version |
| `platform.ts` | Desktop/window-chrome detection, default shell, extra bin dirs, spawn command, login-shell PATH |
| `shared-config.ts` | Symlink/junction/hardlink shared CLAUDE.md + skills; backups, never deletes |
| `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 |
| `paths.ts` | Config file path, accounts root, `~` resolution, `isStrictlyInside` guard |

## Renderer
Expand Down
10 changes: 7 additions & 3 deletions docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e
- `src/main/config-store.ts`: config.json load/save, atomic write, bad-file quarantine
- `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 one CLAUDE.md + skills/ into every account
- `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-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
- `src/shared/ipc-channels.ts`: the complete IPC channel list
Expand All @@ -39,7 +40,7 @@ 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)
- `src/renderer/components/`: UI (PaneGrid, Pane, Terminal, dialogs, Settings/Accounts views; SidebarAccounts = folders + drag-drop; AccountIcon/Avatar = badges; IconPicker)
- `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
Expand Down Expand Up @@ -70,8 +71,11 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e
- Sandboxed preload can't require node_modules: preload is bundled (`externalizeDeps: false`).
- Single-instance lock: a second launch exits.
- Tests use `WRAITHGRID_USER_DATA_DIR` for a throwaway config dir.
- dnd-kit measures drop zones async after drag start: e2e drags must move in paced steps.
- CI skips pushes touching only `*.md`/`docs/**`; installers build only on a `v*.*.*` tag.
- 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.

## Current focus
v1.1.0 released (appearance settings, update check, Windows + distro packages). No active task.
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).
7 changes: 7 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ Entries dated 2026-09-29 are reconstructed from the code and commit history duri
- 2026-09-29 | Platform logic as pure functions of `(platform, env)` | Unit-testable on any OS | Branching on `process.platform` inline
- 2026-09-30 | Adopt the AI Working Agreement (`CLAUDE.md`) + docs context files | Cut per-session re-exploration | None
- 2026-09-30 | CI builds installers only for release tags; docs-only pushes skip CI | Avoid rebuilding downloads on every push/doc edit | Separate package workflow (more files); manual workflow_dispatch packaging (not needed)
- 2026-09-30 | Sharing is two modes: "overall" links ~/.claude into every account (default), "per-account" unlinks | Users want their normal CLAUDE.md, skills and plugins in every account without picking a source account | Picking a source account (old design); copying files into each account (drifts)
- 2026-09-30 | Share settings.json and plugins/ too, not only CLAUDE.md and skills/ | Installed plugins only load when enabled in settings.json | Sharing only plugins/ (plugins stay disabled)
- 2026-09-30 | A link in the way that points elsewhere is removed, not backed up | Links hold no data; lets old source-account links move to ~/.claude | Backing up links (restores stale links later)
- 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 | 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)
2 changes: 1 addition & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# TODO

## Now
- (none)
- Claude mascot ("pet") not animating in panes: needs a repro from the user (which animation, when)

## Next
- (none recorded)
Expand Down
49 changes: 44 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os from 'node:os'
import path from 'node:path'
import { app, BrowserWindow, Menu, nativeTheme, net, session, shell } from 'electron'
import { app, BrowserWindow, Menu, nativeTheme, net, Notification, session, shell } from 'electron'
import { spawn } from 'node-pty'
import { IPC } from '@shared/ipc-channels'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc-contract'
Expand All @@ -18,6 +18,7 @@ import {
} from './platform'
import { PtyManager } from './pty-manager'
import { UpdateChecker } from './update-check'
import { notifyIfNew, type UpdateNotice } from './update-notify'

// Requirements §9 names ~/.config/wraithgrid; Electron would default to the productName.
// WRAITHGRID_USER_DATA_DIR lets tests run against a throwaway config dir.
Expand All @@ -26,6 +27,12 @@ app.setPath(
process.env.WRAITHGRID_USER_DATA_DIR || path.join(app.getPath('appData'), 'wraithgrid')
)

// Windows only shows toasts for an app with an AppUserModelID (matches electron-builder appId).
if (process.platform === 'win32') app.setAppUserModelId('dev.cachewraith.wraithgrid')

/** While the app stays open, look for a new release this often (GitHub allows 60/h). */
const UPDATE_RECHECK_MS = 6 * 60 * 60 * 1000

// Two instances would overwrite each other's config; focus the first one instead.
if (!app.requestSingleInstanceLock()) {
app.exit(0)
Expand Down Expand Up @@ -175,8 +182,42 @@ app.on('second-instance', () => {
mainWindow.focus()
})

/** A native notification; clicking it opens the release page and brings the window back. */
function showUpdateNotice(notice: UpdateNotice): void {
if (!Notification.isSupported()) return
const n = new Notification({
title: notice.title,
body: notice.body,
icon: path.join(__dirname, '../../build/icon.png')
})
n.on('click', () => {
void shell.openExternal(notice.url)
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
n.show()
}

void app.whenReady().then(() => {
store.load()
const updates = new UpdateChecker({
currentVersion: app.getVersion(),
fetch: (url, init) => net.fetch(url, init)
})
const notifyDeps = {
stateFile: path.join(app.getPath('userData'), 'update-notified.json'),
show: showUpdateNotice
}
const notify = (r: Parameters<typeof notifyIfNew>[0]): void =>
void notifyIfNew(r, notifyDeps).catch(() => {})
if (app.isPackaged) {
setInterval(() => {
if (!store.get().settings.checkUpdatesOnLaunch) return
void updates.check().then(notify)
}, UPDATE_RECHECK_MS).unref()
}
Menu.setApplicationMenu(null)
hardenSessions()
registerIpc({
Expand All @@ -186,10 +227,8 @@ void app.whenReady().then(() => {
homeDir,
desktop,
envReady,
updates: new UpdateChecker({
currentVersion: app.getVersion(),
fetch: (url, init) => net.fetch(url, init)
}),
updates,
onLaunchUpdateCheck: notify,
isPackaged: app.isPackaged,
onConfigChanged: (prev, next) => {
const dark = nativeTheme.shouldUseDarkColors
Expand Down
50 changes: 27 additions & 23 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ import type { ConfigStore } from './config-store'
import { buildPaneEnv } from './pane-env'
import { accountsRoot, isStrictlyInside, resolveUserPath } from './paths'
import { resolveDefaultShell, spawnCommandFor, type DesktopInfo } from './platform'
import { ensureSource, linkShared, unlinkShared, type ItemResult } from './shared-config'
import {
ensureSource,
linkShared,
overallSourceDir,
unlinkShared,
type ItemResult
} from './shared-config'
import type { PtyManager } from './pty-manager'
import type { UpdateChecker } from './update-check'

Expand All @@ -54,6 +60,8 @@ export interface IpcDeps {
updates: UpdateChecker
/** Launch-time update checks only run in packaged builds. */
isPackaged: boolean
/** Sees every launch-time check result (e.g. to raise an OS notification). */
onLaunchUpdateCheck?: (result: UpdateCheckResult) => void
/** Called after a valid config replaced the old one. */
onConfigChanged?: (prev: Config, next: Config) => void
}
Expand Down Expand Up @@ -114,15 +122,16 @@ export function registerIpc(deps: IpcDeps): void {
const fail = (error: string): { ok: false; error: string } => ({ ok: false, error })
const dirOf = (acc: { configDir: string }): string => resolveUserPath(acc.configDir, homeDir)

/** Links the shared CLAUDE.md and skills into one account, if sharing is on. */
const sharedSource = overallSourceDir(homeDir)

/** Links the machine's CLAUDE.md, settings, skills and plugins into one account, if sharing is overall. */
const syncShared = async (accountId: string): Promise<void> => {
const cfg = store.get()
const source = cfg.accounts.find((x) => x.id === cfg.settings.sharedSourceAccountId)
const account = cfg.accounts.find((x) => x.id === accountId)
if (!source || !account || source.id === account.id) return
if (cfg.settings.sharedMode !== 'overall' || !account) return
try {
await ensureSource(dirOf(source))
const failed = (await linkShared(dirOf(source), dirOf(account))).filter(
await ensureSource(sharedSource)
const failed = (await linkShared(sharedSource, dirOf(account))).filter(
(r) => r.outcome === 'failed'
)
failed.forEach((r) => log(`could not link shared ${r.item} for ${account.name}: ${r.error}`))
Expand Down Expand Up @@ -292,6 +301,8 @@ export function registerIpc(deps: IpcDeps): void {
const realHome = await realpath(homeDir)
if (!isStrictlyInside(real, realHome))
return fail('Refusing to delete a folder outside your home directory')
// Drop the links to ~/.claude first so a recursive delete can never reach through them.
await unlinkShared(sharedSource, real)
await rm(real, { recursive: true, force: true })
log(`deleted account config dir ${contractHome(target, homeDir)} at the user's request`)
return { ok: true }
Expand All @@ -306,29 +317,21 @@ export function registerIpc(deps: IpcDeps): void {
sharedApplyArgs,
async (a): Promise<SharedApplyResult> => {
const cfg = store.get()
const next = a.sourceAccountId
? cfg.accounts.find((x) => x.id === a.sourceAccountId)
: undefined
if (a.sourceAccountId && !next) return fail('That account no longer exists')
const prev = cfg.accounts.find((x) => x.id === cfg.settings.sharedSourceAccountId)
const report: SharedReportEntry[] = []
const add = (account: string, results: ItemResult[]): void => {
results.forEach((r) => report.push({ account, ...r }))
}
// Switching or stopping: take the old links out first, restoring what they replaced.
if (prev && prev.id !== next?.id) {
for (const acc of cfg.accounts) add(acc.name, await unlinkShared(dirOf(prev), dirOf(acc)))
}
if (next) {
await ensureSource(dirOf(next))
for (const acc of cfg.accounts) {
if (acc.id !== next.id) add(acc.name, await linkShared(dirOf(next), dirOf(acc)))
}
if (a.mode === 'overall') {
await ensureSource(sharedSource)
for (const acc of cfg.accounts) add(acc.name, await linkShared(sharedSource, dirOf(acc)))
} else {
// Take the links out, restoring what they replaced.
for (const acc of cfg.accounts) add(acc.name, await unlinkShared(sharedSource, dirOf(acc)))
}
log(
next
? `sharing CLAUDE.md and skills from ${next.name} with ${cfg.accounts.length - 1} accounts`
: 'stopped sharing CLAUDE.md and skills'
a.mode === 'overall'
? `sharing ~/.claude with ${cfg.accounts.length} accounts`
: 'stopped sharing ~/.claude'
)
return { ok: true, report }
},
Expand Down Expand Up @@ -366,6 +369,7 @@ export function registerIpc(deps: IpcDeps): void {
}
const r = await deps.updates.check()
if (r.status === 'error') log(`update check failed: ${r.error}`)
if (a.reason === 'launch') deps.onLaunchUpdateCheck?.(r)
return r
},
{
Expand Down
Loading