Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/plans/20260721-sidebar-header-reorg-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Revert guide: each item is independent; revert by undoing the listed file(s).
- **Settings is an icon-only gear** in the footer (was a full-width labeled row); tooltip carries the hotkey. `DashboardSidebar.tsx`
- **Org dropdown (expanded trigger): chevron removed** (topbar variant keeps it); **Settings item removed** from the menu (was the v1 TODO item); **"Switch organization" got an icon** (`HiOutlineArrowsRightLeft`). `TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx`
- **`SubmitPromptDialog` moved** to `OrganizationDropdown/components/`; old `DashboardSidebarHelpMenu/` deleted (git-tracked, restorable).
- **Workspaces tab hidden, not deleted** — `SHOW_WORKSPACES_TAB = false` flag; flip to true to restore. `DashboardSidebarHeader.tsx`
- **Workspaces tab hidden, not deleted** — `SHOW_WORKSPACES_TAB = false` flag; flip to true to restore. `DashboardSidebarHeader.tsx` *(Update 2026-07-23: tab restored unconditionally; flag removed.)*
- **New Search row/icon** opens the command palette; clicking while open closes it (state captured on pointerdown because the palette dismisses on outside pointerdown before click). `DashboardSidebarHeader.tsx`
- **Looking-glass button deleted** from `NavigationControls.tsx` (`⌘K` hotkey unaffected).
- **ResourceConsumption moved** from right edge (`ml-auto`) to after back/forward in the top row. `DashboardSidebarHeader.tsx`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,26 @@ export interface UseHostWorkspacesResult {
}

