From 00a4aaba3d7eb8ee7c4de75ea0ac368ee8403311 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 28 Apr 2026 12:13:12 -0600 Subject: [PATCH] fix(desktop): key-based React remount for workspace switching (#415) Co-authored-by: Claude Opus 4.6 --- AGENTS.md | 29 ++++++++ desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src/app/App.tsx | 24 +++++- .../src/app/navigation/searchHitEventCache.ts | 4 + .../features/agents/ui/ManagedAgentRow.tsx | 11 +++ .../src/features/channels/ui/ChannelPane.tsx | 4 + .../src/features/messages/lib/useDrafts.ts | 4 + .../messages/ui/MessageThreadPanel.tsx | 22 ++++++ .../features/workspaces/useWorkspaceInit.ts | 74 +++++++++++-------- .../{useWorkspaces.ts => useWorkspaces.tsx} | 43 +++++++++-- desktop/src/main.tsx | 15 ++-- desktop/src/shared/api/relayClientSession.ts | 56 ++++++++++++++ desktop/src/shared/lib/mediaUrl.ts | 10 +++ desktop/src/shared/ui/avatar.tsx | 2 +- 14 files changed, 252 insertions(+), 48 deletions(-) rename desktop/src/features/workspaces/{useWorkspaces.ts => useWorkspaces.tsx} (77%) diff --git a/AGENTS.md b/AGENTS.md index 8aec2b0cf..374eb3e62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,35 @@ just desktop-dev # web-only dev server (faster iteration) just desktop-app # full Tauri app with native shell ``` +### Workspace Switching + +The desktop app supports multiple workspaces (each backed by a different relay). +Switching workspaces does **not** reload the page — it uses React key-based +remounting. `` in `App.tsx` forces the entire +workspace-scoped subtree to unmount and remount with fresh state. + +**Module-level singletons must be explicitly reset.** React remounting only +clears React state (useState, useRef, context). Module-level variables (Maps, +class instances, cached promises) survive across remounts. Every workspace-scoped +singleton needs a reset function wired into `resetWorkspaceState()` in +`desktop/src/features/workspaces/useWorkspaceInit.ts`. + +Current singletons that are reset on workspace switch: +- `relayClient.disconnect()` — WebSocket teardown + promise rejection +- `resetMediaCaches()` — proxy port and relay origin caches +- `clearSearchHitEventCache()` — search result event cache +- `clearAllDrafts()` — message draft cache + +**If you add a new module-level cache, Map, or class instance that holds +workspace-scoped data, you must add its reset to `resetWorkspaceState()`.** +Failure to do so causes data from the old workspace to leak into the new one. + +Key files: +- `desktop/src/app/App.tsx` — workspace key, init gate, remount boundary +- `desktop/src/features/workspaces/useWorkspaceInit.ts` — `resetWorkspaceState()`, applies config to Tauri backend +- `desktop/src/features/workspaces/useWorkspaces.tsx` — `WorkspacesProvider` context (shared state for App + AppShell) +- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `WorkspacesProvider` > `App`) + --- ## Mobile App (Flutter) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 99abff8dc..79551b90e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -40,7 +40,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/relayClientSession.ts", 835], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + ["src/shared/api/relayClientSession.ts", 890], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index d5ff11abc..4d71da8fa 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,4 +1,5 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useQueryClient } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import { useCallback, useLayoutEffect } from "react"; @@ -6,6 +7,7 @@ import { router } from "@/app/router"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit"; +import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup"; function AppLoadingGate() { @@ -52,11 +54,25 @@ export function App() { void getCurrentWindow().show(); }, []); - const workspace = useWorkspaceInit(); + const queryClient = useQueryClient(); + const { activeWorkspace, reinitKey } = useWorkspaces(); + const workspace = useWorkspaceInit(activeWorkspace); + + // Composite key: changes when workspace ID changes OR when + // the active workspace's config is updated (relayUrl/token). + const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`; + + // Clear stale React Query cache synchronously when workspace changes. + // useLayoutEffect fires before child useEffect hooks, preventing stale + // data from being served to the new workspace's components. + // biome-ignore lint/correctness/useExhaustiveDependencies: workspaceKey drives the re-run intentionally + useLayoutEffect(() => { + queryClient.clear(); + }, [workspaceKey, queryClient]); const handleSetupComplete = useCallback(() => { - // Force a full reload so useWorkspaceInit re-runs and picks up - // the newly-created workspace from localStorage. + // Force a full reload so useWorkspaces re-initializes from localStorage. + // This only runs once — during first-run setup when no workspace existed. window.location.reload(); }, []); @@ -76,5 +92,5 @@ export function App() { return ; } - return ; + return ; } diff --git a/desktop/src/app/navigation/searchHitEventCache.ts b/desktop/src/app/navigation/searchHitEventCache.ts index 12a9ccc74..b57a5f01f 100644 --- a/desktop/src/app/navigation/searchHitEventCache.ts +++ b/desktop/src/app/navigation/searchHitEventCache.ts @@ -38,6 +38,10 @@ export function cacheSearchHitEvent(hit: SearchHit): RelayEvent { return event; } +export function clearSearchHitEventCache(): void { + searchHitEventCache.clear(); +} + export function getCachedSearchHitEvent( eventId: string | null | undefined, ): RelayEvent | null { diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 7eb5494f5..93b3ffc00 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -460,6 +460,9 @@ function AgentOriginBadge({ agent }: { agent: ManagedAgent }) { ); } +/** Grace period after mount before treating "running + no presence" as "Starting…" */ +const PRESENCE_GRACE_MS = 15_000; + function AgentStatusBadge({ presenceLoaded, presenceStatus, @@ -469,8 +472,16 @@ function AgentStatusBadge({ presenceStatus: PresenceStatus | undefined; status: ManagedAgent["status"]; }) { + const [inGracePeriod, setInGracePeriod] = React.useState(true); + + React.useEffect(() => { + const timer = setTimeout(() => setInGracePeriod(false), PRESENCE_GRACE_MS); + return () => clearTimeout(timer); + }, []); + const isActive = status === "running" || status === "deployed"; const isStarting = + !inGracePeriod && presenceLoaded && status === "running" && (!presenceStatus || presenceStatus === "offline"); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 0b9ea9690..aa688db93 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -282,10 +282,14 @@ export const ChannelPane = React.memo(function ChannelPane({ channelName={activeChannel?.name ?? "channel"} currentPubkey={currentPubkey} disabled={isComposerDisabled} + editTarget={editTarget} isSending={isSending} + onCancelEdit={onCancelEdit} onCancelReply={onCancelThreadReply} onClose={onCloseThread} onDelete={onDelete} + onEdit={onEdit} + onEditSave={onEditSave} onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 7846a2523..d95e22ce9 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -10,6 +10,10 @@ export type DraftState = { const sharedDrafts = new Map(); +export function clearAllDrafts(): void { + sharedDrafts.clear(); +} + export function useDrafts() { const saveDraft = React.useCallback( (channelId: string, draft: DraftState) => { diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 32ffa7e26..3d2eb028b 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -19,10 +19,14 @@ type MessageThreadPanelProps = { channelName: string; currentPubkey?: string; disabled?: boolean; + editTarget?: { author: string; body: string; id: string } | null; isSending: boolean; + onCancelEdit?: () => void; onCancelReply: () => void; onClose: () => void; onDelete?: (message: TimelineMessage) => void; + onEdit?: (message: TimelineMessage) => void; + onEditSave?: (content: string) => Promise; onExpandReplies: (message: TimelineMessage) => void; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; @@ -66,10 +70,14 @@ export function MessageThreadPanel({ channelName, currentPubkey, disabled = false, + editTarget, isSending, + onCancelEdit, onCancelReply, onClose, onDelete, + onEdit, + onEditSave, onExpandReplies, onResetWidth, onResizeStart, @@ -179,6 +187,11 @@ export function MessageThreadPanel({ ? onDelete : undefined } + onEdit={ + onEdit && canManageMessage(threadHead, currentPubkey) + ? onEdit + : undefined + } onToggleReaction={onToggleReaction} profiles={profiles} /> @@ -205,6 +218,12 @@ export function MessageThreadPanel({ ? onDelete : undefined } + onEdit={ + onEdit && + canManageMessage(entry.message, currentPubkey) + ? onEdit + : undefined + } onReply={onSelectReplyTarget} onToggleReaction={onToggleReaction} profiles={profiles} @@ -259,8 +278,11 @@ export function MessageThreadPanel({ channelName={channelName} disabled={disabled || isSending || !channelId} draftKey={`thread:${threadHead.id}`} + editTarget={editTarget} isSending={isSending} + onCancelEdit={onCancelEdit} onCancelReply={composerReplyTarget ? onCancelReply : undefined} + onEditSave={onEditSave} onSend={onSend} placeholder={`Reply in thread to ${threadHead.author}`} replyTarget={composerReplyTarget} diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index a2b6d5406..996476288 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -1,12 +1,25 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { relayClient } from "@/shared/api/relayClient"; import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri"; +import { resetMediaCaches } from "@/shared/lib/mediaUrl"; +import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; +import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; -import { - loadActiveWorkspaceId, - loadWorkspaces, - saveActiveWorkspaceId, -} from "./workspaceStorage"; +import type { Workspace } from "./types"; + +/** + * Tear down all workspace-scoped module singletons so the new + * workspace starts with a clean slate. If you add a new module-level + * cache or singleton that holds workspace data, add its reset here. + * See AGENTS.md "Workspace Switching" for the full contract. + */ +function resetWorkspaceState(): void { + relayClient.disconnect(); + resetMediaCaches(); + clearSearchHitEventCache(); + clearAllDrafts(); +} type WorkspaceInitResult = | { isReady: true; needsSetup: false } @@ -14,36 +27,38 @@ type WorkspaceInitResult = | { isReady: false; needsSetup: false }; /** - * Runs once on mount. Loads the active workspace from localStorage - * and calls the Tauri backend to apply the workspace config - * (keys, relay URL, token). + * Applies the active workspace config to the Tauri backend and resets + * all workspace-scoped module singletons when the workspace changes. * * Returns a discriminated union — only render the app after the * workspace is applied. When `needsSetup` is true, the caller * should show a first-run welcome screen. */ -export function useWorkspaceInit(): WorkspaceInitResult { +export function useWorkspaceInit( + activeWorkspace: Workspace | null, +): WorkspaceInitResult { const [result, setResult] = useState({ isReady: false, needsSetup: false, }); + // Track whether this is the initial mount or a workspace switch. + // On the initial mount we skip resetting singletons (they're fresh). + const hasInitializedRef = useRef(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token) — depending on the whole object would trigger resets on name-only changes useEffect(() => { let cancelled = false; async function init() { - const workspaces = loadWorkspaces(); - - if (workspaces.length === 0) { - // No workspaces at all — fetch the build default relay URL - // so the welcome screen can pre-fill it. + if (!activeWorkspace) { + // No workspace — need setup try { const defaultRelayUrl = await getDefaultRelayUrl(); if (!cancelled) { setResult({ isReady: false, needsSetup: true, defaultRelayUrl }); } } catch { - // If we can't get the default, fall back to localhost if (!cancelled) { setResult({ isReady: false, @@ -55,24 +70,23 @@ export function useWorkspaceInit(): WorkspaceInitResult { return; } - // Determine active workspace - let activeId = loadActiveWorkspaceId(); - if (!activeId || !workspaces.find((w) => w.id === activeId)) { - activeId = workspaces[0].id; - saveActiveWorkspaceId(activeId); + // On workspace switch (not initial mount), reset module singletons + // so the new tree starts with a clean slate. + if (hasInitializedRef.current) { + resetWorkspaceState(); } + hasInitializedRef.current = true; - const active = workspaces.find((w) => w.id === activeId); - if (!active) { - if (!cancelled) { - setResult({ isReady: true, needsSetup: false }); - } - return; - } + // Show loading gate while we apply the new workspace config + setResult({ isReady: false, needsSetup: false }); // Apply workspace config to the Tauri backend try { - await applyWorkspace(active.relayUrl, active.nsec, active.token); + await applyWorkspace( + activeWorkspace.relayUrl, + activeWorkspace.nsec, + activeWorkspace.token, + ); } catch (error) { console.error("Failed to apply workspace to backend:", error); } @@ -87,7 +101,7 @@ export function useWorkspaceInit(): WorkspaceInitResult { return () => { cancelled = true; }; - }, []); + }, [activeWorkspace?.id, activeWorkspace?.relayUrl, activeWorkspace?.token]); return result; } diff --git a/desktop/src/features/workspaces/useWorkspaces.ts b/desktop/src/features/workspaces/useWorkspaces.tsx similarity index 77% rename from desktop/src/features/workspaces/useWorkspaces.ts rename to desktop/src/features/workspaces/useWorkspaces.tsx index e9ce8a000..820bd66df 100644 --- a/desktop/src/features/workspaces/useWorkspaces.ts +++ b/desktop/src/features/workspaces/useWorkspaces.tsx @@ -1,4 +1,12 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import type { ReactNode } from "react"; import type { Workspace } from "./types"; import { @@ -11,6 +19,8 @@ import { export type UseWorkspacesReturn = { workspaces: Workspace[]; activeWorkspace: Workspace | null; + /** Counter bumped when the active workspace's config changes (relayUrl/token). */ + reinitKey: number; /** Add a workspace, deduplicating by relayUrl. Returns the final ID in the list. */ addWorkspace: (workspace: Workspace) => string; removeWorkspace: (id: string) => void; @@ -21,12 +31,32 @@ export type UseWorkspacesReturn = { ) => void; }; +const WorkspacesContext = createContext(null); + +export function WorkspacesProvider({ children }: { children: ReactNode }) { + const value = useWorkspacesInternal(); + return ( + + {children} + + ); +} + export function useWorkspaces(): UseWorkspacesReturn { + const ctx = useContext(WorkspacesContext); + if (!ctx) { + throw new Error("useWorkspaces must be used within a WorkspacesProvider"); + } + return ctx; +} + +function useWorkspacesInternal(): UseWorkspacesReturn { const [workspaces, setWorkspacesState] = useState(loadWorkspaces); const [activeId, setActiveId] = useState( loadActiveWorkspaceId, ); + const [reinitKey, setReinitKey] = useState(0); const workspacesRef = useRef(workspaces); workspacesRef.current = workspaces; @@ -76,9 +106,8 @@ export function useWorkspaces(): UseWorkspacesReturn { // If removing the active workspace, switch to first remaining if (activeId === id && next.length > 0) { - setActiveId(next[0].id); saveActiveWorkspaceId(next[0].id); - window.location.reload(); + setActiveId(next[0].id); } return next; @@ -93,7 +122,7 @@ export function useWorkspaces(): UseWorkspacesReturn { return; } saveActiveWorkspaceId(id); - window.location.reload(); + setActiveId(id); }, [activeId], ); @@ -115,12 +144,13 @@ export function useWorkspaces(): UseWorkspacesReturn { saveWorkspaces(next); return next; }); - // If the active workspace's relay URL or token changed, reload to reconnect + // If the active workspace's relay URL or token changed, bump reinitKey + // so the React tree remounts with the new config. if ( id === activeId && (updates.relayUrl || updates.token !== undefined) ) { - window.location.reload(); + setReinitKey((k) => k + 1); } }, [activeId], @@ -129,6 +159,7 @@ export function useWorkspaces(): UseWorkspacesReturn { return { workspaces, activeWorkspace, + reinitKey, addWorkspace, removeWorkspace, switchWorkspace, diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index c80449f6c..ff8988551 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "@/app/App"; import "@/shared/styles/globals.css"; +import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; import { Toaster } from "@/shared/ui/sonner"; import { TooltipProvider } from "@/shared/ui/tooltip"; @@ -29,12 +30,14 @@ function renderApp() { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - - + + + + + + + + , ); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index a285d478c..c8bee2269 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -48,6 +48,62 @@ export class RelayClient { private notifyReconnectListeners = false; private onMessageChannel: Channel | null = null; + /** + * Cleanly tear down the connection without scheduling a reconnect. + * Used during workspace switches to reset the singleton before the + * new workspace applies. + */ + disconnect() { + const error = new Error("Relay disconnected for workspace switch."); + + if (this.reconnectTimeout) { + window.clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + this.keepAliveRequested = false; + this.relayUrl = null; + this.hasConnectedOnce = false; + this.notifyReconnectListeners = false; + + if (this.wsId !== null) { + void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch( + () => {}, + ); + this.wsId = null; + } + + this.connectPromise = null; + + if (this.authRequest) { + window.clearTimeout(this.authRequest.timeout); + this.authRequest.reject(error); + this.authRequest = null; + } + + for (const [subId, sub] of this.subscriptions) { + if (sub.mode === "history") { + window.clearTimeout(sub.timeout); + sub.reject(error); + } + this.subscriptions.delete(subId); + } + + for (const [eventId, pending] of this.pendingEvents) { + window.clearTimeout(pending.timeout); + pending.reject(error); + this.pendingEvents.delete(eventId); + } + + if (this.flushTimeout !== null) { + window.clearTimeout(this.flushTimeout); + this.flushTimeout = null; + } + this.eventBuffer = []; + this.reconnectListeners.clear(); + this.onMessageChannel = null; + this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; + } + async fetchChannelHistory(channelId: string, limit = 50) { return this.fetchHistory(this.buildChannelFilter(channelId, limit)); } diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index 828fc8e64..104135caa 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -70,6 +70,16 @@ if (typeof window !== "undefined") { portPromise = fetchProxyPort(); } +/** + * Reset module-level caches so the next render re-fetches the proxy port + * and relay origin for the new workspace. + */ +export function resetMediaCaches(): void { + cachedPort = null; + portPromise = null; + cachedRelayOrigin = null; +} + /** * If `url` is a Blossom media URL hosted on the Sprout relay, rewrite it * to go through the localhost streaming proxy. External Blossom URLs and diff --git a/desktop/src/shared/ui/avatar.tsx b/desktop/src/shared/ui/avatar.tsx index a9ee8f9cf..9c90537b7 100644 --- a/desktop/src/shared/ui/avatar.tsx +++ b/desktop/src/shared/ui/avatar.tsx @@ -37,7 +37,7 @@ const AvatarFallback = React.forwardRef<