From 5340b4965b42ad9fb9a57328984a8980e6d43d3f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 15:12:22 -0700 Subject: [PATCH 1/6] fix(desktop): stabilize rapid workspace switching --- .superset/lib/setup/main.sh | 13 +- .superset/lib/setup/steps.sh | 47 ++- .../lib/electron-app/factories/app/setup.ts | 7 + apps/desktop/src/main/windows/main.ts | 36 ++ .../lib/performance/stress-instrumentation.ts | 56 +++ .../useDashboardSidebarData.ts | 45 +- .../useDashboardSidebarShortcuts.ts | 164 +++++++- ...useDashboardSidebarShortcuts.utils.test.ts | 54 +++ .../useDashboardSidebarShortcuts.utils.ts | 47 +++ .../ResourceConsumption.tsx | 393 ++++++++++-------- .../resourceConsumptionPolicy.test.ts | 38 ++ .../resourceConsumptionPolicy.ts | 19 + .../BackgroundTerminalsButton.tsx | 305 +++++++++----- .../BackgroundTerminalsButton.utils.test.ts | 53 +++ .../BackgroundTerminalsButton.utils.ts | 71 ++++ .../TerminalSessionDropdown.tsx | 25 +- .../TerminalSessionDropdown.utils.test.ts | 27 ++ .../TerminalSessionDropdown.utils.ts | 12 + .../WorkspaceHostUnavailableState.tsx | 80 ++++ .../WorkspaceHostUnavailableState/index.ts | 1 + .../hooks/useRemoteHostStatus/index.ts | 5 +- .../remoteHostStatusPolicy.test.ts | 80 ++++ .../remoteHostStatusPolicy.ts | 78 ++++ .../useRemoteHostStatus.ts | 51 +-- .../_dashboard/v2-workspace/layout.tsx | 32 +- .../CollectionsProvider/collections.ts | 40 +- .../host-service/src/terminal/terminal.ts | 29 ++ .../src/trpc/router/terminal/terminal.ts | 16 + .../integration/terminal.integration.test.ts | 12 + .../WorkspaceClientProvider.tsx | 79 +++- .../workspaceClientCachePolicy.ts | 22 + .../test/workspaceClientCachePolicy.test.ts | 28 ++ turbo.jsonc | 3 +- 33 files changed, 1568 insertions(+), 400 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/performance/stress-instrumentation.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/WorkspaceHostUnavailableState.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.ts create mode 100644 packages/workspace-client/src/providers/WorkspaceClientProvider/workspaceClientCachePolicy.ts create mode 100644 packages/workspace-client/test/workspaceClientCachePolicy.test.ts diff --git a/.superset/lib/setup/main.sh b/.superset/lib/setup/main.sh index 3ad4e6cfa03..c2ea6bfab96 100644 --- a/.superset/lib/setup/main.sh +++ b/.superset/lib/setup/main.sh @@ -56,9 +56,9 @@ setup_main() { step_failed "Allocate port base" fi - # Step 8: Start Electric SQL - if ! step_start_electric; then - step_failed "Start Electric SQL" + # Step 8: Prepare Electric SQL env + if ! step_prepare_electric; then + step_failed "Prepare Electric SQL" fi # Step 9: Write .env file @@ -66,7 +66,12 @@ setup_main() { step_failed "Write .env file" fi - # Step 10: Setup local MCP in .mcp.json (opt-in) + # Step 10: Start Electric SQL + if ! step_start_electric; then + step_failed "Start Electric SQL" + fi + + # Step 11: Setup local MCP in .mcp.json (opt-in) if [ "$SETUP_LOCAL_MCP" = "1" ]; then if ! step_setup_local_mcp; then step_failed "Setup local MCP" diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 01dc39a483d..57b1c22a772 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -216,6 +216,28 @@ SQL return 0 } +step_prepare_electric() { + WORKSPACE_NAME="${WORKSPACE_NAME:-$(basename "$PWD")}" + + # Sanitize workspace name for Docker (valid chars only, max 64 chars) + local container_suffix + container_suffix=$(echo "$WORKSPACE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//') + ELECTRIC_CONTAINER=$(echo "superset-electric-$container_suffix" | cut -c1-64) + ELECTRIC_SECRET="${ELECTRIC_SECRET:-local_electric_dev_secret}" + + # Step 7 allocates SUPERSET_PORT_BASE; Electric must use that reserved port. + if [ -z "${SUPERSET_PORT_BASE:-}" ]; then + error "SUPERSET_PORT_BASE not set before preparing Electric" + return 1 + fi + + ELECTRIC_PORT=$((SUPERSET_PORT_BASE + 9)) + ELECTRIC_URL="http://localhost:$ELECTRIC_PORT/v1/shape" + + export ELECTRIC_CONTAINER ELECTRIC_PORT ELECTRIC_URL ELECTRIC_SECRET + return 0 +} + step_start_electric() { echo "⚡ Starting Electric SQL container..." @@ -229,13 +251,11 @@ step_start_electric() { return 1 fi - WORKSPACE_NAME="${WORKSPACE_NAME:-$(basename "$PWD")}" - - # Sanitize workspace name for Docker (valid chars only, max 64 chars) - local container_suffix - container_suffix=$(echo "$WORKSPACE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//') - ELECTRIC_CONTAINER=$(echo "superset-electric-$container_suffix" | cut -c1-64) - ELECTRIC_SECRET="${ELECTRIC_SECRET:-local_electric_dev_secret}" + if [ -z "${ELECTRIC_CONTAINER:-}" ] || [ -z "${ELECTRIC_PORT:-}" ] || [ -z "${ELECTRIC_URL:-}" ]; then + if ! step_prepare_electric; then + return 1 + fi + fi # Stop and remove existing container if it exists if docker ps -a --format '{{.Names}}' | grep -q "^${ELECTRIC_CONTAINER}$"; then @@ -244,14 +264,12 @@ step_start_electric() { docker rm "$ELECTRIC_CONTAINER" &> /dev/null || true fi - # Step 6 allocates SUPERSET_PORT_BASE; Electric must use that reserved port. - if [ -z "${SUPERSET_PORT_BASE:-}" ]; then - error "SUPERSET_PORT_BASE not set before starting Electric" + if lsof -nP -iTCP:"$ELECTRIC_PORT" -sTCP:LISTEN &> /dev/null; then + error "Electric port $ELECTRIC_PORT is already in use" return 1 fi local port_flag - ELECTRIC_PORT=$((SUPERSET_PORT_BASE + 9)) port_flag="-p $ELECTRIC_PORT:3000" echo " Clearing stale Electric replication sessions..." @@ -295,14 +313,11 @@ step_start_electric() { if [ "$ready" = false ]; then error "Electric failed to become active within 60s (last status: $health_status). Check logs: docker logs $ELECTRIC_CONTAINER" + echo " Stopping inactive Electric container to free port $ELECTRIC_PORT..." + docker stop "$ELECTRIC_CONTAINER" &> /dev/null || true return 1 fi - ELECTRIC_URL="http://localhost:$ELECTRIC_PORT/v1/shape" - - # Export for use in other steps - export ELECTRIC_CONTAINER ELECTRIC_PORT ELECTRIC_URL ELECTRIC_SECRET - success "Electric SQL running at $ELECTRIC_URL" return 0 } diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 19e10755985..222e8c4de37 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -74,6 +74,13 @@ PLATFORM.IS_WINDOWS && app.commandLine.appendSwitch("force-color-profile", "srgb"); +if (env.NODE_ENV === "development" && process.env.RENDERER_REMOTE_DEBUG_PORT) { + app.commandLine.appendSwitch( + "remote-debugging-port", + process.env.RENDERER_REMOTE_DEBUG_PORT, + ); +} + // Each xterm pane holds one WebGL context. v2 parking keeps panes alive // across workspace switches, so cumulative contexts can reach the low // hundreds — past Chromium's default cap of 16, Blink force-evicts the diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 4867b91d227..90279858241 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -3,6 +3,7 @@ import { workspaces, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; import { app, Notification, nativeTheme } from "electron"; +import log from "electron-log/main"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { localDb } from "main/lib/local-db"; @@ -135,6 +136,40 @@ export async function MainWindow() { window.webContents.setBackgroundThrottling(false); } + if (isDev) { + window.webContents.on( + "console-message", + (_event, level, message, line, sourceId) => { + const shouldForward = + level >= 2 || + message.includes("[stress]") || + message.includes("[main]"); + if (!shouldForward) return; + + const details = sourceId ? ` (${sourceId}:${line})` : ""; + const formatted = `[renderer-console] ${message}${details}`; + if (level >= 3) { + log.error(formatted); + } else if (level >= 2) { + log.warn(formatted); + } else { + log.info(formatted); + } + }, + ); + + window.on("unresponsive", () => { + log.warn("[main-window] Renderer became unresponsive", { + url: window.webContents.getURL(), + }); + }); + window.on("responsive", () => { + log.info("[main-window] Renderer became responsive", { + url: window.webContents.getURL(), + }); + }); + } + if (ipcHandler) { ipcHandler.attachWindow(window); } else { @@ -289,6 +324,7 @@ export async function MainWindow() { window.webContents.on("render-process-gone", (_event, details) => { console.error("[main-window] Renderer process gone:", details); + log.error("[main-window] Renderer process gone", details); }); window.webContents.on("preload-error", (_event, preloadPath, error) => { diff --git a/apps/desktop/src/renderer/lib/performance/stress-instrumentation.ts b/apps/desktop/src/renderer/lib/performance/stress-instrumentation.ts new file mode 100644 index 00000000000..ddc4923218a --- /dev/null +++ b/apps/desktop/src/renderer/lib/performance/stress-instrumentation.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef } from "react"; +import { env } from "renderer/env.renderer"; + +interface RenderStressOptions { + windowMs?: number; + warnAt?: number; + getDetails?: () => Record; +} + +const DEFAULT_WINDOW_MS = 5_000; +const DEFAULT_WARN_AT = 40; + +export function useRenderStressInstrumentation( + name: string, + options: RenderStressOptions = {}, +): void { + const stateRef = useRef({ count: 0, windowStartedAt: 0, warned: false }); + + useEffect(() => { + if (env.NODE_ENV !== "development") return; + + const now = performance.now(); + const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS; + const warnAt = options.warnAt ?? DEFAULT_WARN_AT; + const state = stateRef.current; + + if (state.windowStartedAt === 0 || now - state.windowStartedAt > windowMs) { + state.count = 0; + state.windowStartedAt = now; + state.warned = false; + } + + state.count += 1; + + if (!state.warned && state.count >= warnAt) { + state.warned = true; + console.warn( + "[stress] high renderer commit rate", + JSON.stringify({ + name, + count: state.count, + windowMs, + ...(options.getDetails?.() ?? {}), + }), + ); + } + }); +} + +export function logStressEvent( + name: string, + details?: Record, +): void { + if (env.NODE_ENV !== "development") return; + console.debug("[stress]", name, JSON.stringify(details ?? {})); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 11c29c8bc36..cfa7f03fbff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -169,6 +169,10 @@ export function useDashboardSidebarData() { })), [collections], ); + const hostsByMachineId = useMemo( + () => new Map(hosts.map((host) => [host.machineId, host])), + [hosts], + ); const { data: rawSidebarProjects = [] } = useLiveQuery( (q) => @@ -235,19 +239,15 @@ export function useDashboardSidebarData() { ({ sidebarWorkspaces, workspaces }) => eq(sidebarWorkspaces.workspaceId, workspaces.id), ) - .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => - eq(workspaces.hostId, hosts.machineId), - ) .orderBy( ({ sidebarWorkspaces }) => sidebarWorkspaces.sidebarState.tabOrder, "asc", ) - .select(({ sidebarWorkspaces, workspaces, hosts }) => ({ + .select(({ sidebarWorkspaces, workspaces }) => ({ id: workspaces.id, projectId: sidebarWorkspaces.sidebarState.projectId, hostId: workspaces.hostId, type: workspaces.type, - hostIsOnline: hosts.isOnline, name: workspaces.name, branch: workspaces.branch, taskId: workspaces.taskId, @@ -259,10 +259,18 @@ export function useDashboardSidebarData() { })), [collections], ); + const rawSidebarWorkspacesWithHostStatus = useMemo( + () => + rawSidebarWorkspaces.map((workspace) => ({ + ...workspace, + hostIsOnline: hostsByMachineId.get(workspace.hostId)?.isOnline ?? false, + })), + [hostsByMachineId, rawSidebarWorkspaces], + ); const sidebarWorkspaces = useMemo( - () => getVisibleSidebarWorkspaces(rawSidebarWorkspaces), - [rawSidebarWorkspaces], + () => getVisibleSidebarWorkspaces(rawSidebarWorkspacesWithHostStatus), + [rawSidebarWorkspacesWithHostStatus], ); const localStateWorkspaceIds = useMemo( @@ -270,20 +278,16 @@ export function useDashboardSidebarData() { [rawSidebarWorkspaces], ); - const { data: localMainWorkspaces = [] } = useLiveQuery( + const { data: rawLocalMainWorkspaces = [] } = useLiveQuery( (q) => q .from({ workspaces: collections.v2Workspaces }) - .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => - eq(workspaces.hostId, hosts.machineId), - ) .where(({ workspaces }) => eq(workspaces.type, "main")) - .select(({ workspaces, hosts }) => ({ + .select(({ workspaces }) => ({ id: workspaces.id, projectId: workspaces.projectId, hostId: workspaces.hostId, type: workspaces.type, - hostIsOnline: hosts.isOnline, name: workspaces.name, branch: workspaces.branch, taskId: workspaces.taskId, @@ -294,6 +298,14 @@ export function useDashboardSidebarData() { })), [collections], ); + const localMainWorkspaces = useMemo( + () => + rawLocalMainWorkspaces.map((workspace) => ({ + ...workspace, + hostIsOnline: hostsByMachineId.get(workspace.hostId)?.isOnline ?? false, + })), + [hostsByMachineId, rawLocalMainWorkspaces], + ); // Cloud-row fallback: when workspaces.create has resolved on the host // service but Electric hasn't yet delivered the v2Workspaces row, surface @@ -302,16 +314,13 @@ export function useDashboardSidebarData() { // catches up, at which point the live query takes over seamlessly. const cloudRowFallbackWorkspaces = useMemo(() => { if (inFlightEntries.length === 0) return []; - const hostByMachineId = new Map( - hosts.map((host) => [host.machineId, host]), - ); const rows = inFlightEntries.flatMap((entry) => { const cloudRow = entry.cloudRow; if (!cloudRow) return []; // Electric already delivered; let the live query own this row. if (localStateWorkspaceIds.has(cloudRow.id)) return []; const localState = collections.v2WorkspaceLocalState.get(cloudRow.id); - const host = hostByMachineId.get(cloudRow.hostId); + const host = hostsByMachineId.get(cloudRow.hostId); return [ { id: cloudRow.id, @@ -332,7 +341,7 @@ export function useDashboardSidebarData() { ]; }); return getVisibleSidebarWorkspaces(rows); - }, [collections, hosts, inFlightEntries, localStateWorkspaceIds]); + }, [collections, hostsByMachineId, inFlightEntries, localStateWorkspaceIds]); const visibleSidebarWorkspaces = useMemo(() => { const sidebarProjectIds = new Set( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index 82413df4232..4fe274d39eb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -1,10 +1,17 @@ import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useHotkey } from "renderer/hotkeys"; +import { logStressEvent } from "renderer/lib/performance/stress-instrumentation"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import type { DashboardSidebarProject } from "../../types"; import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; +import { + getRelativeWorkspaceTarget, + shouldRunWorkspaceSwitchHotkey, + WORKSPACE_SWITCH_HOTKEY_RELEASE_MS, + type WorkspaceSwitchDirection, +} from "./useDashboardSidebarShortcuts.utils"; interface WorkspaceLocation { projectId: string; @@ -14,6 +21,7 @@ interface WorkspaceLocation { } const MAX_SHORTCUT_COUNT = 9; +const WORKSPACE_SWITCH_DROP_LOG_INTERVAL_MS = 1_000; function haveSameIds(left: string[], right: string[]): boolean { return ( @@ -131,29 +139,143 @@ export function useDashboardSidebarShortcuts( }); const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + const latestRelativeSwitchStateRef = useRef({ + currentWorkspaceId, + flattenedWorkspaces, + navigate, + revealWorkspace, + }); + latestRelativeSwitchStateRef.current = { + currentWorkspaceId, + flattenedWorkspaces, + navigate, + revealWorkspace, + }; + const lastRelativeSwitchAtRef = useRef(Number.NEGATIVE_INFINITY); + const relativeSwitchInFlightRef = useRef(false); + const relativeSwitchReleaseTimerRef = useRef | null>(null); + const pendingRelativeSwitchDirectionRef = + useRef(null); + const relativeSwitchDropLogRef = useRef({ + count: 0, + lastLoggedAt: Number.NEGATIVE_INFINITY, + }); + const runRelativeWorkspaceSwitchRef = useRef< + ( + direction: WorkspaceSwitchDirection, + source: "hotkey" | "coalesced", + ) => void + >(() => {}); - useHotkey("PREV_WORKSPACE", () => { - if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; - const index = flattenedWorkspaces.findIndex( - (w) => w.id === currentWorkspaceId, - ); - if (index === -1) return; - const prevIndex = index <= 0 ? flattenedWorkspaces.length - 1 : index - 1; - const target = flattenedWorkspaces[prevIndex]; - revealWorkspace(target.id); - navigateToV2Workspace(target.id, navigate); + const scheduleRelativeSwitchRelease = useCallback((delayMs: number) => { + if (relativeSwitchReleaseTimerRef.current) { + clearTimeout(relativeSwitchReleaseTimerRef.current); + } + relativeSwitchReleaseTimerRef.current = setTimeout(() => { + relativeSwitchInFlightRef.current = false; + relativeSwitchReleaseTimerRef.current = null; + + const pendingDirection = pendingRelativeSwitchDirectionRef.current; + pendingRelativeSwitchDirectionRef.current = null; + if (pendingDirection) { + runRelativeWorkspaceSwitchRef.current(pendingDirection, "coalesced"); + } + }, delayMs); + }, []); + + useEffect(() => { + return () => { + if (relativeSwitchReleaseTimerRef.current) { + clearTimeout(relativeSwitchReleaseTimerRef.current); + } + pendingRelativeSwitchDirectionRef.current = null; + }; + }, []); + + const runRelativeWorkspaceSwitch = useCallback( + (direction: WorkspaceSwitchDirection, source: "hotkey" | "coalesced") => { + const now = performance.now(); + const { + currentWorkspaceId: latestCurrentWorkspaceId, + flattenedWorkspaces: latestFlattenedWorkspaces, + navigate: latestNavigate, + revealWorkspace: latestRevealWorkspace, + } = latestRelativeSwitchStateRef.current; + const target = getRelativeWorkspaceTarget( + latestFlattenedWorkspaces, + latestCurrentWorkspaceId, + direction, + ); + if (!target) return; + + lastRelativeSwitchAtRef.current = now; + relativeSwitchInFlightRef.current = true; + scheduleRelativeSwitchRelease(WORKSPACE_SWITCH_HOTKEY_RELEASE_MS); + latestRevealWorkspace(target.id); + logStressEvent("workspace-switch-hotkey.navigate", { + direction, + source, + from: latestCurrentWorkspaceId, + to: target.id, + }); + void navigateToV2Workspace(target.id, latestNavigate, { replace: true }); + }, + [scheduleRelativeSwitchRelease], + ); + runRelativeWorkspaceSwitchRef.current = runRelativeWorkspaceSwitch; + + const switchRelativeWorkspace = useCallback( + (direction: WorkspaceSwitchDirection, event: KeyboardEvent) => { + const now = performance.now(); + const shouldRun = shouldRunWorkspaceSwitchHotkey({ + isNavigating: relativeSwitchInFlightRef.current, + now, + lastRunAt: lastRelativeSwitchAtRef.current, + }); + if (!shouldRun) { + pendingRelativeSwitchDirectionRef.current = direction; + if (!relativeSwitchReleaseTimerRef.current) { + const remainingMs = Math.max( + 0, + WORKSPACE_SWITCH_HOTKEY_RELEASE_MS - + (now - lastRelativeSwitchAtRef.current), + ); + relativeSwitchInFlightRef.current = true; + scheduleRelativeSwitchRelease(remainingMs); + } + + const dropLog = relativeSwitchDropLogRef.current; + dropLog.count++; + if ( + now - dropLog.lastLoggedAt >= + WORKSPACE_SWITCH_DROP_LOG_INTERVAL_MS + ) { + logStressEvent("workspace-switch-hotkey.coalesced", { + direction, + count: dropLog.count, + repeated: event.repeat, + navigating: relativeSwitchInFlightRef.current, + }); + dropLog.count = 0; + dropLog.lastLoggedAt = now; + } + return; + } + + pendingRelativeSwitchDirectionRef.current = null; + runRelativeWorkspaceSwitch(direction, "hotkey"); + }, + [runRelativeWorkspaceSwitch, scheduleRelativeSwitchRelease], + ); + + useHotkey("PREV_WORKSPACE", (event) => { + switchRelativeWorkspace("previous", event); }); - useHotkey("NEXT_WORKSPACE", () => { - if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; - const index = flattenedWorkspaces.findIndex( - (w) => w.id === currentWorkspaceId, - ); - if (index === -1) return; - const nextIndex = index >= flattenedWorkspaces.length - 1 ? 0 : index + 1; - const target = flattenedWorkspaces[nextIndex]; - revealWorkspace(target.id); - navigateToV2Workspace(target.id, navigate); + useHotkey("NEXT_WORKSPACE", (event) => { + switchRelativeWorkspace("next", event); }); return workspaceShortcutLabels; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.test.ts new file mode 100644 index 00000000000..627d9d8bc3c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { + getRelativeWorkspaceTarget, + shouldRunWorkspaceSwitchHotkey, +} from "./useDashboardSidebarShortcuts.utils"; + +const workspaces = [{ id: "a" }, { id: "b" }, { id: "c" }]; + +describe("useDashboardSidebarShortcuts utils", () => { + test("gets previous and next workspace targets with wrapping", () => { + expect(getRelativeWorkspaceTarget(workspaces, "b", "previous")?.id).toBe( + "a", + ); + expect(getRelativeWorkspaceTarget(workspaces, "b", "next")?.id).toBe("c"); + expect(getRelativeWorkspaceTarget(workspaces, "a", "previous")?.id).toBe( + "c", + ); + expect(getRelativeWorkspaceTarget(workspaces, "c", "next")?.id).toBe("a"); + }); + + test("returns null when the current workspace cannot be resolved", () => { + expect(getRelativeWorkspaceTarget(workspaces, null, "next")).toBeNull(); + expect( + getRelativeWorkspaceTarget(workspaces, "missing", "next"), + ).toBeNull(); + expect(getRelativeWorkspaceTarget([], "a", "next")).toBeNull(); + }); + + test("coalesces rapid workspace switch hotkeys while allowing controlled repeats", () => { + expect( + shouldRunWorkspaceSwitchHotkey({ + isNavigating: true, + now: 1_000, + lastRunAt: Number.NEGATIVE_INFINITY, + }), + ).toBe(false); + expect( + shouldRunWorkspaceSwitchHotkey({ + isNavigating: false, + now: 1_000, + lastRunAt: 900, + minIntervalMs: 160, + }), + ).toBe(false); + expect( + shouldRunWorkspaceSwitchHotkey({ + isNavigating: false, + now: 1_000, + lastRunAt: 820, + minIntervalMs: 160, + }), + ).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.ts new file mode 100644 index 00000000000..70e4fb5da37 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.ts @@ -0,0 +1,47 @@ +export const WORKSPACE_SWITCH_HOTKEY_THROTTLE_MS = 160; +export const WORKSPACE_SWITCH_HOTKEY_RELEASE_MS = 160; + +export type WorkspaceSwitchDirection = "previous" | "next"; + +interface WorkspaceLike { + id: string; +} + +interface ShouldRunWorkspaceSwitchHotkeyInput { + isNavigating: boolean; + now: number; + lastRunAt: number; + minIntervalMs?: number; +} + +export function shouldRunWorkspaceSwitchHotkey({ + isNavigating, + now, + lastRunAt, + minIntervalMs = WORKSPACE_SWITCH_HOTKEY_THROTTLE_MS, +}: ShouldRunWorkspaceSwitchHotkeyInput): boolean { + if (isNavigating) return false; + return now - lastRunAt >= minIntervalMs; +} + +export function getRelativeWorkspaceTarget( + workspaces: readonly T[], + currentWorkspaceId: string | null, + direction: WorkspaceSwitchDirection, +): T | null { + if (!currentWorkspaceId || workspaces.length === 0) return null; + const index = workspaces.findIndex( + (workspace) => workspace.id === currentWorkspaceId, + ); + if (index === -1) return null; + + const targetIndex = + direction === "previous" + ? index <= 0 + ? workspaces.length - 1 + : index - 1 + : index >= workspaces.length - 1 + ? 0 + : index + 1; + return workspaces[targetIndex] ?? null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx index 2b8923dd7e4..b3eeb5ace77 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx @@ -12,7 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useLiveQuery } from "@tanstack/react-db"; import { useNavigate } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { HiOutlineArrowPath, HiOutlineBarsArrowDown, @@ -20,6 +20,10 @@ import { } from "react-icons/hi2"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + logStressEvent, + useRenderStressInstrumentation, +} from "renderer/lib/performance/stress-instrumentation"; import { navigateToWorkspace as navigateToV1Workspace, navigateToV2Workspace, @@ -30,6 +34,10 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import { AppResourceSection } from "./components/AppResourceSection"; import { MetricBadge } from "./components/MetricBadge"; import { WorkspaceResourceSection } from "./components/WorkspaceResourceSection"; +import { + getResourceMonitorRefetchInterval, + shouldQueryResourceMonitor, +} from "./resourceConsumptionPolicy"; import type { SessionMetrics, SortOption, UsageValues } from "./types"; import { formatCpu, formatMemory, formatPercent } from "./utils/formatters"; import { normalizeResourceMetricsSnapshot } from "./utils/normalizeSnapshot"; @@ -99,6 +107,58 @@ export function ResourceConsumption({ className, }: ResourceConsumptionProps) { const [open, setOpen] = useState(false); + const { data: enabled } = + electronTrpc.settings.getShowResourceMonitor.useQuery(); + + useRenderStressInstrumentation("ResourceConsumptionTrigger", { + warnAt: 25, + getDetails: () => ({ open, surface }), + }); + + if (!enabled) return null; + + return ( + + + + + + + + + Resources + + + + {open && ( + setOpen(false)} + /> + )} + + ); +} + +interface ResourceConsumptionContentProps { + surface: "v1" | "v2"; + onClose: () => void; +} + +function ResourceConsumptionContent({ + surface, + onClose, +}: ResourceConsumptionContentProps) { const [sortOption, setSortOption] = useState("memory"); const [collapsedProjects, setCollapsedProjects] = useState>( new Set(), @@ -116,8 +176,10 @@ export function ResourceConsumption({ const { data: session } = authClient.useSession(); const organizationId = session?.session?.activeOrganizationId ?? undefined; - const { data: enabled } = - electronTrpc.settings.getShowResourceMonitor.useQuery(); + useRenderStressInstrumentation("ResourceConsumptionContent", { + warnAt: 25, + getDetails: () => ({ surface }), + }); const { data: rawSidebarProjects = [], isReady: sidebarProjectsReady } = useLiveQuery( @@ -190,7 +252,11 @@ export function ResourceConsumption({ sidebarWorkspacesReady && v2ProjectsReady && v2WorkspacesReady); - const shouldQueryMetrics = enabled === true && v2MetadataReady; + const shouldQueryMetrics = shouldQueryResourceMonitor({ + enabled: true, + open: true, + metadataReady: v2MetadataReady, + }); const { data: snapshot, @@ -198,16 +264,21 @@ export function ResourceConsumption({ isFetching, } = electronTrpc.resourceMetrics.getSnapshot.useQuery( { - mode: open ? "interactive" : "idle", + mode: "interactive", surface, organizationId, }, { enabled: shouldQueryMetrics, - refetchInterval: open ? 2000 : 15000, + refetchInterval: getResourceMonitorRefetchInterval(true), }, ); + useEffect(() => { + if (!isFetching) return; + logStressEvent("resource-monitor.fetch", { surface }); + }, [isFetching, surface]); + const normalizedSnapshot = useMemo(() => { const normalized = normalizeResourceMetricsSnapshot(snapshot); if (!normalized || !isV2) return normalized; @@ -250,8 +321,6 @@ export function ResourceConsumption({ terminalTitleOverrides, ]); - if (!enabled) return null; - const getPaneName = (session: SessionMetrics): string => { if (isV2) { return session.title ?? `Terminal ${session.sessionId.slice(0, 8)}`; @@ -266,7 +335,7 @@ export function ResourceConsumption({ } else { void navigateToV1Workspace(workspaceId, navigate); } - setOpen(false); + onClose(); }; const navigateToPane = (workspaceId: string, paneId: string) => { @@ -277,7 +346,7 @@ export function ResourceConsumption({ focusRequestId: crypto.randomUUID(), }, }); - setOpen(false); + onClose(); return; } @@ -287,7 +356,7 @@ export function ResourceConsumption({ setFocusedPane(pane.tabId, paneId); } void navigateToV1Workspace(workspaceId, navigate); - setOpen(false); + onClose(); }; const toggleWorkspace = (workspaceId: string) => { @@ -335,185 +404,141 @@ export function ResourceConsumption({ : hostShareSeverity === "elevated" ? "bg-amber-500/80" : "bg-foreground/40"; - const triggerDotColorClass = - hostShareSeverity === "high" - ? "bg-red-500" - : hostShareSeverity === "elevated" - ? "bg-amber-500" - : null; - return ( - - - - - + + + setSortOption(value as SortOption)} + > + + Memory + + CPU + + Name + + + Sidebar order + + + + + - - - - {normalizedSnapshot - ? `Resources · ${formatMemory(normalizedSnapshot.totalMemory)}` - : "Resources"} - - - - -
-
-

- Resources -

-
- - - - - - - setSortOption(value as SortOption) - } - > - - Memory - - - CPU - - - Name - - - Sidebar order - - - - - -
+ +
- - {normalizedSnapshot && ( - <> -
- - - -
- - -
-
-
- - - Superset uses {formatPercent(trackedMemorySharePercent)} of - system RAM - - - - )}
-
- {normalizedSnapshot && ( - - )} - - {normalizedSnapshot && ( - - )} - - {normalizedSnapshot && normalizedSnapshot.workspaces.length === 0 && ( -
- No active terminal sessions + {normalizedSnapshot && ( + <> +
+ + +
- )} + + +
+
+
+ + + Superset uses {formatPercent(trackedMemorySharePercent)} of + system RAM + + + + )} +
+ +
+ {normalizedSnapshot && ( + + )} + + {normalizedSnapshot && ( + + )} + + {normalizedSnapshot && normalizedSnapshot.workspaces.length === 0 && ( +
+ No active terminal sessions +
+ )} - {!normalizedSnapshot && ( -
- Loading… -
- )} -
- - + {!normalizedSnapshot && ( +
+ Loading… +
+ )} +
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.test.ts new file mode 100644 index 00000000000..b6a860773f3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { + getResourceMonitorRefetchInterval, + shouldQueryResourceMonitor, +} from "./resourceConsumptionPolicy"; + +describe("resourceConsumptionPolicy", () => { + test("keeps resource metrics cold while the popover is closed", () => { + for (let i = 0; i < 10_000; i += 1) { + expect( + shouldQueryResourceMonitor({ + enabled: true, + open: false, + metadataReady: true, + }), + ).toBe(false); + expect(getResourceMonitorRefetchInterval(false)).toBe(false); + } + }); + + test("enables polling only after the popover is open and metadata is ready", () => { + expect( + shouldQueryResourceMonitor({ + enabled: true, + open: true, + metadataReady: true, + }), + ).toBe(true); + expect( + shouldQueryResourceMonitor({ + enabled: true, + open: true, + metadataReady: false, + }), + ).toBe(false); + expect(getResourceMonitorRefetchInterval(true)).toBe(2_000); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.ts new file mode 100644 index 00000000000..f8bd41b807a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/resourceConsumptionPolicy.ts @@ -0,0 +1,19 @@ +export const RESOURCE_MONITOR_REFETCH_INTERVAL_MS = 2_000; + +export function shouldQueryResourceMonitor({ + enabled, + open, + metadataReady, +}: { + enabled: boolean | undefined; + open: boolean; + metadataReady: boolean; +}): boolean { + return enabled === true && open && metadataReady; +} + +export function getResourceMonitorRefetchInterval( + open: boolean, +): number | false { + return open ? RESOURCE_MONITOR_REFETCH_INTERVAL_MS : false; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx index c17c0aa6cf7..0dcc4779b01 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx @@ -11,11 +11,24 @@ import { import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { Archive, ChevronDown, Trash2 } from "lucide-react"; -import { useMemo, useState } from "react"; +import { memo, useMemo, useState } from "react"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { + logStressEvent, + useRenderStressInstrumentation, +} from "renderer/lib/performance/stress-instrumentation"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; import { useStore } from "zustand"; import type { StoreApi } from "zustand/vanilla"; import type { PaneViewerData, TerminalPaneData } from "../../types"; +import { + BACKGROUND_TERMINAL_ATTACHMENT_DEBOUNCE_MS, + getAttachedTerminalIdsKey, + getBackgroundTerminalCountRefetchInterval, + getBackgroundTerminalListRefetchInterval, + getBackgroundTerminalSessions, + parseAttachedTerminalIdsKey, +} from "./BackgroundTerminalsButton.utils"; interface BackgroundTerminalsButtonProps { workspaceId: string; @@ -28,127 +41,189 @@ interface BackgroundTerminalsButtonProps { * terminal pane header). Renders nothing when there are none; otherwise a * single button with a dropdown to re-open or kill each background session. */ -export function BackgroundTerminalsButton({ - workspaceId, - store, -}: BackgroundTerminalsButtonProps) { - const [isOpen, setIsOpen] = useState(false); - const tabs = useStore(store, (s) => s.tabs); - const utils = workspaceTrpc.useUtils(); - const killSession = workspaceTrpc.terminal.killSession.useMutation(); - const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( - { workspaceId }, - { refetchInterval: isOpen ? 2_000 : 5_000, refetchOnWindowFocus: true }, - ); +export const BackgroundTerminalsButton = memo( + function BackgroundTerminalsButton({ + workspaceId, + store, + }: BackgroundTerminalsButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const attachedTerminalIdsKey = useStore(store, (s) => + getAttachedTerminalIdsKey(s.tabs), + ); + const debouncedAttachedTerminalIdsKey = useDebouncedValue( + attachedTerminalIdsKey, + BACKGROUND_TERMINAL_ATTACHMENT_DEBOUNCE_MS, + ); + const attachedTerminalIds = useMemo( + () => parseAttachedTerminalIdsKey(attachedTerminalIdsKey), + [attachedTerminalIdsKey], + ); + const debouncedAttachedTerminalIds = useMemo( + () => parseAttachedTerminalIdsKey(debouncedAttachedTerminalIdsKey), + [debouncedAttachedTerminalIdsKey], + ); + const backgroundCountInput = useMemo( + () => ({ + workspaceId, + attachedTerminalIds: debouncedAttachedTerminalIds, + }), + [workspaceId, debouncedAttachedTerminalIds], + ); + const sessionsInput = useMemo(() => ({ workspaceId }), [workspaceId]); + const utils = workspaceTrpc.useUtils(); + const killSession = workspaceTrpc.terminal.killSession.useMutation(); + const backgroundCountQuery = + workspaceTrpc.terminal.countBackgroundSessions.useQuery( + backgroundCountInput, + { + enabled: !isOpen, + notifyOnChangeProps: ["data"], + refetchInterval: getBackgroundTerminalCountRefetchInterval(isOpen), + refetchOnWindowFocus: false, + staleTime: 5_000, + }, + ); + const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( + sessionsInput, + { + enabled: isOpen, + notifyOnChangeProps: ["data", "isLoading"], + refetchInterval: getBackgroundTerminalListRefetchInterval(isOpen), + refetchOnWindowFocus: isOpen, + staleTime: 1_000, + }, + ); - const attachedTerminalIds = useMemo(() => { - const ids = new Set(); - for (const tab of tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind !== "terminal") continue; - const data = pane.data as Partial; - if (data.terminalId) ids.add(data.terminalId); - } - } - return ids; - }, [tabs]); + useRenderStressInstrumentation("BackgroundTerminalsButton", { + warnAt: 35, + getDetails: () => ({ + isOpen, + attachedTerminalCount: attachedTerminalIds.length, + closedCount: backgroundCountQuery.data?.count ?? null, + }), + }); - const backgroundSessions = useMemo(() => { - const sessions = sessionsQuery.data?.sessions ?? []; - return sessions - .filter((session) => !attachedTerminalIds.has(session.terminalId)) - .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); - }, [sessionsQuery.data?.sessions, attachedTerminalIds]); + const backgroundSessions = useMemo(() => { + const sessions = sessionsQuery.data?.sessions ?? []; + return getBackgroundTerminalSessions(sessions, attachedTerminalIds); + }, [sessionsQuery.data?.sessions, attachedTerminalIds]); - if (backgroundSessions.length === 0) return null; + const backgroundCount = + isOpen && sessionsQuery.data + ? backgroundSessions.length + : (backgroundCountQuery.data?.count ?? backgroundSessions.length); - const label = `${backgroundSessions.length} background terminal session${ - backgroundSessions.length === 1 ? "" : "s" - }`; + if (!isOpen && backgroundCount === 0) return null; - const handleAdopt = (terminalId: string) => { - store.getState().addTab({ - panes: [ - { - kind: "terminal", - data: { terminalId } as TerminalPaneData, - }, - ], - }); - void utils.terminal.listSessions.invalidate({ workspaceId }); - setIsOpen(false); - }; + const label = `${backgroundCount} background terminal session${ + backgroundCount === 1 ? "" : "s" + }`; - const handleKill = async (terminalId: string) => { - try { - await killSession.mutateAsync({ terminalId, workspaceId }); - } catch (error) { - console.error( - "[BackgroundTerminalsButton] Failed to kill session:", - error, - ); - toast.error("Failed to close terminal session"); - } finally { + const handleAdopt = (terminalId: string) => { + store.getState().addTab({ + panes: [ + { + kind: "terminal", + data: { terminalId } as TerminalPaneData, + }, + ], + }); void utils.terminal.listSessions.invalidate({ workspaceId }); - } - }; + void utils.terminal.countBackgroundSessions.invalidate({ workspaceId }); + logStressEvent("background-terminals.adopt", { workspaceId }); + setIsOpen(false); + }; - return ( - - - - - - - Background terminal sessions - - -
- {backgroundSessions.map((session) => ( - handleAdopt(session.terminalId)} - > - - - {session.title ?? "Terminal"} - - {session.createdAt > 0 && ( - - {getRelativeTime(session.createdAt, { format: "compact" })} - - )} - + + + + Background terminal sessions + + +
+ {sessionsQuery.isLoading && ( +
+ Loading sessions… +
+ )} + {!sessionsQuery.isLoading && backgroundSessions.length === 0 && ( +
+ No background terminal sessions +
+ )} + {backgroundSessions.map((session) => ( + handleAdopt(session.terminalId)} > - - - - ))} -
-
- - ); + + + {session.title ?? "Terminal"} + + {session.createdAt > 0 && ( + + {getRelativeTime(session.createdAt, { format: "compact" })} + + )} + +
+ ))} +
+
+
+ ); + }, + areBackgroundTerminalsButtonPropsEqual, +); + +function areBackgroundTerminalsButtonPropsEqual( + prev: BackgroundTerminalsButtonProps, + next: BackgroundTerminalsButtonProps, +) { + return prev.workspaceId === next.workspaceId && prev.store === next.store; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts new file mode 100644 index 00000000000..7e81dd55d7f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { + getAttachedTerminalIdsKey, + getBackgroundTerminalCountRefetchInterval, + getBackgroundTerminalListRefetchInterval, + getBackgroundTerminalSessions, + parseAttachedTerminalIdsKey, +} from "./BackgroundTerminalsButton.utils"; + +describe("BackgroundTerminalsButton utils", () => { + test("keeps the attached terminal key stable across tab object churn", () => { + type WorkspaceTabs = Parameters[0]; + const makeTabs = (): WorkspaceTabs => [ + { + panes: { + a: { kind: "terminal", data: { terminalId: "term-b" } }, + b: { kind: "browser", data: { terminalId: "ignored" } }, + }, + }, + { + panes: { + c: { kind: "terminal", data: { terminalId: "term-a" } }, + }, + }, + ]; + const firstKey = getAttachedTerminalIdsKey(makeTabs()); + + for (let i = 0; i < 10_000; i += 1) { + expect(getAttachedTerminalIdsKey(makeTabs())).toBe(firstKey); + } + expect(parseAttachedTerminalIdsKey(firstKey)).toEqual(["term-a", "term-b"]); + }); + + test("filters attached sessions and sorts background sessions newest first", () => { + expect( + getBackgroundTerminalSessions( + [ + { terminalId: "old", createdAt: 1 }, + { terminalId: "attached", createdAt: 3 }, + { terminalId: "new", createdAt: 5 }, + ], + ["attached"], + ).map((session) => session.terminalId), + ).toEqual(["new", "old"]); + }); + + test("uses shallow count polling only while closed", () => { + expect(getBackgroundTerminalCountRefetchInterval(false)).toBe(15_000); + expect(getBackgroundTerminalCountRefetchInterval(true)).toBe(false); + expect(getBackgroundTerminalListRefetchInterval(false)).toBe(false); + expect(getBackgroundTerminalListRefetchInterval(true)).toBe(2_000); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts new file mode 100644 index 00000000000..58258006d4b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts @@ -0,0 +1,71 @@ +export const BACKGROUND_TERMINAL_ATTACHMENT_DEBOUNCE_MS = 250; +export const BACKGROUND_TERMINAL_COUNT_REFETCH_INTERVAL_MS = 15_000; +export const BACKGROUND_TERMINAL_LIST_REFETCH_INTERVAL_MS = 2_000; + +interface TerminalPaneLike { + kind: string; + data: unknown; +} + +interface WorkspaceTabLike { + panes: Record; +} + +export interface BackgroundTerminalSessionLike { + terminalId: string; + createdAt?: number; +} + +function getTerminalIdFromPaneData(data: unknown): string | null { + if (!data || typeof data !== "object") return null; + const terminalId = (data as { terminalId?: unknown }).terminalId; + return typeof terminalId === "string" && terminalId.length > 0 + ? terminalId + : null; +} + +export function getAttachedTerminalIdsKey( + tabs: readonly WorkspaceTabLike[], +): string { + const ids = new Set(); + for (const tab of tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "terminal") continue; + const terminalId = getTerminalIdFromPaneData(pane.data); + if (terminalId) ids.add(terminalId); + } + } + return JSON.stringify([...ids].sort()); +} + +export function parseAttachedTerminalIdsKey(key: string): string[] { + try { + const parsed = JSON.parse(key); + return Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === "string") + : []; + } catch { + return []; + } +} + +export function getBackgroundTerminalSessions< + T extends BackgroundTerminalSessionLike, +>(sessions: readonly T[], attachedTerminalIds: Iterable): T[] { + const attached = new Set(attachedTerminalIds); + return sessions + .filter((session) => !attached.has(session.terminalId)) + .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); +} + +export function getBackgroundTerminalCountRefetchInterval( + isOpen: boolean, +): number | false { + return isOpen ? false : BACKGROUND_TERMINAL_COUNT_REFETCH_INTERVAL_MS; +} + +export function getBackgroundTerminalListRefetchInterval( + isOpen: boolean, +): number | false { + return isOpen ? BACKGROUND_TERMINAL_LIST_REFETCH_INTERVAL_MS : false; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 222983bcc3a..0985b5217ed 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -13,6 +13,7 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { Check, ChevronDown, LoaderCircle, Plus, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { useRenderStressInstrumentation } from "renderer/lib/performance/stress-instrumentation"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import type { @@ -22,6 +23,11 @@ import type { import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; import { TerminalPaneIcon } from "../TerminalPaneIcon"; +import { + getTerminalSessionListRefetchInterval, + shouldQueryTerminalSessionList, + TERMINAL_SESSION_LIST_STALE_MS, +} from "./TerminalSessionDropdown.utils"; interface TerminalSessionDropdownProps { context: RendererContext; @@ -84,13 +90,26 @@ export function TerminalSessionDropdown({ const terminalInstanceId = context.pane.id; const utils = workspaceTrpc.useUtils(); const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); + const sessionsInput = useMemo(() => ({ workspaceId }), [workspaceId]); const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( - { workspaceId }, + sessionsInput, { - refetchInterval: isOpen ? 2_000 : false, - refetchOnWindowFocus: true, + enabled: shouldQueryTerminalSessionList(isOpen), + notifyOnChangeProps: ["data", "isFetching"], + refetchInterval: getTerminalSessionListRefetchInterval(isOpen), + refetchOnWindowFocus: false, + staleTime: TERMINAL_SESSION_LIST_STALE_MS, }, ); + useRenderStressInstrumentation("TerminalSessionDropdown", { + warnAt: 30, + getDetails: () => ({ + workspaceId, + terminalId, + isOpen, + hasSessionData: Boolean(sessionsQuery.data), + }), + }); const { data: localWorkspaceRows = [] } = useLiveQuery( (query) => query diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.test.ts new file mode 100644 index 00000000000..9d64809a407 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { + getTerminalSessionListRefetchInterval, + shouldQueryTerminalSessionList, + TERMINAL_SESSION_LIST_REFETCH_INTERVAL_MS, +} from "./TerminalSessionDropdown.utils"; + +describe("TerminalSessionDropdown query policy", () => { + it("does not query or poll while closed", () => { + expect(shouldQueryTerminalSessionList(false)).toBe(false); + expect(getTerminalSessionListRefetchInterval(false)).toBe(false); + }); + + it("queries and polls while open", () => { + expect(shouldQueryTerminalSessionList(true)).toBe(true); + expect(getTerminalSessionListRefetchInterval(true)).toBe( + TERMINAL_SESSION_LIST_REFETCH_INTERVAL_MS, + ); + }); + + it("keeps closed dropdowns cold under tab churn", () => { + for (let i = 0; i < 10_000; i++) { + expect(shouldQueryTerminalSessionList(false)).toBe(false); + expect(getTerminalSessionListRefetchInterval(false)).toBe(false); + } + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.ts new file mode 100644 index 00000000000..39e2b657dca --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.utils.ts @@ -0,0 +1,12 @@ +export const TERMINAL_SESSION_LIST_REFETCH_INTERVAL_MS = 2_000; +export const TERMINAL_SESSION_LIST_STALE_MS = 5_000; + +export function shouldQueryTerminalSessionList(isOpen: boolean): boolean { + return isOpen; +} + +export function getTerminalSessionListRefetchInterval( + isOpen: boolean, +): false | number { + return isOpen ? TERMINAL_SESSION_LIST_REFETCH_INTERVAL_MS : false; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/WorkspaceHostUnavailableState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/WorkspaceHostUnavailableState.tsx new file mode 100644 index 00000000000..cebc77fc398 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/WorkspaceHostUnavailableState.tsx @@ -0,0 +1,80 @@ +import { Button } from "@superset/ui/button"; +import { Link } from "@tanstack/react-router"; +import { ArrowRight, Monitor, WifiOff } from "lucide-react"; + +interface WorkspaceHostUnavailableStateProps { + hostName: string; + reason: "offline" | "unreachable"; +} + +export function WorkspaceHostUnavailableState({ + hostName, + reason, +}: WorkspaceHostUnavailableStateProps) { + const title = + reason === "offline" ? "Host is offline" : "Host is unreachable"; + const description = + reason === "offline" + ? "This workspace's host is offline. Bring that device online to reconnect." + : "This workspace's host did not respond. Check the host device and try again."; + + return ( +
+
+
+
+
+ +
+ +
+

+ {title} +

+

+ {description} +

+
+ +
+
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/index.ts new file mode 100644 index 00000000000..12120980732 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/index.ts @@ -0,0 +1 @@ +export { WorkspaceHostUnavailableState } from "./WorkspaceHostUnavailableState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts index e8a304923fa..ab9fcfc1d30 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts @@ -1,4 +1,5 @@ export { + getRemoteHostStatus, type RemoteHostStatus, - useRemoteHostStatus, -} from "./useRemoteHostStatus"; +} from "./remoteHostStatusPolicy"; +export { useRemoteHostStatus } from "./useRemoteHostStatus"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.test.ts new file mode 100644 index 00000000000..5f6752e0a9f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; +import { getRemoteHostStatus } from "./remoteHostStatusPolicy"; + +const baseRemoteInput = { + workspacePresent: true, + localMachineReady: true, + isLocal: false, + liveQueryReady: true, + hostName: "Town-Hall", + hostIsOnline: true, + hostInfoStatus: "success" as const, + hostVersion: MIN_HOST_SERVICE_VERSION, +}; + +describe("getRemoteHostStatus", () => { + test("waits until the workspace, local host id, and host row are ready", () => { + expect( + getRemoteHostStatus({ ...baseRemoteInput, workspacePresent: false }), + ).toEqual({ status: "loading" }); + expect( + getRemoteHostStatus({ ...baseRemoteInput, localMachineReady: false }), + ).toEqual({ status: "loading" }); + expect( + getRemoteHostStatus({ ...baseRemoteInput, liveQueryReady: false }), + ).toEqual({ status: "loading" }); + }); + + test("skips remote checks for the local host", () => { + expect(getRemoteHostStatus({ ...baseRemoteInput, isLocal: true })).toEqual({ + status: "skip", + }); + }); + + test("blocks offline and unreachable remote hosts before mounting a workspace", () => { + expect( + getRemoteHostStatus({ ...baseRemoteInput, hostIsOnline: false }), + ).toEqual({ + status: "unavailable", + hostName: "Town-Hall", + reason: "offline", + }); + expect( + getRemoteHostStatus({ + ...baseRemoteInput, + hostInfoStatus: "error", + hostVersion: undefined, + }), + ).toEqual({ + status: "unavailable", + hostName: "Town-Hall", + reason: "unreachable", + }); + }); + + test("waits while remote host info is still pending", () => { + expect( + getRemoteHostStatus({ + ...baseRemoteInput, + hostInfoStatus: "pending", + hostVersion: undefined, + }), + ).toEqual({ status: "loading" }); + }); + + test("reports incompatible host versions", () => { + expect( + getRemoteHostStatus({ ...baseRemoteInput, hostVersion: "0.0.0" }), + ).toEqual({ + status: "incompatible", + hostName: "Town-Hall", + hostVersion: "0.0.0", + minVersion: MIN_HOST_SERVICE_VERSION, + }); + }); + + test("allows compatible online remote hosts", () => { + expect(getRemoteHostStatus(baseRemoteInput)).toEqual({ status: "ready" }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.ts new file mode 100644 index 00000000000..33bab433106 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.ts @@ -0,0 +1,78 @@ +import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; +import semver from "semver"; + +export type RemoteHostStatus = + | { status: "skip" } + | { status: "loading" } + | { + status: "unavailable"; + hostName: string; + reason: "offline" | "unreachable"; + } + | { + status: "incompatible"; + hostName: string; + hostVersion: string; + minVersion: string; + } + | { status: "ready" }; + +export type RemoteHostInfoStatus = "pending" | "error" | "success"; + +interface RemoteHostStatusPolicyInput { + workspacePresent: boolean; + localMachineReady: boolean; + isLocal: boolean; + liveQueryReady: boolean; + hostName?: string | null; + hostIsOnline?: boolean | null; + hostInfoStatus: RemoteHostInfoStatus; + hostVersion?: string | null; +} + +const UNKNOWN_HOST = "Unknown host"; + +export function getRemoteHostStatus({ + workspacePresent, + localMachineReady, + isLocal, + liveQueryReady, + hostName, + hostIsOnline, + hostInfoStatus, + hostVersion, +}: RemoteHostStatusPolicyInput): RemoteHostStatus { + if (!workspacePresent || !localMachineReady) return { status: "loading" }; + if (isLocal) return { status: "skip" }; + if (!liveQueryReady) return { status: "loading" }; + + const resolvedHostName = hostName ?? UNKNOWN_HOST; + + if (hostIsOnline === false) { + return { + status: "unavailable", + hostName: resolvedHostName, + reason: "offline", + }; + } + + if (hostInfoStatus === "pending") return { status: "loading" }; + if (hostInfoStatus === "error" || !hostVersion) { + return { + status: "unavailable", + hostName: resolvedHostName, + reason: "unreachable", + }; + } + + if (!semver.satisfies(hostVersion, `>=${MIN_HOST_SERVICE_VERSION}`)) { + return { + status: "incompatible", + hostName: resolvedHostName, + hostVersion, + minVersion: MIN_HOST_SERVICE_VERSION, + }; + } + + return { status: "ready" }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts index f3fa0348a4a..11e36304666 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts @@ -1,6 +1,5 @@ import type { SelectV2Workspace } from "@superset/db/schema"; import { buildHostRoutingKey } from "@superset/shared/host-routing"; -import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; import { and, eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; @@ -8,18 +7,10 @@ import { useRelayUrl } from "renderer/hooks/useRelayUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import semver from "semver"; - -export type RemoteHostStatus = - | { status: "skip" } - | { status: "loading" } - | { - status: "incompatible"; - hostName: string; - hostVersion: string; - minVersion: string; - } - | { status: "ready" }; +import { + getRemoteHostStatus, + type RemoteHostStatus, +} from "./remoteHostStatusPolicy"; const HOST_INFO_STALE_MS = 30_000; @@ -31,9 +22,11 @@ export function useRemoteHostStatus( const relayUrl = useRelayUrl(); const organizationId = workspace?.organizationId ?? ""; const hostId = workspace?.hostId ?? ""; + const localMachineReady = machineId != null; const isLocal = workspace != null && machineId != null && workspace.hostId === machineId; - const filterMachineId = !workspace || isLocal ? "" : hostId; + const shouldCheckRemote = workspace != null && localMachineReady && !isLocal; + const filterMachineId = shouldCheckRemote ? hostId : ""; const { data: hostRows = [], isReady } = useLiveQuery( (q) => @@ -47,6 +40,7 @@ export function useRemoteHostStatus( ) .select(({ hosts }) => ({ name: hosts.name, + isOnline: hosts.isOnline, })), [collections, organizationId, filterMachineId], ); @@ -60,26 +54,19 @@ export function useRemoteHostStatus( const infoQuery = useQuery({ queryKey: ["remoteHostInfo", organizationId, hostId], queryFn: () => getHostServiceClientByUrl(hostUrl).host.info.query(), - enabled: workspace != null && !isLocal, + enabled: shouldCheckRemote, staleTime: HOST_INFO_STALE_MS, retry: false, }); - if (!workspace) return { status: "loading" }; - if (isLocal) return { status: "skip" }; - if (!isReady) return { status: "loading" }; - - if (infoQuery.isSuccess) { - const hostVersion = infoQuery.data.version; - if (!semver.satisfies(hostVersion, `>=${MIN_HOST_SERVICE_VERSION}`)) { - return { - status: "incompatible", - hostName: hostRow?.name ?? "Unknown host", - hostVersion, - minVersion: MIN_HOST_SERVICE_VERSION, - }; - } - } - - return { status: "ready" }; + return getRemoteHostStatus({ + workspacePresent: workspace != null, + localMachineReady, + isLocal, + liveQueryReady: isReady, + hostName: hostRow?.name, + hostIsOnline: hostRow?.isOnline, + hostInfoStatus: infoQuery.status, + hostVersion: infoQuery.data?.version, + }); } 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 05a8de21c94..b14f1bec64a 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 @@ -2,12 +2,17 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; +import { + logStressEvent, + useRenderStressInstrumentation, +} from "renderer/lib/performance/stress-instrumentation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useWorkspaceCreatesStore } from "renderer/stores/workspace-creates"; import { WorkspaceCreateErrorState } from "./components/WorkspaceCreateErrorState"; import { WorkspaceCreatingState } from "./components/WorkspaceCreatingState"; import { WorkspaceHostIncompatibleState } from "./components/WorkspaceHostIncompatibleState"; +import { WorkspaceHostUnavailableState } from "./components/WorkspaceHostUnavailableState"; import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { useRemoteHostStatus } from "./hooks/useRemoteHostStatus"; import { WorkspaceProvider } from "./providers/WorkspaceProvider"; @@ -55,6 +60,23 @@ function V2WorkspaceLayout() { }, [ensureWorkspaceInSidebar, workspace]); const hostStatus = useRemoteHostStatus(workspace); + const workspaceHostId = workspace?.hostId; + useRenderStressInstrumentation("V2WorkspaceLayout", { + warnAt: 30, + getDetails: () => ({ + workspaceId, + workspaceHostId, + hostStatus: hostStatus.status, + }), + }); + useEffect(() => { + if (!workspaceId || !workspaceHostId) return; + logStressEvent("v2-workspace.host-status", { + workspaceId, + workspaceHostId, + status: hostStatus.status, + }); + }, [hostStatus.status, workspaceHostId, workspaceId]); if (!workspaceId || !isReady || !workspaces) { return
; @@ -95,9 +117,17 @@ function V2WorkspaceLayout() { if (hostStatus.status === "loading") { return
; } + if (hostStatus.status === "unavailable") { + return ( + + ); + } return ( - + ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index f9be670e241..8b4148b3cdd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -25,6 +25,7 @@ import type { SelectWorkspace, } from "@superset/db/schema"; import type { AppRouter } from "@superset/trpc"; +import { BasicIndex } from "@tanstack/db"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; import { createElectronSQLitePersistence, @@ -35,7 +36,6 @@ import type { LocalStorageCollectionUtils, } from "@tanstack/react-db"; import { - BasicIndex, createCollection, localStorageCollectionOptions, } from "@tanstack/react-db"; @@ -72,6 +72,7 @@ const indexDefaults = { autoIndex: "eager", defaultIndexType: BasicIndex, } as const; +const basicIndexConfig = { indexType: BasicIndex } as const; const createIndexedCollection = (( config: Parameters[0], @@ -303,6 +304,10 @@ function createOrgCollections(organizationId: string): OrgCollections { }, }), ); + v2Projects.createIndex( + (project) => project.githubRepositoryId, + basicIndexConfig, + ); const v2Hosts = createPersistedElectricCollection( electricCollectionOptions({ @@ -332,6 +337,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }, }), ); + v2Hosts.createIndex((host) => host.machineId, basicIndexConfig); const v2Clients = createPersistedElectricCollection( electricCollectionOptions({ @@ -395,6 +401,8 @@ function createOrgCollections(organizationId: string): OrgCollections { }, }), ); + v2UsersHosts.createIndex((userHost) => userHost.hostId, basicIndexConfig); + v2UsersHosts.createIndex((userHost) => userHost.userId, basicIndexConfig); const v2Workspaces = createPersistedElectricCollection( electricCollectionOptions({ @@ -423,6 +431,12 @@ function createOrgCollections(organizationId: string): OrgCollections { }, }), ); + v2Workspaces.createIndex((workspace) => workspace.hostId, basicIndexConfig); + v2Workspaces.createIndex( + (workspace) => workspace.projectId, + basicIndexConfig, + ); + v2Workspaces.createIndex((workspace) => workspace.type, basicIndexConfig); const workspaces = createPersistedElectricCollection( electricCollectionOptions({ @@ -690,6 +704,10 @@ function createOrgCollections(organizationId: string): OrgCollections { getKey: (item) => item.projectId, }), ); + v2SidebarProjects.createIndex( + (sidebarProject) => sidebarProject.tabOrder, + basicIndexConfig, + ); const v2WorkspaceLocalState = createIndexedCollection( localStorageCollectionOptions( @@ -706,6 +724,18 @@ function createOrgCollections(organizationId: string): OrgCollections { ), ), ); + v2WorkspaceLocalState.createIndex( + (localState) => localState.sidebarState.projectId, + basicIndexConfig, + ); + v2WorkspaceLocalState.createIndex( + (localState) => localState.sidebarState.sectionId, + basicIndexConfig, + ); + v2WorkspaceLocalState.createIndex( + (localState) => localState.sidebarState.tabOrder, + basicIndexConfig, + ); const v2SidebarSections = createIndexedCollection( localStorageCollectionOptions({ @@ -715,6 +745,14 @@ function createOrgCollections(organizationId: string): OrgCollections { getKey: (item) => item.sectionId, }), ); + v2SidebarSections.createIndex( + (section) => section.projectId, + basicIndexConfig, + ); + v2SidebarSections.createIndex( + (section) => section.tabOrder, + basicIndexConfig, + ); const v2TerminalPresets = createIndexedCollection( localStorageCollectionOptions({ diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index be3e2a9480f..9257fc8db8d 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -408,6 +408,35 @@ export function listTerminalSessions( })); } +export function countTerminalSessions( + options: { + workspaceId?: string; + includeExited?: boolean; + excludeTerminalIds?: Iterable; + } = {}, +): number { + const includeExited = options.includeExited ?? true; + const excludedTerminalIds = options.excludeTerminalIds + ? new Set(options.excludeTerminalIds) + : null; + let count = 0; + + for (const session of sessions.values()) { + if (!session.listed) continue; + if ( + options.workspaceId !== undefined && + session.workspaceId !== options.workspaceId + ) { + continue; + } + if (!includeExited && session.exited) continue; + if (excludedTerminalIds?.has(session.terminalId)) continue; + count += 1; + } + + return count; +} + export function writeInputToSession({ terminalId, workspaceId, diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index afe9552620a..45eccd3175c 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { getSupervisor, waitForDaemonReady } from "../../../daemon"; import { terminalSessions, workspaces } from "../../../db/schema"; import { + countTerminalSessions, createTerminalSessionInternal, disposeSessionAndWait, listTerminalSessions, @@ -119,6 +120,21 @@ export const terminalRouter = router({ }), })), + countBackgroundSessions: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + attachedTerminalIds: z.array(z.string()).default([]), + }), + ) + .query(({ input }) => ({ + count: countTerminalSessions({ + workspaceId: input.workspaceId, + includeExited: false, + excludeTerminalIds: input.attachedTerminalIds, + }), + })), + writeInput: protectedProcedure .input( z.object({ diff --git a/packages/host-service/test/integration/terminal.integration.test.ts b/packages/host-service/test/integration/terminal.integration.test.ts index ac0ed1a4810..81d1b0f383f 100644 --- a/packages/host-service/test/integration/terminal.integration.test.ts +++ b/packages/host-service/test/integration/terminal.integration.test.ts @@ -124,8 +124,20 @@ describe("terminal router integration", () => { workspaceId: scenario.workspaceId, terminalId, }); + const detachedCount = + await scenario.host.trpc.terminal.countBackgroundSessions.query({ + workspaceId: scenario.workspaceId, + attachedTerminalIds: [], + }); + const attachedCount = + await scenario.host.trpc.terminal.countBackgroundSessions.query({ + workspaceId: scenario.workspaceId, + attachedTerminalIds: [terminalId], + }); expect(spawned).toHaveLength(1); + expect(detachedCount.count).toBe(1); + expect(attachedCount.count).toBe(0); const [{ meta }] = spawned; expect(meta.shell).toBe(fakeFishPath); expect(meta.argv[0]).toBe("-l"); diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx index 3e359b6a871..7a8d2cad892 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx @@ -1,11 +1,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchStreamLink, TRPCClientError } from "@trpc/client"; -import { createContext, type ReactNode, useContext } from "react"; +import { createContext, type ReactNode, useContext, useEffect } from "react"; import superjson from "superjson"; import { workspaceTrpc } from "../../workspace-trpc"; +import { + getIdleWorkspaceClientEvictionKeys, + WORKSPACE_CLIENT_IDLE_DISPOSE_MS, +} from "./workspaceClientCachePolicy"; const STALE_TIME_MS = 5_000; -const GC_TIME_MS = 30 * 60 * 1_000; +const GC_TIME_MS = 5 * 60 * 1_000; const MAX_TIMEOUT_RETRIES = 2; const TIMEOUT_RETRY_BASE_DELAY_MS = 300; @@ -29,16 +33,75 @@ interface WorkspaceClientProviderProps { } interface WorkspaceClients { + clientKey: string; hostUrl: string; queryClient: QueryClient; trpcClient: ReturnType; getWsToken: () => string | null; + activeRefs: number; + lastAccessedAt: number; + disposeTimer: ReturnType | null; } const workspaceClientsCache = new Map(); const WorkspaceClientContext = createContext(null); +function disposeWorkspaceClients(clientKey: string): void { + const clients = workspaceClientsCache.get(clientKey); + if (!clients || clients.activeRefs > 0) return; + if (clients.disposeTimer) { + clearTimeout(clients.disposeTimer); + clients.disposeTimer = null; + } + clients.queryClient.clear(); + workspaceClientsCache.delete(clientKey); +} + +function evictIdleWorkspaceClients(protectedKey?: string): void { + const keys = getIdleWorkspaceClientEvictionKeys( + Array.from(workspaceClientsCache.values(), (clients) => ({ + key: clients.clientKey, + activeRefs: clients.activeRefs, + lastAccessedAt: clients.lastAccessedAt, + })), + undefined, + protectedKey, + ); + + for (const key of keys) { + disposeWorkspaceClients(key); + } +} + +function scheduleWorkspaceClientsDispose(clients: WorkspaceClients): void { + if (clients.disposeTimer) { + clearTimeout(clients.disposeTimer); + } + clients.disposeTimer = setTimeout(() => { + disposeWorkspaceClients(clients.clientKey); + }, WORKSPACE_CLIENT_IDLE_DISPOSE_MS); +} + +function retainWorkspaceClients(clients: WorkspaceClients): () => void { + clients.activeRefs++; + clients.lastAccessedAt = Date.now(); + if (clients.disposeTimer) { + clearTimeout(clients.disposeTimer); + clients.disposeTimer = null; + } + evictIdleWorkspaceClients(clients.clientKey); + + return () => { + clients.activeRefs = Math.max(0, clients.activeRefs - 1); + clients.lastAccessedAt = Date.now(); + if (clients.activeRefs === 0) { + scheduleWorkspaceClientsDispose(clients); + } + evictIdleWorkspaceClients(); + }; +} + function getWorkspaceClients( cacheKey: string, hostUrl: string, @@ -48,6 +111,11 @@ function getWorkspaceClients( const clientKey = `${cacheKey}:${hostUrl}`; const cached = workspaceClientsCache.get(clientKey); if (cached) { + cached.lastAccessedAt = Date.now(); + if (cached.disposeTimer) { + clearTimeout(cached.disposeTimer); + cached.disposeTimer = null; + } return cached; } @@ -85,12 +153,17 @@ function getWorkspaceClients( const getWsToken = wsToken ?? (() => null); const clients: WorkspaceClients = { + clientKey, hostUrl, queryClient, trpcClient, getWsToken, + activeRefs: 0, + lastAccessedAt: Date.now(), + disposeTimer: null, }; workspaceClientsCache.set(clientKey, clients); + evictIdleWorkspaceClients(clientKey); return clients; } @@ -102,6 +175,8 @@ export function WorkspaceClientProvider({ children, }: WorkspaceClientProviderProps) { const clients = getWorkspaceClients(cacheKey, hostUrl, headers, wsToken); + useEffect(() => retainWorkspaceClients(clients), [clients]); + const contextValue: WorkspaceClientContextValue = { hostUrl: clients.hostUrl, queryClient: clients.queryClient, diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/workspaceClientCachePolicy.ts b/packages/workspace-client/src/providers/WorkspaceClientProvider/workspaceClientCachePolicy.ts new file mode 100644 index 00000000000..7742999088c --- /dev/null +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/workspaceClientCachePolicy.ts @@ -0,0 +1,22 @@ +export const MAX_WORKSPACE_CLIENT_CACHE_ENTRIES = 8; +export const WORKSPACE_CLIENT_IDLE_DISPOSE_MS = 60_000; + +export interface WorkspaceClientCachePolicyEntry { + key: string; + activeRefs: number; + lastAccessedAt: number; +} + +export function getIdleWorkspaceClientEvictionKeys( + entries: readonly WorkspaceClientCachePolicyEntry[], + maxEntries = MAX_WORKSPACE_CLIENT_CACHE_ENTRIES, + protectedKey?: string, +): string[] { + if (entries.length <= maxEntries) return []; + + const idleEntries = entries + .filter((entry) => entry.activeRefs === 0 && entry.key !== protectedKey) + .sort((left, right) => left.lastAccessedAt - right.lastAccessedAt); + const evictionCount = entries.length - maxEntries; + return idleEntries.slice(0, evictionCount).map((entry) => entry.key); +} diff --git a/packages/workspace-client/test/workspaceClientCachePolicy.test.ts b/packages/workspace-client/test/workspaceClientCachePolicy.test.ts new file mode 100644 index 00000000000..379536ee7a0 --- /dev/null +++ b/packages/workspace-client/test/workspaceClientCachePolicy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { getIdleWorkspaceClientEvictionKeys } from "../src/providers/WorkspaceClientProvider/workspaceClientCachePolicy"; + +describe("workspace client cache policy", () => { + test("does not evict when the cache is under the limit", () => { + expect( + getIdleWorkspaceClientEvictionKeys([ + { key: "a", activeRefs: 0, lastAccessedAt: 1 }, + { key: "b", activeRefs: 0, lastAccessedAt: 2 }, + ]), + ).toEqual([]); + }); + + test("evicts least recently used idle clients first", () => { + expect( + getIdleWorkspaceClientEvictionKeys( + [ + { key: "active-old", activeRefs: 1, lastAccessedAt: 1 }, + { key: "idle-old", activeRefs: 0, lastAccessedAt: 2 }, + { key: "idle-new", activeRefs: 0, lastAccessedAt: 3 }, + { key: "protected", activeRefs: 0, lastAccessedAt: 4 }, + ], + 2, + "protected", + ), + ).toEqual(["idle-old", "idle-new"]); + }); +}); diff --git a/turbo.jsonc b/turbo.jsonc index 30843360054..125a76a58bd 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -25,7 +25,8 @@ "VERCEL", "VERCEL_ENV", "VERCEL_URL", - "npm_lifecycle_event" + "npm_lifecycle_event", + "RENDERER_REMOTE_DEBUG_PORT" ], "tasks": { "build": { From 7565c95f52be4eaedb1182ff399d5efccab7c941 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 16:28:11 -0700 Subject: [PATCH 2/6] test(host-service): fix terminal adoption e2e --- .../src/terminal/terminal-mode-tracker.ts | 6 +- .../terminal/terminal.adoption.node-test.ts | 58 ++++++++++--------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/packages/host-service/src/terminal/terminal-mode-tracker.ts b/packages/host-service/src/terminal/terminal-mode-tracker.ts index 7534da741e7..05787e1ded7 100644 --- a/packages/host-service/src/terminal/terminal-mode-tracker.ts +++ b/packages/host-service/src/terminal/terminal-mode-tracker.ts @@ -13,7 +13,11 @@ // Pattern adapted from VSCode's XtermSerializer // (src/vs/platform/terminal/node/ptyService.ts). -import { Terminal as HeadlessTerminal } from "@xterm/headless"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { Terminal: HeadlessTerminal } = + require("@xterm/headless") as typeof import("@xterm/headless"); export interface ModeTracker { feed(bytes: Uint8Array): void; diff --git a/packages/host-service/src/terminal/terminal.adoption.node-test.ts b/packages/host-service/src/terminal/terminal.adoption.node-test.ts index 3bcedb95e16..c2a2af89b59 100644 --- a/packages/host-service/src/terminal/terminal.adoption.node-test.ts +++ b/packages/host-service/src/terminal/terminal.adoption.node-test.ts @@ -32,9 +32,10 @@ import { initTerminalBaseEnv } from "./env.ts"; import { __resetSessionsForTesting, createTerminalSessionInternal, - disposeSession, + disposeSessionAndWait, listTerminalSessions, } from "./terminal.ts"; +import { __setAccountShellForTesting } from "./user-shell.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const TEST_HOME = path.join(os.tmpdir(), `host-svc-adopt-${process.pid}`); @@ -63,6 +64,7 @@ before(async () => { process.env.HOST_SERVICE_VERSION = "0.0.0-adoption-e2e"; process.env.NODE_ENV = "development"; + __setAccountShellForTesting("/bin/sh"); initTerminalBaseEnv({ PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? TEST_HOME, @@ -86,6 +88,7 @@ before(async () => { after(async () => { __resetSessionsForTesting(); + __setAccountShellForTesting(undefined); await disposeDaemonClient(); await server.close(); try { @@ -120,7 +123,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = assert.equal(daemonSession.cols, 101); assert.equal(daemonSession.rows, 27); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("existing session accepts a not-yet-queued initialCommand", async () => { @@ -149,7 +152,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = assert.equal(second.initialCommandQueued, true); await waitFor(() => fs.existsSync(sentinelFile), 5000); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adoptOnly refuses to spawn when daemon does not own the session", async () => { @@ -191,11 +194,9 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = db, listed: true, }); - assert.ok( - !("error" in result), - `expected session, got error: ${JSON.stringify(result)}`, - ); - if ("error" in result) return; + if ("error" in result) { + assert.fail(`expected session, got error: ${result.error}`); + } assert.equal(result.terminalId, terminalId); assert.ok(result.pty.pid > 0, "pty pid should be populated"); @@ -206,7 +207,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "new session should be in listTerminalSessions", ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adopts existing daemon session after host-service restart simulation", async () => { @@ -253,7 +254,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = await waitFor(() => buf.includes("after-host-restart"), 3000); disposer.dispose(); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adopted session keeps listed/exited bookkeeping", async () => { @@ -286,7 +287,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = ), ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adopted session does NOT re-fire initialCommand", async () => { @@ -338,7 +339,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "initialCommand re-fired on adopted session — would re-run setup.sh on every host-service restart", ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("adoption when the original workspace row is gone returns a clear error", async () => { @@ -392,13 +393,13 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "adoption with missing workspace must return error, not throw or loop", ); if ("error" in second) { - assert.match(second.error, /Workspace worktree not found/); + assert.match(second.error, /Workspace (not found|worktree)/); } // Daemon still has the orphan session — clean it up directly so the // test suite leaves nothing behind. Production needs a periodic // "orphan session sweep" but that's a separate cleanup concern. - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("replayOnAdoption: false suppresses ring-buffer replay on reconnect", async () => { @@ -445,15 +446,18 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "adopted session should have same shell pid", ); - // session.bufferBytes is the direct signal: the primary subscription - // writes incoming chunks here for WS broadcast. Non-zero after a - // replay-suppressed adopt = bug. + // The shell may still produce live prompt bytes after reconnect, but + // the daemon ring-buffer sentinel from the previous host lifetime must + // not be replayed when replayOnAdoption=false. await new Promise((r) => setTimeout(r, 500)); + const bufferedAfterAdoption = Buffer.concat( + second.buffer.map((b) => Buffer.from(b)), + ).toString("utf8"); assert.equal( - second.bufferBytes, - 0, - `adopted session.bufferBytes must remain 0 when replayOnAdoption=false; got ${second.bufferBytes} bytes (first chunk: ${second.buffer[0] ? JSON.stringify(Buffer.from(second.buffer[0]).toString("utf8").slice(0, 100)) : ""})`, + bufferedAfterAdoption.includes(SENTINEL), + false, + `adopted session replayed prior output despite replayOnAdoption=false: ${JSON.stringify(bufferedAfterAdoption.slice(0, 200))}`, ); // Sanity check: live output still flows post-reattach. @@ -466,7 +470,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = return text.includes(LIVE_SENTINEL); }, 3000); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); test("dispose then re-create with the same id works (no zombie state)", async () => { @@ -485,7 +489,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = assert.ok(!("error" in first)); const firstPid = "error" in first ? -1 : first.pty.pid; - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); // Wait for the daemon's onExit handler to mark the session exited // (SIGTERM → shell exits → wireSession.onExit fires → session.exited @@ -498,11 +502,9 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = db, listed: true, }); - assert.ok( - !("error" in second), - `re-create after dispose failed: ${JSON.stringify(second)}`, - ); - if ("error" in second) return; + if ("error" in second) { + assert.fail(`re-create after dispose failed: ${second.error}`); + } // Different shell pid (real fresh spawn) — not adoption. assert.notEqual( @@ -511,7 +513,7 @@ describe("createTerminalSessionInternal — host-service restart adoption", () = "re-create after dispose should be a fresh spawn, not adoption of the dead session", ); - disposeSession(terminalId, db); + await disposeSessionAndWait(terminalId, db); }); }); From e2951f58b8974d4fbad3e1d534576bb968a735d6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 10:16:34 -0700 Subject: [PATCH 3/6] chore(desktop): remove speculative workspace mitigations --- .../useDashboardSidebarShortcuts.ts | 164 +++--------------- ...useDashboardSidebarShortcuts.utils.test.ts | 54 ------ .../useDashboardSidebarShortcuts.utils.ts | 47 ----- .../WorkspaceHostUnavailableState.tsx | 80 --------- .../WorkspaceHostUnavailableState/index.ts | 1 - .../hooks/useRemoteHostStatus/index.ts | 5 +- .../remoteHostStatusPolicy.test.ts | 80 --------- .../remoteHostStatusPolicy.ts | 78 --------- .../useRemoteHostStatus.ts | 51 ++++-- .../_dashboard/v2-workspace/layout.tsx | 32 +--- .../WorkspaceClientProvider.tsx | 79 +-------- .../workspaceClientCachePolicy.ts | 22 --- .../test/workspaceClientCachePolicy.test.ts | 28 --- 13 files changed, 58 insertions(+), 663 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.test.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/WorkspaceHostUnavailableState.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.test.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.ts delete mode 100644 packages/workspace-client/src/providers/WorkspaceClientProvider/workspaceClientCachePolicy.ts delete mode 100644 packages/workspace-client/test/workspaceClientCachePolicy.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index 4fe274d39eb..82413df4232 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -1,17 +1,10 @@ import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { useHotkey } from "renderer/hotkeys"; -import { logStressEvent } from "renderer/lib/performance/stress-instrumentation"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import type { DashboardSidebarProject } from "../../types"; import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; -import { - getRelativeWorkspaceTarget, - shouldRunWorkspaceSwitchHotkey, - WORKSPACE_SWITCH_HOTKEY_RELEASE_MS, - type WorkspaceSwitchDirection, -} from "./useDashboardSidebarShortcuts.utils"; interface WorkspaceLocation { projectId: string; @@ -21,7 +14,6 @@ interface WorkspaceLocation { } const MAX_SHORTCUT_COUNT = 9; -const WORKSPACE_SWITCH_DROP_LOG_INTERVAL_MS = 1_000; function haveSameIds(left: string[], right: string[]): boolean { return ( @@ -139,143 +131,29 @@ export function useDashboardSidebarShortcuts( }); const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; - const latestRelativeSwitchStateRef = useRef({ - currentWorkspaceId, - flattenedWorkspaces, - navigate, - revealWorkspace, - }); - latestRelativeSwitchStateRef.current = { - currentWorkspaceId, - flattenedWorkspaces, - navigate, - revealWorkspace, - }; - const lastRelativeSwitchAtRef = useRef(Number.NEGATIVE_INFINITY); - const relativeSwitchInFlightRef = useRef(false); - const relativeSwitchReleaseTimerRef = useRef | null>(null); - const pendingRelativeSwitchDirectionRef = - useRef(null); - const relativeSwitchDropLogRef = useRef({ - count: 0, - lastLoggedAt: Number.NEGATIVE_INFINITY, - }); - const runRelativeWorkspaceSwitchRef = useRef< - ( - direction: WorkspaceSwitchDirection, - source: "hotkey" | "coalesced", - ) => void - >(() => {}); - - const scheduleRelativeSwitchRelease = useCallback((delayMs: number) => { - if (relativeSwitchReleaseTimerRef.current) { - clearTimeout(relativeSwitchReleaseTimerRef.current); - } - relativeSwitchReleaseTimerRef.current = setTimeout(() => { - relativeSwitchInFlightRef.current = false; - relativeSwitchReleaseTimerRef.current = null; - - const pendingDirection = pendingRelativeSwitchDirectionRef.current; - pendingRelativeSwitchDirectionRef.current = null; - if (pendingDirection) { - runRelativeWorkspaceSwitchRef.current(pendingDirection, "coalesced"); - } - }, delayMs); - }, []); - - useEffect(() => { - return () => { - if (relativeSwitchReleaseTimerRef.current) { - clearTimeout(relativeSwitchReleaseTimerRef.current); - } - pendingRelativeSwitchDirectionRef.current = null; - }; - }, []); - - const runRelativeWorkspaceSwitch = useCallback( - (direction: WorkspaceSwitchDirection, source: "hotkey" | "coalesced") => { - const now = performance.now(); - const { - currentWorkspaceId: latestCurrentWorkspaceId, - flattenedWorkspaces: latestFlattenedWorkspaces, - navigate: latestNavigate, - revealWorkspace: latestRevealWorkspace, - } = latestRelativeSwitchStateRef.current; - const target = getRelativeWorkspaceTarget( - latestFlattenedWorkspaces, - latestCurrentWorkspaceId, - direction, - ); - if (!target) return; - - lastRelativeSwitchAtRef.current = now; - relativeSwitchInFlightRef.current = true; - scheduleRelativeSwitchRelease(WORKSPACE_SWITCH_HOTKEY_RELEASE_MS); - latestRevealWorkspace(target.id); - logStressEvent("workspace-switch-hotkey.navigate", { - direction, - source, - from: latestCurrentWorkspaceId, - to: target.id, - }); - void navigateToV2Workspace(target.id, latestNavigate, { replace: true }); - }, - [scheduleRelativeSwitchRelease], - ); - runRelativeWorkspaceSwitchRef.current = runRelativeWorkspaceSwitch; - const switchRelativeWorkspace = useCallback( - (direction: WorkspaceSwitchDirection, event: KeyboardEvent) => { - const now = performance.now(); - const shouldRun = shouldRunWorkspaceSwitchHotkey({ - isNavigating: relativeSwitchInFlightRef.current, - now, - lastRunAt: lastRelativeSwitchAtRef.current, - }); - if (!shouldRun) { - pendingRelativeSwitchDirectionRef.current = direction; - if (!relativeSwitchReleaseTimerRef.current) { - const remainingMs = Math.max( - 0, - WORKSPACE_SWITCH_HOTKEY_RELEASE_MS - - (now - lastRelativeSwitchAtRef.current), - ); - relativeSwitchInFlightRef.current = true; - scheduleRelativeSwitchRelease(remainingMs); - } - - const dropLog = relativeSwitchDropLogRef.current; - dropLog.count++; - if ( - now - dropLog.lastLoggedAt >= - WORKSPACE_SWITCH_DROP_LOG_INTERVAL_MS - ) { - logStressEvent("workspace-switch-hotkey.coalesced", { - direction, - count: dropLog.count, - repeated: event.repeat, - navigating: relativeSwitchInFlightRef.current, - }); - dropLog.count = 0; - dropLog.lastLoggedAt = now; - } - return; - } - - pendingRelativeSwitchDirectionRef.current = null; - runRelativeWorkspaceSwitch(direction, "hotkey"); - }, - [runRelativeWorkspaceSwitch, scheduleRelativeSwitchRelease], - ); - - useHotkey("PREV_WORKSPACE", (event) => { - switchRelativeWorkspace("previous", event); + useHotkey("PREV_WORKSPACE", () => { + if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + const index = flattenedWorkspaces.findIndex( + (w) => w.id === currentWorkspaceId, + ); + if (index === -1) return; + const prevIndex = index <= 0 ? flattenedWorkspaces.length - 1 : index - 1; + const target = flattenedWorkspaces[prevIndex]; + revealWorkspace(target.id); + navigateToV2Workspace(target.id, navigate); }); - useHotkey("NEXT_WORKSPACE", (event) => { - switchRelativeWorkspace("next", event); + useHotkey("NEXT_WORKSPACE", () => { + if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + const index = flattenedWorkspaces.findIndex( + (w) => w.id === currentWorkspaceId, + ); + if (index === -1) return; + const nextIndex = index >= flattenedWorkspaces.length - 1 ? 0 : index + 1; + const target = flattenedWorkspaces[nextIndex]; + revealWorkspace(target.id); + navigateToV2Workspace(target.id, navigate); }); return workspaceShortcutLabels; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.test.ts deleted file mode 100644 index 627d9d8bc3c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - getRelativeWorkspaceTarget, - shouldRunWorkspaceSwitchHotkey, -} from "./useDashboardSidebarShortcuts.utils"; - -const workspaces = [{ id: "a" }, { id: "b" }, { id: "c" }]; - -describe("useDashboardSidebarShortcuts utils", () => { - test("gets previous and next workspace targets with wrapping", () => { - expect(getRelativeWorkspaceTarget(workspaces, "b", "previous")?.id).toBe( - "a", - ); - expect(getRelativeWorkspaceTarget(workspaces, "b", "next")?.id).toBe("c"); - expect(getRelativeWorkspaceTarget(workspaces, "a", "previous")?.id).toBe( - "c", - ); - expect(getRelativeWorkspaceTarget(workspaces, "c", "next")?.id).toBe("a"); - }); - - test("returns null when the current workspace cannot be resolved", () => { - expect(getRelativeWorkspaceTarget(workspaces, null, "next")).toBeNull(); - expect( - getRelativeWorkspaceTarget(workspaces, "missing", "next"), - ).toBeNull(); - expect(getRelativeWorkspaceTarget([], "a", "next")).toBeNull(); - }); - - test("coalesces rapid workspace switch hotkeys while allowing controlled repeats", () => { - expect( - shouldRunWorkspaceSwitchHotkey({ - isNavigating: true, - now: 1_000, - lastRunAt: Number.NEGATIVE_INFINITY, - }), - ).toBe(false); - expect( - shouldRunWorkspaceSwitchHotkey({ - isNavigating: false, - now: 1_000, - lastRunAt: 900, - minIntervalMs: 160, - }), - ).toBe(false); - expect( - shouldRunWorkspaceSwitchHotkey({ - isNavigating: false, - now: 1_000, - lastRunAt: 820, - minIntervalMs: 160, - }), - ).toBe(true); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.ts deleted file mode 100644 index 70e4fb5da37..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.utils.ts +++ /dev/null @@ -1,47 +0,0 @@ -export const WORKSPACE_SWITCH_HOTKEY_THROTTLE_MS = 160; -export const WORKSPACE_SWITCH_HOTKEY_RELEASE_MS = 160; - -export type WorkspaceSwitchDirection = "previous" | "next"; - -interface WorkspaceLike { - id: string; -} - -interface ShouldRunWorkspaceSwitchHotkeyInput { - isNavigating: boolean; - now: number; - lastRunAt: number; - minIntervalMs?: number; -} - -export function shouldRunWorkspaceSwitchHotkey({ - isNavigating, - now, - lastRunAt, - minIntervalMs = WORKSPACE_SWITCH_HOTKEY_THROTTLE_MS, -}: ShouldRunWorkspaceSwitchHotkeyInput): boolean { - if (isNavigating) return false; - return now - lastRunAt >= minIntervalMs; -} - -export function getRelativeWorkspaceTarget( - workspaces: readonly T[], - currentWorkspaceId: string | null, - direction: WorkspaceSwitchDirection, -): T | null { - if (!currentWorkspaceId || workspaces.length === 0) return null; - const index = workspaces.findIndex( - (workspace) => workspace.id === currentWorkspaceId, - ); - if (index === -1) return null; - - const targetIndex = - direction === "previous" - ? index <= 0 - ? workspaces.length - 1 - : index - 1 - : index >= workspaces.length - 1 - ? 0 - : index + 1; - return workspaces[targetIndex] ?? null; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/WorkspaceHostUnavailableState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/WorkspaceHostUnavailableState.tsx deleted file mode 100644 index cebc77fc398..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/WorkspaceHostUnavailableState.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { Link } from "@tanstack/react-router"; -import { ArrowRight, Monitor, WifiOff } from "lucide-react"; - -interface WorkspaceHostUnavailableStateProps { - hostName: string; - reason: "offline" | "unreachable"; -} - -export function WorkspaceHostUnavailableState({ - hostName, - reason, -}: WorkspaceHostUnavailableStateProps) { - const title = - reason === "offline" ? "Host is offline" : "Host is unreachable"; - const description = - reason === "offline" - ? "This workspace's host is offline. Bring that device online to reconnect." - : "This workspace's host did not respond. Check the host device and try again."; - - return ( -
-
-
-
-
- -
- -
-

- {title} -

-

- {description} -

-
- -
-
- - -
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/index.ts deleted file mode 100644 index 12120980732..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceHostUnavailableState/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceHostUnavailableState } from "./WorkspaceHostUnavailableState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts index ab9fcfc1d30..e8a304923fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/index.ts @@ -1,5 +1,4 @@ export { - getRemoteHostStatus, type RemoteHostStatus, -} from "./remoteHostStatusPolicy"; -export { useRemoteHostStatus } from "./useRemoteHostStatus"; + useRemoteHostStatus, +} from "./useRemoteHostStatus"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.test.ts deleted file mode 100644 index 5f6752e0a9f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; -import { getRemoteHostStatus } from "./remoteHostStatusPolicy"; - -const baseRemoteInput = { - workspacePresent: true, - localMachineReady: true, - isLocal: false, - liveQueryReady: true, - hostName: "Town-Hall", - hostIsOnline: true, - hostInfoStatus: "success" as const, - hostVersion: MIN_HOST_SERVICE_VERSION, -}; - -describe("getRemoteHostStatus", () => { - test("waits until the workspace, local host id, and host row are ready", () => { - expect( - getRemoteHostStatus({ ...baseRemoteInput, workspacePresent: false }), - ).toEqual({ status: "loading" }); - expect( - getRemoteHostStatus({ ...baseRemoteInput, localMachineReady: false }), - ).toEqual({ status: "loading" }); - expect( - getRemoteHostStatus({ ...baseRemoteInput, liveQueryReady: false }), - ).toEqual({ status: "loading" }); - }); - - test("skips remote checks for the local host", () => { - expect(getRemoteHostStatus({ ...baseRemoteInput, isLocal: true })).toEqual({ - status: "skip", - }); - }); - - test("blocks offline and unreachable remote hosts before mounting a workspace", () => { - expect( - getRemoteHostStatus({ ...baseRemoteInput, hostIsOnline: false }), - ).toEqual({ - status: "unavailable", - hostName: "Town-Hall", - reason: "offline", - }); - expect( - getRemoteHostStatus({ - ...baseRemoteInput, - hostInfoStatus: "error", - hostVersion: undefined, - }), - ).toEqual({ - status: "unavailable", - hostName: "Town-Hall", - reason: "unreachable", - }); - }); - - test("waits while remote host info is still pending", () => { - expect( - getRemoteHostStatus({ - ...baseRemoteInput, - hostInfoStatus: "pending", - hostVersion: undefined, - }), - ).toEqual({ status: "loading" }); - }); - - test("reports incompatible host versions", () => { - expect( - getRemoteHostStatus({ ...baseRemoteInput, hostVersion: "0.0.0" }), - ).toEqual({ - status: "incompatible", - hostName: "Town-Hall", - hostVersion: "0.0.0", - minVersion: MIN_HOST_SERVICE_VERSION, - }); - }); - - test("allows compatible online remote hosts", () => { - expect(getRemoteHostStatus(baseRemoteInput)).toEqual({ status: "ready" }); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.ts deleted file mode 100644 index 33bab433106..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/remoteHostStatusPolicy.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; -import semver from "semver"; - -export type RemoteHostStatus = - | { status: "skip" } - | { status: "loading" } - | { - status: "unavailable"; - hostName: string; - reason: "offline" | "unreachable"; - } - | { - status: "incompatible"; - hostName: string; - hostVersion: string; - minVersion: string; - } - | { status: "ready" }; - -export type RemoteHostInfoStatus = "pending" | "error" | "success"; - -interface RemoteHostStatusPolicyInput { - workspacePresent: boolean; - localMachineReady: boolean; - isLocal: boolean; - liveQueryReady: boolean; - hostName?: string | null; - hostIsOnline?: boolean | null; - hostInfoStatus: RemoteHostInfoStatus; - hostVersion?: string | null; -} - -const UNKNOWN_HOST = "Unknown host"; - -export function getRemoteHostStatus({ - workspacePresent, - localMachineReady, - isLocal, - liveQueryReady, - hostName, - hostIsOnline, - hostInfoStatus, - hostVersion, -}: RemoteHostStatusPolicyInput): RemoteHostStatus { - if (!workspacePresent || !localMachineReady) return { status: "loading" }; - if (isLocal) return { status: "skip" }; - if (!liveQueryReady) return { status: "loading" }; - - const resolvedHostName = hostName ?? UNKNOWN_HOST; - - if (hostIsOnline === false) { - return { - status: "unavailable", - hostName: resolvedHostName, - reason: "offline", - }; - } - - if (hostInfoStatus === "pending") return { status: "loading" }; - if (hostInfoStatus === "error" || !hostVersion) { - return { - status: "unavailable", - hostName: resolvedHostName, - reason: "unreachable", - }; - } - - if (!semver.satisfies(hostVersion, `>=${MIN_HOST_SERVICE_VERSION}`)) { - return { - status: "incompatible", - hostName: resolvedHostName, - hostVersion, - minVersion: MIN_HOST_SERVICE_VERSION, - }; - } - - return { status: "ready" }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts index 11e36304666..f3fa0348a4a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/hooks/useRemoteHostStatus/useRemoteHostStatus.ts @@ -1,5 +1,6 @@ import type { SelectV2Workspace } from "@superset/db/schema"; import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { MIN_HOST_SERVICE_VERSION } from "@superset/shared/host-version"; import { and, eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; @@ -7,10 +8,18 @@ import { useRelayUrl } from "renderer/hooks/useRelayUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { - getRemoteHostStatus, - type RemoteHostStatus, -} from "./remoteHostStatusPolicy"; +import semver from "semver"; + +export type RemoteHostStatus = + | { status: "skip" } + | { status: "loading" } + | { + status: "incompatible"; + hostName: string; + hostVersion: string; + minVersion: string; + } + | { status: "ready" }; const HOST_INFO_STALE_MS = 30_000; @@ -22,11 +31,9 @@ export function useRemoteHostStatus( const relayUrl = useRelayUrl(); const organizationId = workspace?.organizationId ?? ""; const hostId = workspace?.hostId ?? ""; - const localMachineReady = machineId != null; const isLocal = workspace != null && machineId != null && workspace.hostId === machineId; - const shouldCheckRemote = workspace != null && localMachineReady && !isLocal; - const filterMachineId = shouldCheckRemote ? hostId : ""; + const filterMachineId = !workspace || isLocal ? "" : hostId; const { data: hostRows = [], isReady } = useLiveQuery( (q) => @@ -40,7 +47,6 @@ export function useRemoteHostStatus( ) .select(({ hosts }) => ({ name: hosts.name, - isOnline: hosts.isOnline, })), [collections, organizationId, filterMachineId], ); @@ -54,19 +60,26 @@ export function useRemoteHostStatus( const infoQuery = useQuery({ queryKey: ["remoteHostInfo", organizationId, hostId], queryFn: () => getHostServiceClientByUrl(hostUrl).host.info.query(), - enabled: shouldCheckRemote, + enabled: workspace != null && !isLocal, staleTime: HOST_INFO_STALE_MS, retry: false, }); - return getRemoteHostStatus({ - workspacePresent: workspace != null, - localMachineReady, - isLocal, - liveQueryReady: isReady, - hostName: hostRow?.name, - hostIsOnline: hostRow?.isOnline, - hostInfoStatus: infoQuery.status, - hostVersion: infoQuery.data?.version, - }); + if (!workspace) return { status: "loading" }; + if (isLocal) return { status: "skip" }; + if (!isReady) return { status: "loading" }; + + if (infoQuery.isSuccess) { + const hostVersion = infoQuery.data.version; + if (!semver.satisfies(hostVersion, `>=${MIN_HOST_SERVICE_VERSION}`)) { + return { + status: "incompatible", + hostName: hostRow?.name ?? "Unknown host", + hostVersion, + minVersion: MIN_HOST_SERVICE_VERSION, + }; + } + } + + return { status: "ready" }; } 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 b14f1bec64a..05a8de21c94 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 @@ -2,17 +2,12 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; -import { - logStressEvent, - useRenderStressInstrumentation, -} from "renderer/lib/performance/stress-instrumentation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useWorkspaceCreatesStore } from "renderer/stores/workspace-creates"; import { WorkspaceCreateErrorState } from "./components/WorkspaceCreateErrorState"; import { WorkspaceCreatingState } from "./components/WorkspaceCreatingState"; import { WorkspaceHostIncompatibleState } from "./components/WorkspaceHostIncompatibleState"; -import { WorkspaceHostUnavailableState } from "./components/WorkspaceHostUnavailableState"; import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { useRemoteHostStatus } from "./hooks/useRemoteHostStatus"; import { WorkspaceProvider } from "./providers/WorkspaceProvider"; @@ -60,23 +55,6 @@ function V2WorkspaceLayout() { }, [ensureWorkspaceInSidebar, workspace]); const hostStatus = useRemoteHostStatus(workspace); - const workspaceHostId = workspace?.hostId; - useRenderStressInstrumentation("V2WorkspaceLayout", { - warnAt: 30, - getDetails: () => ({ - workspaceId, - workspaceHostId, - hostStatus: hostStatus.status, - }), - }); - useEffect(() => { - if (!workspaceId || !workspaceHostId) return; - logStressEvent("v2-workspace.host-status", { - workspaceId, - workspaceHostId, - status: hostStatus.status, - }); - }, [hostStatus.status, workspaceHostId, workspaceId]); if (!workspaceId || !isReady || !workspaces) { return
; @@ -117,17 +95,9 @@ function V2WorkspaceLayout() { if (hostStatus.status === "loading") { return
; } - if (hostStatus.status === "unavailable") { - return ( - - ); - } return ( - + ); diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx index 7a8d2cad892..3e359b6a871 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx @@ -1,15 +1,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchStreamLink, TRPCClientError } from "@trpc/client"; -import { createContext, type ReactNode, useContext, useEffect } from "react"; +import { createContext, type ReactNode, useContext } from "react"; import superjson from "superjson"; import { workspaceTrpc } from "../../workspace-trpc"; -import { - getIdleWorkspaceClientEvictionKeys, - WORKSPACE_CLIENT_IDLE_DISPOSE_MS, -} from "./workspaceClientCachePolicy"; const STALE_TIME_MS = 5_000; -const GC_TIME_MS = 5 * 60 * 1_000; +const GC_TIME_MS = 30 * 60 * 1_000; const MAX_TIMEOUT_RETRIES = 2; const TIMEOUT_RETRY_BASE_DELAY_MS = 300; @@ -33,75 +29,16 @@ interface WorkspaceClientProviderProps { } interface WorkspaceClients { - clientKey: string; hostUrl: string; queryClient: QueryClient; trpcClient: ReturnType; getWsToken: () => string | null; - activeRefs: number; - lastAccessedAt: number; - disposeTimer: ReturnType | null; } const workspaceClientsCache = new Map(); const WorkspaceClientContext = createContext(null); -function disposeWorkspaceClients(clientKey: string): void { - const clients = workspaceClientsCache.get(clientKey); - if (!clients || clients.activeRefs > 0) return; - if (clients.disposeTimer) { - clearTimeout(clients.disposeTimer); - clients.disposeTimer = null; - } - clients.queryClient.clear(); - workspaceClientsCache.delete(clientKey); -} - -function evictIdleWorkspaceClients(protectedKey?: string): void { - const keys = getIdleWorkspaceClientEvictionKeys( - Array.from(workspaceClientsCache.values(), (clients) => ({ - key: clients.clientKey, - activeRefs: clients.activeRefs, - lastAccessedAt: clients.lastAccessedAt, - })), - undefined, - protectedKey, - ); - - for (const key of keys) { - disposeWorkspaceClients(key); - } -} - -function scheduleWorkspaceClientsDispose(clients: WorkspaceClients): void { - if (clients.disposeTimer) { - clearTimeout(clients.disposeTimer); - } - clients.disposeTimer = setTimeout(() => { - disposeWorkspaceClients(clients.clientKey); - }, WORKSPACE_CLIENT_IDLE_DISPOSE_MS); -} - -function retainWorkspaceClients(clients: WorkspaceClients): () => void { - clients.activeRefs++; - clients.lastAccessedAt = Date.now(); - if (clients.disposeTimer) { - clearTimeout(clients.disposeTimer); - clients.disposeTimer = null; - } - evictIdleWorkspaceClients(clients.clientKey); - - return () => { - clients.activeRefs = Math.max(0, clients.activeRefs - 1); - clients.lastAccessedAt = Date.now(); - if (clients.activeRefs === 0) { - scheduleWorkspaceClientsDispose(clients); - } - evictIdleWorkspaceClients(); - }; -} - function getWorkspaceClients( cacheKey: string, hostUrl: string, @@ -111,11 +48,6 @@ function getWorkspaceClients( const clientKey = `${cacheKey}:${hostUrl}`; const cached = workspaceClientsCache.get(clientKey); if (cached) { - cached.lastAccessedAt = Date.now(); - if (cached.disposeTimer) { - clearTimeout(cached.disposeTimer); - cached.disposeTimer = null; - } return cached; } @@ -153,17 +85,12 @@ function getWorkspaceClients( const getWsToken = wsToken ?? (() => null); const clients: WorkspaceClients = { - clientKey, hostUrl, queryClient, trpcClient, getWsToken, - activeRefs: 0, - lastAccessedAt: Date.now(), - disposeTimer: null, }; workspaceClientsCache.set(clientKey, clients); - evictIdleWorkspaceClients(clientKey); return clients; } @@ -175,8 +102,6 @@ export function WorkspaceClientProvider({ children, }: WorkspaceClientProviderProps) { const clients = getWorkspaceClients(cacheKey, hostUrl, headers, wsToken); - useEffect(() => retainWorkspaceClients(clients), [clients]); - const contextValue: WorkspaceClientContextValue = { hostUrl: clients.hostUrl, queryClient: clients.queryClient, diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/workspaceClientCachePolicy.ts b/packages/workspace-client/src/providers/WorkspaceClientProvider/workspaceClientCachePolicy.ts deleted file mode 100644 index 7742999088c..00000000000 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/workspaceClientCachePolicy.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const MAX_WORKSPACE_CLIENT_CACHE_ENTRIES = 8; -export const WORKSPACE_CLIENT_IDLE_DISPOSE_MS = 60_000; - -export interface WorkspaceClientCachePolicyEntry { - key: string; - activeRefs: number; - lastAccessedAt: number; -} - -export function getIdleWorkspaceClientEvictionKeys( - entries: readonly WorkspaceClientCachePolicyEntry[], - maxEntries = MAX_WORKSPACE_CLIENT_CACHE_ENTRIES, - protectedKey?: string, -): string[] { - if (entries.length <= maxEntries) return []; - - const idleEntries = entries - .filter((entry) => entry.activeRefs === 0 && entry.key !== protectedKey) - .sort((left, right) => left.lastAccessedAt - right.lastAccessedAt); - const evictionCount = entries.length - maxEntries; - return idleEntries.slice(0, evictionCount).map((entry) => entry.key); -} diff --git a/packages/workspace-client/test/workspaceClientCachePolicy.test.ts b/packages/workspace-client/test/workspaceClientCachePolicy.test.ts deleted file mode 100644 index 379536ee7a0..00000000000 --- a/packages/workspace-client/test/workspaceClientCachePolicy.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { getIdleWorkspaceClientEvictionKeys } from "../src/providers/WorkspaceClientProvider/workspaceClientCachePolicy"; - -describe("workspace client cache policy", () => { - test("does not evict when the cache is under the limit", () => { - expect( - getIdleWorkspaceClientEvictionKeys([ - { key: "a", activeRefs: 0, lastAccessedAt: 1 }, - { key: "b", activeRefs: 0, lastAccessedAt: 2 }, - ]), - ).toEqual([]); - }); - - test("evicts least recently used idle clients first", () => { - expect( - getIdleWorkspaceClientEvictionKeys( - [ - { key: "active-old", activeRefs: 1, lastAccessedAt: 1 }, - { key: "idle-old", activeRefs: 0, lastAccessedAt: 2 }, - { key: "idle-new", activeRefs: 0, lastAccessedAt: 3 }, - { key: "protected", activeRefs: 0, lastAccessedAt: 4 }, - ], - 2, - "protected", - ), - ).toEqual(["idle-old", "idle-new"]); - }); -}); From 7eace634dea269b9b37e53e421fb42fee3eefed4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 10:27:28 -0700 Subject: [PATCH 4/6] fix(desktop): show background terminals immediately --- .../terminal/terminal-background-intents.ts | 57 +++++++++++- .../BackgroundTerminalsButton.tsx | 88 ++++++++++++++++++- .../BackgroundTerminalsButton.utils.test.ts | 7 ++ .../BackgroundTerminalsButton.utils.ts | 10 +++ .../TerminalHeaderExtras.tsx | 2 +- .../TerminalSessionDropdown.tsx | 4 +- 6 files changed, 161 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts index acc7662c1ee..c91cc7a5281 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts @@ -1,9 +1,64 @@ const backgroundTerminalIds = new Set(); +const backgroundTerminalMarkersByWorkspace = new Map>(); +const markerListeners = new Set<() => void>(); -export function markTerminalForBackground(terminalId: string): void { +function emitMarkerChange(): void { + for (const listener of markerListeners) { + listener(); + } +} + +function getWorkspaceMarkers(workspaceId: string): Set { + const existing = backgroundTerminalMarkersByWorkspace.get(workspaceId); + if (existing) return existing; + + const markers = new Set(); + backgroundTerminalMarkersByWorkspace.set(workspaceId, markers); + return markers; +} + +export function markTerminalForBackground( + terminalId: string, + workspaceId?: string, +): void { backgroundTerminalIds.add(terminalId); + + if (!workspaceId) return; + + const markers = getWorkspaceMarkers(workspaceId); + if (markers.has(terminalId)) return; + + markers.add(terminalId); + emitMarkerChange(); } export function consumeTerminalBackgroundIntent(terminalId: string): boolean { return backgroundTerminalIds.delete(terminalId); } + +export function clearTerminalBackgroundMarker( + workspaceId: string, + terminalId: string, +): void { + const markers = backgroundTerminalMarkersByWorkspace.get(workspaceId); + if (!markers?.delete(terminalId)) return; + + if (markers.size === 0) { + backgroundTerminalMarkersByWorkspace.delete(workspaceId); + } + emitMarkerChange(); +} + +export function getTerminalBackgroundMarkerIdsKey(workspaceId: string): string { + const markers = backgroundTerminalMarkersByWorkspace.get(workspaceId); + return JSON.stringify(markers ? [...markers].sort() : []); +} + +export function subscribeTerminalBackgroundMarkers( + listener: () => void, +): () => void { + markerListeners.add(listener); + return () => { + markerListeners.delete(listener); + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx index 0dcc4779b01..2e4e9807987 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx @@ -11,12 +11,25 @@ import { import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { Archive, ChevronDown, Trash2 } from "lucide-react"; -import { memo, useMemo, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { logStressEvent, useRenderStressInstrumentation, } from "renderer/lib/performance/stress-instrumentation"; +import { + clearTerminalBackgroundMarker, + getTerminalBackgroundMarkerIdsKey, + subscribeTerminalBackgroundMarkers, +} from "renderer/lib/terminal/terminal-background-intents"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; import { useStore } from "zustand"; import type { StoreApi } from "zustand/vanilla"; @@ -27,6 +40,7 @@ import { getBackgroundTerminalCountRefetchInterval, getBackgroundTerminalListRefetchInterval, getBackgroundTerminalSessions, + getUnattachedTerminalIds, parseAttachedTerminalIdsKey, } from "./BackgroundTerminalsButton.utils"; @@ -58,10 +72,28 @@ export const BackgroundTerminalsButton = memo( () => parseAttachedTerminalIdsKey(attachedTerminalIdsKey), [attachedTerminalIdsKey], ); + const getBackgroundMarkerSnapshot = useCallback( + () => getTerminalBackgroundMarkerIdsKey(workspaceId), + [workspaceId], + ); + const backgroundMarkerIdsKey = useSyncExternalStore( + subscribeTerminalBackgroundMarkers, + getBackgroundMarkerSnapshot, + () => "[]", + ); + const backgroundMarkerIds = useMemo( + () => parseAttachedTerminalIdsKey(backgroundMarkerIdsKey), + [backgroundMarkerIdsKey], + ); const debouncedAttachedTerminalIds = useMemo( () => parseAttachedTerminalIdsKey(debouncedAttachedTerminalIdsKey), [debouncedAttachedTerminalIdsKey], ); + const optimisticBackgroundTerminalIds = useMemo( + () => getUnattachedTerminalIds(backgroundMarkerIds, attachedTerminalIds), + [backgroundMarkerIds, attachedTerminalIds], + ); + const optimisticBackgroundCount = optimisticBackgroundTerminalIds.length; const backgroundCountInput = useMemo( () => ({ workspaceId, @@ -77,7 +109,7 @@ export const BackgroundTerminalsButton = memo( backgroundCountInput, { enabled: !isOpen, - notifyOnChangeProps: ["data"], + notifyOnChangeProps: ["data", "dataUpdatedAt"], refetchInterval: getBackgroundTerminalCountRefetchInterval(isOpen), refetchOnWindowFocus: false, staleTime: 5_000, @@ -99,6 +131,7 @@ export const BackgroundTerminalsButton = memo( getDetails: () => ({ isOpen, attachedTerminalCount: attachedTerminalIds.length, + optimisticBackgroundCount, closedCount: backgroundCountQuery.data?.count ?? null, }), }); @@ -108,10 +141,57 @@ export const BackgroundTerminalsButton = memo( return getBackgroundTerminalSessions(sessions, attachedTerminalIds); }, [sessionsQuery.data?.sessions, attachedTerminalIds]); + const markerObservedAtRef = useRef(0); + useEffect(() => { + markerObservedAtRef.current = + backgroundMarkerIdsKey === "[]" ? 0 : Date.now(); + }, [backgroundMarkerIdsKey]); + + useEffect(() => { + if (!sessionsQuery.data) return; + + const actualBackgroundTerminalIds = new Set( + backgroundSessions.map((session) => session.terminalId), + ); + for (const terminalId of backgroundMarkerIds) { + if (actualBackgroundTerminalIds.has(terminalId)) continue; + clearTerminalBackgroundMarker(workspaceId, terminalId); + } + }, [ + backgroundMarkerIds, + backgroundSessions, + sessionsQuery.data, + workspaceId, + ]); + + useEffect(() => { + if (isOpen || optimisticBackgroundTerminalIds.length === 0) return; + if (debouncedAttachedTerminalIdsKey !== attachedTerminalIdsKey) return; + if (backgroundCountQuery.data?.count !== 0) return; + if (backgroundCountQuery.dataUpdatedAt <= markerObservedAtRef.current) { + return; + } + + for (const terminalId of optimisticBackgroundTerminalIds) { + clearTerminalBackgroundMarker(workspaceId, terminalId); + } + }, [ + attachedTerminalIdsKey, + backgroundCountQuery.data?.count, + backgroundCountQuery.dataUpdatedAt, + debouncedAttachedTerminalIdsKey, + isOpen, + optimisticBackgroundTerminalIds, + workspaceId, + ]); + const backgroundCount = isOpen && sessionsQuery.data ? backgroundSessions.length - : (backgroundCountQuery.data?.count ?? backgroundSessions.length); + : Math.max( + backgroundCountQuery.data?.count ?? 0, + optimisticBackgroundCount, + ); if (!isOpen && backgroundCount === 0) return null; @@ -120,6 +200,7 @@ export const BackgroundTerminalsButton = memo( }`; const handleAdopt = (terminalId: string) => { + clearTerminalBackgroundMarker(workspaceId, terminalId); store.getState().addTab({ panes: [ { @@ -137,6 +218,7 @@ export const BackgroundTerminalsButton = memo( const handleKill = async (terminalId: string) => { try { await killSession.mutateAsync({ terminalId, workspaceId }); + clearTerminalBackgroundMarker(workspaceId, terminalId); } catch (error) { console.error( "[BackgroundTerminalsButton] Failed to kill session:", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts index 7e81dd55d7f..6ab521b3c0a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts @@ -4,6 +4,7 @@ import { getBackgroundTerminalCountRefetchInterval, getBackgroundTerminalListRefetchInterval, getBackgroundTerminalSessions, + getUnattachedTerminalIds, parseAttachedTerminalIdsKey, } from "./BackgroundTerminalsButton.utils"; @@ -44,6 +45,12 @@ describe("BackgroundTerminalsButton utils", () => { ).toEqual(["new", "old"]); }); + test("deduplicates optimistic background terminal markers and ignores attached terminals", () => { + expect( + getUnattachedTerminalIds(["term-b", "term-a", "term-b"], ["term-a"]), + ).toEqual(["term-b"]); + }); + test("uses shallow count polling only while closed", () => { expect(getBackgroundTerminalCountRefetchInterval(false)).toBe(15_000); expect(getBackgroundTerminalCountRefetchInterval(true)).toBe(false); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts index 58258006d4b..0a1649a70a6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts @@ -58,6 +58,16 @@ export function getBackgroundTerminalSessions< .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); } +export function getUnattachedTerminalIds( + terminalIds: Iterable, + attachedTerminalIds: Iterable, +): string[] { + const attached = new Set(attachedTerminalIds); + return [...new Set(terminalIds)] + .filter((terminalId) => !attached.has(terminalId)) + .sort(); +} + export function getBackgroundTerminalCountRefetchInterval( isOpen: boolean, ): number | false { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx index 414ceddd2b3..4b962c1c6e3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx @@ -23,7 +23,7 @@ export function TerminalHeaderExtras({ const data = context.pane.data as TerminalPaneData; const handleMoveToBackground = () => { - markTerminalForBackground(data.terminalId); + markTerminalForBackground(data.terminalId, workspaceId); void context.actions.close(); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index fb6200693a9..440e94f402d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -191,7 +191,7 @@ export function TerminalSessionDropdown({ } if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { - markTerminalForBackground(terminalId); + markTerminalForBackground(terminalId, workspaceId); } state.setPaneData({ @@ -250,7 +250,7 @@ export function TerminalSessionDropdown({ const state = context.store.getState(); const terminalPaneLocations = getTerminalPaneLocations(context); if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { - markTerminalForBackground(terminalId); + markTerminalForBackground(terminalId, workspaceId); } state.setPaneData({ paneId: context.pane.id, From ec1fdfc117a4932cdedc7c67efb7f1240140383d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 16:59:40 -0700 Subject: [PATCH 5/6] fix(desktop): poll background terminal count sooner --- .../BackgroundTerminalsButton.utils.test.ts | 2 +- .../BackgroundTerminalsButton.utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts index 6ab521b3c0a..2c676319d08 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts @@ -52,7 +52,7 @@ describe("BackgroundTerminalsButton utils", () => { }); test("uses shallow count polling only while closed", () => { - expect(getBackgroundTerminalCountRefetchInterval(false)).toBe(15_000); + expect(getBackgroundTerminalCountRefetchInterval(false)).toBe(5_000); expect(getBackgroundTerminalCountRefetchInterval(true)).toBe(false); expect(getBackgroundTerminalListRefetchInterval(false)).toBe(false); expect(getBackgroundTerminalListRefetchInterval(true)).toBe(2_000); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts index 0a1649a70a6..2d01366f7cc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts @@ -1,5 +1,5 @@ export const BACKGROUND_TERMINAL_ATTACHMENT_DEBOUNCE_MS = 250; -export const BACKGROUND_TERMINAL_COUNT_REFETCH_INTERVAL_MS = 15_000; +export const BACKGROUND_TERMINAL_COUNT_REFETCH_INTERVAL_MS = 5_000; export const BACKGROUND_TERMINAL_LIST_REFETCH_INTERVAL_MS = 2_000; interface TerminalPaneLike { From ba8e064bd7dc1c1f0d3927bf8f83412d7584267c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 17:57:38 -0700 Subject: [PATCH 6/6] fix(desktop): poll background terminals every ten seconds --- .../BackgroundTerminalsButton.utils.test.ts | 2 +- .../BackgroundTerminalsButton.utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts index 2c676319d08..1f54c369801 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.test.ts @@ -52,7 +52,7 @@ describe("BackgroundTerminalsButton utils", () => { }); test("uses shallow count polling only while closed", () => { - expect(getBackgroundTerminalCountRefetchInterval(false)).toBe(5_000); + expect(getBackgroundTerminalCountRefetchInterval(false)).toBe(10_000); expect(getBackgroundTerminalCountRefetchInterval(true)).toBe(false); expect(getBackgroundTerminalListRefetchInterval(false)).toBe(false); expect(getBackgroundTerminalListRefetchInterval(true)).toBe(2_000); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts index 2d01366f7cc..89de8aba18a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.utils.ts @@ -1,5 +1,5 @@ export const BACKGROUND_TERMINAL_ATTACHMENT_DEBOUNCE_MS = 250; -export const BACKGROUND_TERMINAL_COUNT_REFETCH_INTERVAL_MS = 5_000; +export const BACKGROUND_TERMINAL_COUNT_REFETCH_INTERVAL_MS = 10_000; export const BACKGROUND_TERMINAL_LIST_REFETCH_INTERVAL_MS = 2_000; interface TerminalPaneLike {