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: 2 additions & 0 deletions frontend/src/components/GuestPaymentWarningDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
restoreMapleApiAuthForUser,
stopAgentRuntimeForUser
} from "@/services/agentRuntimeService";
import { resetWorkspaceModePreference } from "@/services/workspaceModePreference";
import { useState } from "react";
import { getBillingService } from "@/billing/billingService";

Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/VerificationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/WorkspaceModeSwitch.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/settings/DeleteAccountSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
clearMapleApiAuthForUser,
restoreMapleApiAuthForUser
} from "@/services/agentRuntimeService";
import { resetWorkspaceModePreference } from "@/services/workspaceModePreference";
import { SettingsPage, SettingsSection } from "./SettingsPage";

export function DeleteAccountSettings() {
Expand Down Expand Up @@ -90,6 +91,8 @@ export function DeleteAccountSettings() {
cleanupBlockRef.current.retainUntilNextSession();
}

resetWorkspaceModePreference();

try {
getBillingService().clearToken();
} catch (clearError) {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/settings/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -328,6 +329,7 @@ function SettingsLayoutContent() {
nativeAuthCleared = true;
await os.signOut();
signedOut = true;
resetWorkspaceModePreference();
queryClient.clear();
await router.invalidate();
await router.navigate({ to: "/" });
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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")!;
Expand Down
140 changes: 140 additions & 0 deletions frontend/src/services/workspaceModePreference.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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: "/settings",
search: "",
hash: "",
agentModeAvailable: true
},
storage
)
).toBeNull();
expect(
getLaunchWorkspacePath(
{
pathname: "/",
search: "?login=true",
hash: "",
agentModeAvailable: true
},
storage
)
).toBeNull();
});
});
87 changes: 87 additions & 0 deletions frontend/src/services/workspaceModePreference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
export type WorkspaceMode = "chat" | "agent";

export const WORKSPACE_MODE_STORAGE_KEY = "workspaceMode";

type WorkspaceModeStorage = Pick<Storage, "getItem" | "setItem">;

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.
}
}
Loading