From e1fd9fb19b210b3e9c4f1943d6c0c2255e31de80 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:07:06 -0700 Subject: [PATCH 1/8] fix(desktop): restore cmd+click requirement for v1 terminal file links (#3457) Gate `onFileLinkClick` behind metaKey/ctrlKey in v1 helpers so file-path links once again require cmd/ctrl+click, matching pre-#3382 behavior. The shared `LinkDetectorAdapter` (used by v2) still activates on plain click and is untouched. --- .../WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 6be52c40c57..d5102a0b7ee 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -169,6 +169,9 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { } }, onFileLinkClick: (event, link) => { + if (!event.metaKey && !event.ctrlKey) { + return; + } if (onFileLinkClick) { onFileLinkClick(event, link); return; From bba687ffc285b7472d802bf751ff2c6305c92cfa Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 14 Apr 2026 17:14:28 -0700 Subject: [PATCH 2/8] fix(desktop): gate v2 workspace children on collection readiness (#3464) The v2-workspace layout rendered child routes without WorkspaceTrpcProvider during the tick between workspaceId changing and the useLiveQuery join resolving. If the outgoing page hadn't finished unmounting, its hooks (TerminalPane, useGitChangeEvents, etc.) crashed with "useWorkspaceClient must be used within WorkspaceClientProvider". Hold the render until useLiveQuery reports isReady so the new workspace mounts into a valid provider on the same tick, and show an explicit "Workspace not found" state when the collection has hydrated but the id doesn't resolve. --- .../_dashboard/v2-workspace/layout.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index bc6eba977ee..59c1fc0f4db 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -29,7 +29,7 @@ function V2WorkspaceLayout() { const { machineId, activeHostUrl } = useLocalHostService(); const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); - const { data: workspacesWithHost = [] } = useLiveQuery( + const { data: workspacesWithHost = [], isReady } = useLiveQuery( (q) => q .from({ v2Workspaces: collections.v2Workspaces }) @@ -64,22 +64,14 @@ function V2WorkspaceLayout() { ensureWorkspaceInSidebar(workspace.id, workspace.projectId); }, [ensureWorkspaceInSidebar, workspace]); - // TODO: This renders child routes without WorkspaceTrpcProvider when - // the workspace hasn't loaded from collections yet, or during route - // transitions (e.g. navigating away from a workspace). If the outgoing - // workspace page hasn't fully unmounted, its components (TerminalPane, - // etc.) will crash with "useWorkspaceClient must be used within - // WorkspaceClientProvider". Either the layout should never render - // children without the provider, or the provider should move to the - // page level so each page owns its own context. - if (!workspaceId || !workspace) { - return ; + if (!workspaceId || !isReady) { + return null; } - if (!hostUrl) { + if (!workspace || !hostUrl) { return (
- Workspace host service not available + Workspace not found
); } From 6a3a0f674f2547e8557a6a83a3c31b7643b30cb7 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:40:26 -0700 Subject: [PATCH 3/8] fix(desktop): use native clipboard for copy path in v2 sidebar (#3462) * fix(desktop): use native clipboard for copy path in v2 sidebar navigator.clipboard.writeText silently fails when focus is elsewhere (e.g., terminal), so the toast showed but nothing was actually copied. Switch to useCopyToClipboard which routes through Electron's native clipboard via tRPC, matching the v1 pathway. * feat(desktop): wire up Copy Path in v2 dashboard sidebar The workspace item context menu's Copy Path just toasted "coming soon". Fetch the worktreePath from the local host service for local workspaces, copy via electronTrpc.external.copyPath (native clipboard), and show a proper success toast. Non-local workspaces fall back to an explanatory error since the path only exists on the owning machine. * feat(desktop): wire up Open in Finder in v2 dashboard sidebar Share the local worktreePath resolver between Copy Path and Open in Finder, and route Open in Finder through electronTrpc.external.openInFinder instead of toasting "coming soon". * refactor(desktop): extract shared PathActionsMenuItems, wrap copy in try/catch Addresses review feedback on PR #3462: - Pulled the Reveal in Finder / Copy Path / Copy Relative Path items shared between FileContextMenu and FolderContextMenu into a single PathActionsMenuItems component. - Wrap useCopyToClipboard calls in try/catch so IPC failures surface a proper error toast instead of failing silently. * refactor(desktop): hide Open in Finder & Copy Path for non-local workspaces - Gate the Open in Finder / Copy Path items in the dashboard sidebar workspace context menu behind isLocalWorkspace so they only appear for workspaces on the active machine. - Drop the now-redundant hostType guard from the actions hook. - Wrap openInFinder in try/catch in PathActionsMenuItems so native action failures surface a toast (addresses coderabbit/cubic P2 on #3462). --- .../DashboardSidebarWorkspaceItem.tsx | 2 + .../DashboardSidebarWorkspaceContextMenu.tsx | 24 +++++--- ...useDashboardSidebarWorkspaceItemActions.ts | 46 +++++++++++++-- .../FileContextMenu/FileContextMenu.tsx | 33 ++--------- .../FolderContextMenu/FolderContextMenu.tsx | 33 ++--------- .../PathActionsMenuItems.tsx | 59 +++++++++++++++++++ .../components/PathActionsMenuItems/index.ts | 1 + 7 files changed, 129 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 9b2b2b4e551..0f3d5435f2e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -107,6 +107,7 @@ export function DashboardSidebarWorkspaceItem({ diffStats={diffStats} /> } + isLocalWorkspace={hostType === "local-device"} onCreateSection={handleCreateSection} onMoveToSection={(targetSectionId) => moveWorkspaceToSection(id, projectId, targetSectionId) @@ -172,6 +173,7 @@ export function DashboardSidebarWorkspaceItem({ onMoveToSection={(targetSectionId) => moveWorkspaceToSection(id, projectId, targetSectionId) } + isLocalWorkspace={hostType === "local-device"} onOpenInFinder={handleOpenInFinder} onCopyPath={handleCopyPath} onRemoveFromSidebar={() => removeWorkspaceFromSidebar(id)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index 18872835fa0..29e5e6d1d94 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -32,6 +32,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { hoverCardContent?: React.ReactNode; projectId: string; isInSection?: boolean; + isLocalWorkspace: boolean; onHoverCardOpen?: () => void; onCreateSection: () => void; onMoveToSection: (sectionId: string | null) => void; @@ -46,6 +47,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { export function DashboardSidebarWorkspaceContextMenu({ projectId, isInSection, + isLocalWorkspace, onHoverCardOpen, hoverCardContent, onCreateSection, @@ -81,15 +83,19 @@ export function DashboardSidebarWorkspaceContextMenu({ Rename - - - - Open in Finder - - - - Copy Path - + {isLocalWorkspace && ( + <> + + + + Open in Finder + + + + Copy Path + + + )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 629300efa25..8e41962d19b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -1,12 +1,16 @@ import { toast } from "@superset/ui/sonner"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; import { getDeleteFocusTargetWorkspaceId } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getDeleteFocusTargetWorkspaceId"; import { getFlattenedV2WorkspaceIds } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; interface UseDashboardSidebarWorkspaceItemActionsOptions { workspaceId: string; @@ -22,6 +26,8 @@ export function useDashboardSidebarWorkspaceItemActions({ const navigate = useNavigate(); const matchRoute = useMatchRoute(); const collections = useCollections(); + const { activeHostUrl } = useLocalHostService(); + const { copyToClipboard } = useCopyToClipboard(); const { createSection, moveWorkspaceToSection, removeWorkspaceFromSidebar } = useDashboardSidebarState(); @@ -106,12 +112,44 @@ export function useDashboardSidebarWorkspaceItemActions({ moveWorkspaceToSection(workspaceId, projectId, newSectionId); }; - const handleOpenInFinder = () => { - toast.info("Open in Finder is coming soon"); + const resolveWorktreePath = async (): Promise => { + if (!activeHostUrl) { + toast.error("Host service is not available"); + return null; + } + const workspace = await getHostServiceClientByUrl( + activeHostUrl, + ).workspace.get.query({ id: workspaceId }); + if (!workspace?.worktreePath) { + toast.error("Workspace path is not available"); + return null; + } + return workspace.worktreePath; + }; + + const handleOpenInFinder = async () => { + try { + const path = await resolveWorktreePath(); + if (!path) return; + await electronTrpcClient.external.openInFinder.mutate(path); + } catch (error) { + toast.error( + `Failed to open in Finder: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } }; - const handleCopyPath = () => { - toast.info("Copy Path is coming soon"); + const handleCopyPath = async () => { + try { + const path = await resolveWorktreePath(); + if (!path) return; + await copyToClipboard(path); + toast.success("Path copied"); + } catch (error) { + toast.error( + `Failed to copy path: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } }; return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx index c0f46ec2566..7c4ff4168d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx @@ -3,8 +3,7 @@ import { ContextMenuItem, ContextMenuSeparator, } from "@superset/ui/context-menu"; -import { toast } from "@superset/ui/sonner"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { PathActionsMenuItems } from "../PathActionsMenuItems"; interface FileContextMenuProps { absolutePath: string; @@ -23,32 +22,10 @@ export function FileContextMenu({ Open to the Side - - electronTrpcClient.external.openInFinder.mutate(absolutePath) - } - > - Reveal in Finder - - - { - navigator.clipboard.writeText(absolutePath); - toast.success("Path copied"); - }} - > - Copy Path - - {relativePath && ( - { - navigator.clipboard.writeText(relativePath); - toast.success("Relative path copied"); - }} - > - Copy Relative Path - - )} + setTimeout(onRename, 0)}> Rename... diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx index de25fe6bb8f..119e2eb84fe 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx @@ -3,8 +3,7 @@ import { ContextMenuItem, ContextMenuSeparator, } from "@superset/ui/context-menu"; -import { toast } from "@superset/ui/sonner"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { PathActionsMenuItems } from "../PathActionsMenuItems"; interface FolderContextMenuProps { absolutePath: string; @@ -32,32 +31,10 @@ export function FolderContextMenu({ New Folder... - - electronTrpcClient.external.openInFinder.mutate(absolutePath) - } - > - Reveal in Finder - - - { - navigator.clipboard.writeText(absolutePath); - toast.success("Path copied"); - }} - > - Copy Path - - {relativePath && ( - { - navigator.clipboard.writeText(relativePath); - toast.success("Relative path copied"); - }} - > - Copy Relative Path - - )} + setTimeout(onRename, 0)}> Rename... diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx new file mode 100644 index 00000000000..c1bf48057ba --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx @@ -0,0 +1,59 @@ +import { + ContextMenuItem, + ContextMenuSeparator, +} from "@superset/ui/context-menu"; +import { toast } from "@superset/ui/sonner"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +interface PathActionsMenuItemsProps { + absolutePath: string; + relativePath?: string; +} + +export function PathActionsMenuItems({ + absolutePath, + relativePath, +}: PathActionsMenuItemsProps) { + const { copyToClipboard } = useCopyToClipboard(); + + const handleCopy = async (path: string, successMessage: string) => { + try { + await copyToClipboard(path); + toast.success(successMessage); + } catch (error) { + toast.error( + `Failed to copy path: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }; + + const handleRevealInFinder = async () => { + try { + await electronTrpcClient.external.openInFinder.mutate(absolutePath); + } catch (error) { + toast.error( + `Failed to reveal in Finder: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }; + + return ( + <> + + Reveal in Finder + + + handleCopy(absolutePath, "Path copied")}> + Copy Path + + {relativePath && ( + handleCopy(relativePath, "Relative path copied")} + > + Copy Relative Path + + )} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts new file mode 100644 index 00000000000..2f4345f1fcd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts @@ -0,0 +1 @@ +export { PathActionsMenuItems } from "./PathActionsMenuItems"; From cd481f6a63b74f55726b71374b098a5b0c86905f Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:25:41 -0700 Subject: [PATCH 4/8] feat(desktop): close settings with Escape key (#3466) Press Escape on any settings page to navigate back to where settings was opened from. Skips when focus is in a form field, contenteditable, or any open Radix layer (dialog, popover, dropdown). --- .../routes/_authenticated/settings/layout.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index 6af331ed51d..742dde5c275 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -5,10 +5,12 @@ import { useNavigate, } from "@tanstack/react-router"; import { useEffect } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { type SettingsSection, useSetSettingsSearchQuery, + useSettingsOriginRoute, useSettingsSearchQuery, } from "renderer/stores/settings-state"; import { SearchResultsBanner } from "./components/SearchResultsBanner"; @@ -105,6 +107,7 @@ function SettingsLayout() { const isMac = platform === undefined || platform === "darwin"; const searchQuery = useSettingsSearchQuery(); const setSearchQuery = useSetSettingsSearchQuery(); + const originRoute = useSettingsOriginRoute(); const location = useLocation(); const navigate = useNavigate(); const normalizedSearchQuery = searchQuery.trim(); @@ -134,6 +137,17 @@ function SettingsLayout() { } }, [isSearchActive, location.pathname, navigate, normalizedSearchQuery]); + useHotkeys( + "escape", + (event) => { + if (document.querySelector('[data-state="open"]')) return; + event.preventDefault(); + navigate({ to: originRoute }); + }, + { enableOnFormTags: false, enableOnContentEditable: false }, + [navigate, originRoute], + ); + return (
Date: Tue, 14 Apr 2026 17:04:31 -0700 Subject: [PATCH 5/8] chore(desktop): auto-restart host-service on bundle change in dev (#3461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(desktop): auto-restart host-service on bundle change in dev Watches the built host-service.js in NODE_ENV=development and restarts running instances via the coordinator when electron-vite rewrites the bundle. Fast edit→reload loop for packages/host-service and src/main/host-service without restarting Electron. Not true HMR — in-memory host-service state (PTYs, watchers, chat streams) is torn down on each reload. * fix(desktop): wait for host-service bundle to stabilize before reload Rollup rewrites host-service.js via unlink+rewrite, so the fs.watch fires while the file is missing or partially written. The coordinator now polls statSync until size is non-zero and stable for 150ms (5s deadline) before calling restartAll, avoiding MODULE_NOT_FOUND on respawn. --- apps/desktop/src/main/index.ts | 10 +++ .../src/main/lib/host-service-coordinator.ts | 88 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7e3dac5c25b..e7abf95e303 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -14,10 +14,12 @@ import { import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { handleAuthCallback, + loadToken, parseAuthDeepLink, } from "lib/trpc/routers/auth/utils/auth-functions"; import { fetchGitHubOwner } from "lib/trpc/routers/projects/utils/github"; import { applyShellEnvToProcess } from "lib/trpc/routers/workspaces/utils/shell-env"; +import { env as mainEnv } from "main/env.main"; import { DEFAULT_CONFIRM_ON_QUIT, PLATFORM, @@ -602,6 +604,14 @@ if (!gotTheLock) { // before the tray initializes, so it shows accurate status immediately. await getHostServiceManager().discoverAll(); + if (IS_DEV) { + getHostServiceCoordinator().enableDevReload(async () => { + const { token } = await loadToken(); + if (!token) return null; + return { authToken: token, cloudApiUrl: mainEnv.NEXT_PUBLIC_API_URL }; + }); + } + await makeAppSetup(() => MainWindow()); setupAutoUpdater(); initTray(); diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index efb0df8db4b..a39c1898dde 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -1,6 +1,7 @@ import * as childProcess from "node:child_process"; import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; +import * as fs from "node:fs"; import { createServer } from "node:net"; import path from "node:path"; import { settings } from "@superset/local-db"; @@ -103,6 +104,7 @@ export class HostServiceCoordinator extends EventEmitter { >(); private scriptPath = path.join(__dirname, "host-service.js"); private machineId = getHashedDeviceId(); + private devReloadWatcher: fs.FSWatcher | null = null; async start( organizationId: string, @@ -222,6 +224,92 @@ export class HostServiceCoordinator extends EventEmitter { ); } + /** + * Dev-only: watch the built host-service bundle and restart running + * instances when it changes. Gives a fast edit→reload loop for code + * under packages/host-service and src/main/host-service without + * restarting Electron. In-memory host-service state (PTYs, watchers, + * chat streams) is torn down on each reload — this is not true HMR. + */ + enableDevReload( + configProvider: () => Promise, + ): () => void { + if (this.devReloadWatcher) return () => {}; + + const scriptDir = path.dirname(this.scriptPath); + const scriptFile = path.basename(this.scriptPath); + let debounce: ReturnType | null = null; + let reloading = false; + + const waitForStableBundle = async (): Promise => { + const deadline = Date.now() + 5_000; + let lastSize = -1; + let stableSince = 0; + while (Date.now() < deadline) { + try { + const stat = fs.statSync(this.scriptPath); + if (stat.size > 0 && stat.size === lastSize) { + if (Date.now() - stableSince >= 150) return true; + } else { + lastSize = stat.size; + stableSince = Date.now(); + } + } catch { + lastSize = -1; + stableSince = 0; + } + await new Promise((r) => setTimeout(r, 50)); + } + return false; + }; + + const trigger = () => { + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + void (async () => { + if (reloading) return; + if (this.getActiveOrganizationIds().length === 0) return; + reloading = true; + try { + const ready = await waitForStableBundle(); + if (!ready) { + console.warn( + "[host-service] bundle did not stabilize, skipping reload", + ); + return; + } + const config = await configProvider(); + if (!config) return; + console.log( + "[host-service] bundle changed, restarting running instances", + ); + await this.restartAll(config); + } catch (error) { + console.error("[host-service] dev reload failed:", error); + } finally { + reloading = false; + } + })(); + }, 250); + }; + + try { + this.devReloadWatcher = fs.watch(scriptDir, (_event, filename) => { + if (filename && filename !== scriptFile) return; + trigger(); + }); + } catch (error) { + console.error("[host-service] failed to enable dev reload:", error); + return () => {}; + } + + return () => { + if (debounce) clearTimeout(debounce); + this.devReloadWatcher?.close(); + this.devReloadWatcher = null; + }; + } + // ── Adoption ────────────────────────────────────────────────────── private async tryAdopt(organizationId: string): Promise { From 5100fa25b5fb4289ae898ed9ca52acbc6841bb12 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:33:17 +0900 Subject: [PATCH 6/8] fix(fork): adapt upstream PR1 low-risk bundle for fork divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adjustments on top of the cherry-picked upstream commits: 1. apps/desktop/src/main/index.ts — upstream #3461 uses getHostServiceCoordinator() at the dev-reload call site, but fork imports that symbol as getHostServiceManager to minimize diff with the quit-lifecycle code that still uses the legacy name. Rewrite the call to use the local alias so the desktop main process still builds. 2. apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx — upstream #3466 uses [data-state="open"] to suppress Escape when a Radix overlay is open, but that selector also matches Radix Collapsible (settings/agents AgentCard uses Collapsible with data-state="open"), so expanding any card silently disabled the new Escape-to-close behavior. Narrow the selector to role-based overlay elements only (dialog, alertdialog, menu, listbox). --- apps/desktop/src/main/index.ts | 2 +- .../routes/_authenticated/settings/layout.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e7abf95e303..788a3a353ec 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -605,7 +605,7 @@ if (!gotTheLock) { await getHostServiceManager().discoverAll(); if (IS_DEV) { - getHostServiceCoordinator().enableDevReload(async () => { + getHostServiceManager().enableDevReload(async () => { const { token } = await loadToken(); if (!token) return null; return { authToken: token, cloudApiUrl: mainEnv.NEXT_PUBLIC_API_URL }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index 742dde5c275..b45e9b64cc8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -140,7 +140,16 @@ function SettingsLayout() { useHotkeys( "escape", (event) => { - if (document.querySelector('[data-state="open"]')) return; + // FORK NOTE: upstream #3466 used `[data-state="open"]` which also + // matches Radix Collapsible (AgentCard etc.), silently disabling + // Escape whenever any card was expanded. Narrow to role-based + // overlays only so we still defer to open Dialog/Menu/Select. + if ( + document.querySelector( + '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="menu"][data-state="open"], [role="listbox"][data-state="open"]', + ) + ) + return; event.preventDefault(); navigate({ to: originRoute }); }, From 22330a3caa3e8d2d3e1c4508ff5ff8f98045d922 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:53:59 +0900 Subject: [PATCH 7/8] fix(fork): include popper-based overlays in settings Escape guard Address Codex review on #176: the role-based selector missed Radix Popover/HoverCard (e.g. FontFamilyCombobox, ProjectTargetingField), so Escape inside an open popover could navigate away from Settings instead of dismissing the popover. Radix Popper renders popover/hovercard content inside a wrapper with `data-radix-popper-content-wrapper`, which Collapsible (the original regression trigger) never uses. Add a descendant selector for that wrapper to cover popper-based overlays without re-catching Collapsible. --- .../renderer/routes/_authenticated/settings/layout.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index b45e9b64cc8..2036c9b9c60 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -142,11 +142,14 @@ function SettingsLayout() { (event) => { // FORK NOTE: upstream #3466 used `[data-state="open"]` which also // matches Radix Collapsible (AgentCard etc.), silently disabling - // Escape whenever any card was expanded. Narrow to role-based - // overlays only so we still defer to open Dialog/Menu/Select. + // Escape whenever any card was expanded. Narrow to explicit + // overlay shapes: role-based (Dialog/AlertDialog/Menu/Select) plus + // popper-based content (Popover/HoverCard) which has no semantic + // role but is always rendered inside [data-radix-popper-content-wrapper]. + // Collapsible is inline (not popper), so it stays excluded. if ( document.querySelector( - '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="menu"][data-state="open"], [role="listbox"][data-state="open"]', + '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="menu"][data-state="open"], [role="listbox"][data-state="open"], [data-radix-popper-content-wrapper] [data-state="open"]', ) ) return; From e192adca65490bce0490b16ce0749d8eb1ccb848 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:05:53 +0900 Subject: [PATCH 8/8] fix(fork): use replace navigation for settings Escape close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on #176: closing Settings via Escape with plain navigate() pushes a new history entry so the browser back button re-enters Settings instead of returning to whatever preceded it. Switch to navigate({ replace: true }) — the originRoute is already stored globally, so replacing /settings with it is the behavior users expect. --- .../src/renderer/routes/_authenticated/settings/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index 2036c9b9c60..14ff93c3e0b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -154,7 +154,7 @@ function SettingsLayout() { ) return; event.preventDefault(); - navigate({ to: originRoute }); + navigate({ to: originRoute, replace: true }); }, { enableOnFormTags: false, enableOnContentEditable: false }, [navigate, originRoute],