From bb657ec463eccd35af7ece1f75da7038b36dd357 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:22:57 -0700 Subject: [PATCH 01/94] fix(desktop): use --no-track instead of ^{commit} in v1 createWorktree (#3548) v1's createWorktree appended ^{commit} to the start point to prevent implicit upstream tracking. This fails with "fatal: invalid reference" when the ref isn't locally resolvable with that suffix (e.g. stale or missing remote-tracking ref, branches with slashes like feat/workstreams-view). Replace ^{commit} with --no-track, which has the same effect without fragile ref suffix manipulation. Matches v2's host-service approach. Closes #3448 --- .../trpc/routers/workspaces/utils/git.test.ts | 83 +++++++++++++++++++ .../lib/trpc/routers/workspaces/utils/git.ts | 10 +-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts index 24db36461ad..dbb62151dc9 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts @@ -441,6 +441,89 @@ describe("createWorktree hook tolerance", () => { createWorktree(repoPath, "feature/existing-path", worktreePath, "HEAD"), ).rejects.toThrow("already exists"); }, 10_000); + + test("works with remote-tracking ref as start point (no-track prevents upstream)", async () => { + // Set up a "remote" repo with a commit, then clone it so we have origin/ refs + const originPath = join(TEST_DIR, "worktree-notrack-origin"); + mkdirSync(originPath, { recursive: true }); + execSync("git init -b main", { cwd: originPath, stdio: "ignore" }); + execSync("git config user.email 'test@test.com'", { + cwd: originPath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { + cwd: originPath, + stdio: "ignore", + }); + writeFileSync(join(originPath, "README.md"), "# test\n"); + execSync("git add . && git commit -m 'init'", { + cwd: originPath, + stdio: "ignore", + }); + + const clonePath = join(TEST_DIR, "worktree-notrack-clone"); + execSync(`git clone "${originPath}" "${clonePath}"`, { + stdio: "ignore", + }); + execSync("git config user.email 'test@test.com'", { + cwd: clonePath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { + cwd: clonePath, + stdio: "ignore", + }); + + const worktreePath = join(TEST_DIR, "worktree-notrack-wt"); + await createWorktree( + clonePath, + "feature/no-track-test", + worktreePath, + "origin/main", + ); + + expect(existsSync(worktreePath)).toBe(true); + + // Verify the new branch does NOT track origin/main + const trackingResult = execSync( + "git config --get branch.feature/no-track-test.remote 2>&1 || true", + { cwd: worktreePath }, + ) + .toString() + .trim(); + expect(trackingResult).toBe(""); + }, 15_000); + + test("works with a branch name containing slashes as start point", async () => { + // Reproduces #3448: createWorktree previously appended ^{commit} to the + // start point, which can fail with "fatal: invalid reference" when the ref + // is not locally resolvable with that suffix. Using --no-track avoids this. + const repoPath = createTestRepo("worktree-slash-branch"); + seedCommit(repoPath); + + // Create a branch with slashes (like feat/workstreams-view) + execSync("git checkout -b feat/workstreams-view", { + cwd: repoPath, + stdio: "ignore", + }); + execSync("git checkout -", { cwd: repoPath, stdio: "ignore" }); + + const worktreePath = join(TEST_DIR, "worktree-slash-branch-wt"); + await createWorktree( + repoPath, + "feature/new-workspace", + worktreePath, + "feat/workstreams-view", + ); + + expect(existsSync(worktreePath)).toBe(true); + const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("feature/new-workspace"); + }, 10_000); }); describe("getCurrentBranch", () => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 84033314620..a4374cea886 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -555,13 +555,13 @@ export async function createWorktree( mainRepoPath, "worktree", "add", - worktreePath, + // --no-track prevents the new branch from tracking the remote ref + // (e.g. origin/main); push.autoSetupRemote handles first-push tracking. + "--no-track", "-b", branch, - // Append ^{commit} to force Git to treat the startPoint as a commit, - // not a branch ref. This prevents implicit upstream tracking when - // creating a new branch from a remote branch like origin/main. - `${startPoint}^{commit}`, + worktreePath, + startPoint, ], worktreePath, }); From 92b6701ce8e932dc6125a31d533ad12a3466aab3 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:40:54 -0700 Subject: [PATCH 02/94] fix(desktop): guard installUpdate against repeat clicks on macOS (#3549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): guard installUpdate against repeat clicks MacUpdater.quitAndInstall() registers a fresh native-updater `update-downloaded` listener each call when Squirrel.Mac hasn't finished staging. Repeat clicks on the update button stacked listeners, then fanned out into parallel nativeUpdater.quitAndInstall() calls once Squirrel fired — racing to swap the binary and leaving the app on the old version. Matches the reporter's symptom (app quits, reopens on same version). Add an `isInstalling` guard + `status === READY` precondition so repeat clicks collapse to a single quitAndInstall, and clear the flag in the error handler so the user can retry if Squirrel surfaces an error instead of actually quitting. Closes #3507 * test(desktop): make auto-updater tests portable + non-destructive reset Greptile flagged that setupAutoUpdater() short-circuits on non-mac/linux hosts, so the suite would silently fail on a Windows CI runner (handlers never register, guard never resets). Mock shared/constants to pin the platform. CodeRabbit flagged that the beforeEach reset emitted a non-network error, triggering the real ERROR path (which also clears the cached update). Use a network-shaped error so the handler maps back to IDLE without the extra side effect. --- .../desktop/src/main/lib/auto-updater.test.ts | 93 +++++++++++++++++++ apps/desktop/src/main/lib/auto-updater.ts | 21 +++++ 2 files changed, 114 insertions(+) create mode 100644 apps/desktop/src/main/lib/auto-updater.test.ts diff --git a/apps/desktop/src/main/lib/auto-updater.test.ts b/apps/desktop/src/main/lib/auto-updater.test.ts new file mode 100644 index 00000000000..6788b44e54c --- /dev/null +++ b/apps/desktop/src/main/lib/auto-updater.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { EventEmitter } from "node:events"; + +class FakeAutoUpdater extends EventEmitter { + autoDownload = false; + autoInstallOnAppQuit = false; + disableDifferentialDownload = false; + allowDowngrade = false; + setFeedURL = mock(() => {}); + checkForUpdates = mock(() => Promise.resolve(null)); + quitAndInstall = mock(() => {}); +} + +const fakeAutoUpdater = new FakeAutoUpdater(); + +mock.module("electron-updater", () => ({ + autoUpdater: fakeAutoUpdater, +})); + +mock.module("electron", () => ({ + app: { + getPath: mock(() => ""), + getName: mock(() => "test-app"), + getVersion: mock(() => "1.0.0"), + getAppPath: mock(() => ""), + isPackaged: false, + isReady: mock(() => true), + whenReady: mock(() => Promise.resolve()), + }, + dialog: { + showMessageBox: mock(() => Promise.resolve({ response: 0 })), + }, +})); + +mock.module("main/index", () => ({ + setSkipQuitConfirmation: mock(() => {}), +})); + +// auto-updater short-circuits setupAutoUpdater on non-mac/linux hosts, so +// pin the platform here to keep the tests portable across CI runners. +mock.module("shared/constants", () => ({ + PLATFORM: { IS_MAC: true, IS_WINDOWS: false, IS_LINUX: false }, +})); + +const autoUpdater = await import("./auto-updater"); +const { AUTO_UPDATE_STATUS } = await import("shared/auto-update"); + +describe("installUpdate", () => { + beforeEach(() => { + fakeAutoUpdater.removeAllListeners(); + fakeAutoUpdater.quitAndInstall.mockClear(); + fakeAutoUpdater.checkForUpdates.mockClear(); + fakeAutoUpdater.setFeedURL.mockClear(); + autoUpdater.setupAutoUpdater(); + // The module is a singleton; emit a network-shaped error so the + // handler resets isInstalling and maps status back to IDLE without + // tripping the real ERROR path (which would also clear the cache). + fakeAutoUpdater.emit("error", new Error("ECONNRESET reset")); + }); + + test("ignores install requests when no update is ready", () => { + expect(autoUpdater.getUpdateStatus().status).not.toBe( + AUTO_UPDATE_STATUS.READY, + ); + + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + test("collapses repeat install clicks into a single quitAndInstall call", () => { + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + expect(autoUpdater.getUpdateStatus().status).toBe(AUTO_UPDATE_STATUS.READY); + + autoUpdater.installUpdate(); + autoUpdater.installUpdate(); + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + }); + + test("clears the in-flight guard when Squirrel surfaces an error", () => { + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + autoUpdater.installUpdate(); + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + + fakeAutoUpdater.emit("error", new Error("squirrel failed")); + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 0171a91cddd..74535630215 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -82,6 +82,7 @@ function isNetworkError(error: Error | string): boolean { let currentStatus: AutoUpdateStatus = AUTO_UPDATE_STATUS.IDLE; let currentVersion: string | undefined; let isDismissed = false; +let isInstalling = false; function emitStatus( status: AutoUpdateStatus, @@ -111,6 +112,24 @@ export function installUpdate(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); return; } + // MacUpdater.quitAndInstall() registers a fresh native-updater + // `update-downloaded` listener each time it runs before Squirrel.Mac has + // finished staging. Without this guard, repeat clicks fan out into + // parallel quitAndInstall calls once Squirrel fires — racing to swap + // the binary and leaving the app on the old version. + if (isInstalling) { + console.info( + "[auto-updater] Install already in progress, ignoring duplicate request", + ); + return; + } + if (currentStatus !== AUTO_UPDATE_STATUS.READY) { + console.warn( + `[auto-updater] Install ignored: update not ready (status=${currentStatus})`, + ); + return; + } + isInstalling = true; setSkipQuitConfirmation(); autoUpdater.quitAndInstall(false, true); } @@ -242,6 +261,8 @@ export function setupAutoUpdater(): void { ); autoUpdater.on("error", (error) => { + // Allow retry if Squirrel surfaces an error instead of actually quitting. + isInstalling = false; if (isNetworkError(error)) { console.info("[auto-updater] Network unavailable, will retry later"); emitStatus(AUTO_UPDATE_STATUS.IDLE); From 5e8fc2d49e4eb551351316ba98d7fc6ae5d3acac Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 23:47:20 -0700 Subject: [PATCH 03/94] fix(desktop): refresh v2 terminal link tooltip editor + nudge plain clicks (#3552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): refresh v2 terminal link tooltip editor label + nudge plain clicks - LinkHoverTooltip fetched the default editor in a mount-only useEffect, so changing the default editor in settings left the modifier-shift label ("Open in Cursor", etc.) stale until the terminal pane unmounted. Refetch on every hover-start instead. - Plain (no-modifier) clicks on a detected file path in the v2 terminal were silent, which made the modifier-key affordance undiscoverable. On a plain file-link click, show a transient tooltip at the click position ("Hold ⌘ to open · ⌘⇧ for external", or Ctrl/Ctrl+Shift off-mac). Capped at two shows per renderer session via a module-level counter, and suppressed while the modifier-hover tooltip is already visible. Uses framer-motion's AnimatePresence for fade in/out. * refactor(desktop): simpler v2 terminal link tooltip labels - "Open in editor" → "Open in pane" for the ⌘-click file case (native in-app file pane is what actually happens). - Shift variant always says "Open in external editor" instead of interpolating the configured editor name. openFileInEditor uses the global settings defaultEditor (non-editor apps like Finder can't be set there), so the interpolated name could disagree with a user's per-project preference — the generic label never lies. - Drops the getDefaultEditor fetch, defaultEditor state, and getAppOption/ getAppLabel plumbing that went with it. * refactor(desktop): URL ⌘-click tooltip says "Open in pane" for consistency --- .../components/TerminalPane/TerminalPane.tsx | 10 +- .../LinkHoverTooltip/LinkHoverTooltip.tsx | 105 ++++++++---------- .../hooks/useLinkClickHint/index.ts | 1 + .../useLinkClickHint/useLinkClickHint.ts | 35 ++++++ 4 files changed, 93 insertions(+), 58 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 8eb1d762401..7f7eaec3c65 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -26,6 +26,7 @@ import { TerminalSearch } from "renderer/screens/main/components/WorkspaceView/C import { useTheme } from "renderer/stores/theme"; import { resolveTerminalThemeType } from "renderer/stores/theme/utils"; import { LinkHoverTooltip } from "./components/LinkHoverTooltip"; +import { useLinkClickHint } from "./hooks/useLinkClickHint"; import { useLinkHoverState } from "./hooks/useLinkHoverState"; import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; import { shellEscapePaths } from "./utils"; @@ -58,6 +59,7 @@ export function TerminalPane({ onHover: onLinkHover, onLeave: onLinkLeave, } = useLinkHoverState(); + const { hint, showHint } = useLinkClickHint(); const paneData = ctx.pane.data as TerminalPaneData; const { terminalId } = paneData; const containerRef = useRef(null); @@ -159,7 +161,10 @@ export function TerminalPane({ } }, onFileLinkClick: (event, link) => { - if (!event.metaKey && !event.ctrlKey) return; + if (!event.metaKey && !event.ctrlKey) { + showHint(event.clientX, event.clientY); + return; + } event.preventDefault(); if (event.shiftKey) { openInExternalEditor(link.resolvedPath, { @@ -200,6 +205,7 @@ export function TerminalPane({ openInExternalEditor, onLinkHover, onLinkLeave, + showHint, ]); useHotkey( @@ -312,7 +318,7 @@ export function TerminalPane({ Disconnected )} - + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx index 87323399029..36cc26e390d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx @@ -1,75 +1,68 @@ -import type { ExternalApp } from "@superset/local-db"; -import { useEffect, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; import { createPortal } from "react-dom"; -import { getAppOption } from "renderer/components/OpenInExternalDropdown/constants"; import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { LinkClickHint } from "../../hooks/useLinkClickHint"; import type { HoveredLink } from "../../hooks/useLinkHoverState"; const TOOLTIP_OFFSET_PX = 14; +const TOOLTIP_CLASSES = + "pointer-events-none fixed z-50 w-fit rounded-md bg-foreground px-3 py-1.5 text-xs text-background"; + +const isMac = + typeof navigator !== "undefined" && + navigator.platform.toLowerCase().includes("mac"); +const MOD_LABEL = isMac ? "⌘" : "Ctrl"; +const MOD_SHIFT_LABEL = isMac ? "⌘⇧" : "Ctrl+Shift"; +const HINT_LABEL = `Hold ${MOD_LABEL} to open · ${MOD_SHIFT_LABEL} for external`; interface LinkHoverTooltipProps { hoveredLink: HoveredLink | null; + hint: LinkClickHint | null; } -function getAppLabel(app: ExternalApp): string { - const option = getAppOption(app); - return option?.displayLabel ?? option?.label ?? "external editor"; -} - -function getLabel( - info: LinkHoverInfo, - shift: boolean, - defaultEditor: ExternalApp | null, -): string { +function getLabel(info: LinkHoverInfo, shift: boolean): string { if (info.kind === "url") { - return shift ? "Open in external browser" : "Open in browser"; - } - if (shift) { - return defaultEditor - ? `Open in ${getAppLabel(defaultEditor)}` - : "Open externally"; + return shift ? "Open in external browser" : "Open in pane"; } - return info.isDirectory ? "Reveal in sidebar" : "Open in editor"; + if (shift) return "Open in external editor"; + return info.isDirectory ? "Reveal in sidebar" : "Open in pane"; } -export function LinkHoverTooltip({ hoveredLink }: LinkHoverTooltipProps) { - const [defaultEditor, setDefaultEditor] = useState(null); - - useEffect(() => { - let cancelled = false; - electronTrpcClient.settings.getDefaultEditor - .query() - .then((editor) => { - if (!cancelled) setDefaultEditor(editor); - }) - .catch((error) => { - if (cancelled) return; - console.warn( - "[LinkHoverTooltip] Failed to fetch default editor:", - error, - ); - setDefaultEditor(null); - }); - return () => { - cancelled = true; - }; - }, []); - - if (!hoveredLink || !hoveredLink.modifier) return null; - - const label = getLabel(hoveredLink.info, hoveredLink.shift, defaultEditor); +export function LinkHoverTooltip({ hoveredLink, hint }: LinkHoverTooltipProps) { + const showingHover = Boolean(hoveredLink?.modifier); return createPortal( -
- {label} -
, + <> + {hoveredLink?.modifier && ( +
+ {getLabel(hoveredLink.info, hoveredLink.shift)} +
+ )} + + {hint && !showingHover && ( + + {HINT_LABEL} + + )} + + , document.body, ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts new file mode 100644 index 00000000000..c9aacc8b15e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts @@ -0,0 +1 @@ +export { type LinkClickHint, useLinkClickHint } from "./useLinkClickHint"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts new file mode 100644 index 00000000000..4c6d5286f58 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface LinkClickHint { + clientX: number; + clientY: number; +} + +const HINT_DURATION_MS = 2000; +const MAX_HINTS_PER_SESSION = 2; + +let hintsRemaining = MAX_HINTS_PER_SESSION; + +export function useLinkClickHint() { + const [hint, setHint] = useState(null); + const timeoutRef = useRef | null>(null); + + const showHint = useCallback((clientX: number, clientY: number) => { + if (hintsRemaining <= 0) return; + hintsRemaining -= 1; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setHint({ clientX, clientY }); + timeoutRef.current = setTimeout(() => { + setHint(null); + timeoutRef.current = null; + }, HINT_DURATION_MS); + }, []); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { hint, showHint }; +} From 4ba837862781f7bc1d09d042c166c067ffe23b8d Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:51:28 -0700 Subject: [PATCH 04/94] fix(desktop): trigger macOS Local Network permission on startup (#3551) requestLocalNetworkAccess was defined in local-network-permission.ts but never called, so the Info.plist keys (NSLocalNetworkUsageDescription, NSBonjourServices) wired up in electron-builder never had a trigger to prompt the user. On macOS 15+ this causes outbound connections to local-network IPs from the app and its spawned child processes (node, python in the terminal) to be silently blocked, while system binaries like curl escape the same TCC attribution. Call it alongside requestAppleEventsAccess in app ready. Refs #3474 --- apps/desktop/src/main/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index be838b93067..3c0eabe04c6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -32,6 +32,7 @@ import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; import { getHostServiceCoordinator } from "./lib/host-service-coordinator"; import { localDb } from "./lib/local-db"; +import { requestLocalNetworkAccess } from "./lib/local-network-permission"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { initSentry } from "./lib/sentry"; import { @@ -293,6 +294,7 @@ if (!gotTheLock) { await app.whenReady(); registerWithMacOSNotificationCenter(); requestAppleEventsAccess(); + requestLocalNetworkAccess(); // Must register on both default session and the app's custom partition const iconProtocolHandler = (request: Request) => { From 88e4e01d426c32471f870b91062d6e21039fa9cc Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 17 Apr 2026 23:56:25 -0700 Subject: [PATCH 05/94] feat(desktop): restore Tasks link in v2 dashboard sidebar (#3553) Adds a Tasks nav entry (collapsed + expanded) alongside Workspaces, mirroring v1: paywall gate, last-used filter restoration, and active-route highlight. --- .../DashboardSidebarHeader.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index a8c6f2f03d1..b781725cd42 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -1,9 +1,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { HiOutlineClipboardDocumentList } from "react-icons/hi2"; import { LuFolderPlus, LuLayers, LuPlus } from "react-icons/lu"; +import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; import { useHotkeyDisplay } from "renderer/hotkeys"; import { OrganizationDropdown } from "renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown"; +import { useTasksFilterStore } from "renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state"; import { STROKE_WIDTH_THICK } from "renderer/screens/main/components/WorkspaceSidebar/constants"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; @@ -18,12 +21,30 @@ export function DashboardSidebarHeader({ const shortcutText = useHotkeyDisplay("NEW_WORKSPACE").text; const navigate = useNavigate(); const matchRoute = useMatchRoute(); + const { gateFeature } = usePaywall(); const isWorkspacesListOpen = !!matchRoute({ to: "/v2-workspaces" }); + const isTasksOpen = !!matchRoute({ to: "/tasks", fuzzy: true }); + + const { + tab: lastTab, + assignee: lastAssignee, + search: lastSearch, + } = useTasksFilterStore(); const handleWorkspacesClick = () => { navigate({ to: "/v2-workspaces" }); }; + const handleTasksClick = () => { + gateFeature(GATED_FEATURES.TASKS, () => { + const search: Record = {}; + if (lastTab !== "all") search.tab = lastTab; + if (lastAssignee) search.assignee = lastAssignee; + if (lastSearch) search.search = lastSearch; + navigate({ to: "/tasks", search }); + }); + }; + if (isCollapsed) { return (
@@ -47,6 +68,24 @@ export function DashboardSidebarHeader({ Workspaces + + + + + Tasks + + + +
From 1bf690b5a7e68f548e549dc0a3cb0735467a2432 Mon Sep 17 00:00:00 2001 From: Joshua Vial Date: Sun, 19 Apr 2026 04:52:17 +1000 Subject: [PATCH 11/94] fix(desktop): prevent keyboard shortcuts from leaking characters into chat input (#3520) * fix(desktop): prevent default on hotkeys to stop character leak into inputs * fix(desktop): make hotkey preventDefault opt-out-able --- .../src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts b/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts index 8c1fe184b07..33fd696bf77 100644 --- a/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts +++ b/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts @@ -16,7 +16,12 @@ export function useHotkey( callbackRef.current = callback; useHotkeys( keys ?? "", - (e, _h) => callbackRef.current(e), + (e, _h) => { + if (options?.preventDefault !== false) { + e.preventDefault(); + } + callbackRef.current(e); + }, { enableOnFormTags: true, enableOnContentEditable: true, ...options }, [keys], ); From 14370d929b74f2b5d0e148873bd79b885151054e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 18 Apr 2026 12:15:34 -0700 Subject: [PATCH 12/94] =?UTF-8?q?feat(desktop):=20=E2=8C=98=E2=87=A7L=20op?= =?UTF-8?q?ens=20diff=20viewer=20in=20v2=20workspace=20(#3556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename TOGGLE_EXPAND_SIDEBAR to OPEN_DIFF_VIEWER (same binding). In v2, focus any existing diff pane or open one in a new tab, and flip the workspace sidebar to the Changes tab. V1 keeps its existing expand-sidebar behavior under the new ID. --- apps/desktop/src/renderer/hotkeys/registry.ts | 6 ++-- .../useWorkspaceHotkeys.ts | 28 +++++++++++++++++++ .../workspace/$workspaceId/page.tsx | 4 +-- .../WorkspaceView/RightSidebar/index.tsx | 2 +- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/hotkeys/registry.ts b/apps/desktop/src/renderer/hotkeys/registry.ts index 27d19256aa6..cb9aadf4517 100644 --- a/apps/desktop/src/renderer/hotkeys/registry.ts +++ b/apps/desktop/src/renderer/hotkeys/registry.ts @@ -186,14 +186,16 @@ export const HOTKEYS_REGISTRY = { label: "Toggle Changes Tab", category: "Layout", }, - TOGGLE_EXPAND_SIDEBAR: { + OPEN_DIFF_VIEWER: { key: { mac: "meta+shift+l", windows: "ctrl+shift+alt+l", linux: "ctrl+shift+alt+l", }, - label: "Toggle Expand Sidebar", + label: "Open Diff Viewer", category: "Layout", + description: + "Open the diff viewer in a new tab, or focus the existing diff viewer", }, TOGGLE_WORKSPACE_SIDEBAR: { key: { mac: "meta+b", windows: "ctrl+shift+b", linux: "ctrl+shift+b" }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index 83308f52a08..205a334364c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -12,6 +12,7 @@ import type { StoreApi } from "zustand"; import type { BrowserPaneData, ChatPaneData, + DiffPaneData, PaneViewerData, TerminalPaneData, } from "../../types"; @@ -70,6 +71,33 @@ export function useWorkspaceHotkeys({ }); }); + useHotkey("OPEN_DIFF_VIEWER", () => { + if (collections.v2WorkspaceLocalState.get(workspaceId)) { + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.rightSidebarOpen = true; + draft.sidebarState.activeTab = "changes"; + }); + } + + const state = store.getState(); + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "diff") continue; + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + state.addTab({ + panes: [ + { + kind: "diff", + data: { path: "", collapsedFiles: [] } as DiffPaneData, + }, + ], + }); + }); + // --- Tab management --- const isClosingPaneRef = useRef(false); 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 b5cfdee609d..6288cab9459 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 @@ -330,8 +330,8 @@ function WorkspacePage() { // Toggle changes sidebar (⌘L) useHotkey("TOGGLE_SIDEBAR", () => toggleSidebar()); - // Toggle expand/collapse sidebar (⌘⇧L) - useHotkey("TOGGLE_EXPAND_SIDEBAR", () => { + // Open diff viewer (⌘⇧L) + useHotkey("OPEN_DIFF_VIEWER", () => { if (!isSidebarOpen) { setSidebarOpen(true); setSidebarMode(SidebarMode.Changes); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx index 11df11e195c..55ec2680787 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -198,7 +198,7 @@ export function RightSidebar() { From 56e6652ef91bea4a1c9a032ed2efc0e315e41084 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Sat, 18 Apr 2026 12:39:51 -0700 Subject: [PATCH 13/94] fix(desktop): recover terminal from non-monospace font crash (#3513) (#3554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): recover terminal from non-monospace font crash (#3513) Setting the terminal font to a proportional family like "Inter" blanked the app on next launch — the bad value persisted in SQLite and xterm couldn't lay out cells on reload, leaving no way back into settings. - Sanitize the stored family on read: if the primary family isn't monospace (per canvas measurement), fall back to the default terminal font so a poisoned DB value can never blank the renderer. - Hide the "Other" group and custom free-form entry in the terminal font picker so new selections are restricted to monospace candidates. * fix(desktop): reject all-proportional generic terminal font stacks Follow-up on #3554 review. sanitizeTerminalFontFamily previously passed any all-generic CSS value through untouched (e.g. "cursive", "sans-serif", "monospace, sans-serif") because parsePrimaryFontFamily returns null when no concrete family is present — same blank-window crash class as the "Inter" report. Refactor the sanitizer to inspect the full family list: when no concrete primary exists, only trust the value if every entry is a monospace generic; otherwise fall back to the default. Add regression tests. * refactor(desktop): append ", monospace" fallback + cleaner font preview - Always append "monospace" to the sanitized terminal font stack when it doesn't already end with one. Mirrors VS Code's behavior in src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts so that if the configured primary isn't installed on this machine, the browser falls back to the OS monospace generic instead of a proportional default. - Swap the terminal font preview from a box-drawing layout (which rendered as broken in proportional fonts and used tofu glyphs) to a shell session that demonstrates column alignment naturally. - Drop a couple of narrating comments flagged in simplify review. * refactor(desktop): show Nerd Fonts in the editor picker too Nerd Fonts are monospace — the terminal-only gate was pre-existing special-casing and reviewers pointed out it hides a widely-used class of fonts from users picking an editor font. Drop the gate. * fix(desktop): validate the actual CSS primary font, not the first concrete entry Addresses the coderabbit review on b2e6a04b5. The sanitizer previously skipped leading generics when picking the primary to measure, so a value like `sans-serif, "JetBrains Mono"` passed validation because the later concrete entry was monospace — but CSS resolves the first generic (sans-serif) and the terminal still renders proportional. Switch to validating families[0] (the actual CSS primary): if it's a monospace generic, trust the stack; if it's a proportional generic, fall back; if it's concrete, canvas-measure it. Add regression tests. --- .../terminal/appearance/appearance.test.ts | 149 ++++++++++++++++++ .../renderer/lib/terminal/appearance/index.ts | 108 +++++++++++++ .../useTerminalAppearance.ts | 7 +- .../FontFamilyCombobox/FontFamilyCombobox.tsx | 13 +- .../components/FontPreview/FontPreview.tsx | 23 ++- .../TabsContent/Terminal/Terminal.tsx | 9 +- 6 files changed, 282 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts diff --git a/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts b/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts new file mode 100644 index 00000000000..c68eba7ce7f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + sanitizeTerminalFontFamily, +} from "./index"; + +type MeasureFn = (text: string) => { width: number }; + +/** + * Stub `document.createElement("canvas")` so `getContext("2d").measureText` + * returns widths from `measureForFont`. Non-canvas tags defer to the + * existing test-setup stub. + */ +function stubCanvas(measureForFont: (font: string) => MeasureFn) { + const originalCreate = document.createElement; + // biome-ignore lint/suspicious/noExplicitAny: bun:test `mock` wraps arbitrary fns + (document as any).createElement = mock((tag: string) => { + if (tag !== "canvas") { + // biome-ignore lint/suspicious/noExplicitAny: delegating stub accepts any tag + return (originalCreate as any).call(document, tag); + } + let currentFont = ""; + return { + getContext: (kind: string) => { + if (kind !== "2d") return null; + return { + set font(value: string) { + currentFont = value; + }, + get font() { + return currentFont; + }, + measureText: (text: string) => measureForFont(currentFont)(text), + }; + }, + }; + }); + return () => { + // biome-ignore lint/suspicious/noExplicitAny: restoring stubbed method + (document as any).createElement = originalCreate; + }; +} + +const equalWidths: MeasureFn = (text) => ({ width: text.length * 10 }); +const proportionalWidths: MeasureFn = (text) => { + let width = 0; + for (const ch of text) width += ch === "M" ? 16 : 6; + return { width }; +}; + +describe("sanitizeTerminalFontFamily", () => { + let restore: (() => void) | null = null; + + afterEach(() => { + restore?.(); + restore = null; + }); + + test("returns default for null / empty / whitespace", () => { + expect(sanitizeTerminalFontFamily(null)).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(sanitizeTerminalFontFamily(undefined)).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(sanitizeTerminalFontFamily(" ")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("trusts all-generic monospace values without canvas", () => { + expect(sanitizeTerminalFontFamily("monospace")).toBe("monospace"); + expect(sanitizeTerminalFontFamily("ui-monospace")).toBe("ui-monospace"); + }); + + test("falls back when the primary family is a proportional generic", () => { + expect(sanitizeTerminalFontFamily("sans-serif")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("serif")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("cursive")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + // CSS resolves the first generic, so a later monospace entry never wins. + expect(sanitizeTerminalFontFamily("cursive, monospace")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("passes through a stack whose primary generic is monospace", () => { + // The browser resolves the first generic, so "monospace, sans-serif" + // actually renders as monospace — safe. + expect(sanitizeTerminalFontFamily("monospace, sans-serif")).toBe( + "monospace, sans-serif", + ); + }); + + test("falls back when a concrete mono follows a proportional generic", () => { + // Regression: earlier logic picked the first non-generic as the primary, + // letting `sans-serif, "JetBrains Mono"` slip through even though CSS + // renders sans-serif. Validate the actual CSS primary instead. + expect(sanitizeTerminalFontFamily('sans-serif, "JetBrains Mono"')).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("passes a monospace font through when the stack already ends with monospace", () => { + restore = stubCanvas(() => equalWidths); + expect(sanitizeTerminalFontFamily('"JetBrains Mono", monospace')).toBe( + '"JetBrains Mono", monospace', + ); + }); + + test("appends a monospace fallback when the stack lacks one", () => { + // If the primary isn't installed, the browser otherwise falls back to a + // proportional default — appending "monospace" forces OS monospace. + restore = stubCanvas(() => equalWidths); + expect(sanitizeTerminalFontFamily('"JetBrains Mono"')).toBe( + '"JetBrains Mono", monospace', + ); + expect(sanitizeTerminalFontFamily("Menlo")).toBe("Menlo, monospace"); + }); + + test("falls back to default for a proportional primary family (quoted)", () => { + restore = stubCanvas(() => proportionalWidths); + expect(sanitizeTerminalFontFamily('"Inter", sans-serif')).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("falls back to default for a proportional primary family (bare)", () => { + restore = stubCanvas(() => proportionalWidths); + expect(sanitizeTerminalFontFamily("Inter")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("trusts the value when canvas measurement throws", () => { + restore = stubCanvas(() => () => { + throw new Error("canvas unsupported"); + }); + // Use a unique family so the module-level monospace cache doesn't mask + // the canvas error path. + expect(sanitizeTerminalFontFamily('"UnmeasurableFont-ABC-123"')).toBe( + '"UnmeasurableFont-ABC-123", monospace', + ); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/appearance/index.ts b/apps/desktop/src/renderer/lib/terminal/appearance/index.ts index 147f29e9b3e..456e0896683 100644 --- a/apps/desktop/src/renderer/lib/terminal/appearance/index.ts +++ b/apps/desktop/src/renderer/lib/terminal/appearance/index.ts @@ -61,6 +61,114 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = serializeFontFamilyList([ export const DEFAULT_TERMINAL_FONT_SIZE = 14; +const MONOSPACE_GENERIC_FAMILIES = new Set(["monospace", "ui-monospace"]); + +/** Parse a CSS font-family list into trimmed entries, respecting quoted names. */ +function parseFontFamilyList(cssValue: string): string[] { + const families: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (const ch of cssValue) { + if (inQuote) { + if (ch === inQuote) inQuote = null; + else current += ch; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === ",") { + const trimmed = current.trim(); + if (trimmed) families.push(trimmed); + current = ""; + } else { + current += ch; + } + } + const last = current.trim(); + if (last) families.push(last); + return families; +} + +const monospaceCheckCache = new Map(); + +/** + * Heuristically decide whether `family` is a monospace font using canvas + * measurement — monospace fonts render narrow ("iiiiii") and wide ("MMMMMM") + * runs at the same width. Returns `true` (permissive) when the canvas API + * is unavailable (tests/SSR) so we never block a legitimate font. + */ +function isFontFamilyMonospace(family: string): boolean { + const key = family.toLowerCase(); + if (MONOSPACE_GENERIC_FAMILIES.has(key)) return true; + + const cached = monospaceCheckCache.get(key); + if (cached !== undefined) return cached; + + try { + if (typeof document === "undefined") return true; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext?.("2d"); + if (!ctx) return true; + + ctx.font = `16px "${family}"`; + const narrow = ctx.measureText("iiiiii").width; + const wide = ctx.measureText("MMMMMM").width; + // Sub-pixel jitter tolerance. + const isMono = Math.abs(narrow - wide) < 1; + monospaceCheckCache.set(key, isMono); + return isMono; + } catch { + return true; + } +} + +/** + * Guard against a persisted terminal font that would break xterm rendering + * (e.g. a proportional font like "Inter"). Returns the raw CSS value when + * the primary family is monospace; otherwise falls back to the bundled + * default so a poisoned setting can never blank the app on startup. + * + * See issue #3513. The settings UI already prevents new non-monospace + * selections for the terminal, but this recovers users whose DB was + * poisoned before the UI restriction was added. + */ +export function sanitizeTerminalFontFamily( + cssValue: string | null | undefined, +): string { + if (!cssValue || !cssValue.trim()) return DEFAULT_TERMINAL_FONT_FAMILY; + const families = parseFontFamilyList(cssValue); + if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; + + // Validate the actual CSS primary (first entry), not the first non-generic. + // A value like `sans-serif, "JetBrains Mono"` resolves to sans-serif in the + // browser regardless of what follows, so inspecting the later entry would + // let proportional stacks slip through. + const primary = families[0]; + const primaryKey = primary.toLowerCase(); + + if (GENERIC_FONT_FAMILIES.has(primaryKey)) { + if (MONOSPACE_GENERIC_FAMILIES.has(primaryKey)) return cssValue; + console.warn( + `[terminal] Font stack "${cssValue}" has no monospace primary family; falling back to default terminal font.`, + ); + return DEFAULT_TERMINAL_FONT_FAMILY; + } + + if (!isFontFamilyMonospace(primary)) { + console.warn( + `[terminal] Font "${primary}" is not monospace; falling back to default terminal font.`, + ); + return DEFAULT_TERMINAL_FONT_FAMILY; + } + // Ensure a generic monospace tail — if the configured primary isn't + // installed on this machine, the browser falls back to the OS monospace + // generic instead of a proportional default (mirrors VS Code's behavior + // in src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts). + const hasMonoTail = families.some((f) => + MONOSPACE_GENERIC_FAMILIES.has(f.toLowerCase()), + ); + return hasMonoTail ? cssValue : `${cssValue}, monospace`; +} + /** Reads localStorage theme cache for flash-free first paint. */ export function getDefaultTerminalAppearance(): TerminalAppearance { const theme = readCachedTerminalTheme(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts index d4a1f4d9e98..f3b137cb2e0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { - DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, getDefaultTerminalAppearance, + sanitizeTerminalFontFamily, type TerminalAppearance, } from "renderer/lib/terminal/appearance"; import { electronTrpcClient } from "renderer/lib/trpc-client"; @@ -21,8 +21,9 @@ export function useTerminalAppearance(): TerminalAppearance { return useMemo(() => { const theme = terminalTheme ?? fallbackTheme; - const fontFamily = - fontSettings?.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY; + const fontFamily = sanitizeTerminalFontFamily( + fontSettings?.terminalFontFamily, + ); const fontSize = fontSettings?.terminalFontSize ?? DEFAULT_TERMINAL_FONT_SIZE; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx index 2f88f8d9a75..12cb52659a1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx @@ -56,6 +56,11 @@ export function FontFamilyCombobox({ return { nerdFonts: nerd, monoFonts: mono, otherFonts: other }; }, [fonts]); + // Terminal fonts must be monospace — arbitrary free-form names would let + // users pick proportional fonts (see issue #3513), so the custom-entry + // escape hatches below are gated off for the terminal variant. + const allowCustomEntry = variant !== "terminal"; + const hasExactMatch = useMemo(() => { if (!search.trim()) return true; const lower = search.toLowerCase().trim(); @@ -120,7 +125,7 @@ export function FontFamilyCombobox({ /> - {search.trim() ? ( + {allowCustomEntry && search.trim() ? ( - )} + setIsWaitlistOpen(true)} + /> {waitlistModal} ); diff --git a/apps/marketing/src/app/components/CTASection/CTASection.tsx b/apps/marketing/src/app/components/CTASection/CTASection.tsx index 9b4ca00e2ed..508cf4b1784 100644 --- a/apps/marketing/src/app/components/CTASection/CTASection.tsx +++ b/apps/marketing/src/app/components/CTASection/CTASection.tsx @@ -11,11 +11,8 @@ export function CTASection() { <>
-

- Get Superset Today +

+ Try Superset now.

setIsWaitlistOpen(true)} /> diff --git a/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx b/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx index ab70b0621d0..526ed6e0404 100644 --- a/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx +++ b/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx @@ -24,7 +24,7 @@ export function DownloadButton({ ? "px-2 sm:px-4 py-2 text-sm" : "px-3 sm:px-6 py-2 sm:py-3 text-sm sm:text-base"; - const buttonClasses = `bg-foreground text-background ${sizeClasses} font-normal hover:bg-foreground/80 transition-colors flex items-center gap-2 ${className}`; + const buttonClasses = `bg-brand/10 text-[#ff8c3a] border border-brand/20 ${sizeClasses} font-normal hover:bg-brand/15 hover:border-brand/35 transition-colors flex items-center gap-2 ${className}`; if (isMobile) { const appleIcon = ( diff --git a/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/FeatureDemo.tsx b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/FeatureDemo.tsx index c5179d1b0fa..749577a64b2 100644 --- a/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/FeatureDemo.tsx +++ b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/FeatureDemo.tsx @@ -1,7 +1,5 @@ -"use client"; - -import { MeshGradient } from "@superset/ui/mesh-gradient"; import type { ReactNode } from "react"; +import { DitheredBackground } from "./components/DitheredBackground"; interface FeatureDemoProps { children: ReactNode; @@ -16,12 +14,12 @@ export function FeatureDemo({ }: FeatureDemoProps) { return (
{/* Background gradient */} - {/* Content overlay */} diff --git a/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/DitheredBackground.tsx b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/DitheredBackground.tsx new file mode 100644 index 00000000000..5d9958577c9 --- /dev/null +++ b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/DitheredBackground.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { lazy, Suspense } from "react"; + +const Dithering = lazy(() => + import("@paper-design/shaders-react").then((mod) => ({ + default: mod.Dithering, + })), +); + +interface DitheredBackgroundProps { + colors: readonly [string, string, string, string]; + className?: string; +} + +export function DitheredBackground({ + colors, + className = "", +}: DitheredBackgroundProps) { + return ( +
+ + + +
+ ); +} diff --git a/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/index.ts b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/index.ts new file mode 100644 index 00000000000..cfaafce3c76 --- /dev/null +++ b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/index.ts @@ -0,0 +1 @@ +export { DitheredBackground } from "./DitheredBackground"; diff --git a/apps/marketing/src/app/components/HeroSection/HeroSection.tsx b/apps/marketing/src/app/components/HeroSection/HeroSection.tsx index 01976e7fe12..e5df199d90c 100644 --- a/apps/marketing/src/app/components/HeroSection/HeroSection.tsx +++ b/apps/marketing/src/app/components/HeroSection/HeroSection.tsx @@ -26,7 +26,9 @@ export function HeroSection() {

- Orchestrate swarms of Claude Code, Codex, etc. in parallel. - Works for any agents. Built for the AI era. + Orchestrate 100+ coding agents in parallel. Works for any + agents. Built for the AI era.

diff --git a/apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx b/apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx index 59f1608ede5..61a5c62b79c 100644 --- a/apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx +++ b/apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx @@ -56,12 +56,6 @@ export function ProductDemo({ scrollYProgress }: ProductDemoProps) { [containerWidth || 1, constrainedWidth || 1], ); - // Pills shift up to follow the shrinking mockup (minimal on mobile since scale is subtle) - const pillsY = useTransform( - scrollYProgress, - [0, 1], - [0, isMobile ? -6 : -40], - ); return (
{/* Mockup with scroll-driven scale */} @@ -82,11 +76,8 @@ export function ProductDemo({ scrollYProgress }: ProductDemoProps) {
- {/* Selector pills - below mockup, shift up as mockup scales */} - + {/* Selector pills - directly below mockup */} +
{DEMO_OPTIONS.map((option) => ( setActiveOption(option.label as ActiveDemo)} /> ))} - +
); } diff --git a/apps/marketing/src/app/components/HeroSection/components/TypewriterText/TypewriterText.tsx b/apps/marketing/src/app/components/HeroSection/components/TypewriterText/TypewriterText.tsx index e925789b219..ec59a8961e1 100644 --- a/apps/marketing/src/app/components/HeroSection/components/TypewriterText/TypewriterText.tsx +++ b/apps/marketing/src/app/components/HeroSection/components/TypewriterText/TypewriterText.tsx @@ -7,6 +7,7 @@ interface TextSegment { text: string; className?: string; style?: React.CSSProperties; + render?: (visibleText: string) => React.ReactNode; } interface TypewriterTextProps { @@ -71,6 +72,18 @@ export function TypewriterText({ Math.min(segment.text.length, displayedText.length - segStart), ); + if (segment.render) { + return ( + + {segment.render(visibleText)} + + ); + } + return (
-

+

Trusted by builders from

@@ -120,7 +120,7 @@ export function TrustedBySection() { {CLIENT_LOGOS.map((client) => (
(
-
-
-
-

- Code 10x faster with no switching cost -

-

- Superset works with your existing tools. We provide - parallelization and better UX to enhance your Claude Code, - OpenCode, Cursor, etc. -

-
-
- -
-
- {isPlaying ? ( -