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
4 changes: 3 additions & 1 deletion apps/desktop/electron/app-store-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface PersistedUiState {
readonly modelSettingsScopeMode?: ModelSettingsScopeMode;
readonly appGlobalModelSettings?: ModelSettingsSnapshot;
readonly sidebarCollapsed?: boolean;
readonly allowMultiple?: boolean;
}

export interface LegacyPersistedUiState extends PersistedUiState {
Expand Down Expand Up @@ -71,7 +72,8 @@ export async function readPersistedUiState(uiStateFilePath: string): Promise<Leg
? parsed.modelSettingsScopeMode
: undefined,
appGlobalModelSettings: toPersistedModelSettingsSnapshot(parsed.appGlobalModelSettings),
sidebarCollapsed: parsed.sidebarCollapsed === true,
sidebarCollapsed: typeof parsed.sidebarCollapsed === "boolean" ? parsed.sidebarCollapsed : undefined,
allowMultiple: typeof parsed.allowMultiple === "boolean" ? parsed.allowMultiple : undefined,
composerAttachmentsBySession: parsed.composerAttachmentsBySession,
transcripts: parsed.transcripts,
};
Expand Down
20 changes: 19 additions & 1 deletion apps/desktop/electron/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,21 @@ export class DesktopAppStore implements AppStoreInternals {
return this.emit();
}

async setAllowMultiple(allowMultiple: boolean): Promise<DesktopAppState> {
await this.initialize();
if (this.state.allowMultiple === allowMultiple) {
return this.emit();
}
this.state = {
...this.state,
allowMultiple,
lastError: undefined,
revision: this.state.revision + 1,
};
await this.persistUiState();
return this.emit();
}