/**
* The workspace read path: fan out `workspace.list` to every known host
* (local direct, remote via relay), merge, live-update from each host's
* `workspace:changed` events, and persist last-seen lists per host to
* IndexedDB so remote machines still render offline.
* The workspace read path: `workspace.list` per host (local direct, remote
* via relay), merged, live-updated from each host's `workspace:changed`
* events, with last-seen lists persisted per host to IndexedDB so remote
* machines still render offline.
*
* Cloud rows from the still-synced Electric collection fill in only for
* hosts that served nothing (pre-R1 builds, no snapshot) — that fallback
* disappears in R3.
* Unscoped (`scopedHostId` omitted): fans out to every known host — runs
* once inside HostWorkspacesProvider; consumers read the shared result via
* that provider's useHostWorkspaces. Cloud rows from the still-synced
* Electric collection fill in only for hosts that served nothing (pre-R1
* builds, no snapshot) — that fallback disappears in R3.
*
* Runs once inside HostWorkspacesProvider; consumers read the shared
* result via that provider's useHostWorkspaces.
* Scoped (`scopedHostId` a machine id): a single host, no fan-out and no
* cloud fallback — an unreachable host shows its snapshot or nothing, never
* stale cloud rows. Query keys are shared with the provider, so a scoped
* call where the provider is mounted fetches the host once, not twice.
* Passing null resolves no target and runs nothing (stays !isReady).
*/
export function useHostWorkspacesSource(): UseHostWorkspacesResult {
export function useHostWorkspacesSource(
scopedHostId?: string | null,
): UseHostWorkspacesResult {
Comment on lines +68 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm simultaneous unscoped/provider and scoped/page source-hook usage.
rg -n -C3 --type ts 'useHostWorkspacesSource\s*\(' apps/desktop/src/renderer

# Inspect the source-local listener and retain lifecycle.
rg -n -C5 --type ts '"workspace:changed"|\.retain\(\)|getEventBus' \
  apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.ts

Repository: superset-sh/superset

Length of output: 4436


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== useHostWorkspaces outline =="
ast-grep outline apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.ts --view expanded || true

echo "== source-hook key/runtime sections =="
cat -n apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.ts | sed -n '1,260p'

echo "== HostWorkspacesProvider =="
cat -n apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/HostWorkspacesProvider.tsx | sed -n '1,80p'

echo "== v2Workspaces relevant source =="
cat -n apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts | sed -n '220,260p'

echo "== query key implementation =="
rg -n -C4 --type ts 'function getHostWorkspacesQueryKey|const getHostWorkspacesQueryKey|`@tanstack/react-query`|useQueries|useQueryClient|useLiveQuery|retain\(\)' apps/desktop/src/renderer/hooks/host-workspaces apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces

Repository: superset-sh/superset

Length of output: 38368


Deduplicate scoped source per-host lifecycle work.

HostWorkspacesProvider mounts an unscoped useHostWorkspacesSource() while useAccessibleV2Workspaces also mounts useHostWorkspacesSource(selectedHostId) for the selected host. Shared query keys coalesce results, but each hook instance hydrates the IndexedDB snapshot and registers its own workspace:changed listener with bus.retain(), so selected-host events are patched and persisted twice.

  • apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.ts#L68-L70: move per-host snapshot/event ownership into a shared provider-backed source, or otherwise share it across scoped and unscoped consumers.
  • apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/HostWorkspacesProvider.tsx#L14-L15: update the guidance to avoid implying scoped calls are fully safe for non-query lifecycle work.
📍 Affects 2 files
  • apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.ts#L68-L70 (this comment)
  • apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/HostWorkspacesProvider.tsx#L14-L15
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.ts`
around lines 68 - 70, The per-host snapshot hydration and workspace:changed
listener ownership currently runs once per useHostWorkspacesSource instance,
causing duplicate persistence and event patching for scoped and unscoped
consumers. In useHostWorkspacesSource, move or share this lifecycle work through
provider-backed ownership so each host has only one hydrator and retained
listener; update the guidance in HostWorkspacesProvider to clarify that scoped
calls are not inherently safe for non-query lifecycle work. Affected sites:
apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.ts
lines 68-70 require the lifecycle deduplication;
apps/desktop/src/renderer/routes/_authenticated/providers/HostWorkspacesProvider/HostWorkspacesProvider.tsx
lines 14-15 require the guidance update.

const collections = useCollections();
const queryClient = useQueryClient();
const { activeHostUrl, machineId } = useLocalHostService();
Expand All @@ -81,16 +88,17 @@ export function useHostWorkspacesSource(): UseHostWorkspacesResult {
[collections],
);

const targets = useMemo(
() =>
deriveHostWorkspacesQueryTargets({
activeHostUrl,
hosts,
machineId,
relayUrl,
}),
[activeHostUrl, hosts, machineId, relayUrl],
);
const targets = useMemo(() => {
const all = deriveHostWorkspacesQueryTargets({
activeHostUrl,
hosts,
machineId,
relayUrl,
});
return scopedHostId === undefined
? all
: all.filter((target) => target.machineId === scopedHostId);
}, [activeHostUrl, hosts, machineId, relayUrl, scopedHostId]);

// Last-seen snapshots hydrate once per (org, host); live data always wins.
const [snapshots, setSnapshots] = useState<Map<string, HostWorkspaceRow[]>>(
Expand Down Expand Up @@ -209,23 +217,26 @@ export function useHostWorkspacesSource(): UseHostWorkspacesResult {
reachable: live !== undefined && !query?.isError,
};
}),
cloudRows,
cloudRows: scopedHostId === undefined ? cloudRows : [],
}),
[targets, queries, snapshots, cloudRows],
[targets, queries, snapshots, cloudRows, scopedHostId],
);

// Readiness reflects host-query settlement only. The Electric collection
// is a fallback merge, NOT a gate: an Electric collection can stay
// !isReady indefinitely on an offline cold start (it serves persisted
// rows without reaching ready), so gating on cloudReady would hang the
// empty state forever for a genuinely-empty local host while offline.
const isReady = queries.every(
(query, index) =>
query.isSuccess ||
query.isError ||
targets[index]?.hostUrl === null ||
snapshots.has(targets[index]?.machineId ?? ""),
);
// A scoped host that hasn't resolved to a target yet is still loading.
const isReady =
(scopedHostId === undefined || targets.length > 0) &&
queries.every(
(query, index) =>
query.isSuccess ||
query.isError ||
targets[index]?.hostUrl === null ||
snapshots.has(targets[index]?.machineId ?? ""),
);

const cache = useMemo<HostWorkspacesCacheOps>(() => {
const targetFor = (hostId: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ import {
} from "renderer/stores/add-repository-modal";
import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal";

// Temporarily hidden, not removed — flip to true to restore the Workspaces tab.
const SHOW_WORKSPACES_TAB = false;

interface DashboardSidebarHeaderProps {
isCollapsed?: boolean;
}
Expand Down Expand Up @@ -171,25 +168,23 @@ export function DashboardSidebarHeader({
</TooltipContent>
</Tooltip>

{SHOW_WORKSPACES_TAB && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleWorkspacesClick}
className={cn(
"flex size-8 items-center justify-center rounded-md transition-colors",
isWorkspacesListOpen
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<LuLayers className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Workspaces</TooltipContent>
</Tooltip>
)}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleWorkspacesClick}
className={cn(
"flex size-8 items-center justify-center rounded-md transition-colors",
isWorkspacesListOpen
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<LuLayers className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Workspaces</TooltipContent>
</Tooltip>

<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -351,21 +346,19 @@ export function DashboardSidebarHeader({
)}
</button>

{SHOW_WORKSPACES_TAB && (
<button
type="button"
onClick={handleWorkspacesClick}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
isWorkspacesListOpen
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<LuLayers className="size-4 shrink-0" />
<span className="flex-1 text-left">Workspaces</span>
</button>
)}
<button
type="button"
onClick={handleWorkspacesClick}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
isWorkspacesListOpen
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<LuLayers className="size-4 shrink-0" />
<span className="flex-1 text-left">Workspaces</span>
</button>

<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,12 @@ import {
SelectTrigger,
SelectValue,
} from "@superset/ui/select";
import {
LuFolders,
LuLaptop,
LuLayers,
LuMonitor,
LuSearch,
LuX,
} from "react-icons/lu";
import { LuFolders, LuLaptop, LuMonitor, LuSearch, LuX } from "react-icons/lu";
import type {
V2WorkspaceDeviceCounts,
V2WorkspaceHostOption,
V2WorkspaceProjectOption,
} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces";
import {
DEVICE_FILTER_ALL,
DEVICE_FILTER_THIS_DEVICE,
PROJECT_FILTER_ALL,
useV2WorkspacesFilterStore,
Expand All @@ -39,7 +30,6 @@ import { DeviceOptionLabel } from "./components/DeviceOptionLabel";
import { ProjectFilterTriggerLabel } from "./components/ProjectFilterTriggerLabel";

interface V2WorkspacesHeaderProps {
counts: V2WorkspaceDeviceCounts;
hostOptions: V2WorkspaceHostOption[];
projectOptions: V2WorkspaceProjectOption[];
hostsById: Map<
Expand All @@ -53,7 +43,6 @@ interface V2WorkspacesHeaderProps {
}

export function V2WorkspacesHeader({
counts,
hostOptions,
projectOptions,
hostsById,
Expand Down Expand Up @@ -81,9 +70,7 @@ export function V2WorkspacesHeader({
(host) => host.hostId === deviceFilter,
);
const selectedHostFallback =
!selectedRemoteHostFromOptions &&
deviceFilter !== DEVICE_FILTER_ALL &&
deviceFilter !== DEVICE_FILTER_THIS_DEVICE
!selectedRemoteHostFromOptions && deviceFilter !== DEVICE_FILTER_THIS_DEVICE
? hostsById.get(deviceFilter)
: undefined;
const selectedHostLabel = selectedRemoteHostFromOptions
Expand Down Expand Up @@ -215,14 +202,6 @@ export function V2WorkspacesHeader({
<DeviceOptionLabel
icon={<LuLaptop className="size-3.5" />}
label="This device"
count={counts.thisDevice}
/>
</SelectItem>
<SelectItem value={DEVICE_FILTER_ALL}>
<DeviceOptionLabel
icon={<LuLayers className="size-3.5" />}
label="All devices"
count={counts.all}
/>
</SelectItem>
</SelectGroup>
Expand All @@ -239,7 +218,6 @@ export function V2WorkspacesHeader({
<DeviceOptionLabel
icon={<LuMonitor className="size-3.5" />}
label={host.hostName}
count={host.count}
isOnline={host.isOnline}
/>
</SelectItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { cn } from "@superset/ui/utils";
import { LuLaptop, LuLayers, LuMonitor } from "react-icons/lu";
import {
DEVICE_FILTER_ALL,
DEVICE_FILTER_THIS_DEVICE,
} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore";
import { LuLaptop, LuMonitor } from "react-icons/lu";
import { DEVICE_FILTER_THIS_DEVICE } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore";

interface DeviceFilterTriggerLabelProps {
deviceFilter: string;
Expand All @@ -14,14 +11,6 @@ export function DeviceFilterTriggerLabel({
deviceFilter,
selectedRemoteHost,
}: DeviceFilterTriggerLabelProps) {
if (deviceFilter === DEVICE_FILTER_ALL) {
return (
<span className="flex items-center gap-2">
<LuLayers className="size-3.5" />
<span>All devices</span>
</span>
);
}
if (deviceFilter === DEVICE_FILTER_THIS_DEVICE) {
return (
<span className="flex items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import { cn } from "@superset/ui/utils";
interface DeviceOptionLabelProps {
icon: React.ReactNode;
label: string;
count: number;
isOnline?: boolean;
}

export function DeviceOptionLabel({
icon,
label,
count,
isOnline,
}: DeviceOptionLabelProps) {
return (
Expand All @@ -26,9 +24,6 @@ export function DeviceOptionLabel({
)}
/>
) : null}
<span className="tabular-nums text-xs text-muted-foreground">
{count}
</span>
</span>
);
}
Loading
Loading