diff --git a/apps/desktop/src/lib/electron-app/factories/windows/create.ts b/apps/desktop/src/lib/electron-app/factories/windows/create.ts index f8628add0ad..40733b8197f 100644 --- a/apps/desktop/src/lib/electron-app/factories/windows/create.ts +++ b/apps/desktop/src/lib/electron-app/factories/windows/create.ts @@ -3,7 +3,7 @@ import { BrowserWindow, shell } from "electron"; import { registerRoute } from "lib/window-loader"; import type { WindowProps } from "shared/types"; -export function createWindow({ id, ...settings }: WindowProps) { +export function createWindow({ id, path, query, ...settings }: WindowProps) { const window = new BrowserWindow(settings); // Open external URLs in the system browser instead of Electron @@ -19,6 +19,8 @@ export function createWindow({ id, ...settings }: WindowProps) { id, browserWindow: window, htmlFile: join(__dirname, "../renderer/index.html"), + path, + query, }); window.on("closed", window.destroy); diff --git a/apps/desktop/src/lib/trpc/index.ts b/apps/desktop/src/lib/trpc/index.ts index 5bb8b4686f3..5a1d0396dcc 100644 --- a/apps/desktop/src/lib/trpc/index.ts +++ b/apps/desktop/src/lib/trpc/index.ts @@ -4,11 +4,15 @@ import superjson from "superjson"; import type { AppRouter } from "./routers"; import { NotGitRepoError } from "./routers/workspaces/utils/git"; +export interface TrpcContext { + windowId: number | null; +} + /** * Core tRPC initialization * This provides the base router and procedure builders used by all routers */ -const t = initTRPC.create({ +const t = initTRPC.context().create({ transformer: superjson, isServer: true, }); diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 31641f45184..b254cd2ea5b 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -1,6 +1,10 @@ import { observable } from "@trpc/server/observable"; import { appState } from "main/lib/app-state"; import type { TabsState, ThemeState } from "main/lib/app-state/schemas"; +import { + getTabsStateForWindow, + setTabsStateForWindow, +} from "main/lib/app-state/tabs-state"; import { hotkeysEmitter } from "main/lib/hotkeys-events"; import { buildOverridesFromBindings, @@ -235,14 +239,14 @@ export const createUiStateRouter = () => { return router({ // Tabs state procedures tabs: router({ - get: publicProcedure.query((): TabsState => { - return appState.data.tabsState; + get: publicProcedure.query(({ ctx }): TabsState => { + return getTabsStateForWindow(ctx.windowId); }), set: publicProcedure .input(tabsStateSchema) - .mutation(async ({ input }) => { - appState.data.tabsState = input; + .mutation(async ({ ctx, input }) => { + setTabsStateForWindow(ctx.windowId, input); await appState.write(); return { success: true }; }), diff --git a/apps/desktop/src/lib/trpc/routers/window.ts b/apps/desktop/src/lib/trpc/routers/window.ts index 8c92212b430..3c5c45dd7e4 100644 --- a/apps/desktop/src/lib/trpc/routers/window.ts +++ b/apps/desktop/src/lib/trpc/routers/window.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import path from "node:path"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; +import { openWorkspaceWindow } from "main/lib/window-manager"; import { z } from "zod"; import { publicProcedure, router } from ".."; @@ -47,6 +48,19 @@ export const createWindowRouter = (getWindow: () => BrowserWindow | null) => { return homedir(); }), + openWorkspaceWindow: publicProcedure + .input( + z.object({ + workspaceId: z.string().min(1), + tabId: z.string().optional(), + paneId: z.string().optional(), + }), + ) + .mutation(({ input }) => { + openWorkspaceWindow(input); + return { success: true }; + }), + selectDirectory: publicProcedure .input( z diff --git a/apps/desktop/src/lib/window-loader.ts b/apps/desktop/src/lib/window-loader.ts index d31c07dde09..b03aa339c13 100644 --- a/apps/desktop/src/lib/window-loader.ts +++ b/apps/desktop/src/lib/window-loader.ts @@ -15,20 +15,27 @@ export function registerRoute(props: { id: WindowId; browserWindow: BrowserWindow; htmlFile: string; + path?: string; query?: Record; }): void { const isDev = env.NODE_ENV === "development"; + const path = props.path ?? "/"; + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const search = props.query + ? new URLSearchParams(props.query).toString() + : ""; + const hash = search ? `${normalizedPath}?${search}` : normalizedPath; if (isDev) { // Development: load from Vite dev server with hash routing - const url = `http://localhost:${env.DESKTOP_VITE_PORT}/#/`; + const url = `http://localhost:${env.DESKTOP_VITE_PORT}/#${hash}`; console.log("[window-loader] Loading development URL:", url); props.browserWindow.loadURL(url); } else { // Production: load from file with hash routing // TanStack Router uses hash-based routing, so we always start at #/ console.log("[window-loader] Loading file:", props.htmlFile); - props.browserWindow.loadFile(props.htmlFile, { hash: "/" }); + props.browserWindow.loadFile(props.htmlFile, { hash }); } // Log successful loads diff --git a/apps/desktop/src/main/lib/app-state/index.ts b/apps/desktop/src/main/lib/app-state/index.ts index 00e9fe790f5..482c358b485 100644 --- a/apps/desktop/src/main/lib/app-state/index.ts +++ b/apps/desktop/src/main/lib/app-state/index.ts @@ -18,6 +18,10 @@ function ensureValidShape(data: Partial): AppState { ...defaultAppState.tabsState, ...(data.tabsState ?? {}), }, + tabsStateByWindow: { + ...defaultAppState.tabsStateByWindow, + ...(data.tabsStateByWindow ?? {}), + }, themeState: { ...defaultAppState.themeState, ...(data.themeState ?? {}), diff --git a/apps/desktop/src/main/lib/app-state/schemas.ts b/apps/desktop/src/main/lib/app-state/schemas.ts index e93767a761d..0f94b30edce 100644 --- a/apps/desktop/src/main/lib/app-state/schemas.ts +++ b/apps/desktop/src/main/lib/app-state/schemas.ts @@ -8,6 +8,12 @@ import type { Theme } from "shared/themes"; // Re-export for convenience export type { BaseTabsState as TabsState, Pane } from "shared/tabs-types"; +export interface WindowTabsState { + activeTabIds: BaseTabsState["activeTabIds"]; + focusedPaneIds: BaseTabsState["focusedPaneIds"]; + tabHistoryStacks: BaseTabsState["tabHistoryStacks"]; +} + export interface ThemeState { activeThemeId: string; customThemes: Theme[]; @@ -15,6 +21,7 @@ export interface ThemeState { export interface AppState { tabsState: BaseTabsState; + tabsStateByWindow: Record; themeState: ThemeState; hotkeysState: HotkeysState; } @@ -27,6 +34,7 @@ export const defaultAppState: AppState = { focusedPaneIds: {}, tabHistoryStacks: {}, }, + tabsStateByWindow: {}, themeState: { activeThemeId: "dark", customThemes: [], diff --git a/apps/desktop/src/main/lib/app-state/tabs-state.ts b/apps/desktop/src/main/lib/app-state/tabs-state.ts new file mode 100644 index 00000000000..65658e3b9f3 --- /dev/null +++ b/apps/desktop/src/main/lib/app-state/tabs-state.ts @@ -0,0 +1,99 @@ +import { appState } from "."; +import { + defaultAppState, + type TabsState, + type WindowTabsState, +} from "./schemas"; + +function getWindowKey(windowId: number | null | undefined): string | null { + return windowId === null || windowId === undefined ? null : String(windowId); +} + +function toWindowTabsState( + state: Partial | undefined, +): WindowTabsState { + return { + activeTabIds: state?.activeTabIds ?? {}, + focusedPaneIds: state?.focusedPaneIds ?? {}, + tabHistoryStacks: state?.tabHistoryStacks ?? {}, + }; +} + +export function getTabsStateForWindow( + windowId: number | null | undefined, +): TabsState { + const sharedState = appState.data.tabsState ?? defaultAppState.tabsState; + const key = getWindowKey(windowId); + const windowState = + (key ? appState.data.tabsStateByWindow[key] : undefined) ?? + toWindowTabsState(sharedState); + + return { + tabs: sharedState.tabs, + panes: sharedState.panes, + activeTabIds: windowState.activeTabIds, + focusedPaneIds: windowState.focusedPaneIds, + tabHistoryStacks: windowState.tabHistoryStacks, + }; +} + +function getMergedWindowTabsState(): WindowTabsState { + const merged = toWindowTabsState(appState.data.tabsState); + const byWindow = appState.data.tabsStateByWindow; + + for (const state of Object.values(byWindow ?? {})) { + const windowState = toWindowTabsState(state); + Object.assign(merged.activeTabIds, windowState.activeTabIds); + Object.assign(merged.focusedPaneIds, windowState.focusedPaneIds); + Object.assign(merged.tabHistoryStacks, windowState.tabHistoryStacks); + } + + return merged; +} + +export function setTabsStateForWindow( + windowId: number | null | undefined, + tabsState: TabsState, +): void { + const windowState: WindowTabsState = { + activeTabIds: tabsState.activeTabIds, + focusedPaneIds: tabsState.focusedPaneIds, + tabHistoryStacks: tabsState.tabHistoryStacks, + }; + + const key = getWindowKey(windowId); + if (key) { + appState.data.tabsStateByWindow = { + ...appState.data.tabsStateByWindow, + [key]: windowState, + }; + } + + // Shared across windows: tab/pane topology. + // Legacy fallback keeps the latest caller's view state. + appState.data.tabsState = { + tabs: tabsState.tabs, + panes: tabsState.panes, + activeTabIds: windowState.activeTabIds, + focusedPaneIds: windowState.focusedPaneIds, + tabHistoryStacks: windowState.tabHistoryStacks, + }; +} + +export function getMergedTabsState(): TabsState { + const sharedState = appState.data.tabsState ?? defaultAppState.tabsState; + const mergedWindowState = getMergedWindowTabsState(); + + return { + tabs: sharedState.tabs, + panes: sharedState.panes, + activeTabIds: mergedWindowState.activeTabIds, + focusedPaneIds: mergedWindowState.focusedPaneIds, + tabHistoryStacks: mergedWindowState.tabHistoryStacks, + }; +} + +export function resetTabsState(): void { + appState.data.tabsState = defaultAppState.tabsState; + appState.data.tabsStateByWindow = {}; +} diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index 1e24d4150de..940a6ff881f 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -4,6 +4,7 @@ import { env } from "main/env.main"; import { appState } from "main/lib/app-state"; import { hotkeysEmitter } from "main/lib/hotkeys-events"; import { resetTerminalStateDev } from "main/lib/terminal/dev-reset"; +import { openLastActiveWorkspaceWindow } from "main/lib/window-manager"; import { getCurrentPlatform, getEffectiveHotkey, @@ -38,6 +39,7 @@ export function registerMenuHotkeyUpdates() { export function createApplicationMenu() { const closeAccelerator = getMenuAccelerator("CLOSE_WINDOW"); + const newWindowAccelerator = getMenuAccelerator("NEW_WINDOW"); const showHotkeysAccelerator = getMenuAccelerator("SHOW_HOTKEYS"); const openSettingsAccelerator = getMenuAccelerator("OPEN_SETTINGS"); @@ -68,14 +70,22 @@ export function createApplicationMenu() { { role: "togglefullscreen" }, ], }, - { - label: "Window", - submenu: [ - { role: "minimize" }, - { role: "zoom" }, - { type: "separator" }, - { role: "close", accelerator: closeAccelerator }, - ], + { + label: "Window", + submenu: [ + { + label: "New Window", + accelerator: newWindowAccelerator, + click: () => { + openLastActiveWorkspaceWindow(); + }, + }, + { type: "separator" }, + { role: "minimize" }, + { role: "zoom" }, + { type: "separator" }, + { role: "close", accelerator: closeAccelerator }, + ], }, { label: "Help", diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 75204b176cd..ab3017d690f 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -5,7 +5,7 @@ import { handleAuthCallback } from "lib/trpc/routers/auth/utils/auth-functions"; import { NOTIFICATION_EVENTS } from "shared/constants"; import { env } from "shared/env.shared"; import type { AgentLifecycleEvent } from "shared/notification-types"; -import { appState } from "../app-state"; +import { getMergedTabsState } from "../app-state/tabs-state"; import { HOOK_PROTOCOL_VERSION } from "../terminal/env"; import { mapEventType } from "./map-event-type"; @@ -59,7 +59,7 @@ function resolvePaneId( sessionId: string | undefined, ): string | undefined { try { - const tabsState = appState.data.tabsState; + const tabsState = getMergedTabsState(); if (!tabsState) return undefined; // If paneId provided, validate it exists before returning diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index bea94895f0c..66cc447f717 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "node:events"; import { workspaces } from "@superset/local-db"; import { track } from "main/lib/analytics"; -import { appState } from "main/lib/app-state"; +import { getMergedTabsState } from "main/lib/app-state/tabs-state"; import { localDb } from "main/lib/local-db"; import { HistoryReader, truncateUtf8ToLastBytes } from "../../terminal-history"; import { @@ -552,7 +552,7 @@ export class DaemonTerminalManager extends EventEmitter { private getCreateOrAttachPriority(params: CreateSessionParams): number { try { - const tabsState = appState.data?.tabsState; + const tabsState = getMergedTabsState(); const activeTabId = tabsState?.activeTabIds?.[params.workspaceId]; const focusedPaneId = activeTabId && tabsState?.focusedPaneIds?.[activeTabId]; diff --git a/apps/desktop/src/main/lib/terminal/dev-reset.ts b/apps/desktop/src/main/lib/terminal/dev-reset.ts index 2d95e5f8634..8a3ebc78bd3 100644 --- a/apps/desktop/src/main/lib/terminal/dev-reset.ts +++ b/apps/desktop/src/main/lib/terminal/dev-reset.ts @@ -2,7 +2,7 @@ import { rm } from "node:fs/promises"; import { join } from "node:path"; import { SUPERSET_HOME_DIR } from "main/lib/app-environment"; import { appState } from "main/lib/app-state"; -import { defaultAppState } from "main/lib/app-state/schemas"; +import { resetTabsState } from "main/lib/app-state/tabs-state"; import { disposeTerminalHostClient, getTerminalHostClient, @@ -45,7 +45,7 @@ export async function resetTerminalStateDev(): Promise { } // Clear tabs/panes so we don't immediately try to restore a large terminal set. - appState.data.tabsState = defaultAppState.tabsState; + resetTabsState(); try { await appState.write(); } catch (error) { diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 5b343acd3cd..48b442b9a2d 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -19,6 +19,7 @@ import { } from "main/lib/terminal"; import { getTerminalHostClient } from "main/lib/terminal-host/client"; import type { ListSessionsResponse } from "main/lib/terminal-host/types"; +import { openLastActiveWorkspaceWindow } from "main/lib/window-manager"; const POLL_INTERVAL_MS = 5000; @@ -36,21 +37,26 @@ function getTrayIconPath(): string | null { return null; } - const previewPath = join(__dirname, "../resources/tray", TRAY_ICON_FILENAME); - if (existsSync(previewPath)) { - return previewPath; - } - - const devPath = join( - app.getAppPath(), - "src/resources/tray", - TRAY_ICON_FILENAME, - ); - if (existsSync(devPath)) { - return devPath; + const devPaths = [ + join(__dirname, "../resources/tray", TRAY_ICON_FILENAME), + join(__dirname, "../../resources/tray", TRAY_ICON_FILENAME), + join(app.getAppPath(), "src/resources/tray", TRAY_ICON_FILENAME), + join( + app.getAppPath(), + "apps/desktop/src/resources/tray", + TRAY_ICON_FILENAME, + ), + join(process.cwd(), "src/resources/tray", TRAY_ICON_FILENAME), + join(process.cwd(), "apps/desktop/src/resources/tray", TRAY_ICON_FILENAME), + ]; + + for (const iconPath of devPaths) { + if (existsSync(iconPath)) { + return iconPath; + } } - console.warn("[Tray] Icon not found at:", previewPath, "or", devPath); + console.warn("[Tray] Icon not found. Tried paths:", devPaths.join(", ")); return null; } @@ -116,6 +122,10 @@ function openSessionInSuperset(workspaceId: string): void { menuEmitter.emit("open-workspace", workspaceId); } +function openNewWindow(): void { + openLastActiveWorkspaceWindow(); +} + async function killSession(paneId: string): Promise { try { const client = getTerminalHostClient(); @@ -262,6 +272,10 @@ async function updateTrayMenu(): Promise { label: "Open Superset", click: showWindow, }, + { + label: "New Window", + click: openNewWindow, + }, { label: "Settings", click: openSettings, diff --git a/apps/desktop/src/main/lib/window-manager.ts b/apps/desktop/src/main/lib/window-manager.ts new file mode 100644 index 00000000000..fd6c260f677 --- /dev/null +++ b/apps/desktop/src/main/lib/window-manager.ts @@ -0,0 +1,161 @@ +import { join } from "node:path"; +import { settings, workspaces } from "@superset/local-db"; +import { and, eq, isNull } from "drizzle-orm"; +import { BrowserWindow, nativeTheme } from "electron"; +import { createWindow } from "lib/electron-app/factories/windows/create"; +import { localDb } from "main/lib/local-db"; +import { PLATFORM } from "shared/constants"; +import { productName } from "~/package.json"; + +interface IpcWindowHandler { + attachWindow: (window: BrowserWindow) => void; + detachWindow: (window: BrowserWindow) => void; +} + +interface OpenWorkspaceWindowInput { + workspaceId: string; + tabId?: string; + paneId?: string; +} + +interface OpenWindowOptions { + path: string; + query?: Record; +} + +let ipcWindowHandler: IpcWindowHandler | null = null; + +export function registerIpcWindowHandler( + handler: IpcWindowHandler | null, +): void { + ipcWindowHandler = handler; +} + +function openWindowWithRoute({ + path, + query, +}: OpenWindowOptions): BrowserWindow { + const sourceWindow = + BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? null; + + const [sourceWidth, sourceHeight] = sourceWindow + ? sourceWindow.getSize() + : [1280, 900]; + const [sourceX, sourceY] = sourceWindow + ? sourceWindow.getPosition() + : [undefined, undefined]; + const windowTitle = sourceWindow?.getTitle() ?? productName; + const zoomLevel = sourceWindow?.webContents.getZoomLevel(); + + const window = createWindow({ + id: "main", + title: windowTitle, + width: sourceWidth, + height: sourceHeight, + x: sourceX !== undefined ? sourceX + 32 : undefined, + y: sourceY !== undefined ? sourceY + 32 : undefined, + minWidth: 400, + minHeight: 400, + show: false, + backgroundColor: nativeTheme.shouldUseDarkColors ? "#252525" : "#ffffff", + movable: true, + resizable: true, + alwaysOnTop: false, + autoHideMenuBar: true, + frame: false, + titleBarStyle: "hidden", + trafficLightPosition: { x: 16, y: 16 }, + webPreferences: { + preload: join(__dirname, "../preload/index.js"), + webviewTag: true, + partition: "persist:superset", + }, + path, + query, + }); + + if (PLATFORM.IS_MAC) { + window.webContents.setBackgroundThrottling(false); + } + + ipcWindowHandler?.attachWindow(window); + + window.webContents.once("did-finish-load", () => { + if (zoomLevel !== undefined) { + window.webContents.setZoomLevel(zoomLevel); + } + window.show(); + window.focus(); + }); + + window.webContents.once( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL) => { + console.error("[window-manager] Failed to load renderer window:"); + console.error(` Error code: ${errorCode}`); + console.error(` Description: ${errorDescription}`); + console.error(` URL: ${validatedURL}`); + // Show the window so failures are visible to users. + window.show(); + }, + ); + + window.on("close", () => { + ipcWindowHandler?.detachWindow(window); + }); + + return window; +} + +export function openWorkspaceWindow({ + workspaceId, + tabId, + paneId, +}: OpenWorkspaceWindowInput): BrowserWindow { + const query: Record = {}; + if (tabId) query.tabId = tabId; + if (paneId) query.paneId = paneId; + + return openWindowWithRoute({ + path: `/workspace/${workspaceId}`, + query: Object.keys(query).length > 0 ? query : undefined, + }); +} + +export function openWorkspaceIndexWindow(): BrowserWindow { + return openWindowWithRoute({ path: "/workspace" }); +} + +export function openLastActiveWorkspaceWindow(): BrowserWindow { + try { + const appSettings = localDb + .select({ lastActiveWorkspaceId: settings.lastActiveWorkspaceId }) + .from(settings) + .get(); + const lastActiveWorkspaceId = appSettings?.lastActiveWorkspaceId; + + if (lastActiveWorkspaceId) { + const workspace = localDb + .select({ id: workspaces.id }) + .from(workspaces) + .where( + and( + eq(workspaces.id, lastActiveWorkspaceId), + isNull(workspaces.deletingAt), + ), + ) + .get(); + + if (workspace?.id) { + return openWorkspaceWindow({ workspaceId: workspace.id }); + } + } + } catch (error) { + console.warn( + "[window-manager] Failed to resolve last active workspace:", + error, + ); + } + + return openWorkspaceIndexWindow(); +} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index f77fe8eb42f..4a48ed989cc 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,8 +1,7 @@ import { join } from "node:path"; import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; -import type { BrowserWindow } from "electron"; -import { app, Notification, nativeTheme } from "electron"; +import { app, BrowserWindow, Notification, nativeTheme } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { localDb } from "main/lib/local-db"; @@ -14,7 +13,7 @@ import { import type { AgentLifecycleEvent } from "shared/notification-types"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; -import { appState } from "../lib/app-state"; +import { getMergedTabsState } from "../lib/app-state/tabs-state"; import { browserManager } from "../lib/browser/browser-manager"; import { createApplicationMenu, registerMenuHotkeyUpdates } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; @@ -28,6 +27,7 @@ import { getNotificationTitle, getWorkspaceName, } from "../lib/notifications/utils"; +import { registerIpcWindowHandler } from "../lib/window-manager"; import { getInitialWindowBounds, loadWindowState, @@ -63,7 +63,7 @@ function getWorkspaceNameFromDb(workspaceId: string | undefined): string { let currentWindow: BrowserWindow | null = null; // Routers receive this getter so they always see the current window, not a stale reference -const getWindow = () => currentWindow; +const getWindow = () => BrowserWindow.getFocusedWindow() ?? currentWindow; // invalidate() alone may not rebuild corrupted GPU layers — a tiny resize // forces Chromium to reconstruct the compositor layer tree. @@ -139,10 +139,14 @@ export async function MainWindow() { ipcHandler.attachWindow(window); } else { ipcHandler = createIPCHandler({ + createContext: async ({ event }) => ({ + windowId: BrowserWindow.fromWebContents(event.sender)?.id ?? null, + }), router: createAppRouter(getWindow), windows: [window], }); } + registerIpcWindowHandler(ipcHandler); const server = notificationsApp.listen( env.DESKTOP_NOTIFICATIONS_PORT, @@ -168,16 +172,18 @@ export async function MainWindow() { currentWorkspaceId: extractWorkspaceIdFromUrl( window.webContents.getURL(), ), - tabsState: appState.data?.tabsState, + tabsState: getMergedTabsState(), }), getWorkspaceName: getWorkspaceNameFromDb, - getNotificationTitle: (event) => - getNotificationTitle({ + getNotificationTitle: (event) => { + const tabsState = getMergedTabsState(); + return getNotificationTitle({ tabId: event.tabId, paneId: event.paneId, - tabs: appState.data?.tabsState?.tabs, - panes: appState.data?.tabsState?.panes, - }), + tabs: tabsState.tabs, + panes: tabsState.panes, + }); + }, }); notificationManager.start(); diff --git a/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.ts b/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.ts index 11659623aaf..b2dc42709cf 100644 --- a/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.ts +++ b/apps/desktop/src/renderer/lib/persistent-hash-history/persistent-hash-history.ts @@ -3,8 +3,9 @@ import { type HistoryLocation, type RouterHistory, } from "@tanstack/react-router"; +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; -const STORAGE_KEY = "router-history"; +const STORAGE_KEY = getWindowScopedStorageKey("router-history"); const MAX_ENTRIES = 100; type LocationState = HistoryLocation["state"]; diff --git a/apps/desktop/src/renderer/lib/window-scoped-storage.ts b/apps/desktop/src/renderer/lib/window-scoped-storage.ts new file mode 100644 index 00000000000..ca3be3431f9 --- /dev/null +++ b/apps/desktop/src/renderer/lib/window-scoped-storage.ts @@ -0,0 +1,31 @@ +const WINDOW_SCOPE_ID_KEY = "__superset_window_scope_id"; + +function createScopeId(): string { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function getWindowScopeId(): string | null { + if (typeof window === "undefined") return null; + + try { + const existing = window.sessionStorage.getItem(WINDOW_SCOPE_ID_KEY); + if (existing) return existing; + + const created = createScopeId(); + window.sessionStorage.setItem(WINDOW_SCOPE_ID_KEY, created); + return created; + } catch { + return null; + } +} + +export function getWindowScopedStorageKey(baseKey: string): string { + const scopeId = getWindowScopeId(); + return scopeId ? `${baseKey}:${scopeId}` : baseKey; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index a0f010f5e0d..3093b797292 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -78,6 +78,8 @@ function WorkspacePage() { const navigate = useNavigate(); const routeNavigate = Route.useNavigate(); const { tabId: searchTabId, paneId: searchPaneId } = Route.useSearch(); + const allTabs = useTabsStore((s) => s.tabs); + const allPanes = useTabsStore((s) => s.panes); // Keep the file open mode cache warm for addFileViewerPane useFileOpenMode(); @@ -87,19 +89,26 @@ function WorkspacePage() { if (!searchTabId) return; const state = useTabsStore.getState(); - const tab = state.tabs.find( + const tab = allTabs.find( (t) => t.id === searchTabId && t.workspaceId === workspaceId, ); if (!tab) return; state.setActiveTab(workspaceId, searchTabId); - if (searchPaneId && state.panes[searchPaneId]) { + if (searchPaneId && allPanes[searchPaneId]) { state.setFocusedPane(searchTabId, searchPaneId); } routeNavigate({ search: {}, replace: true }); - }, [searchTabId, searchPaneId, workspaceId, routeNavigate]); + }, [ + searchTabId, + searchPaneId, + workspaceId, + routeNavigate, + allTabs, + allPanes, + ]); // Check if workspace is initializing or failed const isInitializing = useIsWorkspaceInitializing(workspaceId); @@ -117,7 +126,6 @@ function WorkspacePage() { // - Interrupted workspaces that aren't currently initializing (shows resume option) const showInitView = isInitializing || hasFailed || hasIncompleteInit; - const allTabs = useTabsStore((s) => s.tabs); const activeTabIds = useTabsStore((s) => s.activeTabIds); const tabHistoryStacks = useTabsStore((s) => s.tabHistoryStacks); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); diff --git a/apps/desktop/src/renderer/stores/changes/store.ts b/apps/desktop/src/renderer/stores/changes/store.ts index 1ced128e4bf..fe48ab0f530 100644 --- a/apps/desktop/src/renderer/stores/changes/store.ts +++ b/apps/desktop/src/renderer/stores/changes/store.ts @@ -1,3 +1,4 @@ +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; import type { ChangeCategory, ChangedFile, @@ -143,7 +144,7 @@ export const useChangesStore = create()( }, }), { - name: "changes-store", + name: getWindowScopedStorageKey("changes-store"), version: 2, migrate: (persisted, version) => { const state = persisted as Record; diff --git a/apps/desktop/src/renderer/stores/file-explorer.ts b/apps/desktop/src/renderer/stores/file-explorer.ts index 0866a500ff8..dce86b5394a 100644 --- a/apps/desktop/src/renderer/stores/file-explorer.ts +++ b/apps/desktop/src/renderer/stores/file-explorer.ts @@ -1,3 +1,4 @@ +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; @@ -158,7 +159,7 @@ export const useFileExplorerStore = create()( }, }), { - name: "file-explorer-store", + name: getWindowScopedStorageKey("file-explorer-store"), partialize: (state) => ({ showHiddenFiles: state.showHiddenFiles, sortBy: state.sortBy, diff --git a/apps/desktop/src/renderer/stores/markdown-preferences/store.ts b/apps/desktop/src/renderer/stores/markdown-preferences/store.ts index a93692dbe7b..823a690b59b 100644 --- a/apps/desktop/src/renderer/stores/markdown-preferences/store.ts +++ b/apps/desktop/src/renderer/stores/markdown-preferences/store.ts @@ -1,3 +1,4 @@ +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; @@ -19,7 +20,7 @@ export const useMarkdownPreferencesStore = create()( }, }), { - name: "markdown-preferences", + name: getWindowScopedStorageKey("markdown-preferences"), }, ), { name: "MarkdownPreferencesStore" }, diff --git a/apps/desktop/src/renderer/stores/ports/store.ts b/apps/desktop/src/renderer/stores/ports/store.ts index 2081bfebba1..a4476c0831e 100644 --- a/apps/desktop/src/renderer/stores/ports/store.ts +++ b/apps/desktop/src/renderer/stores/ports/store.ts @@ -1,3 +1,4 @@ +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; @@ -20,7 +21,7 @@ export const usePortsStore = create()( set({ isListCollapsed: !get().isListCollapsed }), }), { - name: "ports-store", + name: getWindowScopedStorageKey("ports-store"), partialize: (state) => ({ isListCollapsed: state.isListCollapsed, }), diff --git a/apps/desktop/src/renderer/stores/search-dialog-state.ts b/apps/desktop/src/renderer/stores/search-dialog-state.ts index 4b28036871e..c60879fef8c 100644 --- a/apps/desktop/src/renderer/stores/search-dialog-state.ts +++ b/apps/desktop/src/renderer/stores/search-dialog-state.ts @@ -1,3 +1,4 @@ +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; @@ -68,7 +69,7 @@ export const useSearchDialogStore = create()( }, }), { - name: "search-dialog-store", + name: getWindowScopedStorageKey("search-dialog-store"), }, ), { name: "SearchDialogStore" }, diff --git a/apps/desktop/src/renderer/stores/sidebar-state.ts b/apps/desktop/src/renderer/stores/sidebar-state.ts index b198741e3aa..d543f210474 100644 --- a/apps/desktop/src/renderer/stores/sidebar-state.ts +++ b/apps/desktop/src/renderer/stores/sidebar-state.ts @@ -1,3 +1,4 @@ +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; @@ -116,7 +117,7 @@ export const useSidebarStore = create()( }, }), { - name: "sidebar-store", + name: getWindowScopedStorageKey("sidebar-store"), migrate: (persistedState: unknown, _version: number) => { const state = persistedState as Partial; // Convert old percentage-based values (<100) to pixel widths diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index ed5118ceed6..e3d163b3f52 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -3,6 +3,7 @@ import { updateTree } from "react-mosaic-component"; import { getFileOpenMode } from "renderer/hooks/useFileOpenMode"; import { posthog } from "renderer/lib/posthog"; import { trpcTabsStorage } from "renderer/lib/trpc-storage"; +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; import { acknowledgedStatus } from "shared/tabs-types"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; @@ -1773,7 +1774,7 @@ export const useTabsStore = create()( }, }), { - name: "tabs-storage", + name: getWindowScopedStorageKey("tabs-storage"), version: 7, storage: trpcTabsStorage, migrate: (persistedState, version) => { diff --git a/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts index a3406a7fa76..968be22c635 100644 --- a/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts +++ b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts @@ -1,3 +1,4 @@ +import { getWindowScopedStorageKey } from "renderer/lib/window-scoped-storage"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; @@ -113,7 +114,7 @@ export const useWorkspaceSidebarStore = create()( }, }), { - name: "workspace-sidebar-store", + name: getWindowScopedStorageKey("workspace-sidebar-store"), version: 2, // Exclude ephemeral state from persistence partialize: (state) => ({ diff --git a/apps/desktop/src/shared/types/electron.ts b/apps/desktop/src/shared/types/electron.ts index c1aad9b6551..7b26de53c0a 100644 --- a/apps/desktop/src/shared/types/electron.ts +++ b/apps/desktop/src/shared/types/electron.ts @@ -4,5 +4,6 @@ type Route = Parameters[0]; export interface WindowProps extends Electron.BrowserWindowConstructorOptions { id: Route["id"]; + path?: Route["path"]; query?: Route["query"]; }