From 73fa1ca53b899c2fcfed4da150d7f03e427dfc12 Mon Sep 17 00:00:00 2001 From: marks Date: Fri, 24 Jul 2026 16:12:34 -0500 Subject: [PATCH 1/3] Remember workspace mode across launches --- .../src/components/WorkspaceModeSwitch.tsx | 3 +- frontend/src/main.tsx | 9 +- frontend/src/routes/__root.tsx | 5 + .../services/workspaceModePreference.test.ts | 153 ++++++++++++++++++ .../src/services/workspaceModePreference.ts | 101 ++++++++++++ 5 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 frontend/src/services/workspaceModePreference.test.ts create mode 100644 frontend/src/services/workspaceModePreference.ts diff --git a/frontend/src/components/WorkspaceModeSwitch.tsx b/frontend/src/components/WorkspaceModeSwitch.tsx index e970a02f6..b633db0f3 100644 --- a/frontend/src/components/WorkspaceModeSwitch.tsx +++ b/frontend/src/components/WorkspaceModeSwitch.tsx @@ -1,8 +1,9 @@ import { Bot, MessageCircle, type LucideIcon } from "lucide-react"; +import type { WorkspaceMode } from "@/services/workspaceModePreference"; import { cn } from "@/utils/utils"; -export type WorkspaceMode = "chat" | "agent"; +export type { WorkspaceMode } from "@/services/workspaceModePreference"; type WorkspaceModeOption = { mode: WorkspaceMode; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index db5cd5984..6def1d3f1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,18 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import App from "./app"; -import { waitForPlatform } from "@/utils/platform"; +import { isTauriDesktop, waitForPlatform } from "@/utils/platform"; +import { restoreWorkspaceModeAtLaunch } from "@/services/workspaceModePreference"; // Initialize platform detection before rendering async function initializeApp() { // Wait for platform detection to complete // This ensures all platform checks are correct from the first render await waitForPlatform(); + restoreWorkspaceModeAtLaunch(isTauriDesktop()); + + // Create the router only after restoring the launch route so its first + // location snapshot matches the user's saved mode. + const { default: App } = await import("./app"); // Render the app const rootElement = document.getElementById("root")!; diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 79ae00106..507621b0e 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -11,6 +11,7 @@ import { TeamSeatMismatchAlert } from "@/components/team/TeamSeatMismatchAlert"; import { VerificationModal } from "@/components/VerificationModal"; import { transitionAgentAuthUser } from "@/services/agentRuntimeService"; import { getSafeInternalRedirect } from "@/utils/internalRedirect"; +import { rememberWorkspaceModeForPath } from "@/services/workspaceModePreference"; interface RootRouterContext { os: OpenSecretContextType; @@ -54,6 +55,10 @@ function Root() { void transitionAgentAuthUser(userId).catch(() => {}); }, [userId]); + useEffect(() => { + if (userId) rememberWorkspaceModeForPath(location.pathname, location.searchStr); + }, [location.pathname, location.searchStr, userId]); + useEffect(() => { const persistentHome = persistentHomeRef.current; if (!persistentHome) return; diff --git a/frontend/src/services/workspaceModePreference.test.ts b/frontend/src/services/workspaceModePreference.test.ts new file mode 100644 index 000000000..7c3a727cf --- /dev/null +++ b/frontend/src/services/workspaceModePreference.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; + +import { + getLaunchWorkspacePath, + getStoredWorkspaceMode, + rememberWorkspaceMode, + rememberWorkspaceModeForPath, + WORKSPACE_MODE_STORAGE_KEY, + workspaceModeForPath +} from "./workspaceModePreference"; + +class MemoryStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +describe("workspace mode preference", () => { + test("defaults missing and invalid preferences to Chat Mode", () => { + const storage = new MemoryStorage(); + + expect(getStoredWorkspaceMode(storage)).toBe("chat"); + + storage.setItem(WORKSPACE_MODE_STORAGE_KEY, "invalid"); + expect(getStoredWorkspaceMode(storage)).toBe("chat"); + }); + + test("persists repeated mode changes for later launches", () => { + const storage = new MemoryStorage(); + + rememberWorkspaceMode("agent", storage); + expect(getStoredWorkspaceMode(storage)).toBe("agent"); + + rememberWorkspaceMode("chat", storage); + expect(getStoredWorkspaceMode(storage)).toBe("chat"); + + rememberWorkspaceMode("agent", storage); + expect(getStoredWorkspaceMode(storage)).toBe("agent"); + }); + + test("ignores unavailable storage", () => { + const unreadableStorage = { + getItem: () => { + throw new Error("unavailable"); + }, + setItem: () => { + throw new Error("unavailable"); + } + }; + + expect(getStoredWorkspaceMode(unreadableStorage)).toBe("chat"); + expect(() => rememberWorkspaceMode("agent", unreadableStorage)).not.toThrow(); + }); + + test("derives a preference only from workspace routes", () => { + expect(workspaceModeForPath("/")).toBe("chat"); + expect(workspaceModeForPath("/", "?conversation_id=chat-id")).toBe("chat"); + expect(workspaceModeForPath("/agent")).toBe("agent"); + expect(workspaceModeForPath("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/settings")).toBeNull(); + expect(workspaceModeForPath("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/login")).toBeNull(); + }); + + test("does not replace Agent Mode during settings or transient home redirects", () => { + const storage = new MemoryStorage(); + + rememberWorkspaceModeForPath("/agent", "", storage); + rememberWorkspaceModeForPath("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/settings", "", storage); + rememberWorkspaceModeForPath("/", "?credits_success=true", storage); + rememberWorkspaceModeForPath("/", "?team_setup=true", storage); + rememberWorkspaceModeForPath("/", "?api_settings=true", storage); + rememberWorkspaceModeForPath("/", "?login=true", storage); + + expect(getStoredWorkspaceMode(storage)).toBe("agent"); + }); + + test("restores Agent Mode from a bare desktop home launch", () => { + const storage = new MemoryStorage(); + rememberWorkspaceMode("agent", storage); + + expect( + getLaunchWorkspacePath( + { + pathname: "/", + search: "", + hash: "", + agentModeAvailable: true + }, + storage + ) + ).toBe("/agent"); + }); + + test("keeps Chat Mode as the default launch mode", () => { + const storage = new MemoryStorage(); + + expect( + getLaunchWorkspacePath( + { + pathname: "/", + search: "", + hash: "", + agentModeAvailable: true + }, + storage + ) + ).toBeNull(); + }); + + test("does not override unsupported platforms, deep links, or home callbacks", () => { + const storage = new MemoryStorage(); + rememberWorkspaceMode("agent", storage); + + expect( + getLaunchWorkspacePath( + { + pathname: "/", + search: "", + hash: "", + agentModeAvailable: false + }, + storage + ) + ).toBeNull(); + expect( + getLaunchWorkspacePath( + { + pathname: "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/settings", + search: "", + hash: "", + agentModeAvailable: true + }, + storage + ) + ).toBeNull(); + expect( + getLaunchWorkspacePath( + { + pathname: "/", + search: "?login=true", + hash: "", + agentModeAvailable: true + }, + storage + ) + ).toBeNull(); + }); +}); diff --git a/frontend/src/services/workspaceModePreference.ts b/frontend/src/services/workspaceModePreference.ts new file mode 100644 index 000000000..159b6cac2 --- /dev/null +++ b/frontend/src/services/workspaceModePreference.ts @@ -0,0 +1,101 @@ +export type WorkspaceMode = "chat" | "agent"; + +export const WORKSPACE_MODE_STORAGE_KEY = "workspaceMode"; +const TRANSIENT_HOME_SEARCH_PARAMS = ["login", "team_setup", "credits_success", "api_settings"]; + +type WorkspaceModeStorage = Pick; + +type LaunchLocation = { + pathname: string; + search: string; + hash: string; + agentModeAvailable: boolean; +}; + +function getBrowserStorage(): WorkspaceModeStorage | null { + if (typeof window === "undefined") return null; + + try { + return window.localStorage; + } catch { + return null; + } +} + +export function getStoredWorkspaceMode( + storage: WorkspaceModeStorage | null = getBrowserStorage() +): WorkspaceMode { + if (!storage) return "chat"; + + try { + return storage.getItem(WORKSPACE_MODE_STORAGE_KEY) === "agent" ? "agent" : "chat"; + } catch { + return "chat"; + } +} + +export function rememberWorkspaceMode( + mode: WorkspaceMode, + storage: WorkspaceModeStorage | null = getBrowserStorage() +): void { + if (!storage) return; + + try { + storage.setItem(WORKSPACE_MODE_STORAGE_KEY, mode); + } catch { + // A storage failure should never prevent the user from changing modes. + } +} + +export function workspaceModeForPath(pathname: string, search = ""): WorkspaceMode | null { + if (pathname === "/") { + const searchParams = new URLSearchParams(search); + if (TRANSIENT_HOME_SEARCH_PARAMS.some((key) => searchParams.has(key))) return null; + return "chat"; + } + if (pathname === "/agent") return "agent"; + return null; +} + +export function rememberWorkspaceModeForPath( + pathname: string, + search = "", + storage: WorkspaceModeStorage | null = getBrowserStorage() +): void { + const mode = workspaceModeForPath(pathname, search); + if (mode) rememberWorkspaceMode(mode, storage); +} + +export function getLaunchWorkspacePath( + location: LaunchLocation, + storage: WorkspaceModeStorage | null = getBrowserStorage() +): "/agent" | null { + if ( + !location.agentModeAvailable || + location.pathname !== "/" || + location.search !== "" || + location.hash !== "" + ) { + return null; + } + + return getStoredWorkspaceMode(storage) === "agent" ? "/agent" : null; +} + +export function restoreWorkspaceModeAtLaunch(agentModeAvailable: boolean): void { + if (typeof window === "undefined") return; + + const path = getLaunchWorkspacePath({ + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + agentModeAvailable + }); + if (!path) return; + + try { + window.history.replaceState(window.history.state, "", path); + } catch { + // Keep the default home route if this webview does not allow history replacement. + } +} From 3f9cc8f8ca96790c6ef35a49c2d25e2ab808b1fe Mon Sep 17 00:00:00 2001 From: marks Date: Fri, 24 Jul 2026 16:27:25 -0500 Subject: [PATCH 2/3] Persist mode only after explicit selection --- frontend/src/components/Sidebar.tsx | 3 +++ frontend/src/routes/__root.tsx | 5 ---- .../services/workspaceModePreference.test.ts | 25 +------------------ .../src/services/workspaceModePreference.ts | 20 --------------- 4 files changed, 4 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d750d6fe5..b692635d6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -34,6 +34,7 @@ import { import { isTauriDesktop } from "@/utils/platform"; import { useOpenSecret } from "@opensecret/react"; import { FEATURE_FLAGS, flagsClient, isForcedOn } from "@/services/flags"; +import { rememberWorkspaceMode } from "@/services/workspaceModePreference"; import { UpgradePromptDialog } from "@/components/UpgradePromptDialog"; import { hasApiAccess } from "@/billing/billingAccess"; import { WorkspaceModeSwitch, type WorkspaceMode } from "@/components/WorkspaceModeSwitch"; @@ -145,11 +146,13 @@ export function Sidebar({ if (nextMode === "chat") { returnToHome({ replace: false }); + rememberWorkspaceMode(nextMode); return; } try { await router.navigate({ to: "/agent" }); + rememberWorkspaceMode(nextMode); } catch (error) { workspaceModeNavigationStartedRef.current = false; setPendingWorkspaceMode(null); diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 507621b0e..79ae00106 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -11,7 +11,6 @@ import { TeamSeatMismatchAlert } from "@/components/team/TeamSeatMismatchAlert"; import { VerificationModal } from "@/components/VerificationModal"; import { transitionAgentAuthUser } from "@/services/agentRuntimeService"; import { getSafeInternalRedirect } from "@/utils/internalRedirect"; -import { rememberWorkspaceModeForPath } from "@/services/workspaceModePreference"; interface RootRouterContext { os: OpenSecretContextType; @@ -55,10 +54,6 @@ function Root() { void transitionAgentAuthUser(userId).catch(() => {}); }, [userId]); - useEffect(() => { - if (userId) rememberWorkspaceModeForPath(location.pathname, location.searchStr); - }, [location.pathname, location.searchStr, userId]); - useEffect(() => { const persistentHome = persistentHomeRef.current; if (!persistentHome) return; diff --git a/frontend/src/services/workspaceModePreference.test.ts b/frontend/src/services/workspaceModePreference.test.ts index 7c3a727cf..08b353f6d 100644 --- a/frontend/src/services/workspaceModePreference.test.ts +++ b/frontend/src/services/workspaceModePreference.test.ts @@ -4,9 +4,7 @@ import { getLaunchWorkspacePath, getStoredWorkspaceMode, rememberWorkspaceMode, - rememberWorkspaceModeForPath, - WORKSPACE_MODE_STORAGE_KEY, - workspaceModeForPath + WORKSPACE_MODE_STORAGE_KEY } from "./workspaceModePreference"; class MemoryStorage { @@ -58,27 +56,6 @@ describe("workspace mode preference", () => { expect(() => rememberWorkspaceMode("agent", unreadableStorage)).not.toThrow(); }); - test("derives a preference only from workspace routes", () => { - expect(workspaceModeForPath("/")).toBe("chat"); - expect(workspaceModeForPath("/", "?conversation_id=chat-id")).toBe("chat"); - expect(workspaceModeForPath("/agent")).toBe("agent"); - expect(workspaceModeForPath("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/settings")).toBeNull(); - expect(workspaceModeForPath("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/login")).toBeNull(); - }); - - test("does not replace Agent Mode during settings or transient home redirects", () => { - const storage = new MemoryStorage(); - - rememberWorkspaceModeForPath("/agent", "", storage); - rememberWorkspaceModeForPath("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/settings", "", storage); - rememberWorkspaceModeForPath("/", "?credits_success=true", storage); - rememberWorkspaceModeForPath("/", "?team_setup=true", storage); - rememberWorkspaceModeForPath("/", "?api_settings=true", storage); - rememberWorkspaceModeForPath("/", "?login=true", storage); - - expect(getStoredWorkspaceMode(storage)).toBe("agent"); - }); - test("restores Agent Mode from a bare desktop home launch", () => { const storage = new MemoryStorage(); rememberWorkspaceMode("agent", storage); diff --git a/frontend/src/services/workspaceModePreference.ts b/frontend/src/services/workspaceModePreference.ts index 159b6cac2..e02dc2790 100644 --- a/frontend/src/services/workspaceModePreference.ts +++ b/frontend/src/services/workspaceModePreference.ts @@ -1,7 +1,6 @@ export type WorkspaceMode = "chat" | "agent"; export const WORKSPACE_MODE_STORAGE_KEY = "workspaceMode"; -const TRANSIENT_HOME_SEARCH_PARAMS = ["login", "team_setup", "credits_success", "api_settings"]; type WorkspaceModeStorage = Pick; @@ -47,25 +46,6 @@ export function rememberWorkspaceMode( } } -export function workspaceModeForPath(pathname: string, search = ""): WorkspaceMode | null { - if (pathname === "/") { - const searchParams = new URLSearchParams(search); - if (TRANSIENT_HOME_SEARCH_PARAMS.some((key) => searchParams.has(key))) return null; - return "chat"; - } - if (pathname === "/agent") return "agent"; - return null; -} - -export function rememberWorkspaceModeForPath( - pathname: string, - search = "", - storage: WorkspaceModeStorage | null = getBrowserStorage() -): void { - const mode = workspaceModeForPath(pathname, search); - if (mode) rememberWorkspaceMode(mode, storage); -} - export function getLaunchWorkspacePath( location: LaunchLocation, storage: WorkspaceModeStorage | null = getBrowserStorage() From 137ee30ba93ae6c7d28c0a425e7b169f225e853d Mon Sep 17 00:00:00 2001 From: marks Date: Fri, 24 Jul 2026 22:23:40 -0500 Subject: [PATCH 3/3] Reset workspace mode when an account leaves --- frontend/src/components/GuestPaymentWarningDialog.tsx | 2 ++ frontend/src/components/VerificationModal.tsx | 2 ++ .../src/components/settings/DeleteAccountSettings.tsx | 3 +++ frontend/src/components/settings/SettingsLayout.tsx | 2 ++ frontend/src/services/workspaceModePreference.test.ts | 10 ++++++++++ frontend/src/services/workspaceModePreference.ts | 6 ++++++ 6 files changed, 25 insertions(+) diff --git a/frontend/src/components/GuestPaymentWarningDialog.tsx b/frontend/src/components/GuestPaymentWarningDialog.tsx index 12f9ef06e..1d4b8d927 100644 --- a/frontend/src/components/GuestPaymentWarningDialog.tsx +++ b/frontend/src/components/GuestPaymentWarningDialog.tsx @@ -15,6 +15,7 @@ import { restoreMapleApiAuthForUser, stopAgentRuntimeForUser } from "@/services/agentRuntimeService"; +import { resetWorkspaceModePreference } from "@/services/workspaceModePreference"; import { useState } from "react"; import { getBillingService } from "@/billing/billingService"; @@ -69,6 +70,7 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa nativeAuthCleared = true; await os.signOut(); signedOut = true; + resetWorkspaceModePreference(); queryClient.clear(); } catch (error) { console.error("Error during sign out:", error); diff --git a/frontend/src/components/VerificationModal.tsx b/frontend/src/components/VerificationModal.tsx index 7cd9b4cd1..1dfc18630 100644 --- a/frontend/src/components/VerificationModal.tsx +++ b/frontend/src/components/VerificationModal.tsx @@ -19,6 +19,7 @@ import { restoreMapleApiAuthForUser, stopAgentRuntimeForUser } from "@/services/agentRuntimeService"; +import { resetWorkspaceModePreference } from "@/services/workspaceModePreference"; import { getBillingService } from "@/billing/billingService"; import { navigateToSafeInternalRedirect } from "@/utils/internalRedirect"; @@ -148,6 +149,7 @@ export function VerificationModal() { nativeAuthCleared = true; await os.signOut(); signedOut = true; + resetWorkspaceModePreference(); queryClient.clear(); } catch (error) { console.error("Error during sign out:", error); diff --git a/frontend/src/components/settings/DeleteAccountSettings.tsx b/frontend/src/components/settings/DeleteAccountSettings.tsx index d4de9baa4..0e7715132 100644 --- a/frontend/src/components/settings/DeleteAccountSettings.tsx +++ b/frontend/src/components/settings/DeleteAccountSettings.tsx @@ -14,6 +14,7 @@ import { clearMapleApiAuthForUser, restoreMapleApiAuthForUser } from "@/services/agentRuntimeService"; +import { resetWorkspaceModePreference } from "@/services/workspaceModePreference"; import { SettingsPage, SettingsSection } from "./SettingsPage"; export function DeleteAccountSettings() { @@ -90,6 +91,8 @@ export function DeleteAccountSettings() { cleanupBlockRef.current.retainUntilNextSession(); } + resetWorkspaceModePreference(); + try { getBillingService().clearToken(); } catch (clearError) { diff --git a/frontend/src/components/settings/SettingsLayout.tsx b/frontend/src/components/settings/SettingsLayout.tsx index 7fe9515f4..bd01f5633 100644 --- a/frontend/src/components/settings/SettingsLayout.tsx +++ b/frontend/src/components/settings/SettingsLayout.tsx @@ -33,6 +33,7 @@ import { restoreMapleApiAuthForUser, stopAgentRuntimeForUser } from "@/services/agentRuntimeService"; +import { resetWorkspaceModePreference } from "@/services/workspaceModePreference"; import { useLocalState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; import { isIOS } from "@/utils/platform"; @@ -328,6 +329,7 @@ function SettingsLayoutContent() { nativeAuthCleared = true; await os.signOut(); signedOut = true; + resetWorkspaceModePreference(); queryClient.clear(); await router.invalidate(); await router.navigate({ to: "/" }); diff --git a/frontend/src/services/workspaceModePreference.test.ts b/frontend/src/services/workspaceModePreference.test.ts index 08b353f6d..53e907593 100644 --- a/frontend/src/services/workspaceModePreference.test.ts +++ b/frontend/src/services/workspaceModePreference.test.ts @@ -4,6 +4,7 @@ import { getLaunchWorkspacePath, getStoredWorkspaceMode, rememberWorkspaceMode, + resetWorkspaceModePreference, WORKSPACE_MODE_STORAGE_KEY } from "./workspaceModePreference"; @@ -42,6 +43,15 @@ describe("workspace mode preference", () => { expect(getStoredWorkspaceMode(storage)).toBe("agent"); }); + test("resets the launch preference to Chat when an account leaves", () => { + const storage = new MemoryStorage(); + rememberWorkspaceMode("agent", storage); + + resetWorkspaceModePreference(storage); + + expect(getStoredWorkspaceMode(storage)).toBe("chat"); + }); + test("ignores unavailable storage", () => { const unreadableStorage = { getItem: () => { diff --git a/frontend/src/services/workspaceModePreference.ts b/frontend/src/services/workspaceModePreference.ts index e02dc2790..00a7abe05 100644 --- a/frontend/src/services/workspaceModePreference.ts +++ b/frontend/src/services/workspaceModePreference.ts @@ -46,6 +46,12 @@ export function rememberWorkspaceMode( } } +export function resetWorkspaceModePreference( + storage: WorkspaceModeStorage | null = getBrowserStorage() +): void { + rememberWorkspaceMode("chat", storage); +} + export function getLaunchWorkspacePath( location: LaunchLocation, storage: WorkspaceModeStorage | null = getBrowserStorage()