async setModelSettingsScopeMode(modelSettingsScopeMode: ModelSettingsScopeMode): Promise<DesktopAppState> {
await this.initialize();
if (this.state.modelSettingsScopeMode === modelSettingsScopeMode) {
Expand Down Expand Up @@ -731,8 +746,8 @@ export class DesktopAppStore implements AppStoreInternals {
/* ── Internal infrastructure (AppStoreInternals) ───────── */

private async initializeInternal(): Promise<void> {
const persisted = await this.readUiState();
try {
const persisted = await this.readUiState();
this.state = {
...this.state,
activeView: persisted.activeView ?? this.state.activeView,
Expand All @@ -746,6 +761,7 @@ export class DesktopAppStore implements AppStoreInternals {
lastViewedAtBySession: persisted.lastViewedAtBySession ?? {},
workspaceOrder: persisted.workspaceOrder ?? [],
sidebarCollapsed: persisted.sidebarCollapsed ?? this.state.sidebarCollapsed,
allowMultiple: persisted.allowMultiple ?? this.state.allowMultiple,
};
await this.migrateLegacyPersistence(persisted);
this.sessionState.lastViewedAtBySession.clear();
Expand Down Expand Up @@ -796,6 +812,7 @@ export class DesktopAppStore implements AppStoreInternals {
} catch (error) {
this.state = {
...createEmptyDesktopAppState(),
allowMultiple: persisted.allowMultiple ?? false,
lastError: error instanceof Error ? error.message : String(error),
revision: 1,
};
Expand Down Expand Up @@ -1689,6 +1706,7 @@ export class DesktopAppStore implements AppStoreInternals {
modelSettingsScopeMode: this.state.modelSettingsScopeMode,
appGlobalModelSettings: hasStoredModelSettings(this.state.globalModelSettings) ? this.state.globalModelSettings : undefined,
sidebarCollapsed: this.state.sidebarCollapsed || undefined,
allowMultiple: this.state.allowMultiple,
};

await writePersistedUiState(this.uiStateFilePath, payload);
Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "electron";
import { randomUUID } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { DesktopAppStore } from "./app-store";
Expand Down Expand Up @@ -378,12 +379,26 @@ app.setName("pi");
const configuredUserDataDir = process.env.PI_APP_USER_DATA_DIR?.trim() || app.getPath("userData");
app.setPath("userData", configuredUserDataDir);

const hasSingleInstanceLock = app.requestSingleInstanceLock();
function getPersistedAllowMultiple(): boolean {
if (process.env.PI_APP_ALLOW_MULTIPLE === "1") {
return true;
}
try {
const uiStatePath = path.join(configuredUserDataDir, "ui-state.json");
const raw = readFileSync(uiStatePath, "utf8");
const parsed = JSON.parse(raw);
return typeof parsed.allowMultiple === "boolean" ? parsed.allowMultiple : false;
} catch {
return false;
}
}

const hasSingleInstanceLock = getPersistedAllowMultiple() || app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
}

app.on("second-instance", () => {
app.on("second-instance", async () => {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
Expand Down Expand Up @@ -575,6 +590,9 @@ app.whenReady().then(async () => {
ipcMain.handle(desktopIpc.setIntegratedTerminalShell, (_event, shellPath: string) =>
store.setIntegratedTerminalShell(shellPath),
);
ipcMain.handle(desktopIpc.setAllowMultiple, (_event, allowMultiple: boolean) =>
store.setAllowMultiple(allowMultiple),
);
ipcMain.handle(desktopIpc.terminalEnsurePanel, (event, workspaceId: string, terminalScopeId: string, size) => {
return getTerminalService().ensurePanel(event.sender, workspaceId, terminalScopeId, size);
});
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ contextBridge.exposeInMainWorld("piApp", {
ipcRenderer.invoke(desktopIpc.setNotificationPreferences, preferences) as Promise<DesktopAppState>,
setIntegratedTerminalShell: (shellPath: string) =>
ipcRenderer.invoke(desktopIpc.setIntegratedTerminalShell, shellPath) as Promise<DesktopAppState>,
setAllowMultiple: (allowMultiple: boolean) =>
ipcRenderer.invoke(desktopIpc.setAllowMultiple, allowMultiple) as Promise<DesktopAppState>,
ensureTerminalPanel: (workspaceId: string, terminalScopeId: string, size?: Partial<TerminalSize>) =>
ipcRenderer.invoke(desktopIpc.terminalEnsurePanel, workspaceId, terminalScopeId, size) as Promise<TerminalPanelSnapshot>,
createTerminalSession: (workspaceId: string, terminalScopeId: string, size?: Partial<TerminalSize>) =>
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,22 @@
"main": "out/main/main.js",
"scripts": {
"build:deps": "pnpm --dir ../.. --filter @pi-gui/session-driver --filter @pi-gui/pi-sdk-driver --filter @pi-gui/catalogs run build",
"bun:build:deps": "for dir in ../../packages/session-driver ../../packages/pi-sdk-driver ../../packages/catalogs; do (cd \"$dir\" && bun run build); done",
"build:notification-helper": "node scripts/build-notification-status-helper.mjs",
"dev": "node scripts/dev.mjs",
"build": "pnpm run build:deps && pnpm run build:notification-helper && electron-vite build",
"bun:build": "bun run bun:build:deps && bun run build:notification-helper && electron-vite build",
"preview": "pnpm run build:deps && pnpm run build:notification-helper && electron-vite preview",
"bun:preview": "bun run bun:build:deps && bun run build:notification-helper && electron-vite preview",
"start": "pnpm run preview",
"package": "pnpm run build && electron-builder --mac",
"bun:package": "bun run bun:build && bun run scripts/bun-package-wrapper.mjs electron-builder --mac",
"package:dir": "pnpm run build && electron-builder --mac --dir",
"bun:package:dir": "bun run bun:build && bun run scripts/bun-package-wrapper.mjs electron-builder --mac --dir",
"package:linux": "pnpm run build && electron-builder --linux AppImage",
"bun:package:linux": "bun run bun:build && bun run scripts/bun-package-wrapper.mjs electron-builder --linux AppImage",
"package:linux:dir": "pnpm run build && electron-builder --linux --dir",
"bun:package:linux:dir": "bun run bun:build && bun run scripts/bun-package-wrapper.mjs electron-builder --linux --dir",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.electron.json --noEmit",
"verify:runtime-model-registry": "node scripts/assert-runtime-model-registry.mjs",
"verify:packaged-runtime-deps": "pnpm run verify:runtime-model-registry && node scripts/assert-packaged-runtime-deps.mjs",
Expand Down Expand Up @@ -118,4 +125,4 @@
"vite": "^6.2.0",
"vite-tsconfig-paths": "^6.1.1"
}
}
}
36 changes: 36 additions & 0 deletions apps/desktop/scripts/bun-package-wrapper.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";

const ROOT_PACKAGE_JSON = path.resolve(process.cwd(), "../../package.json");
const originalContent = readFileSync(ROOT_PACKAGE_JSON, "utf8");
const json = JSON.parse(originalContent);
const [command, ...args] = process.argv.slice(2);

if (!command) {
throw new Error("Usage: bun-package-wrapper.mjs <command> [...args]");
}

const originalPM = json.packageManager;
json.packageManager = "bun";

writeFileSync(ROOT_PACKAGE_JSON, JSON.stringify(json, null, 2));

console.log(`Temporarily set packageManager to bun (was ${originalPM})`);

let exitCode = 0;
try {
const result = spawnSync(command, args, {
stdio: "inherit",
env: { ...process.env, npm_config_user_agent: "bun" },
});
if (result.error) {
throw result.error;
}
exitCode = result.status ?? (result.signal ? 1 : 0);
} finally {
writeFileSync(ROOT_PACKAGE_JSON, originalContent);
console.log(`Restored packageManager to ${originalPM}`);
}

process.exit(exitCode);
76 changes: 50 additions & 26 deletions apps/desktop/scripts/dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,19 @@ const desktopDir = path.resolve(__dirname, "..");
const repoRoot = path.resolve(desktopDir, "..", "..");
const rawArgs = process.argv.slice(2);
const extraArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;

// pnpm uses package filters to identify workspace packages
const packageFilters = ["@pi-gui/session-driver", "@pi-gui/pi-sdk-driver", "@pi-gui/catalogs"];

// Bun handles these manually by directory
const packagePaths = [
path.resolve(repoRoot, "packages/session-driver"),
path.resolve(repoRoot, "packages/pi-sdk-driver"),
path.resolve(repoRoot, "packages/catalogs"),
];

const isBun = process.versions.bun || process.env.npm_config_user_agent?.includes("bun");

async function run(cmd, args, cwd) {
await new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
Expand Down Expand Up @@ -38,33 +49,46 @@ function start(cmd, args, cwd) {
}

async function main() {
await run(
"pnpm",
["--dir", repoRoot, "--filter", packageFilters[0], "--filter", packageFilters[1], "--filter", packageFilters[2], "run", "build"],
desktopDir,
);

const children = [
start(
if (isBun) {
for (const pkgPath of packagePaths) {
await run("bun", ["run", "build"], pkgPath);
}
} else {
await run(
"pnpm",
[
"--dir",
repoRoot,
"--parallel",
"--filter",
packageFilters[0],
"--filter",
packageFilters[1],
"--filter",
packageFilters[2],
"run",
"build",
"--watch",
],
["--dir", repoRoot, "--filter", packageFilters[0], "--filter", packageFilters[1], "--filter", packageFilters[2], "run", "build"],
desktopDir,
),
start("pnpm", ["exec", "electron-vite", "dev", "--watch", ...extraArgs], desktopDir),
];
);
}

const children = isBun
? [
...packagePaths.map((pkgPath) =>
start("bun", ["x", "tsc", "-w", "-p", "tsconfig.json"], pkgPath),
),
start("bun", ["x", "electron-vite", "dev", "--watch", ...extraArgs], desktopDir),
]
: [
start(
"pnpm",
[
"--dir",
repoRoot,
"--parallel",
"--filter",
packageFilters[0],
"--filter",
packageFilters[1],
"--filter",
packageFilters[2],
"run",
"build",
"--watch",
],
desktopDir,
),
start("pnpm", ["exec", "electron-vite", "dev", "--watch", ...extraArgs], desktopDir),
];

let exiting = false;
const stopChildren = () => {
Expand Down Expand Up @@ -104,4 +128,4 @@ async function main() {
main().catch((error) => {
console.error(error);
process.exit(1);
});
});
6 changes: 6 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,10 @@ export default function App() {
void updateSnapshot(api, setSnapshot, () => api.setIntegratedTerminalShell(shellPath));
};

const handleToggleAllowMultiple = (enabled: boolean) => {
void updateSnapshot(api, setSnapshot, () => api.setAllowMultiple(enabled));
};

const handleRequestNotificationPermission = () => {
if (!api?.requestNotificationPermission) {
return;
Expand Down Expand Up @@ -1911,6 +1915,7 @@ export default function App() {
notificationPermissionPending={notificationPermissionPending}
modelSettingsScopeMode={snapshot.modelSettingsScopeMode}
integratedTerminalShell={snapshot.integratedTerminalShell}
allowMultiple={snapshot.allowMultiple}
themeMode={themeMode}
onLoginProvider={handleLoginProvider}
onLogoutProvider={handleLogoutProvider}
Expand All @@ -1920,6 +1925,7 @@ export default function App() {
onSetDefaultModel={handleSetDefaultModel}
onSetNotificationPreferences={handleSetNotificationPreferences}
onSetIntegratedTerminalShell={handleSetIntegratedTerminalShell}
onToggleAllowMultiple={handleToggleAllowMultiple}
onRequestNotificationPermission={handleRequestNotificationPermission}
onOpenSystemNotificationSettings={handleOpenSystemNotificationSettings}
onSetScopedModelPatterns={handleSetScopedModelPatterns}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/desktop-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export interface DesktopAppState {
readonly extensionCommandCompatibilityByWorkspace: Readonly<Record<string, readonly ExtensionCommandCompatibilityRecord[]>>;
readonly notificationPreferences: NotificationPreferences;
readonly integratedTerminalShell: string;
readonly allowMultiple: boolean;
readonly lastViewedAtBySession: Readonly<Record<string, string>>;
readonly workspaceOrder: readonly string[];
readonly modelSettingsScopeMode: ModelSettingsScopeMode;
Expand Down Expand Up @@ -210,6 +211,7 @@ export function createEmptyDesktopAppState(): DesktopAppState {
attentionNeeded: true,
},
integratedTerminalShell: "",
allowMultiple: false,
lastViewedAtBySession: {},
workspaceOrder: [],
modelSettingsScopeMode: "app-global",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const desktopIpc = {
respondToHostUiRequest: "pi-gui:respond-to-host-ui-request",
setNotificationPreferences: "pi-gui:set-notification-preferences",
setIntegratedTerminalShell: "pi-gui:set-integrated-terminal-shell",
setAllowMultiple: "pi-gui:set-allow-multiple",
terminalEnsurePanel: "pi-gui:terminal-ensure-panel",
terminalCreateSession: "pi-gui:terminal-create-session",
terminalSetActiveSession: "pi-gui:terminal-set-active-session",
Expand Down Expand Up @@ -273,6 +274,7 @@ export interface PiDesktopApi {
): Promise<DesktopAppState>;
setNotificationPreferences(preferences: Partial<NotificationPreferences>): Promise<DesktopAppState>;
setIntegratedTerminalShell(shell: string): Promise<DesktopAppState>;
setAllowMultiple(allowMultiple: boolean): Promise<DesktopAppState>;
ensureTerminalPanel(
workspaceId: string,
terminalScopeId: string,
Expand Down
Loading