Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7af80e5
fix(desktop): refine Author document tabs
Aug 9, 2026
5de5342
feat(desktop): polish visual hierarchy
Aug 9, 2026
209ab56
feat(desktop): refine Read chrome and accent
Aug 9, 2026
78de7bf
feat(desktop): strengthen pane tonal hierarchy
Aug 9, 2026
3b86990
feat(desktop): adopt monochrome product theme
Aug 9, 2026
ae47bab
feat(desktop): add soft glass chrome
Aug 9, 2026
d95786e
fix(desktop): repair popovers and pane dividers
Aug 9, 2026
39f13cc
fix(desktop): simplify pane boundaries
Aug 9, 2026
53d5076
feat(desktop): integrate macOS window chrome
Aug 9, 2026
2f97a64
fix(desktop): contain integrated window chrome
Aug 9, 2026
8db27d0
feat(desktop): move hub context to status bar
Aug 9, 2026
8b886b2
fix(desktop): unify pane controls and popup layering
Aug 9, 2026
0446bca
fix(desktop): refine scrollbars and compact controls
Aug 9, 2026
27bdd2c
fix(desktop): consolidate pane header actions
Aug 9, 2026
c032750
fix(desktop): unify Read controls and restore PTY helper
Aug 9, 2026
731e2fb
fix(desktop): refine workbench menus and pane layout
Aug 9, 2026
fdebaca
fix(desktop): add native navigation controls
Aug 9, 2026
7ba0e28
fix(desktop): fully hide the activity rail
Aug 9, 2026
acd3318
fix(desktop): simplify rail controls and titlebar drag
Aug 9, 2026
c4fcc90
fix(desktop): refine read navigation and settings IA
Aug 9, 2026
1a44630
fix(desktop): restore annotation and html navigation
Aug 9, 2026
a40bfb2
fix(desktop): isolate pdf link overlays from glass
Aug 9, 2026
68a65fa
fix(desktop): keep disconnected launch non-modal
Aug 9, 2026
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: 4 additions & 2 deletions desktop/electron/e2e/annotation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,10 @@ test.afterAll(async () => {
test('D2: drag → target row → companion chip → postAgentInput carries the image', async () => {
test.setTimeout(90_000);

// Connect through the real ConnectPanel (auto-opened on boot): the probe +
// bind hit the mock hub; the token lands in the throwaway safeStorage file.
// Connect through the real status-bar entry and ConnectPanel: disconnected
// launch stays non-modal, then the probe + bind hit the mock hub and the
// token lands in the throwaway safeStorage file.
await page.getByRole('button', { name: 'Connect', exact: true }).click();
const connect = page.locator('.connect');
await expect(connect).toBeVisible({ timeout: 20_000 });
await connect.locator('input').nth(1).fill(`http://127.0.0.1:${String(hubPort)}`);
Expand Down
2 changes: 1 addition & 1 deletion desktop/electron/e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ test('author: the New ▾ menu creates a document and the workspace pane folds',
await expect(page.locator('.read-tabstrip .read-tabitem').last()).toBeVisible();
// Fold the pane via its header chevron → the tree is gone and a slim edge
// button takes its place; clicking that restores the pane.
await page.locator('.author-nav .author-nav-head .author-nav-icon').first().click();
await page.getByRole('button', { name: 'Hide the workspace pane' }).click();
await expect(page.locator('.author-nav')).toHaveCount(0);
await page.locator('.author-nav-show').click();
await expect(page.locator('.author-nav')).toBeVisible();
Expand Down
10 changes: 10 additions & 0 deletions desktop/electron/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ copyright: © TermiPod contributors
# before this step; here we only pack the already-correct .node.
npmRebuild: false

# npm 11 currently installs node-pty's macOS `spawn-helper` without its execute
# bit. The addon launches that helper with posix_spawnp; a packaged app therefore
# fails every local shell with `posix_spawnp failed` unless packaging restores
# mode 0755 before signing.
afterPack: scripts/after-pack.cjs

directories:
output: release
buildResources: build
Expand All @@ -38,6 +44,10 @@ files:
- '!tsconfig.json'
- '!esbuild.mjs'
- '!electron-builder.yml'
- '!scripts/**/*'
# Local packaging output may already exist from an earlier build. Never feed
# it back into app.asar (which recursively balloons the next package).
- '!release/**/*'
# build/ holds the deb maintainer scripts (buildResources) — packaging
# inputs, not runtime payload; keep them out of the asar.
- '!build/**/*'
Expand Down
37 changes: 37 additions & 0 deletions desktop/electron/scripts/after-pack.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const fs = require('node:fs/promises');
const path = require('node:path');

/**
* Restore executable permissions on node-pty's macOS launcher before the app is
* signed. npm 11 may unpack the published helper as 0644; node-pty invokes it
* with posix_spawnp, which then fails even though the binary exists.
*/
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') return;

const resources = path.join(
context.appOutDir,
`${context.packager.appInfo.productFilename}.app`,
'Contents',
'Resources',
'app.asar.unpacked',
'node_modules',
'node-pty',
'prebuilds',
);

let fixed = 0;
for (const arch of ['darwin-arm64', 'darwin-x64']) {
const helper = path.join(resources, arch, 'spawn-helper');
try {
await fs.chmod(helper, 0o755);
fixed += 1;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}

if (fixed === 0) {
throw new Error(`node-pty spawn-helper not found under ${resources}`);
}
};
23 changes: 19 additions & 4 deletions desktop/electron/src/ipc/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
/// - a rendered figure `<svg>` (mermaid/vega/echarts) → the renderer rasterizes
/// it to a PNG `data:` URL and passes it as `imageData`, which we write to
/// the clipboard as a `nativeImage`.
import { clipboard, Menu, nativeImage, type MenuItemConstructorOptions } from 'electron';
import { clipboard, Menu, nativeImage, shell, type MenuItemConstructorOptions } from 'electron';
import type { Handler } from './dispatch';
import { isSafeExternal } from './platform';

export const menuHandlers: Record<string, Handler> = {
/// Pop a native context menu at the cursor. `editable` widens it to the full
/// edit set; `hasSelection` gates cut/copy; an image target adds "Copy image".
/// edit set; `hasSelection` gates cut/copy; `selectAll` keeps document-frame
/// whitespace useful; an image target adds "Copy image".
/// Paste is offered only when the OS clipboard actually holds text (checked
/// here, in the main process, so the renderer needn't touch the async
/// clipboard-read permission path).
Expand All @@ -28,12 +30,23 @@ export const menuHandlers: Record<string, Handler> = {
const wc = ctx.win.webContents;
const editable = args.editable === true;
const hasSelection = args.hasSelection === true;
const linkUrl = typeof args.linkUrl === 'string' && isSafeExternal(args.linkUrl) ? args.linkUrl : '';
const openLinkLabel = typeof args.openLinkLabel === 'string' && args.openLinkLabel !== '' ? args.openLinkLabel : 'Open link in browser';
const copyLinkLabel = typeof args.copyLinkLabel === 'string' && args.copyLinkLabel !== '' ? args.copyLinkLabel : 'Copy link address';
const imageLabel = typeof args.imageLabel === 'string' && args.imageLabel !== '' ? args.imageLabel : 'Copy image';
const template: MenuItemConstructorOptions[] = [];

if (linkUrl !== '') {
template.push(
{ label: openLinkLabel, click: () => void shell.openExternal(linkUrl) },
{ label: copyLinkLabel, click: () => clipboard.writeText(linkUrl) },
);
}

// Image "Copy image" leads the menu when present.
if (typeof args.imageData === 'string' && args.imageData !== '') {
const data = args.imageData;
if (template.length > 0) template.push({ type: 'separator' });
template.push({
label: imageLabel,
click: () => {
Expand All @@ -44,6 +57,7 @@ export const menuHandlers: Record<string, Handler> = {
} else if (args.image === true && typeof args.x === 'number' && typeof args.y === 'number') {
const x = Math.round(args.x);
const y = Math.round(args.y);
if (template.length > 0) template.push({ type: 'separator' });
template.push({ label: imageLabel, click: () => wc.copyImageAt(x, y) });
}

Expand All @@ -56,9 +70,10 @@ export const menuHandlers: Record<string, Handler> = {
{ type: 'separator' },
{ role: 'selectAll' },
);
} else if (hasSelection) {
} else if (hasSelection || args.selectAll === true) {
if (template.length > 0) template.push({ type: 'separator' });
template.push({ role: 'copy' }, { type: 'separator' }, { role: 'selectAll' });
if (hasSelection) template.push({ role: 'copy' }, { type: 'separator' });
template.push({ role: 'selectAll' });
}
if (template.length === 0) return;
Menu.buildFromTemplate(template).popup({ window: ctx.win });
Expand Down
93 changes: 91 additions & 2 deletions desktop/electron/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@
/// M1.1 wires the shell + the platform-helper and migration command families;
/// the hub transport goes renderer-direct (plan §7 rows 1–2), keychain / files /
/// dialogs / draw.io land in later M1 slices.
import { app, BrowserWindow, ipcMain, session, shell } from 'electron';
import {
app,
BrowserWindow,
ipcMain,
Menu,
session,
shell,
type MenuItemConstructorOptions,
} from 'electron';
import path from 'node:path';
import './schemes'; // registers privileged app:// + drawio:// before app ready
import { APP_ORIGIN, registerAppScheme } from './appscheme';
Expand All @@ -36,7 +44,7 @@ import './uihighlight_host';
// Coworking H: importing the module registers the navigate (desktop_open)
// provider. Its IPC reply handler is registered separately, in ipc/dispatch.
import './desktopopen_host';
import { initEvents } from './events';
import { emit, initEvents } from './events';

// The frontend build. In dev (`electron .` from desktop/electron) it resolves to
// desktop/dist; in a packaged app electron-builder ships it as an `extraResource`
Expand All @@ -56,13 +64,88 @@ const ICON = path.join(__dirname, '..', 'assets', 'icon.png');

let mainWindow: BrowserWindow | null = null;

type ShellCommand = 'settings' | 'toggle-navigation';

function sendShellCommand(action: ShellCommand): void {
const win = mainWindow;
if (win === null || win.webContents.isDestroyed()) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
emit(win.webContents, 'shell:command', { action });
}

function installApplicationMenu(): void {
// `app.name` comes from package.json (`termipod-electron`, or the distinct
// review-build lock name), not the product name macOS shows. The packaged
// executable is the stable display name and keeps these native labels correct
// for both TermiPod and side-by-side review builds.
const displayName = app.isPackaged ? path.basename(process.execPath) : 'TermiPod';
const appMenu: MenuItemConstructorOptions = {
label: displayName,
submenu: [
{ label: `About ${displayName}`, click: () => app.showAboutPanel() },
{ type: 'separator' },
{
label: 'Settings…',
accelerator: 'CommandOrControl+,',
click: () => sendShellCommand('settings'),
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ label: `Hide ${displayName}`, accelerator: 'Command+H', click: () => app.hide() },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ label: `Quit ${displayName}`, accelerator: 'Command+Q', click: () => app.quit() },
],
};
const template: MenuItemConstructorOptions[] = [
...(process.platform === 'darwin' ? [appMenu] : []),
{ role: 'fileMenu' },
{ role: 'editMenu' },
{
label: 'View',
submenu: [
{
label: 'Toggle Navigation',
accelerator: 'CommandOrControl+Shift+B',
click: () => sendShellCommand('toggle-navigation'),
},
{ type: 'separator' },
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{ role: 'windowMenu' },
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}

function createWindow(): void {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 900,
minHeight: 600,
title: 'TermiPod — Desktop Workbench',
// On macOS the native title row duplicated the app's own persistent chrome
// and cost ~52px on every workbench. hiddenInset keeps the standard traffic
// lights/window behaviour while allowing the app surface to fill that row.
...(process.platform === 'darwin'
? {
titleBarStyle: 'hiddenInset' as const,
trafficLightPosition: { x: 14, y: 14 },
}
: {}),
icon: ICON,
backgroundColor: '#0b0b10',
show: false, // paint-free first frame; reveal on ready-to-show
Expand Down Expand Up @@ -96,6 +179,11 @@ function createWindow(): void {
if (!url.startsWith(APP_ORIGIN)) e.preventDefault();
});
win.once('ready-to-show', () => win.show());
const publishFullScreen = (): void => {
emit(win.webContents, 'shell:fullscreen', { fullScreen: win.isFullScreen() });
};
win.on('enter-full-screen', publishFullScreen);
win.on('leave-full-screen', publishFullScreen);
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
setShellWindow(null);
Expand Down Expand Up @@ -139,6 +227,7 @@ if (!app.requestSingleInstanceLock()) {
);
}
initEvents();
installApplicationMenu();
registerAppScheme(session.defaultSession, DIST);
registerDrawioScheme(session.defaultSession);
// defaultSession ONLY: webview guests run in isolated partitions, so
Expand Down
14 changes: 14 additions & 0 deletions desktop/electron/src/webtab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ async function applyProxy(proxy: string | null): Promise<void> {
// defaults below cover the pre-push window (a guest can't exist before the
// renderer has mounted and pushed) and the unit-test path.
const guestMenuLabels: Record<GuestMenuAction, string> = {
back: 'Back',
forward: 'Forward',
reload: 'Reload',
openLink: 'Open link in browser',
copyLink: 'Copy link address',
copyImage: 'Copy image',
Expand All @@ -104,6 +107,15 @@ const guestMenuLabels: Record<GuestMenuAction, string> = {
/// a guest, so target `wc` directly).
function runGuestMenuAction(wc: Electron.WebContents, action: GuestMenuAction, params: Electron.ContextMenuParams): void {
switch (action) {
case 'back':
if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
break;
case 'forward':
if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward();
break;
case 'reload':
wc.reload();
break;
case 'openLink':
if (isSafeExternal(params.linkURL)) void shell.openExternal(params.linkURL);
break;
Expand Down Expand Up @@ -134,6 +146,8 @@ function runGuestMenuAction(wc: Electron.WebContents, action: GuestMenuAction, p
/// window so the menu is anchored correctly across multiple windows.
function popupGuestContextMenu(wc: Electron.WebContents, params: Electron.ContextMenuParams): void {
const items = buildGuestMenuTemplate({
canGoBack: wc.navigationHistory.canGoBack(),
canGoForward: wc.navigationHistory.canGoForward(),
linkURL: isSafeExternal(params.linkURL) ? params.linkURL : '',
isImage: params.mediaType === 'image' && params.hasImageContents,
isEditable: params.isEditable,
Expand Down
27 changes: 19 additions & 8 deletions desktop/electron/src/webtab_policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,12 @@ test('isLoopbackHttpUrl: direct predicate spot-checks', () => {
// ── Guest context-menu template ──────────────────────────────────────────────
// This is why kimiweb/webtab now HAVE a right-click menu at all: the guest's
// `context-menu` is handled main-side (webtab.ts), building a native menu from
// this descriptor. The blank base = "nothing under the cursor" (which must
// yield NO menu, not an empty one).
// this descriptor. The blank base = "nothing under the cursor"; navigation
// still keeps that menu useful, like a regular browser.

const NONE: GuestMenuContext = {
canGoBack: false,
canGoForward: false,
linkURL: '',
isImage: false,
isEditable: false,
Expand All @@ -122,13 +124,18 @@ const NONE: GuestMenuContext = {
const actions = (items: ReturnType<typeof buildGuestMenuTemplate>): string[] =>
items.map((it) => (it === 'separator' ? '|' : it.action));

test('guest menu: nothing useful under the cursor ⇒ no menu', () => {
assert.deepEqual(buildGuestMenuTemplate(NONE), []);
test('guest menu: whitespace still offers browser navigation', () => {
const items = buildGuestMenuTemplate(NONE);
assert.deepEqual(actions(items), ['back', 'forward', 'reload']);
const byAction = Object.fromEntries(items.filter((it) => it !== 'separator').map((it) => [it.action, it.enabled]));
assert.equal(byAction.back, false);
assert.equal(byAction.forward, false);
assert.equal(byAction.reload, true);
});

test('guest menu: a plain text selection offers copy + select-all', () => {
const items = buildGuestMenuTemplate({ ...NONE, selectionText: 'hello' });
assert.deepEqual(actions(items), ['copy', '|', 'selectAll']);
assert.deepEqual(actions(items), ['back', 'forward', 'reload', '|', 'copy', '|', 'selectAll']);
});

test('guest menu: an editable field offers the full edit set, enabled per editFlags', () => {
Expand All @@ -141,7 +148,7 @@ test('guest menu: an editable field offers the full edit set, enabled per editFl
canPaste: true,
canSelectAll: true,
});
assert.deepEqual(actions(items), ['cut', 'copy', 'paste', '|', 'selectAll']);
assert.deepEqual(actions(items), ['back', 'forward', 'reload', '|', 'cut', 'copy', 'paste', '|', 'selectAll']);
// editFlags drive enabled state (an empty clipboard ⇒ paste disabled, no
// selection ⇒ cut/copy disabled) — Chromium's own accounting.
const empty = buildGuestMenuTemplate({ ...NONE, isEditable: true });
Expand All @@ -159,9 +166,13 @@ test('guest menu: a link leads, then image, then text (browser ordering)', () =>
isImage: true,
selectionText: 'sel',
});
assert.deepEqual(actions(items), ['openLink', 'copyLink', '|', 'copyImage', '|', 'copy', '|', 'selectAll']);
assert.deepEqual(actions(items), [
'back', 'forward', 'reload', '|', 'openLink', 'copyLink', '|', 'copyImage', '|', 'copy', '|', 'selectAll',
]);
});

test('guest menu: an image alone offers copy image only', () => {
assert.deepEqual(actions(buildGuestMenuTemplate({ ...NONE, isImage: true })), ['copyImage']);
assert.deepEqual(actions(buildGuestMenuTemplate({ ...NONE, isImage: true })), [
'back', 'forward', 'reload', '|', 'copyImage',
]);
});
Loading
Loading