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/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/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/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/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/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/services/workspaceModePreference.test.ts b/frontend/src/services/workspaceModePreference.test.ts new file mode 100644 index 000000000..53e907593 --- /dev/null +++ b/frontend/src/services/workspaceModePreference.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; + +import { + getLaunchWorkspacePath, + getStoredWorkspaceMode, + rememberWorkspaceMode, + resetWorkspaceModePreference, + WORKSPACE_MODE_STORAGE_KEY +} 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("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: () => { + throw new Error("unavailable"); + }, + setItem: () => { + throw new Error("unavailable"); + } + }; + + expect(getStoredWorkspaceMode(unreadableStorage)).toBe("chat"); + expect(() => rememberWorkspaceMode("agent", unreadableStorage)).not.toThrow(); + }); + + 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..00a7abe05 --- /dev/null +++ b/frontend/src/services/workspaceModePreference.ts @@ -0,0 +1,87 @@ +export type WorkspaceMode = "chat" | "agent"; + +export const WORKSPACE_MODE_STORAGE_KEY = "workspaceMode"; + +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 resetWorkspaceModePreference( + storage: WorkspaceModeStorage | null = getBrowserStorage() +): void { + rememberWorkspaceMode("chat", 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. + } +}